2
0
Эх сурвалжийг харах

Password Modify Extended Operation Support

LdapUserDetailsManager can be configured to either use direct
attribute modification or the LDAP Password Modify Extended Operation
to change a user's password.

Fixes: gh-3392
Josh Cummings 6 жил өмнө
parent
commit
7269aacbdd

+ 4 - 1
ldap/spring-security-ldap.gradle

@@ -20,12 +20,15 @@ dependencies {
 		exclude(group: 'org.springframework.data', module: 'spring-data-commons')
 	}
 
+	testCompile project(':spring-security-test')
 	testCompile 'org.slf4j:jcl-over-slf4j'
 	testCompile 'org.slf4j:slf4j-api'
 }
 
 integrationTest {
-	include('**/ApacheDSServerIntegrationTests.class', '**/ApacheDSEmbeddedLdifTests.class')
+	include('**/ApacheDSServerIntegrationTests.class',
+		'**/ApacheDSEmbeddedLdifTests.class',
+		'**/LdapUserDetailsManagerModifyPasswordTests.class')
 //	  exclude('**/OpenLDAPIntegrationTestSuite.class')
 	maxParallelForks = 1
 }

+ 120 - 0
ldap/src/integration-test/java/org/springframework/security/ldap/userdetails/LdapUserDetailsManagerModifyPasswordTests.java

