diff --git a/crypto/src/main/java/org/springframework/security/crypto/password/DelegatingPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password/DelegatingPasswordEncoder.java
new file mode 100644
index 0000000000..c42f020467
--- /dev/null
+++ b/crypto/src/main/java/org/springframework/security/crypto/password/DelegatingPasswordEncoder.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2002-2017 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.crypto.password;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import java.util.Map;
+
+/**
+ * A password encoder that delegates to another PasswordEncoder based upon a prefixed
+ * identifier.
+ *
+ *
Constructing an instance
+ *
+ *
+ *
+ * String idForEncode = "bcrypt";
+ * Map encoders = new HashMap<>();
+ * encoders.put(idForEncode, new BCryptPasswordEncoder());
+ * encoders.put("noop", NoOpPasswordEncoder.getInstance());
+ * encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
+ * encoders.put("scrypt", new SCryptPasswordEncoder());
+ * encoders.put("sha256", new StandardPasswordEncoder());
+ *
+ * PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);
+ *
+ *
+ *
+ * Password Storage Format
+ *
+ * The general format for a password is:
+ *
+ *
+ * {id}encodedPassword
+ *
+ *
+ * Such that "id" is an identifier used to look up which {@link PasswordEncoder} should
+ * be used and "encodedPassword" is the original encoded password for the selected
+ * {@link PasswordEncoder}. The "id" must be at the beginning of the password, start with
+ * "{" and end with "}". If the "id" cannot be found, the "id" will be null.
+ *
+ * For example, the following might be a list of passwords encoded using different "id".
+ * All of the original passwords are "password".
+ *
+ *
+ * {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
+ * {noop}password
+ * {pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
+ * {scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
+ * {sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
+ *
+ *
+ * For the DelegatingPasswordEncoder that we constructed above:
+ *
+ *
+ * - The first password would have a {@code PasswordEncoder} id of "bcrypt" and
+ * encodedPassword of "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG".
+ * When matching it would delegate to
+ * {@link org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder}
+ * - The second password would have a {@code PasswordEncoder} id of "noop" and
+ * encodedPassword of "thisisextremelyunsafe". When matching it would delegate to
+ * {@link NoOpPasswordEncoder}
+ * - The third password would have a {@code PasswordEncoder} id of "pbkdf2" and
+ * encodedPassword of
+ * "5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc".
+ * When matching it would delegate to {@link Pbkdf2PasswordEncoder}
+ * - The fourth password would have a {@code PasswordEncoder} id of "scrypt" and
+ * encodedPassword of
+ * "$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc="
+ * When matching it would delegate to
+ * {@link org.springframework.security.crypto.scrypt.SCryptPasswordEncoder}
+ * - The final password would have a {@code PasswordEncoder} id of "sha256" and
+ * encodedPassword of
+ * "97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0".
+ * When matching it would delegate to {@link StandardPasswordEncoder}
+ *
+ *
+ * Password Encoding
+ *
+ * The @{code idForEncode} passed into the constructor determines which
+ * {@link PasswordEncoder} will be used for encoding passwords. In the
+ * {@code DelegatingPasswordEncoder} we constructed above, that means that the result of
+ * encoding "password" would be delegated to {@code BCryptPasswordEncoder} and be prefixed
+ * with "{bcrypt}". The end result would look like:
+ *
+ *
+ * {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
+ *
+ *
+ * Password Matching
+ *
+ * Matching is done based upon the "id" and the mapping of the "id" to the
+ * {@link PasswordEncoder} provided in the constructor. Our example in "Password Storage
+ * Format" provides a working example of how this is done.
+ *
+ * By default the result of invoking {@link #matches(CharSequence, String)} with a
+ * password with an "id" that is not mapped (including a null id) will result in an
+ * {@link IllegalArgumentException}. This behavior can be customized using
+ * {@link #setDefaultPasswordEncoderForMatches(PasswordEncoder)}.
+ *
+ * @author Rob Winch
+ * @since 5.0
+ */
+public class DelegatingPasswordEncoder implements PasswordEncoder {
+ private static final String PREFIX = "{";
+ private static final String SUFFIX = "}";
+ private final Log logger = LogFactory.getLog(getClass());
+ private final String idForEncode;
+ private final PasswordEncoder passwordEncoderForEncode;
+ private final Map idToPasswordEncoder;
+ private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();
+
+ /**
+ * Creates a new instance
+ * @param idForEncode the id used to lookup which {@link PasswordEncoder} should be
+ * used for {@link #encode(CharSequence)}
+ * @param idToPasswordEncoder a Map of id to {@link PasswordEncoder} used to determine
+ * which {@link PasswordEncoder} should be used for {@link #matches(CharSequence, String)}
+ */
+ public DelegatingPasswordEncoder(String idForEncode,
+ Map idToPasswordEncoder) {
+ if(idForEncode == null) {
+ throw new IllegalArgumentException("idForEncode cannot be null");
+ }
+ if(!idToPasswordEncoder.containsKey(idForEncode)) {
+ throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
+ }
+ for(String id : idToPasswordEncoder.keySet()) {
+ if(id == null) {
+ continue;
+ }
+ if(id.contains(PREFIX)) {
+ throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
+ }
+ if(id.contains(SUFFIX)) {
+ throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
+ }
+ }
+ this.idForEncode = idForEncode;
+ this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
+ this.idToPasswordEncoder = idToPasswordEncoder;
+ }
+
+ /**
+ * Sets the {@link PasswordEncoder} to delegate to for
+ * {@link #matches(CharSequence, String)} if the id is not mapped to a
+ * {@link PasswordEncoder}.
+ *
+ *
+ The encodedPassword provided will be the full password
+ * passed in including the {"id"} portion.* For example, if the password of
+ * "{notmapped}foobar" was used, the "id" would be "notmapped" and the encodedPassword
+ * passed into the {@link PasswordEncoder} would be "{notmapped}foobar".
+ *
+ * @param defaultPasswordEncoderForMatches the encoder to use. The default is to
+ * throw an {@link IllegalArgumentException}
+ */
+ public void setDefaultPasswordEncoderForMatches(
+ PasswordEncoder defaultPasswordEncoderForMatches) {
+ if(defaultPasswordEncoderForMatches == null) {
+ throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
+ }
+ this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
+ }
+
+ @Override
+ public String encode(CharSequence rawPassword) {
+ return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
+ }
+
+ @Override
+ public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
+ if(rawPassword == null && prefixEncodedPassword == null) {
+ return true;
+ }
+ String id = extractId(prefixEncodedPassword);
+ PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
+ if(delegate == null) {
+ return this.defaultPasswordEncoderForMatches
+ .matches(rawPassword, prefixEncodedPassword);
+ }
+ String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
+ return delegate.matches(rawPassword, encodedPassword);
+ }
+
+ private String extractId(String prefixEncodedPassword) {
+ int start = prefixEncodedPassword.indexOf(PREFIX);
+ if(start != 0) {
+ return null;
+ }
+ int end = prefixEncodedPassword.indexOf(SUFFIX, start);
+ if(end < 0) {
+ return null;
+ }
+ return prefixEncodedPassword.substring(start + 1, end);
+ }
+
+ private String extractEncodedPassword(String prefixEncodedPassword) {
+ int start = prefixEncodedPassword.indexOf(SUFFIX);
+ return prefixEncodedPassword.substring(start + 1);
+ }
+
+ /**
+ * Default {@link PasswordEncoder} that throws an exception that a id could
+ */
+ private class UnmappedIdPasswordEncoder implements PasswordEncoder {
+
+ @Override
+ public String encode(CharSequence rawPassword) {
+ throw new UnsupportedOperationException("encode is not supported");
+ }
+
+ @Override
+ public boolean matches(CharSequence rawPassword,
+ String prefixEncodedPassword) {
+ String id = extractId(prefixEncodedPassword);
+ throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
+ }
+ }
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password/DelegatingPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password/DelegatingPasswordEncoderTests.java
new file mode 100644
index 0000000000..f819515104
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password/DelegatingPasswordEncoderTests.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2002-2017 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.crypto.password;
+
+import com.sun.org.apache.xpath.internal.SourceTree;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Rob Winch
+ * @since 5.0
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class DelegatingPasswordEncoderTests {
+ @Mock
+ private PasswordEncoder bcrypt;
+
+ @Mock
+ private PasswordEncoder noop;
+
+ @Mock
+ private PasswordEncoder invalidId;
+
+ private String bcryptId = "bcrypt";
+
+ private String noopId = "noop";
+
+ private String rawPassword = "password";
+
+ private String encodedPassword = "ENCODED-PASSWORD";
+
+ private String bcryptEncodedPassword = "{bcrypt}" + this.encodedPassword;
+
+ private String noopEncodedPassword = "{noop}" + this.encodedPassword;
+
+ private Map delegates;
+
+ private DelegatingPasswordEncoder passwordEncoder;
+
+ @Before
+ public void setup() {
+ this.delegates = new HashMap<>();
+ this.delegates.put(this.bcryptId, this.bcrypt);
+ this.delegates.put(this.noopId, this.noop);
+
+ this.passwordEncoder = new DelegatingPasswordEncoder(this.bcryptId, this.delegates);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void constructorWhenIdForEncodeNullThenIllegalArgumentException() {
+ String id = null;
+ new DelegatingPasswordEncoder(id, this.delegates);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void constructorWhenIdForEncodeDoesNotExistThenIllegalArgumentException() {
+ new DelegatingPasswordEncoder(this.bcryptId + "INVALID", this.delegates);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void setDefaultPasswordEncoderForMatchesWhenNullThenIllegalArgumentException() {
+ this.passwordEncoder.setDefaultPasswordEncoderForMatches(null);
+ }
+
+ @Test
+ public void matchesWhenCustomDefaultPasswordEncoderForMatchesThenDelegates() {
+ String encodedPassword = "{unmapped}" + this.rawPassword;
+ this.passwordEncoder.setDefaultPasswordEncoderForMatches(this.invalidId);
+
+ assertThat(this.passwordEncoder.matches(this.rawPassword, encodedPassword)).isFalse();
+
+ verify(this.invalidId).matches(this.rawPassword, encodedPassword);
+ verifyZeroInteractions(this.bcrypt, this.noop);
+ }
+
+ @Test
+ public void encodeWhenValidThenUsesIdForEncode() {
+ when(this.bcrypt.encode(this.rawPassword)).thenReturn(this.encodedPassword);
+
+ assertThat(this.passwordEncoder.encode(this.rawPassword)).isEqualTo(this.bcryptEncodedPassword);
+ }
+
+ @Test
+ public void matchesWhenBCryptThenDelegatesToBCrypt() {
+ when(this.bcrypt.matches(this.rawPassword, this.encodedPassword)).thenReturn(true);
+
+ assertThat(this.passwordEncoder.matches(this.rawPassword, this.bcryptEncodedPassword)).isTrue();
+
+ verify(this.bcrypt).matches(this.rawPassword, this.encodedPassword);
+ verifyZeroInteractions(this.noop);
+ }
+
+ @Test
+ public void matchesWhenNoopThenDelegatesToNoop() {
+ when(this.noop.matches(this.rawPassword, this.encodedPassword)).thenReturn(true);
+
+ assertThat(this.passwordEncoder.matches(this.rawPassword, this.noopEncodedPassword)).isTrue();
+
+ verify(this.noop).matches(this.rawPassword, this.encodedPassword);
+ verifyZeroInteractions(this.bcrypt);
+ }
+
+ @Test
+ public void matchesWhenUnMappedThenIllegalArgumentException() {
+ assertThatThrownBy(() -> this.passwordEncoder.matches(this.rawPassword, "{unmapped}" + this.rawPassword))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("There is no PasswordEncoder mapped for the id \"unmapped\"");
+
+ verifyZeroInteractions(this.bcrypt, this.noop);
+ }
+
+ @Test
+ public void matchesWhenNoClosingPrefixStringThenIllegalArgumentExcetion() {
+ assertThatThrownBy(() -> this.passwordEncoder.matches(this.rawPassword, "{bcrypt" + this.rawPassword))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("There is no PasswordEncoder mapped for the id \"null\"");
+
+ verifyZeroInteractions(this.bcrypt, this.noop);
+ }
+
+ @Test
+ public void matchesWhenNoStartingPrefixStringThenFalse() {
+ assertThatThrownBy(() -> this.passwordEncoder.matches(this.rawPassword, "bcrypt}" + this.rawPassword))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("There is no PasswordEncoder mapped for the id \"null\"");
+
+ verifyZeroInteractions(this.bcrypt, this.noop);
+ }
+
+ @Test
+ public void matchesWhenNoIdStringThenFalse() {
+ assertThatThrownBy(() -> this.passwordEncoder.matches(this.rawPassword, "{}" + this.rawPassword))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("There is no PasswordEncoder mapped for the id \"\"");
+
+ verifyZeroInteractions(this.bcrypt, this.noop);
+ }
+
+ @Test
+ public void matchesWhenPrefixInMiddleThenFalse() {
+ assertThatThrownBy(() -> this.passwordEncoder.matches(this.rawPassword, "invalid" + this.bcryptEncodedPassword))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("There is no PasswordEncoder mapped for the id \"null\"");
+
+ verifyZeroInteractions(this.bcrypt, this.noop);
+ }
+
+ @Test
+ public void matchesWhenNullIdThenDelegatesToInvalidId() {
+ this.delegates.put(null, this.invalidId);
+ this.passwordEncoder = new DelegatingPasswordEncoder(this.bcryptId, this.delegates);
+ when(this.invalidId.matches(this.rawPassword, this.encodedPassword)).thenReturn(true);
+
+ assertThat(this.passwordEncoder.matches(this.rawPassword, this.encodedPassword)).isTrue();
+
+ verify(this.invalidId).matches(this.rawPassword, this.encodedPassword);
+ verifyZeroInteractions(this.bcrypt, this.noop);
+ }
+}