This commit is contained in:
Tero Saarni 2026-02-03 17:27:14 +01:00 committed by GitHub
commit c3e93ebbc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 619 additions and 72 deletions

View file

@ -199,8 +199,8 @@ public class LDAPServerCapabilitiesManager {
// is not needed anymore
try (LDAPContextManager ldapContextManager = LDAPContextManager.create(session, ldapConfig)) {
LdapContext ldapContext = ldapContextManager.getLdapContext();
if (TEST_AUTHENTICATION.equals(config.getAction()) && LDAPConstants.AUTH_TYPE_NONE.equals(config.getAuthType())) {
// reconnect to force an anonymous bind operation
if (TEST_AUTHENTICATION.equals(config.getAction())) {
// Reconnect to force bind operation.
ldapContext.reconnect(null);
}
} catch (Exception ne) {

View file

@ -257,6 +257,11 @@ public class LDAPConfig {
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);
}

View file

@ -69,6 +69,7 @@ import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.utils.ReadOnlyUserModelDelegate;
import org.keycloak.policy.PasswordPolicyManagerProvider;
import org.keycloak.policy.PolicyError;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.storage.DatastoreProvider;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.storage.StorageId;
@ -85,6 +86,7 @@ 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.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;
@ -822,6 +824,31 @@ public class LDAPStorageProvider implements UserStorageProvider,
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()))) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
if (authSession != null) {
if (authSession.getRequiredActions().stream().noneMatch(action -> Objects.equals(action, UserModel.RequiredAction.UPDATE_PASSWORD.name()))) {
logger.debugf("Adding requiredAction UPDATE_PASSWORD to the authenticationSession of user %s", user.getUsername());
authSession.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
}
} else {
// Just a fallback. It should not happen during normal authentication process
logger.debugf("Adding requiredAction UPDATE_PASSWORD to the 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())

View file

@ -223,6 +223,10 @@ public class LDAPStorageProviderFactory implements UserStorageProviderFactory<LD
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("false")
.add()
.property().name(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY)
.type(ProviderConfigProperty.BOOLEAN_TYPE)
.defaultValue("false")
.add()
.build();
}

View file

@ -0,0 +1,132 @@
/*
* 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;
import java.io.IOException;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
/**
* A decoder for the ASN.1 BER encoding.
*
* Very limited implementation, only supports what is needed by the current LDAP extension controls.
*/
public class BERDecoder {
// Universal tags.
public static final int TAG_SEQUENCE = 0x30;
// Tag classes.
public static final int TAG_CLASS_CONTEXT_SPECIFIC = 0x80;
// Tag forms.
public static final int TAG_FORM_PRIMITIVE = 0x00;
private ByteBuffer encoded;
public BERDecoder(byte[] encodedValue) {
this.encoded = ByteBuffer.wrap(encodedValue);
}
/**
* Start decoding a sequence.
*/
public void startSequence() throws DecodeException {
try {
byte tag = encoded.get();
if (tag != TAG_SEQUENCE) {
throw new DecodeException("Expected SEQUENCE (" + TAG_SEQUENCE + ") but got " + tag);
}
readLength();
} catch (BufferUnderflowException e) {
throw new DecodeException("Unexpected end of input");
}
}
/**
* Check if the next element matches with the given tag, but do not consume it.
*/
public boolean isNextTag(int clazz, int form, int tag) throws DecodeException {
encoded.mark();
try {
int expected = clazz | form | tag;
int unsignedTag = encoded.get() & 0xFF;
encoded.reset();
return unsignedTag == expected;
} catch (BufferUnderflowException e) {
throw new DecodeException("Unexpected end of input");
} finally {
encoded.reset();
}
}
/**
* Skip over the next element.
*/
public void skipElement() throws DecodeException {
try {
int length = readLength();
encoded.position(encoded.position() + length);
} catch (BufferUnderflowException e) {
throw new DecodeException("Unexpected end of input");
}
}
/**
* Drain the value bytes of the next element.
*/
public byte[] drainElementValue() throws DecodeException {
try {
int length = readLength();
byte[] value = new byte[length];
encoded.get(value);
return value;
} catch (BufferUnderflowException e) {
throw new DecodeException("Unexpected end of input");
}
}
private int readLength() throws DecodeException {
int length = encoded.get() & 0xFF;
// Short form.
if ((length & 0x80) == 0) {
return length;
}
// Long form.
int numBytes = length & 0x7F;
if (numBytes > 4) {
throw new DecodeException("Cannot handle more than 4 bytes of length, got " + numBytes + " bytes");
}
length = 0;
for (int i = 0; i < numBytes; i++) {
length = (length << 8) | (encoded.get() & 0xFF);
}
return length;
}
public static final class DecodeException extends IOException {
DecodeException(String message) {
super(message);
}
}
}

View file

@ -4,7 +4,6 @@ import java.io.IOException;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import javax.naming.AuthenticationException;
import javax.naming.Context;
@ -35,19 +34,6 @@ public final class LDAPContextManager implements AutoCloseable {
private final KeycloakSession session;
private final LDAPConfig ldapConfig;
private StartTlsResponse tlsResponse;
private VaultStringSecret vaultStringSecret = new VaultStringSecret() {
@Override
public Optional<String> get() {
return Optional.empty();
}
@Override
public void close() {
}
};
private LdapContext ldapContext;
public LDAPContextManager(KeycloakSession session, LDAPConfig connectionProperties) {
@ -59,26 +45,22 @@ public final class LDAPContextManager implements AutoCloseable {
return new LDAPContextManager(session, connectionProperties);
}
// Create connection that is authenticated as admin user.
private void createLdapContext() throws NamingException {
var tracing = session.getProvider(TracingProvider.class);
tracing.startSpan(LDAPContextManager.class, "createLdapContext");
try {
Hashtable<Object, Object> connProp = getConnectionProperties(ldapConfig);
if (!LDAPConstants.AUTH_TYPE_NONE.equals(ldapConfig.getAuthType())) {
vaultStringSecret = getVaultSecret();
if (vaultStringSecret != null && !ldapConfig.isStartTls() && ldapConfig.getBindCredential() != null) {
connProp.put(SECURITY_CREDENTIALS, vaultStringSecret.get()
.orElse(ldapConfig.getBindCredential()).toCharArray());
}
}
// Create the LDAP context without setting the security principal and credentials yet.
// This avoids triggering an automatic bind request, allowing us to send an optional StartTLS request before binding.
Hashtable<Object, Object> connProp = getNonAuthConnectionProperties(ldapConfig);
if (ldapConfig.isConnectionTrace()) {
connProp.put(LDAPConstants.CONNECTION_TRACE_BER, System.err);
}
ldapContext = new SessionBoundInitialLdapContext(session, connProp, null);
// Send StartTLS request and setup SSL context if needed.
if (ldapConfig.isStartTls()) {
SSLSocketFactory sslSocketFactory = null;
if (LDAPUtil.shouldUseTruststoreSpi(ldapConfig)) {
@ -86,8 +68,7 @@ public final class LDAPContextManager implements AutoCloseable {
sslSocketFactory = provider.getSSLSocketFactory();
}
tlsResponse = startTLS(ldapContext, ldapConfig.getAuthType(), ldapConfig.getBindDN(),
vaultStringSecret.get().orElse(ldapConfig.getBindCredential()), sslSocketFactory);
tlsResponse = startTLS(ldapContext, sslSocketFactory);
// Exception should be already thrown by LDAPContextManager.startTLS if "startTLS" could not be established, but rather do some additional check
if (tlsResponse == null) {
@ -100,6 +81,11 @@ public final class LDAPContextManager implements AutoCloseable {
} finally {
tracing.endSpan();
}
setAdminConnectionAuthProperties(ldapContext);
// Bind will be automatically called when operations are executed on the context,
// or it can be explicitly called by invoking the reconnect() method (e.g., authentication test in LDAPServerCapabilitiesManager.testLDAP()).
}
public LdapContext getLdapContext() throws NamingException {
@ -108,25 +94,18 @@ public final class LDAPContextManager implements AutoCloseable {
return ldapContext;
}
private VaultStringSecret getVaultSecret() {
return LDAPConstants.AUTH_TYPE_NONE.equals(ldapConfig.getAuthType())
? null
: session.vault().getStringSecret(ldapConfig.getBindCredential());
// Get bind password from vault or from directly from configuration, may be null.
private String getBindPassword() {
VaultStringSecret vaultSecret = session.vault().getStringSecret(ldapConfig.getBindCredential());
return vaultSecret.get().orElse(ldapConfig.getBindCredential());
}
public static StartTlsResponse startTLS(LdapContext ldapContext, String authType, String bindDN, String bindCredential, SSLSocketFactory sslSocketFactory) throws NamingException {
public static StartTlsResponse startTLS(LdapContext ldapContext, SSLSocketFactory sslSocketFactory) throws NamingException {
StartTlsResponse tls = null;
try {
tls = (StartTlsResponse) ldapContext.extendedOperation(new StartTlsRequest());
tls.negotiate(sslSocketFactory);
ldapContext.addToEnvironment(Context.SECURITY_AUTHENTICATION, authType);
if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) {
ldapContext.addToEnvironment(Context.SECURITY_PRINCIPAL, bindDN);
ldapContext.addToEnvironment(Context.SECURITY_CREDENTIALS, bindCredential != null ? bindCredential.toCharArray() : null);
}
} catch (Exception e) {
logger.error("Could not negotiate TLS", e);
NamingException ne = new AuthenticationException("Could not negotiate TLS");
@ -134,44 +113,33 @@ public final class LDAPContextManager implements AutoCloseable {
throw ne;
}
// throws AuthenticationException when authentication fails
ldapContext.lookup("");
return tls;
}
// Get connection properties of admin connection
private Hashtable<Object, Object> getConnectionProperties(LDAPConfig ldapConfig) {
Hashtable<Object, Object> env = getNonAuthConnectionProperties(ldapConfig);
// Fill in the connection properties to authenticate as admin.
private void setAdminConnectionAuthProperties(LdapContext ldapContext) throws NamingException {
String authType = ldapConfig.getAuthType();
if (authType != null) {
ldapContext.addToEnvironment(Context.SECURITY_AUTHENTICATION, authType);
}
if(!ldapConfig.isStartTls()) {
String authType = ldapConfig.getAuthType();
String bindPassword = getBindPassword();
if (bindPassword != null) {
ldapContext.addToEnvironment(SECURITY_CREDENTIALS, bindPassword);
}
if (authType != null) env.put(Context.SECURITY_AUTHENTICATION, authType);
String bindDN = ldapConfig.getBindDN();
char[] bindCredential = null;
if (ldapConfig.getBindCredential() != null) {
bindCredential = ldapConfig.getBindCredential().toCharArray();
}
if (!LDAPConstants.AUTH_TYPE_NONE.equals(authType)) {
if (bindDN != null) env.put(Context.SECURITY_PRINCIPAL, bindDN);
if (bindCredential != null) env.put(Context.SECURITY_CREDENTIALS, bindCredential);
}
String bindDN = ldapConfig.getBindDN();
if (bindDN != null) {
ldapContext.addToEnvironment(Context.SECURITY_PRINCIPAL, bindDN);
}
if (logger.isDebugEnabled()) {
Map<Object, Object> copyEnv = new Hashtable<>(env);
Map<Object, Object> copyEnv = new Hashtable<>(ldapContext.getEnvironment());
if (copyEnv.containsKey(Context.SECURITY_CREDENTIALS)) {
copyEnv.put(Context.SECURITY_CREDENTIALS, "**************************************");
}
logger.debugf("Creating LdapContext using properties: [%s]", copyEnv);
}
return env;
}
@ -251,7 +219,6 @@ public final class LDAPContextManager implements AutoCloseable {
@Override
public void close() {
if (vaultStringSecret != null) vaultStringSecret.close();
if (tlsResponse != null) {
try {
tlsResponse.close();

View file

@ -36,6 +36,7 @@ import javax.naming.directory.DirContext;
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;
@ -54,6 +55,9 @@ import org.keycloak.storage.ldap.idm.model.LDAPDn;
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.PasswordPolicyControl;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyControlFactory;
import org.keycloak.storage.ldap.idm.store.ldap.control.PasswordPolicyPasswordChangeException;
import org.keycloak.storage.ldap.idm.store.ldap.extended.PasswordModifyRequest;
import org.keycloak.storage.ldap.mappers.LDAPOperationDecorator;
import org.keycloak.tracing.TracingProvider;
@ -497,13 +501,14 @@ public class LDAPOperationManager {
// Never use connection pool to prevent password caching
env.put("com.sun.jndi.ldap.connect.pool", "false");
if(!this.config.isStartTls()) {
env.put(Context.SECURITY_AUTHENTICATION, "simple");
env.put(Context.SECURITY_PRINCIPAL, dn.toString());
env.put(Context.SECURITY_CREDENTIALS, password);
}
// 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);
// Send StartTLS request and setup SSL context if needed.
if (config.isStartTls()) {
SSLSocketFactory sslSocketFactory = null;
if (LDAPUtil.shouldUseTruststoreSpi(config)) {
@ -511,13 +516,36 @@ public class LDAPOperationManager {
sslSocketFactory = provider.getSSLSocketFactory();
}
tlsResponse = LDAPContextManager.startTLS(authCtx, "simple", dn.toString(), password, sslSocketFactory);
tlsResponse = LDAPContextManager.startTLS(authCtx, sslSocketFactory);
// Exception should be already thrown by LDAPContextManager.startTLS if "startTLS" could not be established, but rather do some additional check
if (tlsResponse == null) {
throw new AuthenticationException("Null TLS Response returned from the authentication");
}
}
// Configure given credentials.
authCtx.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple");
authCtx.addToEnvironment(Context.SECURITY_PRINCIPAL, dn.toString());
authCtx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);
// Send bind request. Throws AuthenticationException when authentication fails.
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()) {
logger.debugf(ae, "Authentication failed for DN [%s]", dn);
@ -660,6 +688,14 @@ public class LDAPOperationManager {
}
}
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();
}

View file

@ -0,0 +1,99 @@
/*
* 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.keycloak.storage.ldap.idm.store.ldap.BERDecoder;
import org.jboss.logging.Logger;
/**
* 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) {
BERDecoder ber = new BERDecoder(encodedValue);
try {
ber.startSequence(); // PasswordPolicyResponseValue ::= SEQUENCE
if (ber.isNextTag(BERDecoder.TAG_CLASS_CONTEXT_SPECIFIC, BERDecoder.TAG_FORM_PRIMITIVE, 0)) { // warning [0] CHOICE
ber.skipElement();
}
if (ber.isNextTag(BERDecoder.TAG_CLASS_CONTEXT_SPECIFIC, BERDecoder.TAG_FORM_PRIMITIVE, 1)) { // error [1] ENUMERATED
int error = new BigInteger(ber.drainElementValue()).intValue();
this.changeAfterReset = error == ERROR_CHANGE_AFTER_RESET;
}
} catch (BERDecoder.DecodeException 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];
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,50 @@
/*
* 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 org.junit.Assert;
import org.junit.Test;
public class PasswordPolicyControlTest {
@Test
public void testDecodeResponseValue() {
PasswordPolicyControl control = new PasswordPolicyControl(new byte[] { 0x30, 0x03, (byte) 0x81, 0x01, 0x02 });
Assert.assertTrue(control.changeAfterReset());
}
@Test
public void testDecodeErrors() {
// Not a sequence.
new PasswordPolicyControl(new byte[] { 0x31, 0x02, (byte) 0x81, 0x01, 0x02 });
// Sequence with invalid length.
new PasswordPolicyControl(new byte[] { 0x30, (byte) 0xFF, (byte) 0x82, 0x01, 0x02 });
// Sequence payload shorter than indicated.
new PasswordPolicyControl(new byte[] { 0x30, 0x03 });
// Sequence payload longer than indicated.
new PasswordPolicyControl(new byte[] { 0x30, 0x03, (byte) 0x81, 0x01, 0x02, 0x00, 0x00 });
// Invalid CHOICE tag.
new PasswordPolicyControl(new byte[] { 0x30, 0x03, (byte) 0x82, 0x01, 0x02 });
}
}

View file

@ -2824,6 +2824,8 @@ 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.
enableLdapPasswordPolicy=Enable LDAP password policy
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

View file

@ -122,6 +122,36 @@ export const LdapSettingsAdvanced = ({
></Controller>
</FormGroup>
<FormGroup
label={t("enableLdapPasswordPolicy")}
labelIcon={
<HelpItem
helpText={t("enableLdapPasswordPolicyHelp")}
fieldLabelId="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={(_event, value) => field.onChange([`${value}`])}
isChecked={field.value[0] === "true"}
label={t("on")}
labelOff={t("off")}
aria-label={t("enableLdapPasswordPolicy")}
/>
)}
></Controller>
</FormGroup>
<FormGroup
label={t("trustEmail")}
labelIcon={

View file

@ -144,6 +144,7 @@ public class LDAPConstants {
public static final String LDAP_MATCHING_RULE_IN_CHAIN = ":1.2.840.113556.1.4.1941:";
public static final String REFERRAL = "referral";
public static final String ENABLE_LDAP_PASSWORD_POLICY = "enableLdapPasswordPolicy";
public static final String CONNECTION_TRACE = "connectionTrace";

View file

@ -161,6 +161,17 @@ public class LDAPRule extends ExternalResource {
break;
}
}
Annotation passwordPolicyAnnotations = description.getAnnotation(LDAPPasswordPolicy.class);
if (passwordPolicyAnnotations != null) {
LDAPPasswordPolicy passwordPolicy = (LDAPPasswordPolicy) passwordPolicyAnnotations;
log.debugf("Enabling LDAP password policy: mustChange=%s.", passwordPolicy.mustChange());
defaultProperties.setProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_ENABLED, "true");
defaultProperties.setProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_MUST_CHANGE, String.valueOf(passwordPolicy.mustChange()));
}
return super.apply(base, description);
}
@ -261,6 +272,13 @@ public class LDAPRule extends ExternalResource {
// Configure the LDAP server to accept not secured connections from clients by default
System.setProperty("PROPERTY_SET_CONFIDENTIALITY_REQUIRED", "false");
}
switch (defaultProperties.getProperty(LDAPEmbeddedServer.PROPERTY_PPOLICY_ENABLED, "false")) {
case "true":
config.put(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY, "true");
break;
default:
config.put(LDAPConstants.ENABLE_LDAP_PASSWORD_POLICY, "false");
}
return config;
}
@ -312,4 +330,10 @@ public class LDAPRule extends ExternalResource {
public boolean isEmbeddedServer() {
return ldapTestConfiguration.isStartEmbeddedLdapServer();
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LDAPPasswordPolicy {
public boolean mustChange() default false;
}
}

View file

@ -0,0 +1,73 @@
/*
* 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.testsuite.federation.ldap;
import org.keycloak.models.RealmModel;
import org.keycloak.storage.ldap.idm.model.LDAPObject;
import org.keycloak.testsuite.util.LDAPRule;
import org.keycloak.testsuite.util.LDAPRule.LDAPPasswordPolicy;
import org.keycloak.testsuite.util.LDAPTestConfiguration;
import org.keycloak.testsuite.util.LDAPTestUtils;
import org.junit.Rule;
import org.junit.Test;
public class LDAPPasswordPolicyTest extends AbstractLDAPTest {
@Rule
// Start an embedded LDAP server with configuration derived from test annotations before each test.
public LDAPRule ldapRule = new LDAPRule()
.assumeTrue((LDAPTestConfiguration ldapConfig) -> {
return ldapConfig.isStartEmbeddedLdapServer();
});
@Override
protected LDAPRule getLDAPRule() {
return ldapRule;
}
@Override
protected void afterImportTestRealm() {
testingClient.server().run(session -> {
LDAPTestContext ctx = LDAPTestContext.init(session);
RealmModel appRealm = ctx.getRealm();
LDAPObject user = LDAPTestUtils.addLDAPUser(ctx.getLdapProvider(), appRealm, "mustchange", "John", "Doe",
"john_old@email.org", null, "1234");
LDAPTestUtils.updateLDAPPassword(ctx.getLdapProvider(), user, "Password1");
});
}
@Test
@LDAPPasswordPolicy(mustChange=true)
public void testForcedPasswordChangeAfterReset() throws Exception {
// Login with user that has to change password.
loginPage.open();
loginPage.login("mustchange", "Password1");
// Forced password change sends user to update password page.
passwordUpdatePage.assertCurrent();
// Repeated login without changing password should still send user to update password page.
loginPage.open();
loginPage.login("mustchange", "Password1");
passwordUpdatePage.assertCurrent();
}
}

View file

@ -34,11 +34,17 @@ import org.apache.commons.lang3.text.StrSubstitutor;
import org.apache.directory.api.ldap.model.entry.DefaultEntry;
import org.apache.directory.api.ldap.model.exception.LdapEntryAlreadyExistsException;
import org.apache.directory.api.ldap.model.exception.LdapException;
import org.apache.directory.api.ldap.model.exception.LdapInvalidDnException;
import org.apache.directory.api.ldap.model.ldif.LdifEntry;
import org.apache.directory.api.ldap.model.ldif.LdifReader;
import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.server.core.api.DirectoryService;
import org.apache.directory.server.core.api.InterceptorEnum;
import org.apache.directory.server.core.api.authn.ppolicy.PasswordPolicyConfiguration;
import org.apache.directory.server.core.api.interceptor.Interceptor;
import org.apache.directory.server.core.api.partition.Partition;
import org.apache.directory.server.core.authn.AuthenticationInterceptor;
import org.apache.directory.server.core.authn.ppolicy.PpolicyConfigContainer;
import org.apache.directory.server.core.factory.DefaultDirectoryServiceFactory;
import org.apache.directory.server.core.factory.JdbmPartitionFactory;
import org.apache.directory.server.core.normalization.NormalizationInterceptor;
@ -76,6 +82,8 @@ public class LDAPEmbeddedServer {
public static final String PROPERTY_ENABLE_SSL = "enableSSL";
public static final String PROPERTY_ENABLE_STARTTLS = "enableStartTLS";
public static final String PROPERTY_SET_CONFIDENTIALITY_REQUIRED = "setConfidentialityRequired";
public static final String PROPERTY_PPOLICY_ENABLED = "ppolicy.enabled";
public static final String PROPERTY_PPOLICY_MUST_CHANGE = "ppolicy.mustChange";
private static final String DEFAULT_BASE_DN = "dc=keycloak,dc=org";
private static final String DEFAULT_BIND_HOST = "localhost";
@ -105,6 +113,8 @@ public class LDAPEmbeddedServer {
protected boolean setConfidentialityRequired = false;
protected String keystoreFile;
protected String certPassword;
protected boolean ppolicyEnabled = false;
protected boolean ppolicyMustChange = false;
protected DirectoryService directoryService;
protected LdapServer ldapServer;
@ -162,6 +172,8 @@ public class LDAPEmbeddedServer {
this.setConfidentialityRequired = Boolean.valueOf(readProperty(PROPERTY_SET_CONFIDENTIALITY_REQUIRED, "false"));
this.keystoreFile = readProperty(PROPERTY_KEYSTORE_FILE, null);
this.certPassword = readProperty(PROPERTY_CERTIFICATE_PASSWORD, null);
this.ppolicyEnabled = Boolean.valueOf(readProperty(PROPERTY_PPOLICY_ENABLED, "false"));
this.ppolicyMustChange = Boolean.valueOf(readProperty(PROPERTY_PPOLICY_MUST_CHANGE, "false"));
}
protected String readProperty(String propertyName, String defaultValue) {
@ -191,6 +203,11 @@ public class LDAPEmbeddedServer {
log.info("Creating LDAP server..");
this.ldapServer = createLdapServer();
if (this.ppolicyEnabled) {
log.info("Enabling Password Policy");
createDefaultPasswordPolicy();
}
}
@ -419,4 +436,19 @@ public class LDAPEmbeddedServer {
}
}
protected void createDefaultPasswordPolicy() throws LdapInvalidDnException {
AuthenticationInterceptor authenticationInterceptor = (AuthenticationInterceptor) this.directoryService
.getInterceptor(InterceptorEnum.AUTHENTICATION_INTERCEPTOR.getName());
PasswordPolicyConfiguration policyConfig = new PasswordPolicyConfiguration();
policyConfig.setPwdMustChange(ppolicyMustChange);
PpolicyConfigContainer policyContainer = new PpolicyConfigContainer();
Dn defaultPolicyDn = new Dn( ldapServer.getDirectoryService().getSchemaManager(), "cn=defaultPasswordPolicy" );
policyContainer.addPolicy( defaultPolicyDn, policyConfig );
policyContainer.setDefaultPolicyDn( defaultPolicyDn );
authenticationInterceptor.setPwdPolicies( policyContainer );
}
}