@@ -0,0 +1,120 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      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.springframework.security.ldap.userdetails;
+
+import javax.annotation.PreDestroy;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.ldap.core.ContextSource;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.ldap.DefaultLdapUsernameToDnMapper;
+import org.springframework.security.ldap.DefaultSpringSecurityContextSource;
+import org.springframework.security.ldap.SpringSecurityLdapTemplate;
+import org.springframework.security.ldap.server.UnboundIdContainer;
+import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * Tests for {@link LdapUserDetailsManager#changePassword}, specifically relating to the
+ * use of the Modify Password Extended Operation.
+ *
+ * @author Josh Cummings
+ */
+@RunWith(SpringJUnit4ClassRunner.class)
+@SecurityTestExecutionListeners
+public class LdapUserDetailsManagerModifyPasswordTests {
+
+	ConfigurableApplicationContext context;
+
+	LdapUserDetailsManager userDetailsManager;
+	ContextSource contextSource;
+
+	@Before
+	public void setup() {
+		this.context = new AnnotationConfigApplicationContext(ContainerConfiguration.class, LdapConfiguration.class);
+		this.contextSource = this.context.getBean(ContextSource.class);
+
+		this.userDetailsManager = new LdapUserDetailsManager(this.contextSource);
+		this.userDetailsManager.setUsePasswordModifyExtensionOperation(true);
+		this.userDetailsManager.setUsernameMapper(new DefaultLdapUsernameToDnMapper("ou=people", "uid"));
+	}
+
+	@After
+	public void teardown() {
+		this.context.close();
+	}
+
+	@Test
+	@WithMockUser(username="bob", password="bobspassword", authorities="ROLE_USER")
+	public void changePasswordWhenOldPasswordIsIncorrectThenThrowsException() {
+		assertThatCode(() ->
+				this.userDetailsManager.changePassword("wrongoldpassword", "bobsnewpassword"))
+				.isInstanceOf(BadCredentialsException.class);
+	}
+
+	@Test
+	@WithMockUser(username="bob", password="bobspassword", authorities="ROLE_USER")
+	public void changePasswordWhenOldPasswordIsCorrectThenPasses() {
+		SpringSecurityLdapTemplate template = new SpringSecurityLdapTemplate(this.contextSource);
+
+		this.userDetailsManager.changePassword("bobspassword",
+				"bobsshinynewandformidablylongandnearlyimpossibletorememberthoughdemonstrablyhardtocrackduetoitshighlevelofentropypasswordofjustice");
+
+		assertThat(template.compare("uid=bob,ou=people", "userPassword",
+				"bobsshinynewandformidablylongandnearlyimpossibletorememberthoughdemonstrablyhardtocrackduetoitshighlevelofentropypasswordofjustice")).isTrue();
+	}
+
+	@Configuration
+	static class LdapConfiguration {
+		@Autowired UnboundIdContainer container;
+
+		@Bean
+		ContextSource contextSource() throws Exception {
+			return new DefaultSpringSecurityContextSource("ldap://127.0.0.1:"
+					+ this.container.getPort() + "/dc=springframework,dc=org");
+		}
+	}
+
+	@Configuration
+	static class ContainerConfiguration {
+		UnboundIdContainer container = new UnboundIdContainer("dc=springframework,dc=org",
+				"classpath:test-server.ldif");
+
+		@Bean
+		UnboundIdContainer ldapContainer() {
+			this.container.setPort(0);
+			return this.container;
+		}
+
+		@PreDestroy
+		void shutdown() {
+			this.container.stop();
+		}
+	}
+}

+ 198 - 33
ldap/src/main/java/org/springframework/security/ldap/userdetails/LdapUserDetailsManager.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2004, 2005, 2006 Acegi Technology Pty Limited
+ * Copyright 2002-2018 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.
@@ -15,12 +15,13 @@
  */
 package org.springframework.security.ldap.userdetails;
 
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.ListIterator;
-
 import javax.naming.Context;
 import javax.naming.NameNotFoundException;
 import javax.naming.NamingEnumeration;
@@ -32,10 +33,13 @@ import javax.naming.directory.DirContext;
 import javax.naming.directory.ModificationItem;
 import javax.naming.directory.SearchControls;
 import javax.naming.directory.SearchResult;
+import javax.naming.ldap.ExtendedRequest;
+import javax.naming.ldap.ExtendedResponse;
 import javax.naming.ldap.LdapContext;
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+
 import org.springframework.ldap.core.AttributesMapper;
 import org.springframework.ldap.core.AttributesMapperCallbackHandler;
 import org.springframework.ldap.core.ContextExecutor;
@@ -70,6 +74,7 @@ import org.springframework.util.Assert;
  * setup.
  *
  * @author Luke Taylor
+ * @author Josh Cummings
  * @since 2.0
  */
 public class LdapUserDetailsManager implements UserDetailsManager {
@@ -123,6 +128,8 @@ public class LdapUserDetailsManager implements UserDetailsManager {
 
 	private String[] attributesToRetrieve;
 
+	private boolean usePasswordModifyExtensionOperation = false;
+
 	public LdapUserDetailsManager(ContextSource contextSource) {
 		template = new LdapTemplate(contextSource);
 	}
@@ -157,8 +164,21 @@ public class LdapUserDetailsManager implements UserDetailsManager {
 	/**
 	 * Changes the password for the current user. The username is obtained from the
 	 * security context.
+	 *
+	 * There are two supported strategies for modifying the user's password depending on
+	 * the capabilities of the corresponding LDAP server.
+	 *
 	 * <p>
-	 * If the old password is supplied, the update will be made by rebinding as the user,
+	 * 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>.
+	 *
+	 * 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.
@@ -178,38 +198,13 @@ public class LdapUserDetailsManager implements UserDetailsManager {
 
 		logger.debug("Changing password for user '" + username);
 
-		final DistinguishedName dn = usernameMapper.buildDn(username);
-		final ModificationItem[] passwordChange = new ModificationItem[] { new ModificationItem(
-				DirContext.REPLACE_ATTRIBUTE, new BasicAttribute(passwordAttributeName,
-						newPassword)) };
+		DistinguishedName userDn = usernameMapper.buildDn(username);
 
-		if (oldPassword == null) {
-			template.modifyAttributes(dn, passwordChange);
-			return;
+		if (usePasswordModifyExtensionOperation) {
+			changePasswordUsingExtensionOperation(userDn, oldPassword, newPassword);
+		} else {
+			changePasswordUsingAttributeModification(userDn, oldPassword, newPassword);
 		}
-
-		template.executeReadWrite(new ContextExecutor() {
-
-			public Object executeWithContext(DirContext dirCtx) throws NamingException {
-				LdapContext ctx = (LdapContext) dirCtx;
-				ctx.removeFromEnvironment("com.sun.jndi.ldap.connect.pool");
-				ctx.addToEnvironment(Context.SECURITY_PRINCIPAL,
-						LdapUtils.getFullDn(dn, 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 e) {
-					throw new BadCredentialsException(
-							"Authentication for password change failed.");
-				}
-
-				ctx.modifyAttributes(dn, passwordChange);
-
-				return null;
-			}
-		});
 	}
 
 	/**
@@ -414,4 +409,174 @@ public class LdapUserDetailsManager implements UserDetailsManager {
 	public void setRoleMapper(AttributesMapper roleMapper) {
 		this.roleMapper = roleMapper;
 	}
+
+	/**
+	 * Sets the method by which a user's password gets modified.
+	 *
+	 * 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>.
+	 *
+	 * If set to {@code false}, then {@link LdapUserDetailsManager#changePassword} will modify
+	 * the user's password by directly modifying attributes on the corresponding entry.
+	 *
+	 * Before using this setting, ensure that the corresponding LDAP server supports this extended operation.
+	 *
+	 * By default, {@code usePasswordModifyExtensionOperation} is false.
+	 *
+	 * @param usePasswordModifyExtensionOperation
+	 * @since 4.2.9
+	 */
+	public void setUsePasswordModifyExtensionOperation(boolean usePasswordModifyExtensionOperation) {
+		this.usePasswordModifyExtensionOperation = usePasswordModifyExtensionOperation;
+	}
+
+	private void changePasswordUsingAttributeModification
+			(DistinguishedName userDn, String oldPassword, String newPassword) {
+
+		final ModificationItem[] passwordChange = new ModificationItem[] { new ModificationItem(
+				DirContext.REPLACE_ATTRIBUTE, new BasicAttribute(passwordAttributeName,
+				newPassword)) };
+
+		if (oldPassword == null) {
+			template.modifyAttributes(userDn, passwordChange);
+			return;
+		}
+
+		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 e) {
+				throw new BadCredentialsException(
+						"Authentication for password change failed.");
+			}
+
+			ctx.modifyAttributes(userDn, passwordChange);
+
+			return null;
+		});
+
+	}
+
+	private void changePasswordUsingExtensionOperation
+			(DistinguishedName userDn, String oldPassword, String newPassword) {
+
+		template.executeReadWrite(dirCtx -> {
+			LdapContext ctx = (LdapContext) dirCtx;
+
+			String userIdentity = LdapUtils.getFullDn(userDn, ctx).encode();
+			PasswordModifyRequest request =
+					new PasswordModifyRequest(userIdentity, oldPassword, newPassword);
+
+			try {
+				return ctx.extendedOperation(request);
+			} catch (javax.naming.AuthenticationException e) {
+				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.
+	 *
+	 * 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 {
+		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();
+
+		public 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 e) {
+				throw new IllegalArgumentException("Failed to BER encode provided value of type: " + type);
+			}
+		}
+	}
 }