Fallback to next LDAP/Kerberos provider when not able to find authenticated Kerberos principal (#22531)

closes #22352 #9422
This commit is contained in:
Marek Posolda 2023-08-29 13:21:01 +02:00 committed by GitHub
parent 248bb17c28
commit 6f989fc132
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 339 additions and 22 deletions

View file

@ -92,4 +92,9 @@ public class KerberosConstants {
*/
public static final String GSS_DELEGATION_CREDENTIAL_DISPLAY_NAME = "gss delegation credential";
/**
* Attribute attached to the credential, which contains authenticated SPNEGO context. This is used in case that some LDAP/Kerberos provider was able to authenticate user via SPNEGO, but wasn't able
* to lookup it in his LDAP tree. In this case, LDAP lookup might be performed by other providers in the chain.
*/
public static final String AUTHENTICATED_SPNEGO_CONTEXT = "authenticatedSpnegoContext";
}

View file

@ -16,6 +16,9 @@ include::topics/templates/release-header.adoc[]
== {project_name_full} 23.0.0
include::topics/23_0_0.adoc[leveloffset=2]
== {project_name_full} 22.0.2
include::topics/22_0_2.adoc[leveloffset=2]
== {project_name_full} 22.0.0
include::topics/22_0_0.adoc[leveloffset=2]

View file

@ -0,0 +1,4 @@
= Improvements in LDAP and Kerberos integration
Keycloak now supports multiple LDAP providers in a realm, which support Kerberos integration with the same Kerberos realm. When an LDAP provider is not able to find the user which was authenticated through
Kerberos/SPNEGO, Keycloak ties to fallback to the next LDAP provider.

View file

@ -190,17 +190,27 @@ public class KerberosFederationProvider implements UserStorageProvider,
if (!(input instanceof UserCredentialModel)) return null;
UserCredentialModel credential = (UserCredentialModel)input;
if (credential.getType().equals(UserCredentialModel.KERBEROS)) {
SPNEGOAuthenticator spnegoAuthenticator = (SPNEGOAuthenticator) credential.getNote(KerberosConstants.AUTHENTICATED_SPNEGO_CONTEXT);
if (spnegoAuthenticator != null) {
logger.debugf("SPNEGO authentication already performed by previous provider. Provider '%s' will try to lookup user with principal kerberos principal '%s'", this, spnegoAuthenticator.getAuthenticatedUsername());
} else {
String spnegoToken = credential.getChallengeResponse();
SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);
spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);
spnegoAuthenticator.authenticate();
}
Map<String, String> state = new HashMap<String, String>();
Map<String, String> state = new HashMap<>();
if (spnegoAuthenticator.isAuthenticated()) {
String username = spnegoAuthenticator.getAuthenticatedUsername();
UserModel user = findOrCreateAuthenticatedUser(realm, username);
if (user == null) {
return CredentialValidationOutput.failed();
// Adding the authenticated SPNEGO, in case that other LDAP/Kerberos providers in the chain are able to lookup user from their LDAP
// This can be the case with more complex setup (like MSAD Forest Trust environment)
// Note that SPNEGO authentication cannot be done again by the other provider due the Kerberos replay protection
credential.setNote(KerberosConstants.AUTHENTICATED_SPNEGO_CONTEXT, spnegoAuthenticator);
return CredentialValidationOutput.fallback();
} else {
String delegationCredential = spnegoAuthenticator.getSerializedDelegationCredential();
if (delegationCredential != null) {
@ -216,7 +226,7 @@ public class KerberosFederationProvider implements UserStorageProvider,
return new CredentialValidationOutput(null, CredentialValidationOutput.Status.CONTINUE, state);
} else {
logger.tracef("SPNEGO Handshake not successful");
return CredentialValidationOutput.failed();
return CredentialValidationOutput.fallback();
}
} else {
@ -282,4 +292,9 @@ public class KerberosFederationProvider implements UserStorageProvider,
return validate(realm, user);
}
@Override
public String toString() {
return "KerberosFederationProvider - " + model.getName();
}
}

View file

