diff --git a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosConstants.java b/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosConstants.java deleted file mode 100644 index 80a24587ff..0000000000 --- a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosConstants.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.keycloak.broker.kerberos; - -/** - * @author Marek Posolda - */ -public class KerberosConstants { - - /** - * Value of HTTP Headers "WWW-Authenticate" or "Authorization" used for SPNEGO/Kerberos - **/ - public static final String NEGOTIATE = "Negotiate"; - - - /** - * Helper parameter for relay state - */ - public static final String RELAY_STATE_PARAM = "RelayState"; - - - /** - * OID of SPNEGO mechanism. See http://www.oid-info.com/get/1.3.6.1.5.5.2 - */ - public static final String SPNEGO_OID = "1.3.6.1.5.5.2"; - - /** - * OID of Kerberos v5 mechanism. See http://www.oid-info.com/get/1.2.840.113554.1.2.2 - */ - public static final String KRB5_OID = "1.2.840.113554.1.2.2"; - -} diff --git a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProvider.java b/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProvider.java deleted file mode 100644 index 84ceb22f2e..0000000000 --- a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProvider.java +++ /dev/null @@ -1,147 +0,0 @@ -package org.keycloak.broker.kerberos; - -import java.net.URI; - -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriBuilder; -import javax.ws.rs.core.UriInfo; - -import org.jboss.logging.Logger; -import org.keycloak.broker.kerberos.impl.KerberosServerSubjectAuthenticator; -import org.keycloak.broker.kerberos.impl.SPNEGOAuthenticator; -import org.keycloak.broker.provider.AbstractIdentityProvider; -import org.keycloak.broker.provider.AuthenticationRequest; -import org.keycloak.broker.provider.AuthenticationResponse; -import org.keycloak.broker.provider.FederatedIdentity; -import org.keycloak.login.LoginFormsProvider; -import org.keycloak.models.FederatedIdentityModel; - -/** - * @author Marek Posolda - */ -public class KerberosIdentityProvider extends AbstractIdentityProvider { - - private static final Logger logger = Logger.getLogger(KerberosIdentityProvider.class); - - public KerberosIdentityProvider(KerberosIdentityProviderConfig config) { - super(config); - } - - - @Override - public AuthenticationResponse handleRequest(AuthenticationRequest request) { - - // Just redirect to handleResponse for now - URI redirectUri = UriBuilder.fromUri(request.getRedirectUri()).queryParam(KerberosConstants.RELAY_STATE_PARAM, request.getState()).build(); - Response response = Response.status(302) - .location(redirectUri) - .build(); - - return AuthenticationResponse.fromResponse(response); - } - - - @Override - public String getRelayState(AuthenticationRequest request) { - UriInfo uriInfo = request.getUriInfo(); - return uriInfo.getQueryParameters().getFirst(KerberosConstants.RELAY_STATE_PARAM); - } - - - @Override - public AuthenticationResponse handleResponse(AuthenticationRequest request) { - String authHeader = request.getHttpRequest().getHttpHeaders().getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); - - // Case when we don't yet have any Negotiate header - if (authHeader == null) { - return sendNegotiateResponse(request, null); - } - - String[] tokens = authHeader.split(" "); - if (tokens.length != 2) { - logger.warn("Invalid length of tokens: " + tokens.length); - return sendNegotiateResponse(request, null); - } else if (!KerberosConstants.NEGOTIATE.equalsIgnoreCase(tokens[0])) { - logger.warn("Unknown scheme " + tokens[0]); - return sendNegotiateResponse(request, null); - } else { - String spnegoToken = tokens[1]; - SPNEGOAuthenticator spnegoAuthenticator = createSPNEGOAuthenticator(spnegoToken); - spnegoAuthenticator.authenticate(); - - if (spnegoAuthenticator.isAuthenticated()) { - FederatedIdentity federatedIdentity = getFederatedIdentity(spnegoAuthenticator); - return AuthenticationResponse.end(federatedIdentity); - } else { - return sendNegotiateResponse(request, spnegoAuthenticator.getResponseToken()); - } - } - } - - protected SPNEGOAuthenticator createSPNEGOAuthenticator(String spnegoToken) { - KerberosServerSubjectAuthenticator kerberosAuth = createKerberosSubjectAuthenticator(); - return new SPNEGOAuthenticator(kerberosAuth, spnegoToken); - } - - protected KerberosServerSubjectAuthenticator createKerberosSubjectAuthenticator() { - return new KerberosServerSubjectAuthenticator(getConfig()); - } - - - /** - * Send response with header "WWW-Authenticate: Negotiate {negotiateToken}" - * - * @param negotiateToken token to be send back in response or null if just "WWW-Authenticate: Negotiate" should be sent - * @return AuthenticationResponse - */ - protected AuthenticationResponse sendNegotiateResponse(AuthenticationRequest request, String negotiateToken) { - String negotiateHeader = negotiateToken == null ? KerberosConstants.NEGOTIATE : KerberosConstants.NEGOTIATE + " " + negotiateToken; - - if (logger.isTraceEnabled()) { - logger.trace("Sending back " + HttpHeaders.WWW_AUTHENTICATE + ": " + negotiateHeader); - } - - Response response; - LoginFormsProvider loginFormsProvider = request.getSession().getProvider(LoginFormsProvider.class) - .setRealm(request.getRealm()) - .setUriInfo(request.getUriInfo()) - .setStatus(Response.Status.UNAUTHORIZED); - - if (request.getClientSession().getUserSession() == null) { - // User not logged. Display HTML with login form as fallback if SPNEGO token not found - response = loginFormsProvider.setClient(request.getClientSession().getClient()) - .setClientSessionCode(getRelayState(request)) - .setWarning("errorKerberosLogin") - .createLogin(); - } else { - // User logged and linking account. Display HTML with error if SPNEGO token not found - response = loginFormsProvider.setError("errorKerberosLinkAccount") - .createErrorPage(); - } - - response.getMetadata().putSingle(HttpHeaders.WWW_AUTHENTICATE, negotiateHeader); - return AuthenticationResponse.fromResponse(response); - } - - - protected FederatedIdentity getFederatedIdentity(SPNEGOAuthenticator spnegoAuthenticator) { - String kerberosUsername = spnegoAuthenticator.getPrincipal(); - FederatedIdentity user = new FederatedIdentity(kerberosUsername); - user.setUsername(kerberosUsername); - - // Just guessing email - String[] tokens = kerberosUsername.split("@"); - String email = tokens[0] + "@" + tokens[1].toLowerCase(); - user.setEmail(email); - return user; - } - - - @Override - public Response retrieveToken(FederatedIdentityModel identity) { - logger.warn("retrieveToken unsupported for Kerberos right now"); - return null; - } -} diff --git a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProviderConfig.java b/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProviderConfig.java deleted file mode 100644 index 50dd85a704..0000000000 --- a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProviderConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.keycloak.broker.kerberos; - -import org.keycloak.models.IdentityProviderModel; - -/** - * @author Marek Posolda - */ -public class KerberosIdentityProviderConfig extends IdentityProviderModel { - - public KerberosIdentityProviderConfig(IdentityProviderModel identityProviderModel) { - super(identityProviderModel); - } - - public String getServerPrincipal() { - return getConfig().get("serverPrincipal"); - } - - public String getKeyTab() { - return getConfig().get("keyTab"); - } - - public boolean getDebug() { - return Boolean.valueOf(getConfig().get("debug")); - } - -} diff --git a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProviderFactory.java b/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProviderFactory.java deleted file mode 100644 index 42b7428757..0000000000 --- a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/KerberosIdentityProviderFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.keycloak.broker.kerberos; - -import org.keycloak.broker.provider.AbstractIdentityProviderFactory; -import org.keycloak.models.IdentityProviderModel; - -/** - * @author Marek Posolda - */ -public class KerberosIdentityProviderFactory extends AbstractIdentityProviderFactory { - - public static final String PROVIDER_ID = "kerberos"; - - @Override - public String getId() { - return PROVIDER_ID; - } - - @Override - public String getName() { - return "Kerberos"; - } - - @Override - public KerberosIdentityProvider create(IdentityProviderModel model) { - return new KerberosIdentityProvider(new KerberosIdentityProviderConfig(model)); - } -} diff --git a/broker/kerberos/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/broker/kerberos/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory deleted file mode 100644 index 4c72cb8377..0000000000 --- a/broker/kerberos/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory +++ /dev/null @@ -1 +0,0 @@ -org.keycloak.broker.kerberos.KerberosIdentityProviderFactory \ No newline at end of file diff --git a/broker/pom.xml b/broker/pom.xml index e2ae6ffa12..7121b2b031 100755 --- a/broker/pom.xml +++ b/broker/pom.xml @@ -18,7 +18,6 @@ core oidc saml - kerberos diff --git a/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java index f1ea7153c2..b7dad57706 100755 --- a/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/CredentialRepresentation.java @@ -10,6 +10,7 @@ public class CredentialRepresentation { public static final String PASSWORD_TOKEN = "password-token"; public static final String TOTP = "totp"; public static final String CLIENT_CERT = "cert"; + public static final String KERBEROS = "kerberos"; protected String type; protected String device; diff --git a/dependencies/server-all/pom.xml b/dependencies/server-all/pom.xml index c2546e8682..f697926c71 100755 --- a/dependencies/server-all/pom.xml +++ b/dependencies/server-all/pom.xml @@ -95,7 +95,7 @@ org.keycloak - keycloak-broker-kerberos + keycloak-kerberos-federation ${project.version} diff --git a/examples/providers/federation-provider/src/main/java/org/keycloak/examples/federation/properties/BasePropertiesFederationProvider.java b/examples/providers/federation-provider/src/main/java/org/keycloak/examples/federation/properties/BasePropertiesFederationProvider.java index 0d63474e14..964560c21e 100755 --- a/examples/providers/federation-provider/src/main/java/org/keycloak/examples/federation/properties/BasePropertiesFederationProvider.java +++ b/examples/providers/federation-provider/src/main/java/org/keycloak/examples/federation/properties/BasePropertiesFederationProvider.java @@ -1,5 +1,6 @@ package org.keycloak.examples.federation.properties; +import org.keycloak.models.CredentialValidationOutput; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; @@ -127,6 +128,11 @@ public abstract class BasePropertiesFederationProvider implements UserFederation return supportedCredentialTypes; } + @Override + public Set getSupportedCredentialTypes() { + return supportedCredentialTypes; + } + @Override public boolean validCredentials(RealmModel realm, UserModel user, List input) { for (UserCredentialModel cred : input) { @@ -155,6 +161,11 @@ public abstract class BasePropertiesFederationProvider implements UserFederation return true; } + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel credential) { + return CredentialValidationOutput.failed(); + } + @Override public void close() { diff --git a/broker/kerberos/pom.xml b/federation/kerberos/pom.xml similarity index 58% rename from broker/kerberos/pom.xml rename to federation/kerberos/pom.xml index 2a7d75e2e3..6f7b6baefe 100644 --- a/broker/kerberos/pom.xml +++ b/federation/kerberos/pom.xml @@ -1,6 +1,5 @@ - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> keycloak-parent org.keycloak @@ -9,21 +8,22 @@ 4.0.0 - keycloak-broker-kerberos - Keycloak Broker Kerberos - - jar + keycloak-kerberos-federation + Keycloak Kerberos Federation + org.keycloak - keycloak-broker-core + keycloak-core ${project.version} + provided org.keycloak - keycloak-login-api + keycloak-model-api ${project.version} + provided org.jboss.logging @@ -36,4 +36,17 @@ provided + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/CommonKerberosConfig.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/CommonKerberosConfig.java new file mode 100644 index 0000000000..6fe528c351 --- /dev/null +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/CommonKerberosConfig.java @@ -0,0 +1,46 @@ +package org.keycloak.federation.kerberos; + +import java.util.Map; + +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.models.utils.KerberosConstants; + +/** + * Common configuration useful for all providers + * + * @author Marek Posolda + */ +public abstract class CommonKerberosConfig { + + private final UserFederationProviderModel providerModel; + + public CommonKerberosConfig(UserFederationProviderModel userFederationProvider) { + this.providerModel = userFederationProvider; + } + + // Should be always true for KerberosFederationProvider + public boolean isAllowKerberosAuthentication() { + return Boolean.valueOf(getConfig().get(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION)); + } + + public String getKerberosRealm() { + return getConfig().get("kerberosRealm"); + } + + public String getServerPrincipal() { + return getConfig().get("serverPrincipal"); + } + + public String getKeyTab() { + return getConfig().get("keyTab"); + } + + public boolean getDebug() { + return Boolean.valueOf(getConfig().get("debug")); + } + + protected Map getConfig() { + return providerModel.getConfig(); + } + +} diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosConfig.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosConfig.java new file mode 100644 index 0000000000..950f9cd38a --- /dev/null +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosConfig.java @@ -0,0 +1,34 @@ +package org.keycloak.federation.kerberos; + +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserFederationProviderModel; + +/** + * Configuration specific to {@link KerberosFederationProvider} + * + * @author Marek Posolda + */ +public class KerberosConfig extends CommonKerberosConfig { + + public KerberosConfig(UserFederationProviderModel userFederationProvider) { + super(userFederationProvider); + } + + public UserFederationProvider.EditMode getEditMode() { + String editModeString = getConfig().get("editMode"); + if (editModeString == null) { + return UserFederationProvider.EditMode.UNSYNCED; + } else { + return UserFederationProvider.EditMode.valueOf(editModeString); + } + } + + public boolean isAllowPasswordAuthentication() { + return Boolean.valueOf(getConfig().get("allowPasswordAuthentication")); + } + + public boolean isUpdateProfileFirstLogin() { + return Boolean.valueOf(getConfig().get("updateProfileFirstLogin")); + } + +} diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProvider.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProvider.java new file mode 100644 index 0000000000..35f58859d6 --- /dev/null +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProvider.java @@ -0,0 +1,241 @@ +package org.keycloak.federation.kerberos; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jboss.logging.Logger; +import org.keycloak.federation.kerberos.impl.KerberosUsernamePasswordAuthenticator; +import org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator; +import org.keycloak.models.CredentialValidationOutput; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserCredentialValueModel; +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KerberosConstants; + +/** + * @author Marek Posolda + */ +public class KerberosFederationProvider implements UserFederationProvider { + + private static final Logger logger = Logger.getLogger(KerberosFederationProvider.class); + public static final String KERBEROS_PRINCIPAL = "KERBEROS_PRINCIPAL"; + + protected KeycloakSession session; + protected UserFederationProviderModel model; + protected KerberosConfig kerberosConfig; + protected KerberosFederationProviderFactory factory; + + public KerberosFederationProvider(KeycloakSession session,UserFederationProviderModel model, KerberosFederationProviderFactory factory) { + this.session = session; + this.model = model; + this.kerberosConfig = new KerberosConfig(model); + this.factory = factory; + } + + @Override + public UserModel proxy(UserModel local) { + if (kerberosConfig.getEditMode() == EditMode.READ_ONLY) { + return new ReadOnlyKerberosUserModelDelegate(local, this); + } else { + return local; + } + } + + @Override + public boolean synchronizeRegistrations() { + return false; + } + + @Override + public UserModel register(RealmModel realm, UserModel user) { + return null; + } + + @Override + public boolean removeUser(RealmModel realm, UserModel user) { + // TODO: Not sure if federation provider is expected to delete user in localStorage. Looks rather like a bug in UserFederationManager.removeUser . + return session.userStorage().removeUser(realm, user); + } + + @Override + public UserModel getUserByUsername(RealmModel realm, String username) { + if (username.contains("@")) { + username = username.split("@")[0]; + } + + KerberosUsernamePasswordAuthenticator authenticator = factory.createKerberosUsernamePasswordAuthenticator(kerberosConfig); + if (authenticator.isUserAvailable(username)) { + return findOrCreateAuthenticatedUser(realm, username); + } else { + return null; + } + } + + @Override + public UserModel getUserByEmail(RealmModel realm, String email) { + return null; + } + + @Override + public List searchByAttributes(Map attributes, RealmModel realm, int maxResults) { + return Collections.emptyList(); + } + + @Override + public void preRemove(RealmModel realm) { + + } + + @Override + public void preRemove(RealmModel realm, RoleModel role) { + + } + + @Override + public boolean isValid(UserModel local) { + // KerberosUsernamePasswordAuthenticator.isUserAvailable is an overhead, so avoid it for now + + String kerberosPrincipal = local.getUsername() + "@" + kerberosConfig.getKerberosRealm(); + return model.getId().equals(local.getFederationLink()) && kerberosPrincipal.equals(local.getAttribute(KERBEROS_PRINCIPAL)); + } + + @Override + public Set getSupportedCredentialTypes(UserModel local) { + Set supportedCredTypes = new HashSet(); + supportedCredTypes.add(UserCredentialModel.KERBEROS); + + if (kerberosConfig.isAllowPasswordAuthentication()) { + boolean passwordSupported = true; + if (kerberosConfig.getEditMode() == EditMode.UNSYNCED ) { + + // Password from KC database has preference over kerberos password + for (UserCredentialValueModel cred : local.getCredentialsDirectly()) { + if (cred.getType().equals(UserCredentialModel.PASSWORD)) { + passwordSupported = false; + } + } + } + + if (passwordSupported) { + supportedCredTypes.add(UserCredentialModel.PASSWORD); + } + } + + return supportedCredTypes; + } + + @Override + public Set getSupportedCredentialTypes() { + Set supportedCredTypes = new HashSet(); + supportedCredTypes.add(UserCredentialModel.KERBEROS); + return supportedCredTypes; + } + + @Override + public boolean validCredentials(RealmModel realm, UserModel user, List input) { + for (UserCredentialModel cred : input) { + if (cred.getType().equals(UserCredentialModel.PASSWORD)) { + return validPassword(user.getUsername(), cred.getValue()); + } else { + return false; // invalid cred type + } + } + return true; + } + + protected boolean validPassword(String username, String password) { + if (kerberosConfig.isAllowPasswordAuthentication()) { + KerberosUsernamePasswordAuthenticator authenticator = factory.createKerberosUsernamePasswordAuthenticator(kerberosConfig); + return authenticator.validUser(username, password); + } else { + return false; + } + } + + @Override + public boolean validCredentials(RealmModel realm, UserModel user, UserCredentialModel... input) { + return validCredentials(realm, user, Arrays.asList(input)); + } + + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel credential) { + if (credential.getType().equals(UserCredentialModel.KERBEROS)) { + String spnegoToken = credential.getValue(); + SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig); + + spnegoAuthenticator.authenticate(); + + if (spnegoAuthenticator.isAuthenticated()) { + Map state = new HashMap(); + state.put(KerberosConstants.GSS_DELEGATION_CREDENTIAL, spnegoAuthenticator.getDelegationCredential()); + + String username = spnegoAuthenticator.getAuthenticatedUsername(); + UserModel user = findOrCreateAuthenticatedUser(realm, username); + + return new CredentialValidationOutput(user, CredentialValidationOutput.Status.AUTHENTICATED, state); + } else { + Map state = new HashMap(); + state.put(KerberosConstants.RESPONSE_TOKEN, spnegoAuthenticator.getResponseToken()); + return new CredentialValidationOutput(null, CredentialValidationOutput.Status.CONTINUE, state); + } + + } else { + return CredentialValidationOutput.failed(); + } + } + + @Override + public void close() { + + } + + /** + * Called after successful authentication + * + * @param realm + * @param username username without realm prefix + * @return + */ + protected UserModel findOrCreateAuthenticatedUser(RealmModel realm, String username) { + UserModel user = session.userStorage().getUserByUsername(username, realm); + if (user != null) { + logger.debug("Kerberos authenticated user " + username + " found in Keycloak storage"); + if (!isValid(user)) { + throw new IllegalStateException("User with username " + username + " already exists, but is not linked to provider [" + model.getDisplayName() + + "] or kerberos principal is not correct. Kerberos principal on user is: " + user.getAttribute(KERBEROS_PRINCIPAL)); + } + + return proxy(user); + } else { + return importUserToKeycloak(realm, username); + } + } + + protected UserModel importUserToKeycloak(RealmModel realm, String username) { + // Just guessing email from kerberos realm + String email = username + "@" + kerberosConfig.getKerberosRealm().toLowerCase(); + + logger.info("Creating kerberos user: " + username + ", email: " + email + " to local Keycloak storage"); + UserModel user = session.userStorage().addUser(realm, username); + user.setEnabled(true); + user.setEmail(email); + user.setFederationLink(model.getId()); + user.setAttribute(KERBEROS_PRINCIPAL, username + "@" + kerberosConfig.getKerberosRealm()); + + if (kerberosConfig.isUpdateProfileFirstLogin()) { + user.addRequiredAction(UserModel.RequiredAction.UPDATE_PROFILE); + } + + return proxy(user); + } +} diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProviderFactory.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProviderFactory.java new file mode 100644 index 0000000000..871587ca0e --- /dev/null +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/KerberosFederationProviderFactory.java @@ -0,0 +1,80 @@ +package org.keycloak.federation.kerberos; + +import java.util.Collections; +import java.util.Date; +import java.util.Set; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.federation.kerberos.impl.KerberosServerSubjectAuthenticator; +import org.keycloak.federation.kerberos.impl.KerberosUsernamePasswordAuthenticator; +import org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserFederationProviderFactory; +import org.keycloak.models.UserFederationProviderModel; + +/** + * Factory for standalone Kerberos federation provider. Standalone means that it's not backed by LDAP. For Kerberos backed by LDAP (like MS AD or ApacheDS environment) + * you should rather use LDAP Federation Provider. + * + * @author Marek Posolda + */ +public class KerberosFederationProviderFactory implements UserFederationProviderFactory { + + private static final Logger logger = Logger.getLogger(KerberosFederationProviderFactory.class); + public static final String PROVIDER_NAME = "kerberos"; + @Override + public UserFederationProvider getInstance(KeycloakSession session, UserFederationProviderModel model) { + return new KerberosFederationProvider(session, model, this); + } + + @Override + public Set getConfigurationOptions() { + return Collections.emptySet(); + } + + @Override + public String getId() { + return PROVIDER_NAME; + } + + @Override + public void syncAllUsers(KeycloakSessionFactory sessionFactory, String realmId, UserFederationProviderModel model) { + logger.warn("Sync users not supported for this provider"); + } + + @Override + public void syncChangedUsers(KeycloakSessionFactory sessionFactory, String realmId, UserFederationProviderModel model, Date lastSync) { + logger.warn("Sync users not supported for this provider"); + } + + @Override + public UserFederationProvider create(KeycloakSession session) { + throw new IllegalAccessError("Illegal to call this method"); + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void close() { + + } + + protected SPNEGOAuthenticator createSPNEGOAuthenticator(String spnegoToken, CommonKerberosConfig kerberosConfig) { + KerberosServerSubjectAuthenticator kerberosAuth = createKerberosSubjectAuthenticator(kerberosConfig); + return new SPNEGOAuthenticator(kerberosConfig, kerberosAuth, spnegoToken); + } + + protected KerberosServerSubjectAuthenticator createKerberosSubjectAuthenticator(CommonKerberosConfig kerberosConfig) { + return new KerberosServerSubjectAuthenticator(kerberosConfig); + } + + protected KerberosUsernamePasswordAuthenticator createKerberosUsernamePasswordAuthenticator(CommonKerberosConfig kerberosConfig) { + return new KerberosUsernamePasswordAuthenticator(kerberosConfig); + } +} diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/ReadOnlyKerberosUserModelDelegate.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/ReadOnlyKerberosUserModelDelegate.java new file mode 100644 index 0000000000..7ac3ca7953 --- /dev/null +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/ReadOnlyKerberosUserModelDelegate.java @@ -0,0 +1,28 @@ +package org.keycloak.federation.kerberos; + +import org.keycloak.models.ModelReadOnlyException; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.UserModelDelegate; + +/** + * @author Marek Posolda + */ +public class ReadOnlyKerberosUserModelDelegate extends UserModelDelegate { + + protected KerberosFederationProvider provider; + + public ReadOnlyKerberosUserModelDelegate(UserModel delegate, KerberosFederationProvider provider) { + super(delegate); + this.provider = provider; + } + + @Override + public void updateCredential(UserCredentialModel cred) { + if (provider.getSupportedCredentialTypes(delegate).contains(cred.getType())) { + throw new ModelReadOnlyException("Can't change password in Keycloak database. Change password with your Kerberos server"); + } + + delegate.updateCredential(cred); + } +} diff --git a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/impl/KerberosServerSubjectAuthenticator.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosServerSubjectAuthenticator.java similarity index 89% rename from broker/kerberos/src/main/java/org/keycloak/broker/kerberos/impl/KerberosServerSubjectAuthenticator.java rename to federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosServerSubjectAuthenticator.java index 3384a4348c..08b3f1d722 100644 --- a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/impl/KerberosServerSubjectAuthenticator.java +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosServerSubjectAuthenticator.java @@ -1,4 +1,4 @@ -package org.keycloak.broker.kerberos.impl; +package org.keycloak.federation.kerberos.impl; import java.util.HashMap; import java.util.Map; @@ -10,7 +10,7 @@ import javax.security.auth.login.LoginContext; import javax.security.auth.login.LoginException; import org.jboss.logging.Logger; -import org.keycloak.broker.kerberos.KerberosIdentityProviderConfig; +import org.keycloak.federation.kerberos.CommonKerberosConfig; /** * @author Marek Posolda @@ -19,10 +19,10 @@ public class KerberosServerSubjectAuthenticator { private static final Logger logger = Logger.getLogger(KerberosServerSubjectAuthenticator.class); - private final KerberosIdentityProviderConfig config; + private final CommonKerberosConfig config; private LoginContext loginContext; - public KerberosServerSubjectAuthenticator(KerberosIdentityProviderConfig config) { + public KerberosServerSubjectAuthenticator(CommonKerberosConfig config) { this.config = config; } diff --git a/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java new file mode 100644 index 0000000000..6515950d35 --- /dev/null +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/KerberosUsernamePasswordAuthenticator.java @@ -0,0 +1,126 @@ +package org.keycloak.federation.kerberos.impl; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; + +import org.jboss.logging.Logger; +import org.keycloak.federation.kerberos.CommonKerberosConfig; + +/** + * @author Marek Posolda + */ +public class KerberosUsernamePasswordAuthenticator { + + private static final Logger logger = Logger.getLogger(KerberosUsernamePasswordAuthenticator.class); + + private final CommonKerberosConfig config; + + public KerberosUsernamePasswordAuthenticator(CommonKerberosConfig config) { + this.config = config; + } + + /** + * Returns true if user with given username exists in kerberos database + * + * @param username username without Kerberos realm attached + * @return true if user available + */ + public boolean isUserAvailable(String username) { + String principal = getKerberosPrincipal(username); + + logger.debug("Checking existence of principal: " + principal); + try { + LoginContext loginContext = new LoginContext("does-not-matter", null, + createJaasCallbackHandler(principal, "fake-password-which-nobody-has"), + createJaasConfiguration()); + + loginContext.login(); + + throw new IllegalStateException("Didn't expect to end here"); + } catch (LoginException le) { + String message = le.getMessage(); + logger.debug("Message from kerberos: " + message); + + // Bit cumbersome, but seems to work with tested kerberos servers + boolean exists = (!message.contains("Client not found")); + return exists; + } + } + + /** + * Returns true if user was successfully authenticated against Kerberos + * + * @param username username without Kerberos realm attached + * @param password kerberos password + * @return true if user was successfully authenticated + */ + public boolean validUser(String username, String password) { + String principal = getKerberosPrincipal(username); + + logger.debug("Validating password of principal: " + principal); + try { + LoginContext loginContext = new LoginContext("does-not-matter", null, + createJaasCallbackHandler(principal, password), + createJaasConfiguration()); + + loginContext.login(); + logger.debug("Principal " + principal + " authenticated succesfully"); + + loginContext.logout(); + return true; + } catch (LoginException le) { + logger.debug("Failed to authenticate user " + username, le); + return false; + } + } + + + protected String getKerberosPrincipal(String username) { + return username + "@" + config.getKerberosRealm(); + } + + protected CallbackHandler createJaasCallbackHandler(final String principal, final String password) { + return new CallbackHandler() { + + @Override + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof NameCallback) { + NameCallback nameCallback = (NameCallback) callback; + nameCallback.setName(principal); + } else if (callback instanceof PasswordCallback) { + PasswordCallback passwordCallback = (PasswordCallback) callback; + passwordCallback.setPassword(password.toCharArray()); + } else { + throw new UnsupportedCallbackException(callback, "Unsupported callback: " + callback.getClass().getCanonicalName()); + } + } + } + }; + } + + protected Configuration createJaasConfiguration() { + return new Configuration() { + + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + Map options = new HashMap(); + options.put("storeKey", "true"); + options.put("debug", String.valueOf(config.getDebug())); + AppConfigurationEntry kerberosLMConfiguration = new AppConfigurationEntry("com.sun.security.auth.module.Krb5LoginModule", AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options); + return new AppConfigurationEntry[] { kerberosLMConfiguration }; + } + }; + } +} diff --git a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/impl/SPNEGOAuthenticator.java b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/SPNEGOAuthenticator.java similarity index 80% rename from broker/kerberos/src/main/java/org/keycloak/broker/kerberos/impl/SPNEGOAuthenticator.java rename to federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/SPNEGOAuthenticator.java index 212587a8c2..59983637ab 100644 --- a/broker/kerberos/src/main/java/org/keycloak/broker/kerberos/impl/SPNEGOAuthenticator.java +++ b/federation/kerberos/src/main/java/org/keycloak/federation/kerberos/impl/SPNEGOAuthenticator.java @@ -1,4 +1,4 @@ -package org.keycloak.broker.kerberos.impl; +package org.keycloak.federation.kerberos.impl; import java.io.IOException; import java.security.PrivilegedActionException; @@ -12,6 +12,7 @@ import org.ietf.jgss.GSSCredential; import org.ietf.jgss.GSSException; import org.ietf.jgss.GSSManager; import org.jboss.logging.Logger; +import org.keycloak.federation.kerberos.CommonKerberosConfig; /** * @author Marek Posolda @@ -24,13 +25,15 @@ public class SPNEGOAuthenticator { private final KerberosServerSubjectAuthenticator kerberosSubjectAuthenticator; private final String spnegoToken; + private final CommonKerberosConfig kerberosConfig; private boolean authenticated = false; - private String principal = null; + private String authenticatedKerberosPrincipal = null; private GSSCredential delegationCredential; private String responseToken = null; - public SPNEGOAuthenticator(KerberosServerSubjectAuthenticator kerberosSubjectAuthenticator, String spnegoToken) { + public SPNEGOAuthenticator(CommonKerberosConfig kerberosConfig, KerberosServerSubjectAuthenticator kerberosSubjectAuthenticator, String spnegoToken) { + this.kerberosConfig = kerberosConfig; this.kerberosSubjectAuthenticator = kerberosSubjectAuthenticator; this.spnegoToken = spnegoToken; } @@ -61,10 +64,6 @@ public class SPNEGOAuthenticator { return authenticated; } - public String getPrincipal() { - return principal; - } - public String getResponseToken() { return responseToken; } @@ -73,6 +72,19 @@ public class SPNEGOAuthenticator { return delegationCredential; } + /** + * @return username to be used in Keycloak. Username is authenticated kerberos principal without realm name + */ + public String getAuthenticatedUsername() { + String[] tokens = authenticatedKerberosPrincipal.split("@"); + String username = tokens[0]; + if (!tokens[1].equalsIgnoreCase(kerberosConfig.getKerberosRealm())) { + throw new IllegalStateException("Invalid kerberos realm. Realm from the ticket: " + tokens[1] + ", configured realm: " + kerberosConfig.getKerberosRealm()); + } + return username; + } + + private class AcceptSecContext implements PrivilegedExceptionAction { @Override @@ -87,7 +99,7 @@ public class SPNEGOAuthenticator { logAuthDetails(gssContext); if (gssContext.isEstablished()) { - principal = gssContext.getSrcName().toString(); + authenticatedKerberosPrincipal = gssContext.getSrcName().toString(); // What should be done with delegation credential? Figure out if there are use-cases for storing it as claims in FederatedIdentity if (gssContext.getCredDelegState()) { @@ -107,6 +119,7 @@ public class SPNEGOAuthenticator { } + protected GSSContext establishContext() throws GSSException, IOException { GSSContext gssContext = GSS_MANAGER.createContext((GSSCredential) null); @@ -117,6 +130,7 @@ public class SPNEGOAuthenticator { return gssContext; } + protected void logAuthDetails(GSSContext gssContext) throws GSSException { if (log.isDebugEnabled()) { String message = new StringBuilder("SPNEGO Security context accepted with token: " + responseToken) diff --git a/federation/kerberos/src/main/resources/META-INF/services/org.keycloak.models.UserFederationProviderFactory b/federation/kerberos/src/main/resources/META-INF/services/org.keycloak.models.UserFederationProviderFactory new file mode 100644 index 0000000000..040f5c972e --- /dev/null +++ b/federation/kerberos/src/main/resources/META-INF/services/org.keycloak.models.UserFederationProviderFactory @@ -0,0 +1 @@ +org.keycloak.federation.kerberos.KerberosFederationProviderFactory \ No newline at end of file diff --git a/federation/ldap/pom.xml b/federation/ldap/pom.xml index 7149e43462..a449335c8b 100755 --- a/federation/ldap/pom.xml +++ b/federation/ldap/pom.xml @@ -25,6 +25,12 @@ ${project.version} provided + + org.keycloak + keycloak-kerberos-federation + ${project.version} + provided + org.keycloak keycloak-picketlink-api diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProvider.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProvider.java index a49a989b24..9a926041ca 100755 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProvider.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProvider.java @@ -1,6 +1,10 @@ package org.keycloak.federation.ldap; import org.jboss.logging.Logger; +import org.keycloak.federation.kerberos.impl.KerberosUsernamePasswordAuthenticator; +import org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator; +import org.keycloak.federation.ldap.kerberos.LDAPProviderKerberosConfig; +import org.keycloak.models.CredentialValidationOutput; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelException; import org.keycloak.models.RealmModel; @@ -10,6 +14,7 @@ import org.keycloak.models.UserCredentialValueModel; import org.keycloak.models.UserFederationProvider; import org.keycloak.models.UserFederationProviderModel; import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KerberosConstants; import org.picketlink.idm.IdentityManagementException; import org.picketlink.idm.IdentityManager; import org.picketlink.idm.PartitionManager; @@ -17,7 +22,7 @@ import org.picketlink.idm.model.basic.BasicModel; import org.picketlink.idm.model.basic.User; import org.picketlink.idm.query.IdentityQuery; -import java.util.Collections; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; @@ -36,28 +41,32 @@ public class LDAPFederationProvider implements UserFederationProvider { public static final String SYNC_REGISTRATIONS = "syncRegistrations"; public static final String EDIT_MODE = "editMode"; + protected LDAPFederationProviderFactory factory; protected KeycloakSession session; protected UserFederationProviderModel model; protected PartitionManager partitionManager; protected EditMode editMode; + protected LDAPProviderKerberosConfig kerberosConfig; - protected static final Set supportedCredentialTypes = new HashSet(); + protected final Set supportedCredentialTypes = new HashSet(); - static - { - supportedCredentialTypes.add(UserCredentialModel.PASSWORD); - } - - public LDAPFederationProvider(KeycloakSession session, UserFederationProviderModel model, PartitionManager partitionManager) { + public LDAPFederationProvider(LDAPFederationProviderFactory factory, KeycloakSession session, UserFederationProviderModel model, PartitionManager partitionManager) { + this.factory = factory; this.session = session; this.model = model; this.partitionManager = partitionManager; + this.kerberosConfig = new LDAPProviderKerberosConfig(model); String editModeString = model.getConfig().get(EDIT_MODE); if (editModeString == null) { editMode = EditMode.READ_ONLY; } else { editMode = EditMode.valueOf(editModeString); } + + supportedCredentialTypes.add(UserCredentialModel.PASSWORD); + if (kerberosConfig.isAllowKerberosAuthentication()) { + supportedCredentialTypes.add(UserCredentialModel.KERBEROS); + } } private ModelException convertIDMException(IdentityManagementException ie) { @@ -97,16 +106,23 @@ public class LDAPFederationProvider implements UserFederationProvider { @Override public Set getSupportedCredentialTypes(UserModel local) { + Set supportedCredentialTypes = new HashSet(this.supportedCredentialTypes); if (editMode == EditMode.UNSYNCED ) { for (UserCredentialValueModel cred : local.getCredentialsDirectly()) { if (cred.getType().equals(UserCredentialModel.PASSWORD)) { - return Collections.emptySet(); + // User has changed password in KC local database. Use KC password instead of LDAP password + supportedCredentialTypes.remove(UserCredentialModel.PASSWORD); } } } return supportedCredentialTypes; } + @Override + public Set getSupportedCredentialTypes() { + return new HashSet(this.supportedCredentialTypes); + } + @Override public boolean synchronizeRegistrations() { return "true".equalsIgnoreCase(model.getConfig().get(SYNC_REGISTRATIONS)) && editMode == EditMode.WRITABLE; @@ -244,6 +260,8 @@ public class LDAPFederationProvider implements UserFederationProvider { imported.setLastName(picketlinkUser.getLastName()); imported.setFederationLink(model.getId()); imported.setAttribute(LDAP_ID, picketlinkUser.getId()); + + logger.debugf("Added new user from LDAP. Username: " + imported.getUsername() + ", Email: ", imported.getEmail() + ", LDAP_ID: " + picketlinkUser.getId()); return proxy(imported); } @@ -285,10 +303,17 @@ public class LDAPFederationProvider implements UserFederationProvider { } public boolean validPassword(String username, String password) { - try { - return LDAPUtils.validatePassword(partitionManager, username, password); - } catch (IdentityManagementException ie) { - throw convertIDMException(ie); + if (kerberosConfig.isAllowKerberosAuthentication() && kerberosConfig.isUseKerberosForPasswordAuthentication()) { + // Use Kerberos JAAS (Krb5LoginModule) + KerberosUsernamePasswordAuthenticator authenticator = factory.createKerberosUsernamePasswordAuthenticator(kerberosConfig); + return authenticator.validUser(username, password); + } else { + // Use Naming LDAP API + try { + return LDAPUtils.validatePassword(partitionManager, username, password); + } catch (IdentityManagementException ie) { + throw convertIDMException(ie); + } } } @@ -307,14 +332,37 @@ public class LDAPFederationProvider implements UserFederationProvider { @Override public boolean validCredentials(RealmModel realm, UserModel user, UserCredentialModel... input) { - for (UserCredentialModel cred : input) { - if (cred.getType().equals(UserCredentialModel.PASSWORD)) { - return validPassword(user.getUsername(), cred.getValue()); - } else { - return false; // invalid cred type + return validCredentials(realm, user, Arrays.asList(input)); + } + + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel credential) { + if (credential.getType().equals(UserCredentialModel.KERBEROS)) { + if (kerberosConfig.isAllowKerberosAuthentication()) { + String spnegoToken = credential.getValue(); + SPNEGOAuthenticator spnegoAuthenticator = factory.createSPNEGOAuthenticator(spnegoToken, kerberosConfig); + + spnegoAuthenticator.authenticate(); + + if (spnegoAuthenticator.isAuthenticated()) { + Map state = new HashMap(); + state.put(KerberosConstants.GSS_DELEGATION_CREDENTIAL, spnegoAuthenticator.getDelegationCredential()); + + // TODO: This assumes that LDAP "uid" is equal to kerberos principal name. Like uid "hnelson" and kerberos principal "hnelson@KEYCLOAK.ORG". + // Check if it's correct or if LDAP attribute for mapping kerberos principal should be available (For ApacheDS it seems to be attribute "krb5PrincipalName" but on MSAD it's likely different) + String username = spnegoAuthenticator.getAuthenticatedUsername(); + UserModel user = findOrCreateAuthenticatedUser(realm, username); + + return new CredentialValidationOutput(user, CredentialValidationOutput.Status.AUTHENTICATED, state); + } else { + Map state = new HashMap(); + state.put(KerberosConstants.RESPONSE_TOKEN, spnegoAuthenticator.getResponseToken()); + return new CredentialValidationOutput(null, CredentialValidationOutput.Status.CONTINUE, state); + } } } - return true; + + return CredentialValidationOutput.failed(); } @Override @@ -330,7 +378,6 @@ public class LDAPFederationProvider implements UserFederationProvider { if (currentUser == null) { // Add new user to Keycloak importUserFromPicketlink(realm, picketlinkUser); - logger.debugf("Added new user from LDAP: %s", username); } else { if ((fedModel.getId().equals(currentUser.getFederationLink())) && (picketlinkUser.getId().equals(currentUser.getAttribute(LDAPFederationProvider.LDAP_ID)))) { // Update keycloak user @@ -345,4 +392,27 @@ public class LDAPFederationProvider implements UserFederationProvider { } } } + + /** + * Called after successful kerberos authentication + * + * @param realm + * @param username username without realm prefix + * @return + */ + protected UserModel findOrCreateAuthenticatedUser(RealmModel realm, String username) { + UserModel user = session.userStorage().getUserByUsername(username, realm); + if (user != null) { + logger.debug("Kerberos authenticated user " + username + " found in Keycloak storage"); + if (!isValid(user)) { + throw new IllegalStateException("User with username " + username + " already exists, but is not linked to provider [" + model.getDisplayName() + + "] or LDAP_ID is not correct. LDAP_ID on user is: " + user.getAttribute(LDAP_ID)); + } + + return proxy(user); + } else { + // Creating user to local storage + return getUserByUsername(realm, username); + } + } } diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProviderFactory.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProviderFactory.java index 44987e96f5..16a877f681 100755 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProviderFactory.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPFederationProviderFactory.java @@ -2,6 +2,11 @@ package org.keycloak.federation.ldap; import org.jboss.logging.Logger; import org.keycloak.Config; +import org.keycloak.federation.kerberos.CommonKerberosConfig; +import org.keycloak.federation.kerberos.KerberosConfig; +import org.keycloak.federation.kerberos.impl.KerberosServerSubjectAuthenticator; +import org.keycloak.federation.kerberos.impl.KerberosUsernamePasswordAuthenticator; +import org.keycloak.federation.kerberos.impl.SPNEGOAuthenticator; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionTask; @@ -45,7 +50,7 @@ public class LDAPFederationProviderFactory implements UserFederationProviderFact public LDAPFederationProvider getInstance(KeycloakSession session, UserFederationProviderModel model) { PartitionManagerProvider idmProvider = session.getProvider(PartitionManagerProvider.class); PartitionManager partition = idmProvider.getPartitionManager(model); - return new LDAPFederationProvider(session, model, partition); + return new LDAPFederationProvider(this, session, model, partition); } @Override @@ -140,4 +145,17 @@ public class LDAPFederationProviderFactory implements UserFederationProviderFact LDAPFederationProvider ldapFedProvider = getInstance(session, fedModel); ldapFedProvider.importPicketlinkUsers(realm, users, fedModel); } + + protected SPNEGOAuthenticator createSPNEGOAuthenticator(String spnegoToken, CommonKerberosConfig kerberosConfig) { + KerberosServerSubjectAuthenticator kerberosAuth = createKerberosSubjectAuthenticator(kerberosConfig); + return new SPNEGOAuthenticator(kerberosConfig, kerberosAuth, spnegoToken); + } + + protected KerberosServerSubjectAuthenticator createKerberosSubjectAuthenticator(CommonKerberosConfig kerberosConfig) { + return new KerberosServerSubjectAuthenticator(kerberosConfig); + } + + protected KerberosUsernamePasswordAuthenticator createKerberosUsernamePasswordAuthenticator(CommonKerberosConfig kerberosConfig) { + return new KerberosUsernamePasswordAuthenticator(kerberosConfig); + } } diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/kerberos/LDAPProviderKerberosConfig.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/kerberos/LDAPProviderKerberosConfig.java new file mode 100644 index 0000000000..b98c5cae71 --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/kerberos/LDAPProviderKerberosConfig.java @@ -0,0 +1,20 @@ +package org.keycloak.federation.ldap.kerberos; + +import org.keycloak.federation.kerberos.CommonKerberosConfig; +import org.keycloak.models.UserFederationProviderModel; + +/** + * Configuration specific to {@link org.keycloak.federation.ldap.LDAPFederationProvider} + * + * @author Marek Posolda + */ +public class LDAPProviderKerberosConfig extends CommonKerberosConfig { + + public LDAPProviderKerberosConfig(UserFederationProviderModel userFederationProvider) { + super(userFederationProvider); + } + + public boolean isUseKerberosForPasswordAuthentication() { + return Boolean.valueOf(getConfig().get("useKerberosForPasswordAuthentication")); + } +} diff --git a/federation/pom.xml b/federation/pom.xml index b990d51f3b..7c1487a532 100755 --- a/federation/pom.xml +++ b/federation/pom.xml @@ -17,6 +17,7 @@ ldap + kerberos diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js index ff854afef9..054c218279 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js @@ -875,6 +875,36 @@ module.config([ '$routeProvider', function($routeProvider) { }, controller : 'LDAPCtrl' }) + .when('/realms/:realm/user-federation/providers/kerberos/:instance', { + templateUrl : 'partials/federated-kerberos.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + instance : function(UserFederationInstanceLoader) { + return UserFederationInstanceLoader(); + }, + providerFactory : function() { + return { id: "kerberos" }; + } + }, + controller : 'GenericUserFederationCtrl' + }) + .when('/create/user-federation/:realm/providers/kerberos', { + templateUrl : 'partials/federated-kerberos.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + instance : function() { + return {}; + }, + providerFactory : function() { + return { id: "kerberos" }; + } + }, + controller : 'GenericUserFederationCtrl' + }) .when('/create/user-federation/:realm/providers/:provider', { templateUrl : 'partials/federated-generic.html', resolve : { diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js index 5c25e1b01b..52b313a3ae 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js @@ -435,7 +435,7 @@ module.controller('RealmRequiredCredentialsCtrl', function($scope, Realm, realm, $scope.userCredentialOptions = { 'multiple' : true, 'simple_tags' : true, - 'tags' : ['password', 'totp', 'cert'] + 'tags' : ['password', 'totp', 'cert', 'kerberos'] }; $scope.changed = false; @@ -653,8 +653,7 @@ module.controller('RealmIdentityProviderCtrl', function($scope, $filter, $upload $scope.identityProvider.name = providerFactory.name; $scope.identityProvider.enabled = true; $scope.identityProvider.updateProfileFirstLogin = true; - // Kerberos is suggested as default provider, others not - $scope.identityProvider.authenticateByDefault = (providerFactory.id === "kerberos"); + $scope.identityProvider.authenticateByDefault = false; $scope.newIdentityProvider = true; } diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/users.js b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/users.js index 1f36f90661..fb0ce1bef1 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/users.js +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/users.js @@ -378,9 +378,23 @@ module.controller('GenericUserFederationCtrl', function($scope, $location, Notif instance.priority = 0; $scope.fullSyncEnabled = false; $scope.changedSyncEnabled = false; + + if (providerFactory.id === 'kerberos') { + instance.config.debug = false; + instance.config.allowPasswordAuthentication = true; + instance.config.editMode = 'UNSYNCED'; + instance.config.updateProfileFirstLogin = true; + instance.config.allowKerberosAuthentication = true; + } } else { $scope.fullSyncEnabled = (instance.fullSyncPeriod && instance.fullSyncPeriod > 0); $scope.changedSyncEnabled = (instance.changedSyncPeriod && instance.changedSyncPeriod > 0); + + if (providerFactory.id === 'kerberos') { + instance.config.debug = (instance.config.debug === 'true' || instance.config.debug === true); + instance.config.allowPasswordAuthentication = (instance.config.allowPasswordAuthentication === 'true' || instance.config.allowPasswordAuthentication === true); + instance.config.updateProfileFirstLogin = (instance.config.updateProfileFirstLogin === 'true' || instance.config.updateProfileFirstLogin === true); + } } $scope.changed = false; @@ -488,25 +502,30 @@ module.controller('LDAPCtrl', function($scope, $location, Notifications, Dialog, instance.providerName = "ldap"; instance.config = {}; instance.priority = 0; - $scope.syncRegistrations = false; - $scope.userAccountControlsAfterPasswordUpdate = true; - instance.config.userAccountControlsAfterPasswordUpdate = "true"; + instance.config.syncRegistrations = false; + instance.config.userAccountControlsAfterPasswordUpdate = true; + instance.config.connectionPooling = true; + instance.config.pagination = true; - $scope.connectionPooling = true; - instance.config.connectionPooling = "true"; + instance.config.allowKerberosAuthentication = false; + instance.config.debug = false; + instance.config.useKerberosForPasswordAuthentication = false; - $scope.pagination = true; - instance.config.pagination = "true"; instance.config.batchSizeForSync = DEFAULT_BATCH_SIZE; $scope.fullSyncEnabled = false; $scope.changedSyncEnabled = false; } else { - $scope.syncRegistrations = instance.config.syncRegistrations && instance.config.syncRegistrations == "true"; - $scope.userAccountControlsAfterPasswordUpdate = instance.config.userAccountControlsAfterPasswordUpdate && instance.config.userAccountControlsAfterPasswordUpdate == "true"; - $scope.connectionPooling = instance.config.connectionPooling && instance.config.connectionPooling == "true"; - $scope.pagination = instance.config.pagination && instance.config.pagination == "true"; + instance.config.syncRegistrations = (instance.config.syncRegistrations === 'true' || instance.config.syncRegistrations === true); + instance.config.userAccountControlsAfterPasswordUpdate = (instance.config.userAccountControlsAfterPasswordUpdate === 'true' || instance.config.userAccountControlsAfterPasswordUpdate === true); + instance.config.connectionPooling = (instance.config.connectionPooling === 'true' || instance.config.connectionPooling === true); + instance.config.pagination = (instance.config.pagination === 'true' || instance.config.pagination === true); + + instance.config.allowKerberosAuthentication = (instance.config.allowKerberosAuthentication === 'true' || instance.config.allowKerberosAuthentication === true); + instance.config.debug = (instance.config.debug === 'true' || instance.config.debug === true); + instance.config.useKerberosForPasswordAuthentication = (instance.config.useKerberosForPasswordAuthentication === 'true' || instance.config.useKerberosForPasswordAuthentication === true); + if (!instance.config.batchSizeForSync) { instance.config.batchSizeForSync = DEFAULT_BATCH_SIZE; } @@ -534,21 +553,6 @@ module.controller('LDAPCtrl', function($scope, $location, Notifications, Dialog, $scope.realm = realm; - function watchBooleanProperty(propertyName) { - $scope.$watch(propertyName, function() { - if ($scope[propertyName]) { - $scope.instance.config[propertyName] = "true"; - } else { - $scope.instance.config[propertyName] = "false"; - } - }) - } - - watchBooleanProperty('syncRegistrations'); - watchBooleanProperty('userAccountControlsAfterPasswordUpdate'); - watchBooleanProperty('connectionPooling'); - watchBooleanProperty('pagination'); - $scope.$watch('fullSyncEnabled', function(newVal, oldVal) { if (oldVal == newVal) { return; diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/federated-kerberos.html b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/federated-kerberos.html new file mode 100644 index 0000000000..1b30221c36 --- /dev/null +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/federated-kerberos.html @@ -0,0 +1,118 @@ +
+
+ +
+ + +

Kerberos Provider Settings

+

Add Standalone Kerberos Provider

+

* Required fields

+
+ +
+ Required Settings +
+ +
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + + +
+
+
+
\ No newline at end of file diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/federated-ldap.html b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/federated-ldap.html index 69c53e2862..66ff5c2d07 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/federated-ldap.html +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/federated-ldap.html @@ -58,7 +58,7 @@
- +
@@ -139,27 +139,73 @@
- +
- +
- +
+
+ Kerberos integration +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
Sync settings
diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/realm-identity-provider-kerberos.html b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/realm-identity-provider-kerberos.html deleted file mode 100644 index b061234563..0000000000 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/realm-identity-provider-kerberos.html +++ /dev/null @@ -1,78 +0,0 @@ -
-
- -

-
- -

{{identityProvider.name}} Provider Settings

-

* Required fields

-
-
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- - -
-
-
-
diff --git a/forms/common-themes/src/main/resources/theme/login/base/messages/messages.properties b/forms/common-themes/src/main/resources/theme/login/base/messages/messages.properties index c7e1fff9f2..5c5d1f7e92 100755 --- a/forms/common-themes/src/main/resources/theme/login/base/messages/messages.properties +++ b/forms/common-themes/src/main/resources/theme/login/base/messages/messages.properties @@ -98,8 +98,7 @@ actionPasswordWarning=You need to change your password to activate your account. actionEmailWarning=You need to verify your email address to activate your account. actionFollow=Please fill in the fields below. -errorKerberosLogin=Kerberos ticket not available. Use different login mechanism -errorKerberosLinkAccount=Kerberos ticket not available. +errorKerberosLogin=Kerberos ticket not available. Authenticate with password. successHeader=Success! errorHeader=Error! diff --git a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java index 0d1f931268..45f3adb1b8 100755 --- a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java +++ b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java @@ -54,6 +54,8 @@ public interface LoginFormsProvider extends Provider { public LoginFormsProvider setQueryParams(MultivaluedMap queryParams); + public LoginFormsProvider setResponseHeader(String headerName, String headerValue); + public LoginFormsProvider setFormData(MultivaluedMap formData); public LoginFormsProvider setStatus(Response.Status status); diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java index 1360800ce9..44a7585004 100755 --- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -57,6 +57,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { private List realmRolesRequested; private MultivaluedMap resourceRolesRequested; private MultivaluedMap queryParams; + private Map httpResponseHeaders = new HashMap(); private String accessRequestMessage; private URI actionUri; @@ -226,6 +227,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { String result = freeMarker.processTemplate(attributes, Templates.getTemplate(page), theme); Response.ResponseBuilder builder = Response.status(status).type(MediaType.TEXT_HTML).entity(result); BrowserSecurityHeaderSetup.headers(builder, realm); + for (Map.Entry entry : httpResponseHeaders.entrySet()) { + builder.header(entry.getKey(), entry.getValue()); + } return builder.build(); } catch (FreeMarkerException e) { logger.error("Failed to process template", e); @@ -335,6 +339,12 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { return this; } + @Override + public LoginFormsProvider setResponseHeader(String headerName, String headerValue) { + this.httpResponseHeaders.put(headerName, headerValue); + return this; + } + @Override public void close() { } diff --git a/model/api/src/main/java/org/keycloak/models/CredentialValidationOutput.java b/model/api/src/main/java/org/keycloak/models/CredentialValidationOutput.java new file mode 100644 index 0000000000..70e4093ab5 --- /dev/null +++ b/model/api/src/main/java/org/keycloak/models/CredentialValidationOutput.java @@ -0,0 +1,46 @@ +package org.keycloak.models; + +import java.util.HashMap; +import java.util.Map; + +/** + * Output of credential validation + * + * @author Marek Posolda + */ +public class CredentialValidationOutput { + + private final UserModel authenticatedUser; // authenticated user. + private final Status authStatus; // status whether user is authenticated or more steps needed + private final Map state; // Additional state related to authentication. It can contain data to be sent back to client or data about used credentials. + + public CredentialValidationOutput(UserModel authenticatedUser, Status authStatus, Map state) { + this.authenticatedUser = authenticatedUser; + this.authStatus = authStatus; + this.state = state; + } + + public static CredentialValidationOutput failed() { + return new CredentialValidationOutput(null, CredentialValidationOutput.Status.FAILED, new HashMap()); + } + + public UserModel getAuthenticatedUser() { + return authenticatedUser; + } + + public Status getAuthStatus() { + return authStatus; + } + + public Map getState() { + return state; + } + + public CredentialValidationOutput merge(CredentialValidationOutput that) { + throw new IllegalStateException("Not supported yet"); + } + + public static enum Status { + AUTHENTICATED, FAILED, CONTINUE + } +} diff --git a/model/api/src/main/java/org/keycloak/models/RequiredCredentialModel.java b/model/api/src/main/java/org/keycloak/models/RequiredCredentialModel.java index 9d77a7a29f..0840b04f39 100755 --- a/model/api/src/main/java/org/keycloak/models/RequiredCredentialModel.java +++ b/model/api/src/main/java/org/keycloak/models/RequiredCredentialModel.java @@ -54,6 +54,7 @@ public class RequiredCredentialModel { public static final RequiredCredentialModel TOTP; public static final RequiredCredentialModel CLIENT_CERT; public static final RequiredCredentialModel SECRET; + public static final RequiredCredentialModel KERBEROS; static { Map map = new HashMap(); @@ -81,6 +82,12 @@ public class RequiredCredentialModel { CLIENT_CERT.setSecret(false); CLIENT_CERT.setFormLabel("clientCertificate"); map.put(CLIENT_CERT.getType(), CLIENT_CERT); + KERBEROS = new RequiredCredentialModel(); + KERBEROS.setType(UserCredentialModel.KERBEROS); + KERBEROS.setInput(false); + KERBEROS.setSecret(false); + KERBEROS.setFormLabel("kerberos"); + map.put(KERBEROS.getType(), KERBEROS); BUILT_IN = Collections.unmodifiableMap(map); } } diff --git a/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java b/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java index 2f1fbf7aa2..5fb60050eb 100755 --- a/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java +++ b/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java @@ -14,6 +14,7 @@ public class UserCredentialModel { public static final String SECRET = "secret"; public static final String TOTP = "totp"; public static final String CLIENT_CERT = "cert"; + public static final String KERBEROS = "kerberos"; protected String type; protected String value; @@ -49,6 +50,13 @@ public class UserCredentialModel { return model; } + public static UserCredentialModel kerberos(String token) { + UserCredentialModel model = new UserCredentialModel(); + model.setType(KERBEROS); + model.setValue(token); + return model; + } + public static UserCredentialModel generateSecret() { UserCredentialModel model = new UserCredentialModel(); model.setType(SECRET); diff --git a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java index 1b3bdd1ca5..7d3ecf470b 100755 --- a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java +++ b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java @@ -3,6 +3,7 @@ package org.keycloak.models; import org.jboss.logging.Logger; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -355,27 +356,40 @@ public class UserFederationManager implements UserProvider { @Override public boolean validCredentials(RealmModel realm, UserModel user, UserCredentialModel... input) { - UserFederationProvider link = getFederationLink(realm, user); - if (link != null) { - validateUser(realm, user); - Set supportedCredentialTypes = link.getSupportedCredentialTypes(user); - if (supportedCredentialTypes.size() > 0) { - List fedCreds = new ArrayList(); - List localCreds = new ArrayList(); - for (UserCredentialModel cred : input) { - if (supportedCredentialTypes.contains(cred.getType())) { - fedCreds.add(cred); - } else { - localCreds.add(cred); - } - } - if (!link.validCredentials(realm, user, fedCreds)) { - return false; - } - return session.userStorage().validCredentials(realm, user, localCreds); - } + return validCredentials(realm, user, Arrays.asList(input)); + } + + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel... input) { + List fedProviderModels = realm.getUserFederationProviders(); + List fedProviders = new ArrayList(); + for (UserFederationProviderModel fedProviderModel : fedProviderModels) { + fedProviders.add(getFederationProvider(fedProviderModel)); } - return session.userStorage().validCredentials(realm, user, input); + + CredentialValidationOutput result = null; + for (UserCredentialModel cred : input) { + UserFederationProvider providerSupportingCreds = null; + + // Find provider, which supports required credential type + for (UserFederationProvider fedProvider : fedProviders) { + if (fedProvider.getSupportedCredentialTypes().contains(cred.getType())) { + providerSupportingCreds = fedProvider; + break; + } + } + + if (providerSupportingCreds == null) { + logger.warn("Don't have provider supporting credentials of type " + cred.getType()); + return CredentialValidationOutput.failed(); + } + + CredentialValidationOutput currentResult = providerSupportingCreds.validCredentials(realm, cred); + result = (result == null) ? currentResult : result.merge(currentResult); + } + + // For now, validCredentials(realm, input) is not supported for local userProviders + return (result != null) ? result : CredentialValidationOutput.failed(); } @Override diff --git a/model/api/src/main/java/org/keycloak/models/UserFederationProvider.java b/model/api/src/main/java/org/keycloak/models/UserFederationProvider.java index 16e81b5412..948e8c9185 100755 --- a/model/api/src/main/java/org/keycloak/models/UserFederationProvider.java +++ b/model/api/src/main/java/org/keycloak/models/UserFederationProvider.java @@ -130,6 +130,14 @@ public interface UserFederationProvider extends Provider { */ Set getSupportedCredentialTypes(UserModel user); + /** + * What UserCredentialModel types should be handled by this provider? This is called in scenarios when we don't know user, + * who is going to authenticate (For example Kerberos authentication). + * + * @return + */ + Set getSupportedCredentialTypes(); + /** * Validate credentials for this user. This method will only be called with credential parameters supported * by this provider @@ -141,6 +149,15 @@ public interface UserFederationProvider extends Provider { */ boolean validCredentials(RealmModel realm, UserModel user, List input); boolean validCredentials(RealmModel realm, UserModel user, UserCredentialModel... input); + + /** + * Validate credentials of unknown user. The authenticated user is recognized based on provided credentials and returned back in CredentialValidationOutput + * @param realm + * @param input + * @return + */ + CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel credential); + void close(); } diff --git a/model/api/src/main/java/org/keycloak/models/UserProvider.java b/model/api/src/main/java/org/keycloak/models/UserProvider.java index 0d0a25b4a6..b508638ea4 100755 --- a/model/api/src/main/java/org/keycloak/models/UserProvider.java +++ b/model/api/src/main/java/org/keycloak/models/UserProvider.java @@ -43,5 +43,7 @@ public interface UserProvider extends Provider { boolean validCredentials(RealmModel realm, UserModel user, List input); boolean validCredentials(RealmModel realm, UserModel user, UserCredentialModel... input); + CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel... input); + void close(); } diff --git a/model/api/src/main/java/org/keycloak/models/utils/KerberosConstants.java b/model/api/src/main/java/org/keycloak/models/utils/KerberosConstants.java new file mode 100644 index 0000000000..6f8a830a1f --- /dev/null +++ b/model/api/src/main/java/org/keycloak/models/utils/KerberosConstants.java @@ -0,0 +1,38 @@ +package org.keycloak.models.utils; + +/** + * @author Marek Posolda + */ +public class KerberosConstants { + + /** + * Value of HTTP Headers "WWW-Authenticate" or "Authorization" used for SPNEGO/Kerberos + **/ + public static final String NEGOTIATE = "Negotiate"; + + /** + * OID of SPNEGO mechanism. See http://www.oid-info.com/get/1.3.6.1.5.5.2 + */ + public static final String SPNEGO_OID = "1.3.6.1.5.5.2"; + + /** + * OID of Kerberos v5 mechanism. See http://www.oid-info.com/get/1.2.840.113554.1.2.2 + */ + public static final String KRB5_OID = "1.2.840.113554.1.2.2"; + + /** + * Configuration federation provider model attribute. It's always true for KerberosFederationProvider and configurable for LDAPFederationProvider + */ + public static final String ALLOW_KERBEROS_AUTHENTICATION = "allowKerberosAuthentication"; + + /** + * Internal attribute used in "state" map . Contains token to be passed in HTTP Response back to browser to continue handshake + */ + public static final String RESPONSE_TOKEN = "SpnegoResponseToken"; + + /** + * Internal attribute used in "state" map . Contains credential from SPNEGO/Kerberos successful authentication + */ + public static final String GSS_DELEGATION_CREDENTIAL = "GssDelegationCredential"; + +} diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java index 017a26e2bd..8dbb0449ce 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java @@ -1,5 +1,6 @@ package org.keycloak.models.cache; +import org.keycloak.models.CredentialValidationOutput; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakTransaction; import org.keycloak.models.RealmModel; @@ -282,6 +283,11 @@ public class DefaultCacheUserProvider implements CacheUserProvider { return getDelegate().validCredentials(realm, user, input); } + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel... input) { + return getDelegate().validCredentials(realm, input); + } + @Override public void preRemove(RealmModel realm) { realmInvalidations.add(realm.getId()); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java index e468b16ca0..857b3e81a6 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java @@ -1,5 +1,6 @@ package org.keycloak.models.cache; +import org.keycloak.models.CredentialValidationOutput; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; @@ -155,6 +156,11 @@ public class NoCacheUserProvider implements CacheUserProvider { return getDelegate().validCredentials(realm, user, input); } + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel... input) { + return getDelegate().validCredentials(realm, input); + } + @Override public void preRemove(RealmModel realm) { getDelegate().preRemove(realm); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index 438c700b49..c94c068fb9 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -1,6 +1,7 @@ package org.keycloak.models.jpa; import org.keycloak.models.ApplicationModel; +import org.keycloak.models.CredentialValidationOutput; import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -375,4 +376,10 @@ public class JpaUserProvider implements UserProvider { public boolean validCredentials(RealmModel realm, UserModel user, UserCredentialModel... input) { return CredentialValidation.validCredentials(realm, user, input); } + + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel... input) { + // Not supported yet + return null; + } } diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java index 94227112d0..5babac03c9 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java @@ -6,6 +6,7 @@ import com.mongodb.QueryBuilder; import org.keycloak.connections.mongo.api.MongoStore; import org.keycloak.connections.mongo.api.context.MongoStoreInvocationContext; import org.keycloak.models.ApplicationModel; +import org.keycloak.models.CredentialValidationOutput; import org.keycloak.models.FederatedIdentityModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -378,4 +379,10 @@ public class MongoUserProvider implements UserProvider { public boolean validCredentials(RealmModel realm, UserModel user, UserCredentialModel... input) { return CredentialValidation.validCredentials(realm, user, input); } + + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel... input) { + // Not supported yet + return null; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java b/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java index fd20b16e8e..acf39cf473 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java @@ -41,6 +41,7 @@ import org.keycloak.services.ForbiddenException; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager.AuthenticationStatus; import org.keycloak.services.managers.ClientSessionCode; +import org.keycloak.services.managers.HttpAuthenticationManager; import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.resources.flows.Flows; @@ -883,6 +884,11 @@ public class OpenIDConnectService { response = authManager.checkNonFormAuthentication(session, clientSession, realm, uriInfo, request, clientConnection, headers, event); if (response != null) return response; + // SPNEGO/Kerberos authentication TODO: This should be somehow pluggable instead of hardcoded this way (Authentication interceptors?) + HttpAuthenticationManager httpAuthManager = new HttpAuthenticationManager(session, clientSession, realm, uriInfo, request, clientConnection, event); + HttpAuthenticationManager.HttpAuthOutput httpAuthOutput = httpAuthManager.spnegoAuthenticate(); + if (httpAuthOutput.getResponse() != null) return httpAuthOutput.getResponse(); + if (prompt != null && prompt.equals("none")) { OpenIDConnect oauth = new OpenIDConnect(session, realm, uriInfo); return oauth.cancelLogin(clientSession); @@ -911,6 +917,11 @@ public class OpenIDConnectService { LoginFormsProvider forms = Flows.forms(session, realm, clientSession.getClient(), uriInfo) .setClientSessionCode(accessCode); + // Attach state from SPNEGO authentication + if (httpAuthOutput.getChallenge() != null) { + httpAuthOutput.getChallenge().sendChallenge(forms); + } + String rememberMeUsername = AuthenticationManager.getRememberMeUsername(realm, headers); if (loginHint != null || rememberMeUsername != null) { diff --git a/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationChallenge.java b/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationChallenge.java new file mode 100644 index 0000000000..0be8bcc27d --- /dev/null +++ b/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationChallenge.java @@ -0,0 +1,11 @@ +package org.keycloak.services.managers; + +import org.keycloak.login.LoginFormsProvider; + +/** + * @author Marek Posolda + */ +public interface HttpAuthenticationChallenge { + + void addChallenge(LoginFormsProvider loginFormsProvider); +} diff --git a/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationManager.java new file mode 100644 index 0000000000..ca0488e27b --- /dev/null +++ b/services/src/main/java/org/keycloak/services/managers/HttpAuthenticationManager.java @@ -0,0 +1,167 @@ +package org.keycloak.services.managers; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.ClientConnection; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.login.LoginFormsProvider; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.CredentialValidationOutput; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredCredentialModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.utils.KerberosConstants; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.resources.flows.Flows; + +/** + * Handle HTTP authentication types requiring complex handshakes with multiple HTTP request/responses + * + * @author Marek Posolda + */ +public class HttpAuthenticationManager { + + private static final Logger logger = Logger.getLogger(HttpAuthenticationManager.class); + + private KeycloakSession session; + private RealmModel realm; + private UriInfo uriInfo; + private HttpRequest request; + private EventBuilder event; + private ClientConnection clientConnection; + private ClientSessionModel clientSession; + + public HttpAuthenticationManager(KeycloakSession session, ClientSessionModel clientSession, RealmModel realm, UriInfo uriInfo, + HttpRequest request, + ClientConnection clientConnection, + EventBuilder event) { + this.session = session; + this.realm = realm; + this.uriInfo = uriInfo; + this.request = request; + this.event = event; + this.clientConnection = clientConnection; + this.clientSession = clientSession; + } + + + public HttpAuthOutput spnegoAuthenticate() { + boolean kerberosSupported = false; + for (RequiredCredentialModel c : realm.getRequiredCredentials()) { + if (c.getType().equals(CredentialRepresentation.KERBEROS)) { + logger.debug("Kerberos authentication is supported"); + kerberosSupported = true; + } + } + + if (!kerberosSupported) { + return new HttpAuthOutput(null, null); + } + + String authHeader = request.getHttpHeaders().getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); + + // Case when we don't yet have any Negotiate header + if (authHeader == null) { + return challengeNegotiation(null); + } + + String[] tokens = authHeader.split(" "); + if (tokens.length != 2) { + logger.warn("Invalid length of tokens: " + tokens.length); + return challengeNegotiation(null); + } else if (!KerberosConstants.NEGOTIATE.equalsIgnoreCase(tokens[0])) { + logger.warn("Unknown scheme " + tokens[0]); + return challengeNegotiation(null); + } else { + String spnegoToken = tokens[1]; + UserCredentialModel spnegoCredential = UserCredentialModel.kerberos(spnegoToken); + + CredentialValidationOutput output = session.users().validCredentials(realm, spnegoCredential); + + if (output.getAuthStatus() == CredentialValidationOutput.Status.AUTHENTICATED) { + return sendResponse(output.getAuthenticatedUser(), "spnego"); + } else { + String spnegoResponseToken = (String) output.getState().get(KerberosConstants.RESPONSE_TOKEN); + return challengeNegotiation(spnegoResponseToken); + } + } + } + + + // Send response after successful authentication + private HttpAuthOutput sendResponse(UserModel user, String authMethod) { + Response response; + if (!user.isEnabled()) { + event.error(Errors.USER_DISABLED); + response = Flows.forwardToSecurityFailurePage(session, realm, uriInfo, Messages.ACCOUNT_DISABLED); + } else { + UserSessionModel userSession = session.sessions().createUserSession(realm, user, user.getUsername(), clientConnection.getRemoteAddr(), authMethod, false); + TokenManager.attachClientSession(userSession, clientSession); + event.session(userSession); + response = AuthenticationManager.nextActionAfterAuthentication(session, userSession, clientSession, clientConnection, request, uriInfo, event); + } + + return new HttpAuthOutput(response, null); + } + + + private HttpAuthOutput challengeNegotiation(final String negotiateToken) { + return new HttpAuthOutput(null, new HttpAuthChallenge() { + + @Override + public void sendChallenge(LoginFormsProvider loginFormsProvider) { + String negotiateHeader = negotiateToken == null ? KerberosConstants.NEGOTIATE : KerberosConstants.NEGOTIATE + " " + negotiateToken; + + if (logger.isTraceEnabled()) { + logger.trace("Sending back " + HttpHeaders.WWW_AUTHENTICATE + ": " + negotiateHeader); + } + + loginFormsProvider.setStatus(Response.Status.UNAUTHORIZED); + loginFormsProvider.setResponseHeader(HttpHeaders.WWW_AUTHENTICATE, negotiateHeader); + loginFormsProvider.setWarning("errorKerberosLogin"); + } + + }); + } + + + public class HttpAuthOutput { + + // It's non-null if we want to immediately send response to user + private final Response response; + + // It's non-null if challenge should be attached to rendered login form + private final HttpAuthChallenge challenge; + + public HttpAuthOutput(Response response, HttpAuthChallenge challenge) { + this.response = response; + this.challenge = challenge; + } + + public Response getResponse() { + return response; + } + + public HttpAuthChallenge getChallenge() { + return challenge; + } + } + + + public interface HttpAuthChallenge { + + void sendChallenge(LoginFormsProvider loginFormsProvider); + + } + +} diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserFederationResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserFederationResource.java index 751f0929db..730252cda1 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserFederationResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserFederationResource.java @@ -5,9 +5,12 @@ import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.spi.NotFoundException; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.RequiredCredentialModel; +import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserFederationProvider; import org.keycloak.models.UserFederationProviderFactory; import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.models.utils.KerberosConstants; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.provider.ProviderFactory; import org.keycloak.representations.idm.UserFederationProviderFactoryRepresentation; @@ -118,6 +121,7 @@ public class UserFederationResource { UserFederationProviderModel model = realm.addUserFederationProvider(rep.getProviderName(), rep.getConfig(), rep.getPriority(), displayName, rep.getFullSyncPeriod(), rep.getChangedSyncPeriod(), rep.getLastSync()); new UsersSyncManager().refreshPeriodicSyncForProvider(session.getKeycloakSessionFactory(), session.getProvider(TimerProvider.class), model, realm.getId()); + checkKerberosCredential(model); return Response.created(uriInfo.getAbsolutePathBuilder().path(model.getId()).build()).build(); } @@ -141,6 +145,7 @@ public class UserFederationResource { rep.getFullSyncPeriod(), rep.getChangedSyncPeriod(), rep.getLastSync()); realm.updateUserFederationProvider(model); new UsersSyncManager().refreshPeriodicSyncForProvider(session.getKeycloakSessionFactory(), session.getProvider(TimerProvider.class), model, realm.getId()); + checkKerberosCredential(model); } /** @@ -223,5 +228,23 @@ public class UserFederationResource { throw new NotFoundException("could not find provider"); } + // Automatically add "kerberos" to required realm credentials if it's supported by saved provider + private void checkKerberosCredential(UserFederationProviderModel model) { + String allowKerberosCfg = model.getConfig().get(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION); + if (Boolean.valueOf(allowKerberosCfg)) { + boolean found = false; + List currentCreds = realm.getRequiredCredentials(); + for (RequiredCredentialModel cred : currentCreds) { + if (cred.getType().equals(UserCredentialModel.KERBEROS)) { + found = true; + } + } + + if (!found) { + realm.addRequiredCredential(UserCredentialModel.KERBEROS); + logger.info("Added 'kerberos' to required realm credentials"); + } + } + } } diff --git a/testsuite/integration/src/main/java/org/keycloak/testutils/DummyUserFederationProvider.java b/testsuite/integration/src/main/java/org/keycloak/testutils/DummyUserFederationProvider.java index 753409a1eb..99644214bc 100755 --- a/testsuite/integration/src/main/java/org/keycloak/testutils/DummyUserFederationProvider.java +++ b/testsuite/integration/src/main/java/org/keycloak/testutils/DummyUserFederationProvider.java @@ -1,5 +1,6 @@ package org.keycloak.testutils; +import org.keycloak.models.CredentialValidationOutput; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserCredentialModel; @@ -76,6 +77,11 @@ public class DummyUserFederationProvider implements UserFederationProvider { return Collections.emptySet(); } + @Override + public Set getSupportedCredentialTypes() { + return Collections.emptySet(); + } + @Override public boolean validCredentials(RealmModel realm, UserModel user, List input) { return false; @@ -86,6 +92,11 @@ public class DummyUserFederationProvider implements UserFederationProvider { return false; } + @Override + public CredentialValidationOutput validCredentials(RealmModel realm, UserCredentialModel credential) { + return CredentialValidationOutput.failed(); + } + @Override public void close() { diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java index 45f638cac6..2dd3164575 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/AbstractIdentityProviderModelTest.java @@ -18,7 +18,6 @@ package org.keycloak.testsuite.broker; import org.junit.Before; -import org.keycloak.broker.kerberos.KerberosIdentityProviderFactory; import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; import org.keycloak.broker.saml.SAMLIdentityProviderFactory; import org.keycloak.social.facebook.FacebookIdentityProviderFactory; @@ -48,7 +47,6 @@ public abstract class AbstractIdentityProviderModelTest extends AbstractModelTes this.expectedProviders.add(FacebookIdentityProviderFactory.PROVIDER_ID); this.expectedProviders.add(GitHubIdentityProviderFactory.PROVIDER_ID); this.expectedProviders.add(TwitterIdentityProviderFactory.PROVIDER_ID); - this.expectedProviders.add(KerberosIdentityProviderFactory.PROVIDER_ID); this.expectedProviders = Collections.unmodifiableSet(this.expectedProviders); } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java index 9b9d72367a..36d2bda835 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/broker/ImportIdentityProviderTest.java @@ -18,9 +18,6 @@ package org.keycloak.testsuite.broker; import org.junit.Test; -import org.keycloak.broker.kerberos.KerberosIdentityProvider; -import org.keycloak.broker.kerberos.KerberosIdentityProviderConfig; -import org.keycloak.broker.kerberos.KerberosIdentityProviderFactory; import org.keycloak.broker.oidc.OAuth2IdentityProviderConfig; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; @@ -157,8 +154,6 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes assertGitHubIdentityProviderConfig(identityProvider); } else if (TwitterIdentityProviderFactory.PROVIDER_ID.equals(providerId)) { assertTwitterIdentityProviderConfig(identityProvider); - } else if (KerberosIdentityProviderFactory.PROVIDER_ID.equals(providerId)) { - assertKerberosIdentityProviderConfig(identityProvider); } else { continue; } @@ -276,21 +271,6 @@ public class ImportIdentityProviderTest extends AbstractIdentityProviderModelTes assertEquals("clientSecret", config.getClientSecret()); } - private void assertKerberosIdentityProviderConfig(IdentityProviderModel identityProvider) { - KerberosIdentityProvider kerberosIdentityProvider = new KerberosIdentityProviderFactory().create(identityProvider); - KerberosIdentityProviderConfig config = kerberosIdentityProvider.getConfig(); - - assertEquals("model-kerberos", config.getId()); - assertEquals(KerberosIdentityProviderFactory.PROVIDER_ID, config.getProviderId()); - assertEquals("Kerberos", config.getName()); - assertEquals(true, config.isEnabled()); - assertEquals(true, config.isUpdateProfileFirstLogin()); - assertEquals(false, config.isAuthenticateByDefault()); - assertEquals("HTTP/server.domain.org@DOMAIN.ORG", config.getServerPrincipal()); - assertEquals("/etc/http.keytab", config.getKeyTab()); - assertTrue(config.getDebug()); - } - private RealmModel installTestRealm() throws IOException { RealmRepresentation realmRepresentation = loadJson("broker-test/test-realm-with-broker.json");