Skip to content

Commit

Permalink
Add limited support for LDAP password policy control
Browse files Browse the repository at this point in the history
Send password policy control during LDAP bind operation and receive response
according to chapter 9.1 of [1].  When server responds with error
"changeAfterReset" then prompt user to update their password by adding
UPDATE_PASSWORD required action

Following preconditions need to be met:

- import mode enabled, otherwise required actions cannot be persisted.
- edit mode is set writable, so that users can modify their passwords.

Without these preconditions changeAfterReset is treated as failed
authentication.

[1] https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11

Signed-off-by: Tero Saarni <tero.saarni@est.tech>
  • Loading branch information
tsaarni committed Dec 16, 2024
1 parent 5b8a237 commit 205c41a
Show file tree
Hide file tree
Showing 15 changed files with 381 additions and 2 deletions.
4 changes: 4 additions & 0 deletions federation/ldap/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
</dependency>
<dependency>
<groupId>org.wildfly.security</groupId>
<artifactId>wildfly-elytron</artifactId>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,11 @@ public String getReferral() {
return config.getFirst(LDAPConstants.REFERRAL);
}

public boolean isEnableLdapPasswordPolicy() {
String enableLdapPasswordPolicy = config.getFirst(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY);
return Boolean.parseBoolean(enableLdapPasswordPolicy);
}

