Skip to content
Merged
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 @@ -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";
}
3 changes: 3 additions & 0 deletions docs/documentation/release_notes/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ include::topics/templates/document-attributes.adoc[]
:release_header_latest_link: {releasenotes_link_latest}
include::topics/templates/release-header.adoc[]

== {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]

Expand Down
4 changes: 4 additions & 0 deletions docs/documentation/release_notes/topics/22_0_2.adoc
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -190,17 +190,27 @@ public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput
if (!(input instanceof UserCredentialModel)) return null;
UserCredentialModel credential = (UserCredentialModel)input;
if (credential.getType().equals(UserCredentialModel.KERBEROS)) {
String spnegoToken = credential.getChallengeResponse();
SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);
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 = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);

spnegoAuthenticator.authenticate();
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) {
Expand All @@ -216,7 +226,7 @@ public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput
return new CredentialValidationOutput(null, CredentialValidationOutput.Status.CONTINUE, state);
} else {
logger.tracef("SPNEGO Handshake not successful");
return CredentialValidationOutput.failed();
return CredentialValidationOutput.fallback();
}

} else {
Expand Down Expand Up @@ -282,4 +292,9 @@ protected UserModel importUserToKeycloak(RealmModel realm, String username) {

return validate(realm, user);
}

@Override
public String toString() {
return "KerberosFederationProvider - " + model.getName();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -725,14 +725,19 @@ public boolean isValid(RealmModel realm, UserModel user, CredentialInput input)

@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()) {
String spnegoToken = credential.getChallengeResponse();
SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);
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 = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig);

spnegoAuthenticator.authenticate();
spnegoAuthenticator.authenticate();
}

Map<String, String> state = new HashMap<>();
if (spnegoAuthenticator.isAuthenticated()) {
Expand All @@ -743,8 +748,13 @@ public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput
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) {
Expand All @@ -760,12 +770,12 @@ public CredentialValidationOutput authenticate(RealmModel realm, CredentialInput
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
Expand Down Expand Up @@ -899,4 +909,9 @@ private Stream<LDAPObject> paginatedSearchLDAP(LDAPQuery ldapQuery, Integer firs

return ldapQuery.getResultList().stream();
}

@Override
public String toString() {
return "LDAPStorageProvider - " + getModel().getName();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,25 @@ public CredentialValidationOutput getUserByCredential(RealmModel realm, Credenti
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ public CredentialValidationOutput(UserModel authenticatedUser, Status authStatus
}

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() {
Expand All @@ -63,6 +67,27 @@ public CredentialValidationOutput merge(CredentialValidationOutput that) {
}

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,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ public void setAlgorithm(String algorithm) {
this.algorithm = algorithm;
}

public void setNote(String key, String value) {
public void setNote(String key, Object value) {
this.notes.put(key, value);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ protected void removeAllUsers() {
}


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);
Expand All @@ -294,10 +294,17 @@ protected void assertUser(String expectedUsername, String expectedEmail, String
} 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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading