8000 Restrict Kerberos password authentication to allowed realms (26.8) by mabartos · Pull Request #54939 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public class KerberosConstants {
*/
public static final String ALLOW_KERBEROS_AUTHENTICATION = "allowKerberosAuthentication";
public static final String KERBEROS_REALM = "kerberosRealm";
public static final String ALLOWED_KERBEROS_REALMS = "allowedKerberosRealms";
public static final String SERVER_PRINCIPAL = "serverPrincipal";
public static final String KEYTAB = "keyTab";
public static final String DEBUG = "debug";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ for further lookups of this user. To avoid conflicts when there is user `john` i
lowercased. For instance username would be `john@a`. Just in case when realm matches with the configured `Kerberos realm`, the realm suffix might be omitted from the generated username. For
instance username would be `john` for the Kerberos principal `john@A` as long as the `Kerberos realm` is configured on the Kerberos provider is `A`.

Username and password authentication accepts only the configured *Kerberos realm* in a username such as `john@A`.
List every other realm whose users log in this way in the *Allowed Kerberos realms* option of the provider.
A username with any other realm is rejected before {project_name} contacts a KDC.
For the LDAP provider the realm comes from the Kerberos principal attribute of the user instead of the username.
SPNEGO authentication is not affected.

==== Troubleshooting

If you have issues, enable additional logging to debug the problem:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ With fine-grained admin permissions v2, the listing and the count are restricted
With the deprecated fine-grained admin permissions v1, a caller can be allowed to view some resources but not others, so `totalResults` counts the resources matching the query, before the per-resource view check is applied, and should be treated as an upper bound; a page can also be shorter than the requested `count`, or even empty, while later pages still contain resources the caller is allowed to see, so neither a short page nor an empty page means that no further resources exist, and clients should keep incrementing `startIndex` by `count` until `startIndex` exceeds `totalResults`.
Without any fine-grained admin permissions, the per-resource view check evaluates the same way for every resource of a given type, so access is all-or-nothing: a client holding only a query role and no realm-wide view role gets a `totalResults` of `0`, consistently across every page, rather than the unfiltered database count.

=== Kerberos password authentication accepts only allowed realms
// https://github.com/keycloak/keycloak/issues/54271

Username and password authentication through the Kerberos and LDAP user storage providers now accepts only the configured Kerberos realm and the realms listed in the new Allowed Kerberos realms option of the provider.
A login with any other realm fails as for an unknown user.
If users from a realm with a cross-realm trust log in with a username and password, add that realm to Allowed Kerberos realms after the upgrade.
For the LDAP provider the realm comes from the Kerberos principal attribute of the user, so list every realm found there, for example the child domains of an Active Directory forest.
SPNEGO authentication is not affected.

// ------------------------ Notable changes ------------------------ //
== Notable changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@

package org.keycloak.federation.kerberos;

import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;

import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.utils.StringUtil;

