mirror of
https://github.com/keycloak/keycloak.git
synced 2026-02-03 20:39:33 -05:00
Merge ac5d798331 into 3e568fc81b
This commit is contained in:
commit
c3e93ebbc2
17 changed files with 619 additions and 72 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 );
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue