diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPConfig.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPConfig.java index 18c2d8fc3d..e8af5a9dd8 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPConfig.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/LDAPConfig.java @@ -113,12 +113,6 @@ public class LDAPConfig { return uuidAttrName; } - // TODO: Remove and use mapper instead? - public boolean isUserAccountControlsAfterPasswordUpdate() { - String userAccountCtrls = config.get(LDAPConstants.USER_ACCOUNT_CONTROLS_AFTER_PASSWORD_UPDATE); - return userAccountCtrls==null ? false : Boolean.parseBoolean(userAccountCtrls); - } - public boolean isPagination() { String pagination = config.get(LDAPConstants.PAGINATION); return pagination==null ? false : Boolean.parseBoolean(pagination); 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 698a392c12..f93de4633b 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 @@ -41,6 +41,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import javax.naming.AuthenticationException; + /** * @author Marek Posolda * @author Bill Burke @@ -366,7 +368,22 @@ public class LDAPFederationProvider implements UserFederationProvider { } else { // Use Naming LDAP API LDAPObject ldapUser = loadAndValidateUser(realm, user); - return ldapIdentityStore.validatePassword(ldapUser, password); + + try { + ldapIdentityStore.validatePassword(ldapUser, password); + return true; + } catch (AuthenticationException ae) { + + // Check if any mapper provides callback for handle LDAP AuthenticationException + Set federationMappers = realm.getUserFederationMappersByFederationProvider(getModel().getId()); + boolean processed = false; + for (UserFederationMapperModel mapperModel : federationMappers) { + LDAPFederationMapper ldapMapper = getMapper(mapperModel); + processed = processed || ldapMapper.onAuthenticationFailure(mapperModel, this, ldapUser, user, ae, realm); + } + + return processed; + } } } 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 551d940acd..539b9c7b02 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 @@ -16,6 +16,7 @@ import org.keycloak.federation.ldap.mappers.FullNameLDAPFederationMapperFactory; import org.keycloak.federation.ldap.mappers.LDAPFederationMapper; import org.keycloak.federation.ldap.mappers.UserAttributeLDAPFederationMapper; import org.keycloak.federation.ldap.mappers.UserAttributeLDAPFederationMapperFactory; +import org.keycloak.federation.ldap.mappers.msad.MSADUserAccountControlMapperFactory; import org.keycloak.mappers.UserFederationMapper; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -188,6 +189,12 @@ public class LDAPFederationProviderFactory extends UserFederationEventAwareProvi UserAttributeLDAPFederationMapper.ALWAYS_READ_VALUE_FROM_LDAP, alwaysReadValueFromLDAP, UserAttributeLDAPFederationMapper.IS_MANDATORY_IN_LDAP, "false"); realm.addUserFederationMapper(mapperModel); + + // MSAD specific mapper for account state propagation + if (activeDirectory) { + mapperModel = KeycloakModelUtils.createUserFederationMapperModel("MSAD account controls", newProviderModel.getId(), MSADUserAccountControlMapperFactory.PROVIDER_ID); + realm.addUserFederationMapper(mapperModel); + } } diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/model/LDAPObject.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/model/LDAPObject.java index cbb28f96ed..a334ed0264 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/model/LDAPObject.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/model/LDAPObject.java @@ -66,6 +66,10 @@ public class LDAPObject { readOnlyAttributeNames.add(readOnlyAttribute.toLowerCase()); } + public void removeReadOnlyAttributeName(String readOnlyAttribute) { + readOnlyAttributeNames.remove(readOnlyAttribute.toLowerCase()); + } + public String getRdnAttributeName() { return rdnAttributeName; } diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/query/internal/LDAPQuery.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/query/internal/LDAPQuery.java index 6939697db2..3019942206 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/query/internal/LDAPQuery.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/query/internal/LDAPQuery.java @@ -189,4 +189,8 @@ public class LDAPQuery { return this.conditions; } + public LDAPFederationProvider getLdapProvider() { + return ldapFedProvider; + } + } diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/IdentityStore.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/IdentityStore.java index 23c6d99eb5..77691b10b0 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/IdentityStore.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/IdentityStore.java @@ -2,6 +2,8 @@ package org.keycloak.federation.ldap.idm.store; import java.util.List; +import javax.naming.AuthenticationException; + import org.keycloak.federation.ldap.LDAPConfig; import org.keycloak.federation.ldap.idm.model.LDAPObject; import org.keycloak.federation.ldap.idm.query.internal.LDAPQuery; @@ -65,8 +67,9 @@ public interface IdentityStore { * * @param user Keycloak user * @param password Ldap password + * @throws AuthenticationException if authentication is not successful */ - boolean validatePassword(LDAPObject user, String password); + void validatePassword(LDAPObject user, String password) throws AuthenticationException; /** * Updates the specified credential value. diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPIdentityStore.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPIdentityStore.java index 8193679be4..e217fe4e03 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPIdentityStore.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPIdentityStore.java @@ -11,6 +11,7 @@ import java.util.NoSuchElementException; import java.util.Set; import java.util.TreeSet; +import javax.naming.AuthenticationException; import javax.naming.NamingEnumeration; import javax.naming.NamingException; import javax.naming.directory.Attribute; @@ -173,18 +174,14 @@ public class LDAPIdentityStore implements IdentityStore { // *************** CREDENTIALS AND USER SPECIFIC STUFF @Override - public boolean validatePassword(LDAPObject user, String password) { + public void validatePassword(LDAPObject user, String password) throws AuthenticationException { String userDN = user.getDn().toString(); if (logger.isTraceEnabled()) { logger.tracef("Using DN [%s] for authentication of user", userDN); } - if (operationManager.authenticate(userDN, password)) { - return true; - } - - return false; + operationManager.authenticate(userDN, password); } @Override @@ -225,15 +222,6 @@ public class LDAPIdentityStore implements IdentityStore { List modItems = new ArrayList(); modItems.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE, unicodePwd)); - // Used in ActiveDirectory to put account into "enabled" state (aka userAccountControl=512, see http://support.microsoft.com/kb/305144/en ) after password update. If value is -1, it's ignored - // TODO: Remove and use mapper instead - if (getConfig().isUserAccountControlsAfterPasswordUpdate()) { - BasicAttribute userAccountControl = new BasicAttribute("userAccountControl", "512"); - modItems.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE, userAccountControl)); - - logger.debugf("Attribute userAccountControls will be switched to 512 after password update of user [%s]", userDN); - } - operationManager.modifyAttributes(userDN, modItems.toArray(new ModificationItem[] {})); } catch (Exception e) { throw new ModelException(e); diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPOperationManager.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPOperationManager.java index 8a5b299437..3d0d22bd76 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPOperationManager.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/idm/store/ldap/LDAPOperationManager.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Properties; import java.util.Set; +import javax.naming.AuthenticationException; import javax.naming.Binding; import javax.naming.Context; import javax.naming.InitialContext; @@ -320,15 +321,15 @@ public class LDAPOperationManager { * * @param dn * @param password + * @throws AuthenticationException if authentication is not successful * - * @return */ - public boolean authenticate(String dn, String password) { + public void authenticate(String dn, String password) throws AuthenticationException { InitialContext authCtx = null; try { if (password == null || password.isEmpty()) { - throw new Exception("Empty password used"); + throw new AuthenticationException("Empty password used"); } Hashtable env = new Hashtable(this.connectionProperties); @@ -342,13 +343,15 @@ public class LDAPOperationManager { authCtx = new InitialLdapContext(env, null); - return true; - } catch (Exception e) { + } catch (AuthenticationException ae) { if (logger.isDebugEnabled()) { - logger.debugf(e, "Authentication failed for DN [%s]", dn); + logger.debugf(ae, "Authentication failed for DN [%s]", dn); } - return false; + throw ae; + } catch (Exception e) { + logger.errorf(e, "Unexpected exception when validating password of DN [%s]", dn); + throw new AuthenticationException("Unexpected exception when validating password of user"); } finally { if (authCtx != null) { try { diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/AbstractLDAPFederationMapper.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/AbstractLDAPFederationMapper.java index 2a79a48673..4990817037 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/AbstractLDAPFederationMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/AbstractLDAPFederationMapper.java @@ -3,6 +3,8 @@ package org.keycloak.federation.ldap.mappers; import java.util.Collections; import java.util.List; +import javax.naming.AuthenticationException; + import org.keycloak.federation.ldap.LDAPFederationProvider; import org.keycloak.federation.ldap.idm.model.LDAPObject; import org.keycloak.federation.ldap.idm.query.internal.LDAPQuery; @@ -70,6 +72,10 @@ public abstract class AbstractLDAPFederationMapper { return Collections.emptyList(); } + public boolean onAuthenticationFailure(LDAPObject ldapUser, UserModel user, AuthenticationException ldapException) { + return false; + } + public static boolean parseBooleanParameter(UserFederationMapperModel mapperModel, String paramName) { String paramm = mapperModel.getConfig().get(paramName); diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapper.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapper.java index 8d5ab4ea86..a27b9bf1ba 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapper.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapper.java @@ -1,5 +1,7 @@ package org.keycloak.federation.ldap.mappers; +import javax.naming.AuthenticationException; + import org.keycloak.federation.ldap.LDAPFederationProvider; import org.keycloak.federation.ldap.idm.model.LDAPObject; import org.keycloak.federation.ldap.idm.query.internal.LDAPQuery; @@ -59,4 +61,17 @@ public interface LDAPFederationMapper extends UserFederationMapper { * @param query */ void beforeLDAPQuery(UserFederationMapperModel mapperModel, LDAPQuery query); + + /** + * Called when LDAP authentication of specified user fails. If any mapper returns true from this method, AuthenticationException won't be rethrown! + * + * @param mapperModel + * @param ldapProvider + * @param realm + * @param user + * @param ldapUser + * @param ldapException + * @return true if mapper processed the AuthenticationException and did some actions based on that. In that case, AuthenticationException won't be rethrown! + */ + boolean onAuthenticationFailure(UserFederationMapperModel mapperModel, LDAPFederationProvider ldapProvider, LDAPObject ldapUser, UserModel user, AuthenticationException ldapException, RealmModel realm); } diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapperBridge.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapperBridge.java index b77bf3836f..3bc4451ff7 100644 --- a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapperBridge.java +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/LDAPFederationMapperBridge.java @@ -2,6 +2,8 @@ package org.keycloak.federation.ldap.mappers; import java.util.List; +import javax.naming.AuthenticationException; + import org.keycloak.federation.ldap.LDAPFederationProvider; import org.keycloak.federation.ldap.idm.model.LDAPObject; import org.keycloak.federation.ldap.idm.query.internal.LDAPQuery; @@ -55,7 +57,7 @@ public class LDAPFederationMapperBridge implements LDAPFederationMapper { @Override public void beforeLDAPQuery(UserFederationMapperModel mapperModel, LDAPQuery query) { // Improve if needed - getDelegate(mapperModel, null, null).beforeLDAPQuery(query); + getDelegate(mapperModel, query.getLdapProvider(), null).beforeLDAPQuery(query); } @@ -64,6 +66,11 @@ public class LDAPFederationMapperBridge implements LDAPFederationMapper { return getDelegate(mapperModel, ldapProvider, realm).getGroupMembers(group, firstResult, maxResults); } + @Override + public boolean onAuthenticationFailure(UserFederationMapperModel mapperModel, LDAPFederationProvider ldapProvider, LDAPObject ldapUser, UserModel user, AuthenticationException ldapException, RealmModel realm) { + return getDelegate(mapperModel, ldapProvider, realm).onAuthenticationFailure(ldapUser, user, ldapException); + } + private AbstractLDAPFederationMapper getDelegate(UserFederationMapperModel mapperModel, UserFederationProvider federationProvider, RealmModel realm) { LDAPFederationProvider ldapProvider = (LDAPFederationProvider) federationProvider; return factory.createMapper(mapperModel, ldapProvider, realm); diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/MSADUserAccountControlMapper.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/MSADUserAccountControlMapper.java new file mode 100644 index 0000000000..cd233a1dc1 --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/MSADUserAccountControlMapper.java @@ -0,0 +1,249 @@ +package org.keycloak.federation.ldap.mappers.msad; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.naming.AuthenticationException; + +import org.jboss.logging.Logger; +import org.keycloak.federation.ldap.LDAPFederationProvider; +import org.keycloak.federation.ldap.idm.model.LDAPObject; +import org.keycloak.federation.ldap.idm.query.internal.LDAPQuery; +import org.keycloak.federation.ldap.mappers.AbstractLDAPFederationMapper; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserFederationMapperModel; +import org.keycloak.models.UserFederationProvider; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.UserModelDelegate; + +/** + * Mapper specific to MSAD. It's able to read the userAccountControl and pwdLastSet attributes and set actions in Keycloak based on that. + * It's also able to handle exception code from LDAP user authentication (See http://www-01.ibm.com/support/docview.wss?uid=swg21290631 ) + * + * @author Marek Posolda + */ +public class MSADUserAccountControlMapper extends AbstractLDAPFederationMapper { + + private static final Logger logger = Logger.getLogger(MSADUserAccountControlMapper.class); + + private static final Pattern AUTH_EXCEPTION_REGEX = Pattern.compile(".*AcceptSecurityContext error, data ([0-9a-f]*), v.*"); + + public MSADUserAccountControlMapper(UserFederationMapperModel mapperModel, LDAPFederationProvider ldapProvider, RealmModel realm) { + super(mapperModel, ldapProvider, realm); + } + + @Override + public void beforeLDAPQuery(LDAPQuery query) { + query.addReturningLdapAttribute(LDAPConstants.PWD_LAST_SET); + query.addReturningLdapAttribute(LDAPConstants.USER_ACCOUNT_CONTROL); + + // This needs to be read-only and can be set to writable just on demand + query.addReturningReadOnlyLdapAttribute(LDAPConstants.PWD_LAST_SET); + + if (ldapProvider.getEditMode() != UserFederationProvider.EditMode.WRITABLE) { + query.addReturningReadOnlyLdapAttribute(LDAPConstants.USER_ACCOUNT_CONTROL); + } + } + + @Override + public UserModel proxy(LDAPObject ldapUser, UserModel delegate) { + return new MSADUserModelDelegate(delegate, ldapUser); + } + + @Override + public void onRegisterUserToLDAP(LDAPObject ldapUser, UserModel localUser) { + + } + + @Override + public void onImportUserFromLDAP(LDAPObject ldapUser, UserModel user, boolean isCreate) { + + } + + @Override + public boolean onAuthenticationFailure(LDAPObject ldapUser, UserModel user, AuthenticationException ldapException) { + String exceptionMessage = ldapException.getMessage(); + Matcher m = AUTH_EXCEPTION_REGEX.matcher(exceptionMessage); + if (m.matches()) { + String errorCode = m.group(1); + return processAuthErrorCode(errorCode, user); + } else { + return false; + } + } + + protected boolean processAuthErrorCode(String errorCode, UserModel user) { + logger.debugf("MSAD Error code is '%s' after failed LDAP login of user", errorCode, user.getUsername()); + + if (ldapProvider.getEditMode() == UserFederationProvider.EditMode.WRITABLE) { + if (errorCode.equals("532") || errorCode.equals("773")) { + // User needs to change his MSAD password. Allow him to login, but add UPDATE_PASSWORD required action + user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); + return true; + } else if (errorCode.equals("533")) { + // User is disabled in MSAD. Set him to disabled in KC as well + user.setEnabled(false); + return true; + } + } + + return false; + } + + + public class MSADUserModelDelegate extends UserModelDelegate { + + private final LDAPObject ldapUser; + + public MSADUserModelDelegate(UserModel delegate, LDAPObject ldapUser) { + super(delegate); + this.ldapUser = ldapUser; + } + + @Override + public boolean isEnabled() { + boolean kcEnabled = super.isEnabled(); + + if (getPwdLastSet() > 0) { + // Merge KC and MSAD + return kcEnabled && !getUserAccountControl().has(UserAccountControl.ACCOUNTDISABLE); + } else { + // If new MSAD user is created and pwdLastSet is still 0, MSAD account is in disabled state. So read just from Keycloak DB. User is not able to login via MSAD anyway + return kcEnabled; + } + } + + @Override + public void setEnabled(boolean enabled) { + // Always update DB + super.setEnabled(enabled); + + if (ldapProvider.getEditMode() == UserFederationProvider.EditMode.WRITABLE && getPwdLastSet() > 0) { + logger.debugf("Going to propagate enabled=%s for ldapUser '%s' to MSAD", enabled, ldapUser.getDn().toString()); + + UserAccountControl control = getUserAccountControl(); + if (enabled) { + control.remove(UserAccountControl.ACCOUNTDISABLE); + } else { + control.add(UserAccountControl.ACCOUNTDISABLE); + } + + updateUserAccountControl(control); + } + } + + @Override + public void updateCredential(UserCredentialModel cred) { + // Update LDAP password first + super.updateCredential(cred); + + if (ldapProvider.getEditMode() == UserFederationProvider.EditMode.WRITABLE && cred.getType().equals(UserCredentialModel.PASSWORD)) { + logger.debugf("Going to update userAccountControl for ldap user '%s' after successful password update", ldapUser.getDn().toString()); + + // Normally it's read-only + ldapUser.removeReadOnlyAttributeName(LDAPConstants.PWD_LAST_SET); + + ldapUser.setSingleAttribute(LDAPConstants.PWD_LAST_SET, "-1"); + + UserAccountControl control = getUserAccountControl(); + control.remove(UserAccountControl.PASSWD_NOTREQD); + control.remove(UserAccountControl.PASSWORD_EXPIRED); + + if (super.isEnabled()) { + control.remove(UserAccountControl.ACCOUNTDISABLE); + } + + updateUserAccountControl(control); + } + } + + @Override + public void addRequiredAction(RequiredAction action) { + String actionName = action.name(); + addRequiredAction(actionName); + } + + @Override + public void addRequiredAction(String action) { + // Always update DB + super.addRequiredAction(action); + + if (ldapProvider.getEditMode() == UserFederationProvider.EditMode.WRITABLE && RequiredAction.UPDATE_PASSWORD.toString().equals(action)) { + logger.debugf("Going to propagate required action UPDATE_PASSWORD to MSAD for ldap user '%s' ", ldapUser.getDn().toString()); + + // Normally it's read-only + ldapUser.removeReadOnlyAttributeName(LDAPConstants.PWD_LAST_SET); + + ldapUser.setSingleAttribute(LDAPConstants.PWD_LAST_SET, "0"); + ldapProvider.getLdapIdentityStore().update(ldapUser); + } + } + + @Override + public void removeRequiredAction(RequiredAction action) { + String actionName = action.name(); + removeRequiredAction(actionName); + } + + @Override + public void removeRequiredAction(String action) { + // Always update DB + super.removeRequiredAction(action); + + if (ldapProvider.getEditMode() == UserFederationProvider.EditMode.WRITABLE && RequiredAction.UPDATE_PASSWORD.toString().equals(action)) { + + // Don't set pwdLastSet in MSAD when it is new user + UserAccountControl accountControl = getUserAccountControl(); + if (accountControl.getValue() != 0 && !accountControl.has(UserAccountControl.PASSWD_NOTREQD)) { + logger.debugf("Going to remove required action UPDATE_PASSWORD from MSAD for ldap user '%s' ", ldapUser.getDn().toString()); + + // Normally it's read-only + ldapUser.removeReadOnlyAttributeName(LDAPConstants.PWD_LAST_SET); + + ldapUser.setSingleAttribute(LDAPConstants.PWD_LAST_SET, "-1"); + ldapProvider.getLdapIdentityStore().update(ldapUser); + } + } + } + + @Override + public Set getRequiredActions() { + Set requiredActions = super.getRequiredActions(); + + if (ldapProvider.getEditMode() == UserFederationProvider.EditMode.WRITABLE) { + if (getPwdLastSet() == 0 || getUserAccountControl().has(UserAccountControl.PASSWORD_EXPIRED)) { + requiredActions = new HashSet<>(requiredActions); + requiredActions.add(RequiredAction.UPDATE_PASSWORD.toString()); + return requiredActions; + } + } + + return requiredActions; + } + + protected long getPwdLastSet() { + String pwdLastSet = ldapUser.getAttributeAsString(LDAPConstants.PWD_LAST_SET); + return pwdLastSet == null ? 0 : Long.parseLong(pwdLastSet); + } + + protected UserAccountControl getUserAccountControl() { + String userAccountControl = ldapUser.getAttributeAsString(LDAPConstants.USER_ACCOUNT_CONTROL); + long longValue = userAccountControl == null ? 0 : Long.parseLong(userAccountControl); + return new UserAccountControl(longValue); + } + + // Update user in LDAP + protected void updateUserAccountControl(UserAccountControl accountControl) { + String userAccountControlValue = String.valueOf(accountControl.getValue()); + logger.debugf("Updating userAccountControl of user '%s' to value '%s'", ldapUser.getDn().toString(), userAccountControlValue); + + ldapUser.setSingleAttribute(LDAPConstants.USER_ACCOUNT_CONTROL, userAccountControlValue); + ldapProvider.getLdapIdentityStore().update(ldapUser); + } + } + +} diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/MSADUserAccountControlMapperFactory.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/MSADUserAccountControlMapperFactory.java new file mode 100644 index 0000000000..cacce00879 --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/MSADUserAccountControlMapperFactory.java @@ -0,0 +1,68 @@ +package org.keycloak.federation.ldap.mappers.msad; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.keycloak.federation.ldap.LDAPFederationProvider; +import org.keycloak.federation.ldap.mappers.AbstractLDAPFederationMapper; +import org.keycloak.federation.ldap.mappers.AbstractLDAPFederationMapperFactory; +import org.keycloak.mappers.MapperConfigValidationException; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserFederationMapperModel; +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.provider.ProviderConfigProperty; + +/** + * @author Marek Posolda + */ +public class MSADUserAccountControlMapperFactory extends AbstractLDAPFederationMapperFactory { + + public static final String PROVIDER_ID = LDAPConstants.MSAD_USER_ACCOUNT_CONTROL_MAPPER; + protected static final List configProperties = new ArrayList(); + + static { + } + + @Override + public String getHelpText() { + return "Mapper specific to MSAD. It's able to integrate the MSAD user account state into Keycloak account state (account enabled, password is expired etc). It's using userAccountControl and pwdLastSet MSAD attributes for that. " + + "For example if pwdLastSet is 0, the Keycloak user is required to update password, if userAccountControl is 514 (disabled account) the Keycloak user is disabled as well etc. Mapper is also able to handle exception code from LDAP user authentication."; + } + + @Override + public String getDisplayCategory() { + return ATTRIBUTE_MAPPER_CATEGORY; + } + + @Override + public String getDisplayType() { + return "MSAD User Account Control"; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + public Map getDefaultConfig(UserFederationProviderModel providerModel) { + return Collections.emptyMap(); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void validateConfig(RealmModel realm, UserFederationMapperModel mapperModel) throws MapperConfigValidationException { + } + + @Override + protected AbstractLDAPFederationMapper createMapper(UserFederationMapperModel mapperModel, LDAPFederationProvider federationProvider, RealmModel realm) { + return new MSADUserAccountControlMapper(mapperModel, federationProvider, realm); + } +} diff --git a/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/UserAccountControl.java b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/UserAccountControl.java new file mode 100644 index 0000000000..c7f831715c --- /dev/null +++ b/federation/ldap/src/main/java/org/keycloak/federation/ldap/mappers/msad/UserAccountControl.java @@ -0,0 +1,58 @@ +package org.keycloak.federation.ldap.mappers.msad; + +/** + * See https://support.microsoft.com/en-us/kb/305144 + * + * @author Marek Posolda + */ +public class UserAccountControl { + + public static final long SCRIPT = 0x0001l; + public static final long ACCOUNTDISABLE = 0x0002l; + public static final long HOMEDIR_REQUIRED = 0x0008l; + public static final long LOCKOUT = 0x0010l; + public static final long PASSWD_NOTREQD = 0x0020l; + public static final long PASSWD_CANT_CHANGE = 0x0040l; + public static final long ENCRYPTED_TEXT_PWD_ALLOWED = 0x0080l; + public static final long TEMP_DUPLICATE_ACCOUNT = 0x0100l; + public static final long NORMAL_ACCOUNT = 0x0200l; + public static final long INTERDOMAIN_TRUST_ACCOUNT = 0x0800l; + public static final long WORKSTATION_TRUST_ACCOUNT = 0x1000l; + public static final long SERVER_TRUST_ACCOUNT = 0x2000l; + public static final long DONT_EXPIRE_PASSWORD = 0x10000l; + public static final long MNS_LOGON_ACCOUNT = 0x20000l; + public static final long SMARTCARD_REQUIRED = 0x40000l; + public static final long TRUSTED_FOR_DELEGATION = 0x80000l; + public static final long NOT_DELEGATED = 0x100000l; + public static final long USE_DES_KEY_ONLY = 0x200000l; + public static final long DONT_REQ_PREAUTH = 0x400000l; + public static final long PASSWORD_EXPIRED = 0x800000l; + public static final long TRUSTED_TO_AUTH_FOR_DELEGATION = 0x1000000l; + public static final long PARTIAL_SECRETS_ACCOUNT = 0x04000000l; + + private long value; + + public UserAccountControl(long value) { + this.value = value; + } + + public boolean has(long feature) { + return (this.value & feature) > 0; + } + + public void add(long feature) { + if (!has(feature)) { + this.value += feature; + } + } + + public void remove(long feature) { + if (has(feature)) { + this.value -= feature; + } + } + + public long getValue() { + return value; + } +} diff --git a/federation/ldap/src/main/resources/META-INF/services/org.keycloak.mappers.UserFederationMapperFactory b/federation/ldap/src/main/resources/META-INF/services/org.keycloak.mappers.UserFederationMapperFactory index ac130a551e..0575a65042 100644 --- a/federation/ldap/src/main/resources/META-INF/services/org.keycloak.mappers.UserFederationMapperFactory +++ b/federation/ldap/src/main/resources/META-INF/services/org.keycloak.mappers.UserFederationMapperFactory @@ -2,4 +2,5 @@ org.keycloak.federation.ldap.mappers.UserAttributeLDAPFederationMapperFactory org.keycloak.federation.ldap.mappers.FullNameLDAPFederationMapperFactory org.keycloak.federation.ldap.mappers.HardcodedLDAPRoleMapperFactory org.keycloak.federation.ldap.mappers.membership.role.RoleLDAPFederationMapperFactory -org.keycloak.federation.ldap.mappers.membership.group.GroupLDAPFederationMapperFactory \ No newline at end of file +org.keycloak.federation.ldap.mappers.membership.group.GroupLDAPFederationMapperFactory +org.keycloak.federation.ldap.mappers.msad.MSADUserAccountControlMapperFactory \ No newline at end of file diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html index 6263f3d9f6..1ddf888015 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html @@ -183,14 +183,6 @@ Does the LDAP server support pagination. -
- -
- -
- Useful just for Active Directory. If enabled, then Keycloak will always set - Active Directory userAccountControl attribute to 512 after password update. This would mean that particular user will be enabled in Active Directory -
diff --git a/model/api/src/main/java/org/keycloak/migration/MigrationModel.java b/model/api/src/main/java/org/keycloak/migration/MigrationModel.java index 596f2783da..c541b3b652 100755 --- a/model/api/src/main/java/org/keycloak/migration/MigrationModel.java +++ b/model/api/src/main/java/org/keycloak/migration/MigrationModel.java @@ -1,7 +1,5 @@ package org.keycloak.migration; -import org.keycloak.provider.Provider; -import org.keycloak.provider.ProviderFactory; /** * @author Bill Burke @@ -11,7 +9,7 @@ public interface MigrationModel { /** * Must have the form of major.minor.micro as the version is parsed and numbers are compared */ - public static final String LATEST_VERSION = "1.7.0"; + String LATEST_VERSION = "1.8.0"; String getStoredVersion(); void setStoredVersion(String version); diff --git a/model/api/src/main/java/org/keycloak/migration/MigrationModelManager.java b/model/api/src/main/java/org/keycloak/migration/MigrationModelManager.java index 06df07311c..20918de12f 100755 --- a/model/api/src/main/java/org/keycloak/migration/MigrationModelManager.java +++ b/model/api/src/main/java/org/keycloak/migration/MigrationModelManager.java @@ -6,6 +6,7 @@ import org.keycloak.migration.migrators.MigrateTo1_4_0; import org.keycloak.migration.migrators.MigrateTo1_5_0; import org.keycloak.migration.migrators.MigrateTo1_6_0; import org.keycloak.migration.migrators.MigrateTo1_7_0; +import org.keycloak.migration.migrators.MigrateTo1_8_0; import org.keycloak.migration.migrators.MigrationTo1_2_0_CR1; import org.keycloak.models.KeycloakSession; @@ -61,6 +62,12 @@ public class MigrationModelManager { } new MigrateTo1_7_0().migrate(session); } + if (stored == null || stored.lessThan(MigrateTo1_8_0.VERSION)) { + if (stored != null) { + logger.debug("Migrating older model to 1.8.0 updates"); + } + new MigrateTo1_8_0().migrate(session); + } model.setStoredVersion(MigrationModel.LATEST_VERSION); } diff --git a/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_8_0.java b/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_8_0.java new file mode 100644 index 0000000000..b32f953364 --- /dev/null +++ b/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_8_0.java @@ -0,0 +1,48 @@ +package org.keycloak.migration.migrators; + +import java.util.List; +import java.util.Map; +import org.keycloak.migration.ModelVersion; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.LDAPConstants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserFederationMapperModel; +import org.keycloak.models.UserFederationProviderModel; +import org.keycloak.models.utils.KeycloakModelUtils; + +/** + * @author Marek Posolda + */ +public class MigrateTo1_8_0 { + + public static final ModelVersion VERSION = new ModelVersion("1.8.0"); + + public void migrate(KeycloakSession session) { + List realms = session.realms().getRealms(); + for (RealmModel realm : realms) { + + List federationProviders = realm.getUserFederationProviders(); + for (UserFederationProviderModel fedProvider : federationProviders) { + + if (fedProvider.getProviderName().equals(LDAPConstants.LDAP_PROVIDER)) { + Map config = fedProvider.getConfig(); + + if (isActiveDirectory(config)) { + + // Create mapper for MSAD account controls + if (realm.getUserFederationMapperByName(fedProvider.getId(), "MSAD account controls") == null) { + UserFederationMapperModel mapperModel = KeycloakModelUtils.createUserFederationMapperModel("MSAD account controls", fedProvider.getId(), LDAPConstants.MSAD_USER_ACCOUNT_CONTROL_MAPPER); + realm.addUserFederationMapper(mapperModel); + } + } + } + } + + } + } + + private boolean isActiveDirectory(Map ldapConfig) { + String vendor = ldapConfig.get(LDAPConstants.VENDOR); + return vendor != null && vendor.equals(LDAPConstants.VENDOR_ACTIVE_DIRECTORY); + } +} diff --git a/model/api/src/main/java/org/keycloak/models/LDAPConstants.java b/model/api/src/main/java/org/keycloak/models/LDAPConstants.java index 2d403f373e..020bfbd02a 100644 --- a/model/api/src/main/java/org/keycloak/models/LDAPConstants.java +++ b/model/api/src/main/java/org/keycloak/models/LDAPConstants.java @@ -6,6 +6,7 @@ package org.keycloak.models; public class LDAPConstants { public static final String LDAP_PROVIDER = "ldap"; + public static final String MSAD_USER_ACCOUNT_CONTROL_MAPPER = "msad-user-account-control-mapper"; public static final String VENDOR = "vendor"; public static final String VENDOR_RHDS = "rhds"; @@ -43,9 +44,6 @@ public class LDAPConstants { // Config option to specify if registrations will be synced or not public static final String SYNC_REGISTRATIONS = "syncRegistrations"; - // Applicable just for active directory - public static final String USER_ACCOUNT_CONTROLS_AFTER_PASSWORD_UPDATE = "userAccountControlsAfterPasswordUpdate"; - // Custom user search filter public static final String CUSTOM_USER_SEARCH_FILTER = "customUserSearchFilter"; @@ -53,9 +51,6 @@ public class LDAPConstants { public static final String LDAP_ID = "LDAP_ID"; public static final String LDAP_ENTRY_DN = "LDAP_ENTRY_DN"; - // String used in config to divide more possible values (for example more userDns), which are saved in DB as single string - public static final String CONFIG_DIVIDER = ":::"; - // Those are forked from Picketlink public static final String GIVENNAME = "givenName"; public static final String CN = "cn"; @@ -73,6 +68,8 @@ public class LDAPConstants { public static final String GROUP_OF_NAMES = "groupOfNames"; public static final String GROUP_OF_ENTRIES = "groupOfEntries"; public static final String GROUP_OF_UNIQUE_NAMES = "groupOfUniqueNames"; + public static final String USER_ACCOUNT_CONTROL = "userAccountControl"; + public static final String PWD_LAST_SET = "pwdLastSet"; public static final String COMMA = ","; public static final String EQUAL = "="; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/LDAPTestConfiguration.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/LDAPTestConfiguration.java index 06c8f5ca47..3920208251 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/LDAPTestConfiguration.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/LDAPTestConfiguration.java @@ -39,7 +39,6 @@ public class LDAPTestConfiguration { PROP_MAPPINGS.put(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, "idm.test.ldap.username.ldap.attribute"); PROP_MAPPINGS.put(LDAPConstants.RDN_LDAP_ATTRIBUTE, "idm.test.ldap.rdn.ldap.attribute"); PROP_MAPPINGS.put(LDAPConstants.USER_OBJECT_CLASSES, "idm.test.ldap.user.object.classes"); - PROP_MAPPINGS.put(LDAPConstants.USER_ACCOUNT_CONTROLS_AFTER_PASSWORD_UPDATE, "idm.test.ldap.user.account.controls.after.password.update"); PROP_MAPPINGS.put(LDAPConstants.EDIT_MODE, "idm.test.ldap.edit.mode"); PROP_MAPPINGS.put(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION, "idm.test.kerberos.allow.kerberos.authentication"); @@ -62,7 +61,6 @@ public class LDAPTestConfiguration { DEFAULT_VALUES.put(LDAPConstants.BATCH_SIZE_FOR_SYNC, String.valueOf(LDAPConstants.DEFAULT_BATCH_SIZE_FOR_SYNC)); DEFAULT_VALUES.put(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, null); DEFAULT_VALUES.put(LDAPConstants.USER_OBJECT_CLASSES, null); - DEFAULT_VALUES.put(LDAPConstants.USER_ACCOUNT_CONTROLS_AFTER_PASSWORD_UPDATE, "false"); DEFAULT_VALUES.put(LDAPConstants.EDIT_MODE, UserFederationProvider.EditMode.READ_ONLY.toString()); DEFAULT_VALUES.put(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION, "false"); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPTestConfiguration.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPTestConfiguration.java index 46b1be4d61..7b1f4c2358 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPTestConfiguration.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/LDAPTestConfiguration.java @@ -40,7 +40,6 @@ public class LDAPTestConfiguration { PROP_MAPPINGS.put(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, "idm.test.ldap.username.ldap.attribute"); PROP_MAPPINGS.put(LDAPConstants.RDN_LDAP_ATTRIBUTE, "idm.test.ldap.rdn.ldap.attribute"); PROP_MAPPINGS.put(LDAPConstants.USER_OBJECT_CLASSES, "idm.test.ldap.user.object.classes"); - PROP_MAPPINGS.put(LDAPConstants.USER_ACCOUNT_CONTROLS_AFTER_PASSWORD_UPDATE, "idm.test.ldap.user.account.controls.after.password.update"); PROP_MAPPINGS.put(LDAPConstants.EDIT_MODE, "idm.test.ldap.edit.mode"); PROP_MAPPINGS.put(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION, "idm.test.kerberos.allow.kerberos.authentication"); @@ -63,7 +62,6 @@ public class LDAPTestConfiguration { DEFAULT_VALUES.put(LDAPConstants.BATCH_SIZE_FOR_SYNC, String.valueOf(LDAPConstants.DEFAULT_BATCH_SIZE_FOR_SYNC)); DEFAULT_VALUES.put(LDAPConstants.USERNAME_LDAP_ATTRIBUTE, null); DEFAULT_VALUES.put(LDAPConstants.USER_OBJECT_CLASSES, null); - DEFAULT_VALUES.put(LDAPConstants.USER_ACCOUNT_CONTROLS_AFTER_PASSWORD_UPDATE, "false"); DEFAULT_VALUES.put(LDAPConstants.EDIT_MODE, UserFederationProvider.EditMode.READ_ONLY.toString()); DEFAULT_VALUES.put(KerberosConstants.ALLOW_KERBEROS_AUTHENTICATION, "false"); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/base/FederationProvidersIntegrationTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/base/FederationProvidersIntegrationTest.java index 1d41595f6f..b07e23dfcc 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/base/FederationProvidersIntegrationTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/federation/ldap/base/FederationProvidersIntegrationTest.java @@ -749,7 +749,7 @@ public class FederationProvidersIntegrationTest { } @Test - public void testUnsynced() { + public void testUnsynced() throws Exception { KeycloakSession session = keycloakRule.startSession(); try { RealmModel appRealm = session.realms().getRealmByName("test");