LdapUserDetailsManager.java
/*
* Copyright 2004-present the original author or authors.
*
* 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
*
* https://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.springframework.security.ldap.userdetails;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Serial;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import javax.naming.Context;
import javax.naming.NameNotFoundException;
import javax.naming.NamingEnumeration;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.BasicAttribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.ModificationItem;
import javax.naming.directory.SearchControls;
import javax.naming.ldap.ExtendedRequest;
import javax.naming.ldap.ExtendedResponse;
import javax.naming.ldap.LdapContext;
import javax.naming.ldap.LdapName;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.ldap.core.AttributesMapper;
import org.springframework.ldap.core.AttributesMapperCallbackHandler;
import org.springframework.ldap.core.ContextSource;
import org.springframework.ldap.core.DirContextAdapter;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.core.SearchExecutor;
import org.springframework.ldap.support.LdapNameBuilder;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.ldap.DefaultLdapUsernameToDnMapper;
import org.springframework.security.ldap.LdapUsernameToDnMapper;
import org.springframework.security.ldap.LdapUtils;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.util.Assert;
/**
* An Ldap implementation of UserDetailsManager.
* <p>
* It is designed around a standard setup where users and groups/roles are stored under
* separate contexts, defined by the "userDnBase" and "groupSearchBase" properties
* respectively.
* <p>
* In this case, LDAP is being used purely to retrieve information and this class can be
* used in place of any other UserDetailsService for authentication. Authentication isn't
* performed directly against the directory, unlike with the LDAP authentication provider
* setup.
*
* @author Luke Taylor
* @author Josh Cummings
* @since 2.0
*/
public class LdapUserDetailsManager implements UserDetailsManager {
private final Log logger = LogFactory.getLog(LdapUserDetailsManager.class);
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
/**
* The strategy for mapping usernames to LDAP distinguished names. This will be used
* when building DNs for creating new users etc.
*/
LdapUsernameToDnMapper usernameMapper = new DefaultLdapUsernameToDnMapper("cn=users", "uid");
/** The DN under which groups are stored */
private LdapName groupSearchBase = LdapNameBuilder.newInstance("cn=groups").build();
/** Password attribute name */
private String passwordAttributeName = "userPassword";
/** The attribute which corresponds to the role name of a group. */
private String groupRoleAttributeName = "cn";
/** The attribute which contains members of a group */
private String groupMemberAttributeName = "uniquemember";
private String rolePrefix = "ROLE_";
/** The pattern to be used for the user search. {0} is the user's DN */
private String groupSearchFilter = "(uniquemember={0})";
/**
* The strategy used to create a UserDetails object from the LDAP context, username
* and list of authorities. This should be set to match the required UserDetails
* implementation.
*/
private UserDetailsContextMapper userDetailsMapper = new InetOrgPersonContextMapper();
private final LdapTemplate template;
/** Default context mapper used to create a set of roles from a list of attributes */
private AttributesMapper<GrantedAuthority> roleMapper = (attributes) -> {
Attribute roleAttr = attributes.get(this.groupRoleAttributeName);
NamingEnumeration<?> ne = roleAttr.getAll();
Object group = ne.next();
String role = group.toString();
return new SimpleGrantedAuthority(this.rolePrefix + role.toUpperCase(Locale.ROOT));
};
private String[] attributesToRetrieve;
private boolean usePasswordModifyExtensionOperation = false;
public LdapUserDetailsManager(ContextSource contextSource) {
this.template = new LdapTemplate(contextSource);
}
@Override
public UserDetails loadUserByUsername(String username) {
LdapName dn = this.usernameMapper.buildLdapName(username);
List<GrantedAuthority> authorities = getUserAuthorities(dn, username);
this.logger.debug(LogMessage.format("Loading user '%s' with DN '%s'", username, dn));
DirContextAdapter userCtx = loadUserAsContext(dn, username);
return this.userDetailsMapper.mapUserFromContext(userCtx, username, authorities);
}
private DirContextAdapter loadUserAsContext(final LdapName dn, final String username) {
return this.template.executeReadOnly((ctx) -> {
try {
Attributes attrs = ctx.getAttributes(dn, this.attributesToRetrieve);
return new DirContextAdapter(attrs, LdapUtils.getFullDn(dn, ctx));
}
catch (NameNotFoundException ex) {
throw UsernameNotFoundException.fromUsername(username, ex);
}
});
}
/**
* Changes the password for the current user. The username is obtained from the
* security context.
*
* <p>
* There are two supported strategies for modifying the user's password depending on
* the capabilities of the corresponding LDAP server.
*
* <p>
* Configured one way, this method will modify the user's password via the
* <a target="_blank" href="https://tools.ietf.org/html/rfc3062"> LDAP Password Modify
* Extended Operation </a>.
*
* <p>
* See {@link LdapUserDetailsManager#setUsePasswordModifyExtensionOperation(boolean)}
* for details.
* </p>
*
* <p>
* By default, though, if the old password is supplied, the update will be made by
* rebinding as the user, thus modifying the password using the user's permissions. If
* <code>oldPassword</code> is null, the update will be attempted using a standard
* read/write context supplied by the context source.
* </p>
* @param oldPassword the old password
* @param newPassword the new value of the password.
*/
@Override
public void changePassword(final String oldPassword, final String newPassword) {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
Assert.notNull(authentication,
"No authentication object found in security context. Can't change current user's password!");
String username = authentication.getName();
this.logger.debug(LogMessage.format("Changing password for user '%s'", username));
LdapName userDn = this.usernameMapper.buildLdapName(username);
if (this.usePasswordModifyExtensionOperation) {
changePasswordUsingExtensionOperation(userDn, oldPassword, newPassword);
}
else {
changePasswordUsingAttributeModification(userDn, oldPassword, newPassword);
}
}
/**
* @param dn the distinguished name of the entry - may be either relative to the base
* context or a complete DN including the name of the context (either is supported).
* @param username the user whose roles are required.
* @return the granted authorities returned by the group search
*/
List<GrantedAuthority> getUserAuthorities(final LdapName dn, final String username) {
SearchExecutor se = (ctx) -> {
LdapName fullDn = LdapUtils.getFullDn(dn, ctx);
SearchControls ctrls = new SearchControls();
ctrls.setReturningAttributes(new String[] { this.groupRoleAttributeName });
return ctx.search(this.groupSearchBase, this.groupSearchFilter,
new String[] { fullDn.toString(), username }, ctrls);
};
AttributesMapperCallbackHandler<GrantedAuthority> roleCollector = new AttributesMapperCallbackHandler<>(
this.roleMapper);
this.template.search(se, roleCollector);
return roleCollector.getList();
}
@Override
public void createUser(UserDetails user) {
DirContextAdapter ctx = new DirContextAdapter();
copyToContext(user, ctx);
LdapName dn = this.usernameMapper.buildLdapName(user.getUsername());
this.logger.debug(LogMessage.format("Creating new user '%s' with DN '%s'", user.getUsername(), dn));
this.template.bind(dn, ctx, null);
// Check for any existing authorities which might be set for this
// DN and remove them
List<GrantedAuthority> authorities = getUserAuthorities(dn, user.getUsername());
if (!authorities.isEmpty()) {
removeAuthorities(dn, authorities);
}
addAuthorities(dn, user.getAuthorities());
}
@Override
public void updateUser(UserDetails user) {
LdapName dn = this.usernameMapper.buildLdapName(user.getUsername());
this.logger.debug(LogMessage.format("Updating new user '%s' with DN '%s'", user.getUsername(), dn));
List<GrantedAuthority> authorities = getUserAuthorities(dn, user.getUsername());
DirContextAdapter ctx = loadUserAsContext(dn, user.getUsername());
ctx.setUpdateMode(true);
copyToContext(user, ctx);
// Remove the objectclass attribute from the list of mods (if present).
List<ModificationItem> mods = new LinkedList<>(Arrays.asList(ctx.getModificationItems()));
ListIterator<ModificationItem> modIt = mods.listIterator();
while (modIt.hasNext()) {
ModificationItem mod = modIt.next();
Attribute a = mod.getAttribute();
if ("objectclass".equalsIgnoreCase(a.getID())) {
modIt.remove();
}
}
this.template.modifyAttributes(dn, mods.toArray(new ModificationItem[0]));
// template.rebind(dn, ctx, null);
// Remove the old authorities and replace them with the new one
removeAuthorities(dn, authorities);
addAuthorities(dn, user.getAuthorities());
}
@Override
public void deleteUser(String username) {
LdapName dn = this.usernameMapper.buildLdapName(username);
removeAuthorities(dn, getUserAuthorities(dn, username));
this.template.unbind(dn);
}
@Override
public boolean userExists(String username) {
LdapName dn = this.usernameMapper.buildLdapName(username);
try {
Object obj = this.template.lookup(dn);
if (obj instanceof Context) {
LdapUtils.closeContext((Context) obj);
}
return true;
}
catch (org.springframework.ldap.NameNotFoundException ex) {
return false;
}
}
/**
* Creates a DN from a group name.
* @param group the name of the group
* @return the DN of the corresponding group, including the groupSearchBase
*/
protected LdapName buildGroupDn(String group) {
return LdapNameBuilder.newInstance(this.groupSearchBase)
.add(this.groupRoleAttributeName, group.toLowerCase(Locale.ROOT))
.build();
}
protected void copyToContext(UserDetails user, DirContextAdapter ctx) {
this.userDetailsMapper.mapUserToContext(user, ctx);
}
protected void addAuthorities(LdapName userDn, Collection<? extends GrantedAuthority> authorities) {
modifyAuthorities(LdapNameBuilder.newInstance(userDn).build(), authorities, DirContext.ADD_ATTRIBUTE);
}
protected void removeAuthorities(LdapName userDn, Collection<? extends GrantedAuthority> authorities) {
modifyAuthorities(LdapNameBuilder.newInstance(userDn).build(), authorities, DirContext.REMOVE_ATTRIBUTE);
}
private void modifyAuthorities(final LdapName userDn, final Collection<? extends GrantedAuthority> authorities,
final int modType) {
this.template.executeReadWrite((ctx) -> {
for (GrantedAuthority authority : authorities) {
String group = convertAuthorityToGroup(authority);
LdapName fullDn = LdapUtils.getFullDn(userDn, ctx);
ModificationItem addGroup = new ModificationItem(modType,
new BasicAttribute(this.groupMemberAttributeName, fullDn.toString()));
ctx.modifyAttributes(buildGroupDn(group), new ModificationItem[] { addGroup });
}
return null;
});
}
private String convertAuthorityToGroup(GrantedAuthority authority) {
String group = authority.getAuthority();
if (group.startsWith(this.rolePrefix)) {
group = group.substring(this.rolePrefix.length());
}
return group;
}
public void setUsernameMapper(LdapUsernameToDnMapper usernameMapper) {
this.usernameMapper = usernameMapper;
}
public void setPasswordAttributeName(String passwordAttributeName) {
this.passwordAttributeName = passwordAttributeName;
}
public void setGroupSearchBase(String groupSearchBase) {
this.groupSearchBase = LdapNameBuilder.newInstance(groupSearchBase).build();
}
public void setGroupRoleAttributeName(String groupRoleAttributeName) {
this.groupRoleAttributeName = groupRoleAttributeName;
}
public void setAttributesToRetrieve(String[] attributesToRetrieve) {
Assert.notNull(attributesToRetrieve, "attributesToRetrieve cannot be null");
this.attributesToRetrieve = attributesToRetrieve;
}
public void setUserDetailsMapper(UserDetailsContextMapper userDetailsMapper) {
this.userDetailsMapper = userDetailsMapper;
}
/**
* Sets the name of the multi-valued attribute which holds the DNs of users who are
* members of a group.
* <p>
* Usually this will be <tt>uniquemember</tt> (the default value) or <tt>member</tt>.
* </p>
* @param groupMemberAttributeName the name of the attribute used to store group
* members.
*/
public void setGroupMemberAttributeName(String groupMemberAttributeName) {
Assert.hasText(groupMemberAttributeName, "groupMemberAttributeName should have text");
this.groupMemberAttributeName = groupMemberAttributeName;
this.groupSearchFilter = "(" + groupMemberAttributeName + "={0})";
}
public void setRoleMapper(AttributesMapper roleMapper) {
this.roleMapper = roleMapper;
}
/**
* Sets the method by which a user's password gets modified.
*
* <p>
* If set to {@code true}, then {@link LdapUserDetailsManager#changePassword} will
* modify the user's password by way of the
* <a target="_blank" href="https://tools.ietf.org/html/rfc3062">Password Modify
* Extension Operation</a>.
*
* <p>
* If set to {@code false}, then {@link LdapUserDetailsManager#changePassword} will
* modify the user's password by directly modifying attributes on the corresponding
* entry.
*
* <p>
* Before using this setting, ensure that the corresponding LDAP server supports this
* extended operation.
*
* <p>
* By default, {@code usePasswordModifyExtensionOperation} is false.
* @param usePasswordModifyExtensionOperation whether to use the
* <a target="_blank" href="https://tools.ietf.org/html/rfc3062">Password Modify
* Extension Operation</a> to modify the password
* @since 4.2.9
*/
public void setUsePasswordModifyExtensionOperation(boolean usePasswordModifyExtensionOperation) {
this.usePasswordModifyExtensionOperation = usePasswordModifyExtensionOperation;
}
/**
* Sets the {@link SecurityContextHolderStrategy} to use. The default action is to use
* the {@link SecurityContextHolderStrategy} stored in {@link SecurityContextHolder}.
*
* @since 5.8
*/
public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null");
this.securityContextHolderStrategy = securityContextHolderStrategy;
}
/**
* Sets the role prefix used when converting authorities. The default value is "ROLE_"
* @param rolePrefix role prefix
* @since 6.3
*/
public void setRolePrefix(String rolePrefix) {
Assert.notNull(rolePrefix, "A rolePrefix must be supplied");
this.rolePrefix = rolePrefix;
}
private void changePasswordUsingAttributeModification(LdapName userDn, String oldPassword, String newPassword) {
ModificationItem[] passwordChange = new ModificationItem[] { new ModificationItem(DirContext.REPLACE_ATTRIBUTE,
new BasicAttribute(this.passwordAttributeName, newPassword)) };
if (oldPassword == null) {
this.template.modifyAttributes(userDn, passwordChange);
return;
}
this.template.executeReadWrite((dirCtx) -> {
LdapContext ctx = (LdapContext) dirCtx;
ctx.removeFromEnvironment("com.sun.jndi.ldap.connect.pool");
ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, LdapUtils.getFullDn(userDn, ctx).toString());
ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, oldPassword);
// TODO: reconnect doesn't appear to actually change the credentials
try {
ctx.reconnect(null);
}
catch (javax.naming.AuthenticationException ex) {
throw new BadCredentialsException("Authentication for password change failed.");
}
ctx.modifyAttributes(userDn, passwordChange);
return null;
});
}
private void changePasswordUsingExtensionOperation(LdapName userDn, String oldPassword, String newPassword) {
this.template.executeReadWrite((dirCtx) -> {
LdapContext ctx = (LdapContext) dirCtx;
String userIdentity = LdapUtils.getFullDn(userDn, ctx).toString();
PasswordModifyRequest request = new PasswordModifyRequest(userIdentity, oldPassword, newPassword);
try {
return ctx.extendedOperation(request);
}
catch (javax.naming.AuthenticationException ex) {
throw new BadCredentialsException("Authentication for password change failed.");
}
});
}
/**
* An implementation of the
* <a target="_blank" href="https://tools.ietf.org/html/rfc3062"> LDAP Password Modify
* Extended Operation </a> client request.
*
* <p>
* Can be directed at any LDAP server that supports the Password Modify Extended
* Operation.
*
* @author Josh Cummings
* @since 4.2.9
*/
private static class PasswordModifyRequest implements ExtendedRequest {
@Serial
private static final long serialVersionUID = 3154223576081503237L;
private static final byte SEQUENCE_TYPE = 48;
private static final String PASSWORD_MODIFY_OID = "1.3.6.1.4.1.4203.1.11.1";
private static final byte USER_IDENTITY_OCTET_TYPE = -128;
private static final byte OLD_PASSWORD_OCTET_TYPE = -127;
private static final byte NEW_PASSWORD_OCTET_TYPE = -126;
private final ByteArrayOutputStream value = new ByteArrayOutputStream();
PasswordModifyRequest(String userIdentity, String oldPassword, String newPassword) {
ByteArrayOutputStream elements = new ByteArrayOutputStream();
if (userIdentity != null) {
berEncode(USER_IDENTITY_OCTET_TYPE, userIdentity.getBytes(), elements);
}
if (oldPassword != null) {
berEncode(OLD_PASSWORD_OCTET_TYPE, oldPassword.getBytes(), elements);
}
if (newPassword != null) {
berEncode(NEW_PASSWORD_OCTET_TYPE, newPassword.getBytes(), elements);
}
berEncode(SEQUENCE_TYPE, elements.toByteArray(), this.value);
}
@Override
public String getID() {
return PASSWORD_MODIFY_OID;
}
@Override
public byte[] getEncodedValue() {
return this.value.toByteArray();
}
@Override
public ExtendedResponse createExtendedResponse(String id, byte[] berValue, int offset, int length) {
return null;
}
/**
* Only minimal support for <a target="_blank" href=
* "https://www.itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf"> BER
* encoding </a>; just what is necessary for the Password Modify request.
*
*/
private void berEncode(byte type, byte[] src, ByteArrayOutputStream dest) {
int length = src.length;
dest.write(type);
if (length < 128) {
dest.write(length);
}
else if ((length & 0x0000_00FF) == length) {
dest.write((byte) 0x81);
dest.write((byte) (length & 0xFF));
}
else if ((length & 0x0000_FFFF) == length) {
dest.write((byte) 0x82);
dest.write((byte) ((length >> 8) & 0xFF));
dest.write((byte) (length & 0xFF));
}
else if ((length & 0x00FF_FFFF) == length) {
dest.write((byte) 0x83);
dest.write((byte) ((length >> 16) & 0xFF));
dest.write((byte) ((length >> 8) & 0xFF));
dest.write((byte) (length & 0xFF));
}
else {
dest.write((byte) 0x84);
dest.write((byte) ((length >> 24) & 0xFF));
dest.write((byte) ((length >> 16) & 0xFF));
dest.write((byte) ((length >> 8) & 0xFF));
dest.write((byte) (length & 0xFF));
}
try {
dest.write(src);
}
catch (IOException ex) {
throw new IllegalArgumentException("Failed to BER encode provided value of type: " + type);
}
}
}
}