@ -725,14 +725,19 @@ public class LDAPStorageProvider implements UserStorageProvider,
@Override
public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput cred) {
if (!(cred instanceof UserCredentialModel)) return CredentialValidationOutput.failed();
if (!(cred instanceof UserCredentialModel)) return CredentialValidationOutput.fallback();
UserCredentialModel credential = (UserCredentialModel)cred;
if (credential.getType().equals(UserCredentialModel.KERBEROS)) {
if (kerberosConfig.isAllowKerberosAuthentication()) {
SPNEGOAuthenticator spnegoAuthenticator = (SPNEGOAuthenticator) credential.getNote(KerberosConstants.AUTHENTICATED_SPNEGO_CONTEXT);
if (spnegoAuthenticator != null) {
logger.debugf("SPNEGO authentication already performed by previous provider. Provider '%s' will try to lookup user with principal kerberos principal '%s'", this, spnegoAuthenticator.getAuthenticatedUsername());
} else {
String spnegoToken = credential.getChallengeResponse();
SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);
spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);
spnegoAuthenticator.authenticate();
}
Map<String, String> state = new HashMap<>();
if (spnegoAuthenticator.isAuthenticated()) {
@ -743,8 +748,13 @@ public class LDAPStorageProvider implements UserStorageProvider,
UserModel user = findOrCreateAuthenticatedUser(realm, username);
if (user == null) {
logger.warnf("Kerberos/SPNEGO authentication succeeded with username [%s], but couldn't find or create user with federation provider [%s]", username, model.getName());
return CredentialValidationOutput.failed();
logger.debugf("Kerberos/SPNEGO authentication succeeded with kerberos principal [%s], but couldn't find or create user with federation provider [%s]", username, model.getName());
// Adding the authenticated SPNEGO, in case that other LDAP/Kerberos providers in the chain are able to lookup user from their LDAP
// This can be the case with more complex setup (like MSAD Forest Trust environment)
// Note that SPNEGO authentication cannot be done again by the other provider due the Kerberos replay protection
credential.setNote(KerberosConstants.AUTHENTICATED_SPNEGO_CONTEXT, spnegoAuthenticator);
return CredentialValidationOutput.fallback();
} else {
String delegationCredential = spnegoAuthenticator.getSerializedDelegationCredential();
if (delegationCredential != null) {
@ -760,12 +770,12 @@ public class LDAPStorageProvider implements UserStorageProvider,
return new CredentialValidationOutput(null, CredentialValidationOutput.Status.CONTINUE, state);
} else {
logger.tracef("SPNEGO Handshake not successful");
return CredentialValidationOutput.failed();
return CredentialValidationOutput.fallback();
}
}
}
return CredentialValidationOutput.failed();
return CredentialValidationOutput.fallback();
}
@Override
@ -899,4 +909,9 @@ public class LDAPStorageProvider implements UserStorageProvider,
return ldapQuery.getResultList().stream();
}
@Override
public String toString() {
return "LDAPStorageProvider - " + getModel().getName();
}
}

View file