/**
* Common configuration useful for all providers
Expand Down Expand Up @@ -52,6 +57,24 @@ public String getKerberosRealm() {
return getConfig().getFirst(KerberosConstants.KERBEROS_REALM);
}

public List<String> getAllowedKerberosRealms() {
List<String> realms = getConfig().getList(KerberosConstants.ALLOWED_KERBEROS_REALMS);
if (realms == null) {
return List.of();
}
return realms.stream().filter(StringUtil::isNotBlank).map(String::trim).toList();
}

public boolean isAllowedKerberosRealm(String realm) {
if (realm == null) {
return false;
}
String canonical = realm.toUpperCase(Locale.ROOT);
return Stream.concat(Stream.ofNullable(getKerberosRealm()), getAllowedKerberosRealms().stream())
.map(allowed -> allowed.toUpperCase(Locale.ROOT))
.anyMatch(canonical::equals);
}

public String getServerPrincipal() {
return getConfig().getFirst(KerberosConstants.SERVER_PRINCIPAL);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ public List<ProviderConfigProperty> getConfigProperties() {
.helpText("kerberos-realm.tooltip")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property().name(KerberosConstants.ALLOWED_KERBEROS_REALMS)
.label("allowed-kerberos-realms")
.helpText("allowed-kerberos-realms.tooltip")
.type(ProviderConfigProperty.MULTIVALUED_STRING_TYPE)
.add()
.property().name(KerberosConstants.SERVER_PRINCIPAL)
.label("server-principal")
.helpText("server-principal.tooltip")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

package org.keycloak.federation.kerberos;

import java.util.Locale;

/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
Expand All @@ -34,7 +36,7 @@ public KerberosPrincipal(String kerberosPrincipal) {
throw new IllegalArgumentException("Kerberos principal '" + kerberosPrincipal + "' not valid");
}
this.prefix = parts[0];
this.realm = parts[1].toUpperCase();
this.realm = parts[1].toUpperCase(Locale.ROOT);
this.kerberosPrincipal = prefix + "@" + realm;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,14 @@ public KerberosUsernamePasswordAuthenticator(CommonKerberosConfig config, boolea
*/
public boolean isUserAvailable(String username) {
logger.debugf("Checking existence of user: %s", username);
String principal;
try {
principal = getKerberosPrincipal(username);
} catch (LoginException le) {
logger.debugf("User %s is not available: %s", username, le.getMessage());
return false;
}
try {
String principal = getKerberosPrincipal(username);
loginContext = new LoginContext("does-not-matter", null,
createJaasCallbackHandler(principal, "fake-password-which-nobody-has"),
createJaasConfiguration());
Expand Down Expand Up @@ -209,11 +215,26 @@ public void logoutSubject() {


public String getKerberosPrincipal(String username) throws LoginException {
String name = username;
String realm = config.getKerberosRealm();
if (username.contains("@")) {
return new KerberosPrincipal(username).toString();
} else {
return username + "@" + config.getKerberosRealm();
KerberosPrincipal principal;
try {
principal = new KerberosPrincipal(username);
} catch (IllegalArgumentException e) {
throw new LoginException("Kerberos principal not valid");
}
name = principal.getPrefix();
realm = principal.getRealm();
if (!config.isAllowedKerberosRealm(realm)) {
logger.debugf("Kerberos realm %s of user %s is not the configured realm nor an allowed realm", realm, username);
throw new LoginException("Kerberos realm not allowed");
}
}
if (name.isEmpty() || name.endsWith("\\")) {
throw new LoginException("Kerberos principal not valid");
}
return name + "@" + realm;
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package org.keycloak.federation.kerberos.impl;

import java.util.Locale;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginException;

import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.federation.kerberos.KerberosConfig;

import org.junit.Assert;
import org.junit.Test;

public class KerberosUsernamePasswordAuthenticatorTest {

@Test
public void usernameWithoutRealmGetsConfiguredRealm() throws LoginException {
Assert.assertEquals("jduke@KEYCLOAK.ORG", authenticator().getKerberosPrincipal("jduke"));
}

@Test
public void backslashElsewhereInTheNameIsKept() throws LoginException {
Assert.assertEquals("KEYCLOAK\\jduke@KEYCLOAK.ORG", authenticator().getKerberosPrincipal("KEYCLOAK\\jduke"));
}

@Test
public void configuredRealmIsAcceptedIgnoringCase() throws LoginException {
Assert.assertEquals("jduke@KEYCLOAK.ORG", authenticator().getKerberosPrincipal("jduke@keycloak.org"));
}

@Test
public void allowedRealmIsAccepted() throws LoginException {
Assert.assertEquals("jduke@KC2.COM", authenticator("KC2.COM").getKerberosPrincipal("jduke@kc2.com"));
}

@Test
public void allowedRealmsAreTrimmedAndBlankEntriesIgnored() throws LoginException {
Assert.assertEquals("jduke@KC2.COM", authenticator(" kc2.com ", "").getKerberosPrincipal("jduke@KC2.COM"));
}

@Test
public void realmIsCanonicalizedIndependentlyOfTheDefaultLocale() throws LoginException {
Locale original = Locale.getDefault();
Locale.setDefault(Locale.forLanguageTag("tr-TR"));
try {
Assert.assertEquals("jduke@INVALID.EXAMPLE", authenticator("invalid.example").getKerberosPrincipal("jduke@invalid.example"));
} finally {
Locale.setDefault(original);
}
}

@Test(expected = LoginException.class)
public void lookalikeRealmIsRejected() throws LoginException {
authenticator("KC2.COM").getKerberosPrincipal("jduke@\u212AC2.COM");
}

@Test(expected = LoginException.class)
public void realmNotAllowedIsRejected() throws LoginException {
authenticator("KC2.COM").getKerberosPrincipal("jduke@KC3.COM");
}

@Test
public void realmNotAllowedIsRejectedBeforeContactingKdc() {
KerberosUsernamePasswordAuthenticator authenticator = authenticator();
Assert.assertFalse(authenticator.isUserAvailable("jduke@KC2.COM"));
Assert.assertFalse(authenticator.validUser("jduke@KC2.COM", "theduke"));
}

@Test
public void malformedPrincipalIsRejectedBeforeContactingKdc() {
KerberosUsernamePasswordAuthenticator authenticator = authenticator();
for (String username : new String[] { "jduke@", "a@b@c", "@KEYCLOAK.ORG", "jduke\\@KEYCLOAK.ORG", "jduke\\" }) {
Assert.assertFalse(username, authenticator.isUserAvailable(username));
Assert.assertFalse(username, authenticator.validUser(username, "theduke"));
}
}

// The JAAS login is the first step that contacts a KDC, so it must never be reached for a realm that is not allowed
private static KerberosUsernamePasswordAuthenticator authenticator(String... allowedRealms) {
MultivaluedHashMap<String, String> config = new MultivaluedHashMap<>();
config.putSingle(KerberosConstants.KERBEROS_REALM, "KEYCLOAK.ORG");
for (String realm : allowedRealms) {
config.add(KerberosConstants.ALLOWED_KERBEROS_REALMS, realm);
}
ComponentModel model = new ComponentModel();
model.setConfig(config);
return new KerberosUsernamePasswordAuthen 10BC8 ticator(new KerberosConfig(model)) {
@Override
protected Configuration createJaasConfiguration() {
throw new AssertionError("KDC must not be contacted");
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ private static List<ProviderConfigProperty> getConfigProps(ComponentModel parent
.property().name(KerberosConstants.KERBEROS_REALM)
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property().name(KerberosConstants.ALLOWED_KERBEROS_REALMS)
.type(ProviderConfigProperty.MULTIVALUED_STRING_TYPE)
.add()
.property().name(KerberosConstants.KERBEROS_PRINCIPAL_ATTRIBUTE)
.type(ProviderConfigProperty.STRING_TYPE)
.add()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3174,6 +3174,9 @@ emailInvalid=You must enter a valid email.
chooseAPolicyProvider=Choose a policy provider
clientAuthenticationHelp=The client authentication method (cfr. https\://openid.net/specs/openid-connect-core-1_0.html\#ClientAuthentication). In case of JWT signed with private key, the realm private key is used.
kerberosRealmHelp=Name of kerberos realm. For example, FOO.ORG.
allowedKerberosRealms=Allowed Kerberos realms
allowedKerberosRealmsHelp=Other Kerberos realms allowed in the Kerberos principal used for username/password authentication, typically realms with a cross-realm trust to the Kerberos realm. The Kerberos realm is always allowed. A principal with any other realm is rejected without contacting a KDC.
addAllowedKerberosRealm=Add allowed Kerberos realm
roleCreateError=Could not create role\: {{error}}
clientSecretHelp=The client secret registered with the identity provider. This field is able to obtain its value from vault, use ${vault.ID} format.
oidcClientSecretHelp=The client secret registered with the client. This field is able to obtain its value from vault, use ${vault.ID} format.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormAccess } from "../../components/form/FormAccess";
import { MultiLineInput } from "../../components/multi-line-input/MultiLineInput";
import { WizardSectionHeader } from "../../components/wizard-section-header/WizardSectionHeader";
import { useRealm } from "../../context/realm-context/RealmContext";

Expand Down Expand Up @@ -82,6 +83,23 @@ export const KerberosSettingsRequired = ({
required: t("validateRealm"),
}}
/>
<FormGroup
label={t("allowedKerberosRealms")}
labelIcon={
<HelpItem
helpText={t("allowedKerberosRealmsHelp")}
fieldLabelId="allowedKerberosRealms"
/>
}
fieldId="kc-allowed-kerberos-realms"
>
<MultiLineInput
id="kc-allowed-kerberos-realms"
name="config.allowedKerberosRealms"
aria-label={t("allowedKerberosRealms")}
addButtonLabel="addAllowedKerberosRealm"
/>
</FormGroup>
<TextControl
name="config.serverPrincipal.0"
label={t("serverPrincipal")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { useTranslation } from "react-i18next";
import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../../components/form/FormAccess";
import { MultiLineInput } from "../../components/multi-line-input/MultiLineInput";
import { WizardSectionHeader } from "../../components/wizard-section-header/WizardSectionHeader";

export type LdapSettingsKerberosIntegrationProps = {
Expand Down Expand Up @@ -80,6 +81,23 @@ export const LdapSettingsKerberosIntegration = ({
required: t("validateRealm"),
}}
/>
<FormGroup
label={t("allowedKerberosRealms")}
labelIcon={
<HelpItem
helpText={t("allowedKerberosRealmsHelp")}
fieldLabelId="allowedKerberosRealms"
/>
}
fieldId="kc-allowed-kerberos-realms"
>
<MultiLineInput
id="kc-allowed-kerberos-realms"
name="config.allowedKerberosRealms"
aria-label={t("allowedKerberosRealms")}
addButtonLabel="addAllowedKerberosRealm"
/>
</FormGroup>
<TextControl
name="config.serverPrincipal.0"
label={t("serverPrincipal")}
Expand Down
32 changes: 32 additions & 0 deletions js/apps/admin-ui/test/user-federation/kerberos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const firstKerberosName = `${kerberosName}-1`;
const firstKerberosRealm = `${kerberosRealm}-1`;
const firstKerberosPrincipal = `${kerberosPrincipal}-1`;
const firstKerberosKeytab = `${kerberosKeytab}-1`;
const firstAllowedRealm = "KC2.COM";
const secondAllowedRealm = "KC3.COM";

const secondKerberosName = `${kerberosName}-2`;
const secondKerberosRealm = `${kerberosRealm}-2`;
Expand Down Expand Up @@ -67,10 +69,17 @@ test.describe.serial("User Fed Kerberos tests", () => {
kerberosRealm: firstKerberosRealm,
serverPrincipal: firstKerberosPrincipal,
keyTab: firstKerberosKeytab,
allowedKerberosRealms: firstAllowedRealm,
},
});
await clickSave(page, provider);
await assertNotificationMessage(page, createdSuccessMessage);

await goToUserFederation(page);
await clickUserFederationCard(page, "new-kerberos");
await expect(page.getByTestId("config.allowedKerberosRealms0")).toHaveValue(
firstAllowedRealm,
);
});

test.describe.serial("Edit Kerberos provider", () => {
Expand Down Expand Up @@ -132,6 +141,29 @@ test.describe.serial("User Fed Kerberos tests", () => {
await expect(page.getByTestId("update-first-login")).toBeChecked();
});

test("Should save several allowed Kerberos realms", async ({ page }) => {
await clickUserFederationCard(page, firstKerberosName);
await page
.getByTestId("config.allowedKerberosRealms0")
.fill(firstAllowedRealm);
await page.getByTestId("config.allowedKerberosRealms-addValue").click();
await page
.getByTestId("config.allowedKerberosRealms1")
.fill(secondAllowedRealm);
await clickSave(page, provider);
await assertNotificationMessage(page, savedSuccessMessage);

await goToUserFederation(page);
await clickUserFederationCard(page, firstKerberosName);
const input1 = page.getByTestId("config.allowedKerberosRealms0");
const input2 = page.getByTestId("config.allowedKerberosRealms1");
await expect(input1).toHaveValue(/\S+/);
await expect(input2).toHaveValue(/\S+/);
expect([await input1.inputValue(), await input2.inputValue()]).toEqual(
expect.arrayContaining([firstAllowedRealm, secondAllowedRealm]),
);
});

test("Should set cache policy to evict_daily", async ({ page }) => {
await clickUserFederationCard(page, firstKerberosName);
await selectItem(page, "#kc-cache-policy", dailyPolicy);
Expand Down
Loading
Loading
0