public void addBinaryAttribute(String attrName) {
binaryAttributeNames.add(attrName);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQueryConditionsBuilder;
import org.keycloak.storage.ldap.idm.store.ldap.LDAPIdentityStore;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyPasswordChangeException;
import org.keycloak.storage.ldap.kerberos.LDAPProviderKerberosConfig;
import org.keycloak.storage.ldap.mappers.LDAPMappersComparator;
import org.keycloak.storage.ldap.mappers.LDAPOperationDecorator;
Expand Down Expand Up @@ -820,6 +821,22 @@ public boolean validPassword(RealmModel realm, UserModel user, String password)
try {
ldapIdentityStore.validatePassword(ldapUser, password);
return true;
} catch (PasswordPolicyPasswordChangeException e) {
// LDAP password policy requires a forced password change.
// Check for (1) import enabled, so that we can persist required actions and
// (2) edit mode writable, so that user can modify LDAP password.
if (!model.isImportEnabled() || editMode != EditMode.WRITABLE) {
logger.debugf("User '%s' in realm '%s' is forced to change password but UPDATE_PASSWORD cannot be set: import not enabled or edit mode not writable. Failing login.", user.getUsername(), realm.getName());
return false;
}
if (user.getRequiredActionsStream()
.noneMatch(action -> Objects.equals(action, UserModel.RequiredAction.UPDATE_PASSWORD.name()))) {
logger.debugf("Adding requiredAction UPDATE_PASSWORD to user %s", user.getUsername());
user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
} else {
logger.tracef("Skip adding required action UPDATE_PASSWORD. It was already set on user '%s' in realm '%s'", user.getUsername(), realm.getName());
}
return true;
} catch (AuthenticationException ae) {
AtomicReference<Boolean> processed = new AtomicReference<>(false);
realm.getComponentsStream(model.getId(), LDAPStorageMapper.class.getName())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,9 @@ private static List<ProviderConfigProperty> getConfigProps(ComponentModel parent
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("false")
.add()
.property().name(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY)
.defaultValue("false")
.add()
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static LDAPContextManager create(KeycloakSession session, LDAPConfig conn
private void createLdapContext() throws NamingException {
// Create connection but avoid triggering automatic bind request by not setting security principal and credentials yet.
// That allows us to send optional StartTLS request before binding.
ldapContext = new InitialLdapContext(LDAPContextManager.getNonAuthConnectionProperties(ldapConfig), null);
ldapContext = new InitialLdapContext(getNonAuthConnectionProperties(ldapConfig), null);

// Send StartTLS request and setup SSL context if needed.
if (ldapConfig.isStartTls()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
import org.keycloak.storage.ldap.idm.query.Condition;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQuery;
import org.keycloak.storage.ldap.idm.query.internal.LDAPQueryConditionsBuilder;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyPasswordChangeException;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyControl;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyControlFactory;
import org.keycloak.storage.ldap.idm.store.ldap.extended.PasswordModifyRequest;
import org.keycloak.storage.ldap.mappers.LDAPOperationDecorator;

Expand All @@ -42,6 +45,7 @@
import javax.naming.directory.ModificationItem;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.BasicControl;
import javax.naming.ldap.Control;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;
Expand Down Expand Up @@ -492,6 +496,9 @@ public void authenticate(LdapName dn, String password) throws AuthenticationExce
// Never use connection pool to prevent password caching
env.put("com.sun.jndi.ldap.connect.pool", "false");

// Prepare to receive password policy response control.
env.put(LdapContext.CONTROL_FACTORIES, PasswordPolicyControlFactory.class.getName());

// Create connection but avoid triggering automatic bind request by not setting security principal and credentials yet.
// That allows us to send optional StartTLS request before binding.
authCtx = new InitialLdapContext(env, null);
Expand Down Expand Up @@ -520,7 +527,21 @@ public void authenticate(LdapName dn, String password) throws AuthenticationExce
authCtx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);

// Send bind request. Throws AuthenticationException when authentication fails.
authCtx.reconnect(null);
authCtx.reconnect(getControls());

// Check for password policy response control in the response.
// If present and forced password change is required, throw an exception.
Control[] responseControls = authCtx.getResponseControls();
if (responseControls != null) {
for (Control control : responseControls) {
if (control instanceof PasswordPolicyControl) {
PasswordPolicyControl response = (PasswordPolicyControl) control;
if (response.changeAfterReset()) {
throw new PasswordPolicyPasswordChangeException();
}
}
}
}

} catch (AuthenticationException ae) {
if (logger.isDebugEnabled()) {
Expand Down Expand Up @@ -663,6 +684,14 @@ public String toString() {
}
}

private Control[] getControls() {
// If enabled, send a passwordPolicyRequest control as non-critical.
if (config.isEnableLdapPasswordPolicy()) {
return new Control[] { new BasicControl(PasswordPolicyControl.OID, false, null) };
}
return null;
}

private String getUuidAttributeName() {
return this.config.getUuidLDAPAttributeName();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.storage.ldap.idm.store.ldap.control;

import java.math.BigInteger;

import javax.naming.ldap.Control;

import org.jboss.logging.Logger;
import org.wildfly.security.asn1.ASN1;
import org.wildfly.security.asn1.ASN1Exception;
import org.wildfly.security.asn1.DERDecoder;

/**
* Implements (parts of) draft-behera-ldap-password-policy
*/
public class PasswordPolicyControl implements Control {

/* https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11#section-6.1 */
public static final String OID = "1.3.6.1.4.1.42.2.27.8.5.1";

private static final Logger logger = Logger.getLogger(PasswordPolicyControl.class);

private static final int ERROR_CHANGE_AFTER_RESET = 2;

private boolean changeAfterReset;

/*
* https://datatracker.ietf.org/doc/html/draft-behera-ldap-password-policy-11#section-6.2
*
* PasswordPolicyResponseValue ::= SEQUENCE {
* warning [0] CHOICE {
* timeBeforeExpiration [0] INTEGER (0 .. maxInt),
* graceAuthNsRemaining [1] INTEGER (0 .. maxInt) } OPTIONAL,
* error [1] ENUMERATED {
* passwordExpired (0),
* accountLocked (1),
* changeAfterReset (2),
* passwordModNotAllowed (3),
* mustSupplyOldPassword (4),
* insufficientPasswordQuality (5),
* passwordTooShort (6),
* passwordTooYoung (7),
* passwordInHistory (8),
* passwordTooLong (9) } OPTIONAL }
*/

PasswordPolicyControl(byte[] encodedValue) {
DERDecoder der = new DERDecoder(encodedValue);

try {
der.startSequence(); // PasswordPolicyResponseValue ::= SEQUENCE
if (der.isNextType(ASN1.CONTEXT_SPECIFIC_MASK, 0, false)) { // warning [0] CHOICE
der.skipElement();
}
if (der.isNextType(ASN1.CONTEXT_SPECIFIC_MASK, 1, false)) { // error [1] ENUMERATED
der.decodeImplicit(1);
int error = new BigInteger(der.drainElementValue()).intValue();
this.changeAfterReset = error == ERROR_CHANGE_AFTER_RESET;
}
der.endSequence();
} catch (ASN1Exception ignored) {
logger.errorf("Failed to parse PasswordPolicyResponseValue: %s", ignored.getMessage());
}
}

public boolean changeAfterReset() {
return changeAfterReset;
}

@Override
public String getID() {
return OID;
}

@Override
public boolean isCritical() {
return Control.NONCRITICAL;
}

@Override
public byte[] getEncodedValue() {
return new byte[0];
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.storage.ldap.idm.store.ldap.control;

import javax.naming.NamingException;
import javax.naming.ldap.Control;
import javax.naming.ldap.ControlFactory;

public class PasswordPolicyControlFactory extends ControlFactory {

@Override
public Control getControlInstance(Control ctl) throws NamingException {
if (ctl.getID().equals(PasswordPolicyControl.OID)) {
return new PasswordPolicyControl(ctl.getEncodedValue());
}
return null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.storage.ldap.idm.store.ldap.control;

import javax.naming.AuthenticationException;

/**
* PasswordPolicyPasswordChangeException is thrown when LDAP password policy response control indicates error "changeAfterReset".
*/
public class PasswordPolicyPasswordChangeException extends AuthenticationException {

public PasswordPolicyPasswordChangeException() {
super();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,7 @@ deleteClientPolicyProfileSuccess=Profile successfully removed from the policy.
reGenerateSigningExplain=If you regenerate signing key for client, the Keycloak database will be updated and you may need to download a new adapter for this client.
evaluate=Evaluate
enableLdapv3Password=Enable the LDAPv3 password modify extended operation
enableLdapPasswordPolicy=Enable LDAP password policy
status=Status
dragInstruction=Click and drag to change priority
clients=Clients
Expand Down Expand Up @@ -2724,6 +2725,7 @@ rolesHelp=Select the roles you want to associate with the selected user.
samlEntityDescriptor=SAML entity descriptor
passwordPolicyHintsEnabled=Password policy hints enabled
enableLdapv3PasswordHelp=Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify extended operation usually requires that LDAP user already has password in the LDAP server. So when this is used with 'Sync Registrations', it can be good to add also 'Hardcoded LDAP attribute mapper' with randomly generated initial password.
enableLdapPasswordPolicyHelp=Use the LDAP password policy as outlined in IETF draft-behera-ldap-password-policy. When this option is enabled, users will be prompted to change their password upon logging in if the server indicates that a password change is necessary.
syncMode=Sync mode
details=Details
privateRSAKeyHelp=Private RSA Key encoded in PEM format
Expand Down
30 changes: 30 additions & 0 deletions js/apps/admin-ui/src/user-federation/ldap/LdapSettingsAdvanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,36 @@ export const LdapSettingsAdvanced = ({
></Controller>
</FormGroup>

<FormGroup
label={t("enableLdapPasswordPolicy")}
labelIcon={
<HelpItem
helpText={t("user-federation-help:enableLdapPasswordPolicyHelp")}
fieldLabelId="user-federation:enableLdapPasswordPolicy"
/>
}
fieldId="kc-enable-ldap-password-policy"
hasNoPaddingTop
>
<Controller
name="config.enableLdapPasswordPolicy"
defaultValue={["false"]}
control={form.control}
render={({ field }) => (
<Switch
id={"kc-enable-ldap-password-policy"}
data-testid="ldap-password-policy"
isDisabled={false}
onChange={(value) => field.onChange([`${value}`])}
isChecked={field.value[0] === "true"}
label={t("common:on")}
labelOff={t("common:off")}
aria-label={t("enableLdapPasswordPolicy")}
/>
)}
></Controller>
</FormGroup>

<FormGroup
label={t("trustEmail")}
labelIcon={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ public class LDAPConstants {
public static final String MODIFY_TIMESTAMP = "modifyTimestamp";

public static final String LDAP_MATCHING_RULE_IN_CHAIN = ":1.2.840.113556.1.4.1941:";
public static final String ENABLE_LDAP_PASSWORD_POLICY = "enableLdapPasswordPolicy";

public static final String REFERRAL = "referral";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,18 @@ public Statement apply(Statement base, Description description) {
break;
}
}

Annotation passwordPolicyAnnotations = description.getAnnotation(LDAPPasswordPolicy.class);
if (passwordPolicyAnnotations != null) {
LDAPPasswordPolicy passwordPolicy = (LDAPPasswordPolicy) passwordPolicyAnnotations;

log.debugf("Enabling LDAP password policy: mustChange=%s.", passwordPolicy.mustChange());

serverProperties.setProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_ENABLED, "true");
serverProperties.setProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_MUST_CHANGE, String.valueOf(passwordPolicy.mustChange()));
clientConfig.put(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY, "true");
}

return super.apply(base, description);
}

Expand Down Expand Up @@ -268,4 +280,10 @@ public enum Encryption {
STARTTLS
}
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LDAPPasswordPolicy {
public boolean mustChange() default false;
}
}
Loading

0 comments on commit 205c41a

Please sign in to comment.