@ -146,11 +146,25 @@ public class UserStorageManager extends AbstractStorageManager<UserStorageProvid
credentialAuthenticationStream = Stream.concat(credentialAuthenticationStream,
getCredentialProviders(session, CredentialAuthentication.class));
return credentialAuthenticationStream
CredentialValidationOutput result = null;
for (CredentialAuthentication credentialAuthentication : credentialAuthenticationStream
.filter(credentialAuthentication -> credentialAuthentication.supportsCredentialAuthenticationFor(input.getType()))
.map(credentialAuthentication -> credentialAuthentication.authenticate(realm, input))
.filter(Objects::nonNull)
.findFirst().orElse(null);
.collect(Collectors.toList())) {
CredentialValidationOutput validationOutput = credentialAuthentication.authenticate(realm, input);
if (Objects.nonNull(validationOutput)) {
CredentialValidationOutput.Status status = validationOutput.getAuthStatus();
if (status == CredentialValidationOutput.Status.AUTHENTICATED || status == CredentialValidationOutput.Status.CONTINUE || status == CredentialValidationOutput.Status.FAILED) {
logger.tracef("Attempt to authenticate credential '%s' with provider '%s' finished with '%s'.", input.getType(), credentialAuthentication, status);
if (status == CredentialValidationOutput.Status.AUTHENTICATED) {
logger.tracef("Authenticated user is '%s'", validationOutput.getAuthenticatedUser().getUsername());
}
result = validationOutput;
break;
}
}
logger.tracef("Did not authenticate user by provider '%s' with the credential type '%s'. Will try to fallback to other user storage providers", credentialAuthentication, input.getType());
}
return result;
}
protected void deleteInvalidUser(final RealmModel realm, final UserModel user) {

View file

@ -38,7 +38,11 @@ public class CredentialValidationOutput {
}
public static CredentialValidationOutput failed() {
return new CredentialValidationOutput(null, CredentialValidationOutput.Status.FAILED, new HashMap<String, String>());
return new CredentialValidationOutput(null, CredentialValidationOutput.Status.FAILED, new HashMap<>());
}
public static CredentialValidationOutput fallback() {
return new CredentialValidationOutput(null, CredentialValidationOutput.Status.FALLBACK, new HashMap<>());
}
public UserModel getAuthenticatedUser() {
@ -63,6 +67,27 @@ public class CredentialValidationOutput {
}
public enum Status {
AUTHENTICATED, FAILED, CONTINUE
/**
* User was successfully authenticated. The {@link #getAuthenticatedUser()} must return authenticated user when this is used
*/
AUTHENTICATED,
/**
* Federation provider failed to authenticate user. This is typically used when user storage provider recognizes the user, but credentials
* are incorrect, so federation provider can mark whole authentication as not successful without eventual fallback to other user storage provider
*/
FAILED,
/**
* Federation provider was not able to recognize the user. It is possible that credential was valid, but fereration provider was not able to lookup the user in it's storage.
* Fallback to other user storage provider in the chain might be possible
*/
FALLBACK,
/**
* Federation provider did not fully authenticate user. It may be needed to ask user for further challenge to then re-try authentication with same federation provider
*/
CONTINUE,
}
}

View file

@ -193,7 +193,7 @@ public class UserCredentialModel implements CredentialInput {
this.algorithm = algorithm;
}
public void setNote(String key, String value) {
public void setNote(String key, Object value) {
this.notes.put(key, value);
}

View file

@ -279,7 +279,7 @@ public abstract class AbstractKerberosTest extends AbstractAuthTest {
}
protected void assertUser(String expectedUsername, String expectedEmail, String expectedFirstname,
protected UserRepresentation assertUser(String expectedUsername, String expectedEmail, String expectedFirstname,
String expectedLastname, boolean updateProfileActionExpected) {
try {
UserRepresentation user = ApiUtil.findUserByUsername(testRealmResource(), expectedUsername);
@ -294,10 +294,17 @@ public abstract class AbstractKerberosTest extends AbstractAuthTest {
} else {
Assert.assertTrue(user.getRequiredActions().isEmpty());
}
return user;
} finally {
}
}
protected void assertUserStorageProvider(UserRepresentation user, String providerName) {
if (user.getFederationLink() == null) Assert.fail("Federation link on user " + user.getUsername() + " was null");
ComponentRepresentation rep = testRealmResource().components().component(user.getFederationLink()).toRepresentation();
Assert.assertEquals(providerName, rep.getName());
}
protected OAuthClient.AccessTokenResponse assertAuthenticationSuccess(String codeUrl) throws Exception {
List<NameValuePair> pairs = URLEncodedUtils.parse(new URI(codeUrl), "UTF-8");

View file

@ -0,0 +1,113 @@
/*
* Copyright 2023 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.testsuite.federation.kerberos;
import jakarta.ws.rs.core.Response;
import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.component.PrioritizedComponentModel;
import org.keycloak.federation.kerberos.CommonKerberosConfig;
import org.keycloak.models.LDAPConstants;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.storage.ldap.LDAPStorageProviderFactory;
import org.keycloak.storage.ldap.kerberos.LDAPProviderKerberosConfig;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.KerberosEmbeddedServer;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.util.KerberosRule;
import org.keycloak.testsuite.util.OAuthClient;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class KerberosLdapMultipleLDAPProvidersTest extends AbstractKerberosTest {
private static final String PROVIDER_CONFIG_LOCATION = "classpath:kerberos/kerberos-ldap-crt-connection.properties";
@ClassRule
public static KerberosRule kerberosRule = new KerberosRule(PROVIDER_CONFIG_LOCATION, KerberosEmbeddedServer.DEFAULT_KERBEROS_REALM);
@ClassRule
public static KerberosRule kerberosRule2 = new KerberosRule(PROVIDER_CONFIG_LOCATION, KerberosEmbeddedServer.DEFAULT_KERBEROS_REALM_2);
@Override
protected KerberosRule getKerberosRule() {
return kerberosRule;
}
@Override
protected CommonKerberosConfig getKerberosConfig() {
return new LDAPProviderKerberosConfig(getUserStorageConfiguration());
}
@Override
protected ComponentRepresentation getUserStorageConfiguration() {
ComponentRepresentation rep = getUserStorageConfiguration("kerberos-ldap", LDAPStorageProviderFactory.PROVIDER_NAME);
// This provider works. It would be executed as 2nd provider (individual tests are supposed to add other provider, which should have lower priority to be invoked first)
rep.getConfig().putSingle(PrioritizedComponentModel.PRIORITY, "10");
return rep;
}
@Test
public void test01spnegoWith1stProviderBrokenKerberosConfiguration() throws Exception {
// Add LDAP, which is invoked first. The Kerberos configuration is broken, so SPNEGO workflow is failing entirely
ComponentRepresentation rep = getUserStorageConfiguration();
rep.setName("kerberos-ldap-foo");
rep.getConfig().putSingle(PrioritizedComponentModel.PRIORITY, "1");
rep.getConfig().putSingle(KerberosConstants.KERBEROS_REALM, "FOO.ORG");
rep.getConfig().putSingle(KerberosConstants.SERVER_PRINCIPAL, "HTTP/localhost@FOO.ORG");
Response resp = testRealmResource().components().add(rep);
getCleanup().addComponentId(ApiUtil.getCreatedId(resp));
resp.close();
OAuthClient.AccessTokenResponse tokenResponse = assertSuccessfulSpnegoLogin("hnelson2@KC2.COM", "hnelson2", "secret");
AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
Assert.assertEquals(token.getEmail(), "hnelson2@kc2.com");
UserRepresentation user = assertUser("hnelson2", "hnelson2@kc2.com", "Horatio", "Nelson", false);
assertUserStorageProvider(user, "kerberos-ldap");
}
@Test
public void test02spnegoWith1stProviderBrokenLookupOfKerberosUser() throws Exception {
// Add LDAP, which is invoked first. The Kerberos configuration is OK, so SPNEGO workflow should be fine.
// However lookup LDAP based on Kerberos principal is broken, so fallback to next provider would be needed
ComponentRepresentation rep = getUserStorageConfiguration();
rep.setName("kerberos-ldap-broken-lookup");
rep.getConfig().putSingle(PrioritizedComponentModel.PRIORITY, "1");
rep.getConfig().putSingle(LDAPConstants.CUSTOM_USER_SEARCH_FILTER, "(mail=nonexistent@email.org)");
Response resp = testRealmResource().components().add(rep);
getCleanup().addComponentId(ApiUtil.getCreatedId(resp));
resp.close();
OAuthClient.AccessTokenResponse tokenResponse = assertSuccessfulSpnegoLogin("hnelson2@KC2.COM", "hnelson2", "secret");
AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
Assert.assertEquals(token.getEmail(), "hnelson2@kc2.com");
UserRepresentation user = assertUser("hnelson2", "hnelson2@kc2.com", "Horatio", "Nelson", false);
assertUserStorageProvider(user, "kerberos-ldap");
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright 2023 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.testsuite.federation.kerberos;
import jakarta.ws.rs.core.Response;
import org.junit.ClassRule;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.keycloak.common.constants.KerberosConstants;
import org.keycloak.component.PrioritizedComponentModel;
import org.keycloak.federation.kerberos.CommonKerberosConfig;
import org.keycloak.federation.kerberos.KerberosConfig;
import org.keycloak.federation.kerberos.KerberosFederationProviderFactory;
import org.keycloak.models.LDAPConstants;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.storage.ldap.LDAPStorageProviderFactory;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.KerberosEmbeddedServer;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.util.KerberosRule;
import org.keycloak.testsuite.util.OAuthClient;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class KerberosStandaloneMultipleProvidersTest extends AbstractKerberosTest {
private static final String PROVIDER_CONFIG_LOCATION = "classpath:kerberos/kerberos-standalone-connection.properties";
@ClassRule
public static KerberosRule kerberosRule = new KerberosRule(PROVIDER_CONFIG_LOCATION, KerberosEmbeddedServer.DEFAULT_KERBEROS_REALM);
@ClassRule
public static KerberosRule kerberosRule2 = new KerberosRule(PROVIDER_CONFIG_LOCATION, KerberosEmbeddedServer.DEFAULT_KERBEROS_REALM_2);
@Override
protected KerberosRule getKerberosRule() {
return kerberosRule;
}
@Override
protected CommonKerberosConfig getKerberosConfig() {
return new KerberosConfig(getUserStorageConfiguration());
}
@Override
protected ComponentRepresentation getUserStorageConfiguration() {
ComponentRepresentation rep = getUserStorageConfiguration("kerberos-standalone", KerberosFederationProviderFactory.PROVIDER_NAME);
// This provider works. It would be executed as 2nd provider (individual tests are supposed to add other provider, which should have lower priority to be invoked first)
rep.getConfig().putSingle(PrioritizedComponentModel.PRIORITY, "10");
return rep;
}
@Test
public void test01spnegoWith1stProviderBrokenKerberosConfiguration() throws Exception {
// Add LDAP, which is invoked first. The Kerberos configuration is broken, so SPNEGO workflow is failing entirely
ComponentRepresentation rep = getUserStorageConfiguration();
rep.setName("kerberos-foo");
rep.getConfig().putSingle(PrioritizedComponentModel.PRIORITY, "1");
rep.getConfig().putSingle(KerberosConstants.KERBEROS_REALM, "FOO.ORG");
rep.getConfig().putSingle(KerberosConstants.SERVER_PRINCIPAL, "HTTP/localhost@FOO.ORG");
Response resp = testRealmResource().components().add(rep);
getCleanup().addComponentId(ApiUtil.getCreatedId(resp));
resp.close();
OAuthClient.AccessTokenResponse tokenResponse = assertSuccessfulSpnegoLogin("hnelson2@KC2.COM", "hnelson2", "secret");
AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
Assert.assertEquals(token.getEmail(), "hnelson2@keycloak.org");
UserRepresentation user = assertUser("hnelson2", "hnelson2@keycloak.org", null, null, false);
assertUserStorageProvider(user, "kerberos-standalone");
}
@Test
public void test02spnegoWith1stProviderBrokenLookupOfKerberosUser() throws Exception {
// Add LDAP, which is invoked first. The Kerberos configuration is OK, so SPNEGO workflow should be fine.
// However lookup LDAP based on Kerberos principal is broken, so fallback to next provider would be needed
ComponentRepresentation rep = getUserStorageConfiguration();
rep.setName("kerberos-ldap-broken-lookup");
rep.setProviderId(LDAPStorageProviderFactory.PROVIDER_NAME);
rep.getConfig().putSingle(PrioritizedComponentModel.PRIORITY, "1");
rep.getConfig().putSingle(LDAPConstants.CUSTOM_USER_SEARCH_FILTER, "(mail=nonexistent@email.org)");
Response resp = testRealmResource().components().add(rep);
getCleanup().addComponentId(ApiUtil.getCreatedId(resp));
resp.close();
OAuthClient.AccessTokenResponse tokenResponse = assertSuccessfulSpnegoLogin("hnelson2@KC2.COM", "hnelson2", "secret");
AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
Assert.assertEquals(token.getEmail(), "hnelson2@keycloak.org");
UserRepresentation user = assertUser("hnelson2", "hnelson2@keycloak.org", null, null, false);
assertUserStorageProvider(user, "kerberos-standalone");
}
}