Index: ../oak/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/security/user/UserImportHistoryTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/security/user/UserImportHistoryTest.java	(revision )
+++ ../oak/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/security/user/UserImportHistoryTest.java	(revision )
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.jackrabbit.oak.jcr.security.user;
+
+import javax.jcr.Node;
+
+import org.apache.jackrabbit.api.security.user.Authorizable;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Testing user import with default {@link org.apache.jackrabbit.oak.spi.xml.ImportBehavior}
+ * and pw-history content: test that the history is imported irrespective of the
+ * configuration.
+ */
+public class UserImportHistoryTest extends AbstractImportTest {
+
+    @Override
+    protected String getTargetPath() {
+        return USERPATH;
+    }
+
+    @Override
+    protected String getImportBehavior() {
+        return null;
+    }
+
+    /**
+     * @since Oak 1.3.3
+     */
+    @Test
+    public void testImportUserWithPwdHistory() throws Exception {
+        // import user
+        String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
+                "<sv:node sv:name=\"y\" xmlns:mix=\"http://www.jcp.org/jcr/mix/1.0\" xmlns:nt=\"http://www.jcp.org/jcr/nt/1.0\" xmlns:fn_old=\"http://www.w3.org/2004/10/xpath-functions\" xmlns:fn=\"http://www.w3.org/2005/xpath-functions\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xmlns:sv=\"http://www.jcp.org/jcr/sv/1.0\" xmlns:rep=\"internal\" xmlns:jcr=\"http://www.jcp.org/jcr/1.0\">" +
+                "   <sv:property sv:name=\"jcr:primaryType\" sv:type=\"Name\">" +
+                "      <sv:value>rep:User</sv:value>" +
+                "   </sv:property>" +
+                "   <sv:property sv:name=\"jcr:uuid\" sv:type=\"String\">" +
+                "      <sv:value>41529076-9594-360e-ae48-5922904f345d</sv:value>" +
+                "   </sv:property>" +
+                "   <sv:property sv:name=\"rep:password\" sv:type=\"String\">" +
+                "      <sv:value>pw</sv:value>" +
+                "   </sv:property>" +
+                "   <sv:property sv:name=\"rep:principalName\" sv:type=\"String\">" +
+                "      <sv:value>yPrincipal</sv:value>" +
+                "   </sv:property>" +
+                "   <sv:node sv:name=\"" + UserConstants.REP_PWD + "\">" +
+                "      <sv:property sv:name=\"jcr:primaryType\" sv:type=\"Name\">" +
+                "         <sv:value>" + UserConstants.NT_REP_PASSWORD + "</sv:value>" +
+                "      </sv:property>" +
+                "      <sv:property sv:name=\"" + UserConstants.REP_PWD_HISTORY + "\" sv:type=\"String\" sv:multiple=\"true\">" +
+                "         <sv:value>{sha1}8efd86fb78a56a5145ed7739dcb00c78581c5375</sv:value>" +
+                "      </sv:property>" +
+                "   </sv:node>" +
+                "</sv:node>";
+
+        doImport(USERPATH, xml);
+
+        Authorizable authorizable = getUserManager().getAuthorizable("y");
+        Node userNode = getImportSession().getNode(authorizable.getPath());
+        assertTrue(userNode.hasNode(UserConstants.REP_PWD));
+
+        Node pwdNode = userNode.getNode(UserConstants.REP_PWD);
+        assertTrue(pwdNode.hasProperty(UserConstants.REP_PWD_HISTORY));
+        assertEquals("{sha1}8efd86fb78a56a5145ed7739dcb00c78581c5375", pwdNode.getProperty(UserConstants.REP_PWD_HISTORY).getString());
+    }
+}
Index: ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java	(revision 1691506)
+++ ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java	(revision )
@@ -41,6 +41,7 @@
     private static final boolean SUPPORT_AUTOSAVE = true;
     private static final Integer MAX_AGE = 10;
     private static final boolean INITIAL_PASSWORD_CHANGE = true;
+    private static final Integer PASSWORD_HISTORY_SIZE = 12;
 
     @Override
     protected ConfigurationParameters getSecurityConfigParameters() {
@@ -71,6 +72,7 @@
         assertEquals(parameters.getConfigValue(UserConstants.PARAM_SUPPORT_AUTOSAVE, false), SUPPORT_AUTOSAVE);
         assertEquals(parameters.getConfigValue(UserConstants.PARAM_PASSWORD_MAX_AGE, UserConstants.DEFAULT_PASSWORD_MAX_AGE), MAX_AGE);
         assertEquals(parameters.getConfigValue(UserConstants.PARAM_PASSWORD_INITIAL_CHANGE, UserConstants.DEFAULT_PASSWORD_INITIAL_CHANGE), INITIAL_PASSWORD_CHANGE);
+        assertEquals(parameters.getConfigValue(UserConstants.PARAM_PASSWORD_HISTORY_SIZE, UserConstants.PASSWORD_HISTORY_DISABLED_SIZE), PASSWORD_HISTORY_SIZE);
     }
 
     private ConfigurationParameters getParams() {
@@ -85,6 +87,7 @@
             put(UserConstants.PARAM_SUPPORT_AUTOSAVE, SUPPORT_AUTOSAVE);
             put(UserConstants.PARAM_PASSWORD_MAX_AGE, MAX_AGE);
             put(UserConstants.PARAM_PASSWORD_INITIAL_CHANGE, INITIAL_PASSWORD_CHANGE);
+            put(UserConstants.PARAM_PASSWORD_HISTORY_SIZE, PASSWORD_HISTORY_SIZE);
         }});
         return params;
     }
Index: ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/PasswordHistoryTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/PasswordHistoryTest.java	(revision )
+++ ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/PasswordHistoryTest.java	(revision )
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.jackrabbit.oak.security.user;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import javax.annotation.Nonnull;
+import javax.jcr.RepositoryException;
+import javax.jcr.nodetype.ConstraintViolationException;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import org.apache.jackrabbit.api.security.user.User;
+import org.apache.jackrabbit.oak.AbstractSecurityTest;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
+import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil;
+import org.apache.jackrabbit.oak.util.NodeUtil;
+import org.apache.jackrabbit.oak.util.TreeUtil;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * @see OAK-2445
+ */
+public class PasswordHistoryTest extends AbstractSecurityTest implements UserConstants {
+
+    private static final String[] PASSWORDS = {
+            "abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz0", "123", "456", "789"
+    };
+
+    private static final ConfigurationParameters CONFIG = ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, 10);
+
+    @Override
+    protected ConfigurationParameters getSecurityConfigParameters() {
+        return ConfigurationParameters.of(ImmutableMap.of(UserConfiguration.NAME, CONFIG));
+    }
+
+    @Nonnull
+    private List<String> getHistory(@Nonnull User user) throws RepositoryException {
+        return ImmutableList.copyOf(TreeUtil.getStrings(
+                root.getTree(user.getPath()).getChild(REP_PWD),
+                REP_PWD_HISTORY)).reverse();
+    }
+
+    /**
+     * Use reflection to access the private fields stored in the PasswordHistory
+     */
+    private static Integer getMaxSize(@Nonnull PasswordHistory history) throws Exception {
+        Field maxSize = history.getClass().getDeclaredField("maxSize");
+        maxSize.setAccessible(true);
+        return (Integer) maxSize.get(history);
+    }
+
+    /**
+     * Use reflection to access the private fields stored in the PasswordHistory
+     */
+    private static boolean isEnabled(@Nonnull PasswordHistory history) throws Exception {
+        Field isEnabled = history.getClass().getDeclaredField("isEnabled");
+        isEnabled.setAccessible(true);
+        return (Boolean) isEnabled.get(history);
+    }
+
+    @Test
+    public void testNoPwdTreeOnUserCreation() throws Exception {
+        User user = getTestUser();
+        assertFalse(root.getTree(user.getPath()).hasChild(REP_PWD));
+    }
+
+    @Test
+    public void testHistoryEmptyOnUserCreationWithPassword() throws Exception {
+        User user = getTestUser(); // the user is created with a password set
+
+        // the rep:pwd child must not exist. without the rep:pwd child no password history can exist.
+        assertFalse(root.getTree(user.getPath()).hasChild(REP_PWD));
+    }
+
+    @Test
+    public void testHistoryWithSinglePasswordChange() throws Exception {
+        // the user must be able to change the password
+        User user = getTestUser();
+        String oldPassword = TreeUtil.getString(root.getTree(user.getPath()), REP_PASSWORD);
+        user.changePassword("newPwd");
+        root.commit();
+
+        // after changing the password, 1 password history entry should be present and the
+        // recorded password should be equal to the user's initial password
+        // however, the user's current password must not match the old password.
+        assertTrue(root.getTree(user.getPath()).hasChild(REP_PWD));
+
+        Tree pwTree = root.getTree(user.getPath()).getChild(REP_PWD);
+        assertTrue(pwTree.hasProperty(REP_PWD_HISTORY));
+
+        List<String> history = getHistory(user);
+        assertEquals(1, history.size());
+        assertEquals(oldPassword, history.iterator().next());
+
+        String currentPw = TreeUtil.getString(root.getTree(user.getPath()), REP_PASSWORD);
+        assertNotSame(currentPw, oldPassword);
+    }
+
+    @Test
+    public void testHistoryMaxSize() throws Exception {
+        User user = getTestUser();
+
+        // we're changing the password 12 times, history max is 10
+        for (String pw : PASSWORDS) {
+            user.changePassword(pw);
+            root.commit();
+        }
+
+        assertEquals(10, getHistory(user).size());
+    }
+
+    @Test
+    public void testHistoryOrder() throws Exception {
+        User user = getTestUser();
+
+        // we're changing the password 12 times, history max is 10
+        for (String pw : PASSWORDS) {
+            user.changePassword(pw);
+        }
+
+        // we skip the first entry in the password list as it was shifted out
+        // due to max history size = 10.
+        int i = 1;
+        for (String pwHash : getHistory(user)) {
+            assertTrue(PasswordUtil.isSame(pwHash, PASSWORDS[i++]));
+        }
+    }
+
+    @Test
+    public void testRepeatedPwAfterHistorySizeReached() throws Exception {
+        User user = getTestUser();
+        for (String pw : PASSWORDS) {
+            user.changePassword(pw);
+        }
+
+        // changing pw back to the original value (as used for creation) must succeed
+        user.changePassword(user.getID());
+        // now, using all old passwords must also succeed as they get shifted out
+        for (String pw : PASSWORDS) {
+            user.changePassword(pw);
+        }
+    }
+
+    @Test(expected = ConstraintViolationException.class)
+    public void testHistoryViolationAtFirstChange() throws Exception {
+        User user = getTestUser();
+        user.changePassword(user.getID());
+    }
+
+    @Test(expected = ConstraintViolationException.class)
+    public void testHistoryViolation() throws Exception {
+        User user = getTestUser();
+        user.changePassword("abc");
+        user.changePassword("def");
+        user.changePassword("abc");
+    }
+
+    @Test
+    public void testNoHistoryUpdateOnViolation() throws Exception {
+        User user = getTestUser();
+        try {
+            user.changePassword("abc");
+            user.changePassword("def");
+            user.changePassword("abc");
+            fail("history violation not detected");
+        } catch (ConstraintViolationException e) {
+            String[] expected = new String[] {user.getID(), "abc"};
+            int i = 0;
+            for (String pwHash : getHistory(user)) {
+                assertTrue(PasswordUtil.isSame(pwHash, expected[i++]));
+            }
+        }
+    }
+
+    @Test
+    public void testEnabledPasswordHistory() throws Exception {
+        PasswordHistory history = new PasswordHistory(CONFIG);
+        assertTrue(isEnabled(history));
+        assertEquals(10, getMaxSize(history).longValue());
+    }
+
+    @Test
+    public void testHistoryUpperLimit() throws Exception {
+        PasswordHistory history = new PasswordHistory(ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, Integer.MAX_VALUE));
+
+        assertTrue(isEnabled(history));
+        assertEquals(1000, getMaxSize(history).longValue());
+    }
+
+    @Test
+    public void testDisabledPasswordHistory() throws Exception {
+        User user = getTestUser();
+        Tree userTree = root.getTree(user.getPath());
+
+        List<ConfigurationParameters> configs = ImmutableList.of(
+                ConfigurationParameters.EMPTY,
+                ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, PASSWORD_HISTORY_DISABLED_SIZE),
+                ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, -1),
+                ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, Integer.MIN_VALUE)
+        );
+
+        for (ConfigurationParameters config : configs) {
+            PasswordHistory disabledHistory = new PasswordHistory(config);
+
+            assertFalse(isEnabled(disabledHistory));
+            assertFalse(disabledHistory.updatePasswordHistory(userTree, user.getID()));
+        }
+    }
+
+    @Test(expected = ConstraintViolationException.class)
+    public void testCheckPasswordHistory() throws Exception {
+        Tree userTree = root.getTree(getTestUser().getPath());
+
+        PasswordHistory history = new PasswordHistory(CONFIG);
+        assertTrue(isEnabled(history));
+        assertEquals(10, getMaxSize(history).longValue());
+
+        history.updatePasswordHistory(userTree, getTestUser().getID());
+    }
+
+    @Test
+    public void testConfigurationChange() throws Exception {
+        User user = getTestUser();
+        Tree userTree = root.getTree(user.getPath());
+
+        PasswordHistory history = new PasswordHistory(CONFIG);
+        for (String pw : PASSWORDS) {
+            assertTrue(history.updatePasswordHistory(userTree, pw));
+        }
+        assertEquals(10, getHistory(user).size());
+
+        // change configuration to a smaller size
+        history = new PasswordHistory(ConfigurationParameters.of(PARAM_PASSWORD_HISTORY_SIZE, 5));
+        List<String> oldPwds = getHistory(user);
+        assertEquals(10, oldPwds.size());
+
+        // only the configured max-size number of entries in the history must be
+        // checked. additional entries in the history must be ignored
+        Iterables.skip(oldPwds, 6);
+        history.updatePasswordHistory(userTree, oldPwds.iterator().next());
+
+        // after chaning the pwd again however the rep:pwdHistory property must
+        // only contain the max-size number of passwords
+        assertEquals(5, getHistory(user).size());
+
+        history = new PasswordHistory(CONFIG);
+        history.updatePasswordHistory(userTree, "newPwd");
+        assertEquals(6, getHistory(user).size());
+    }
+
+    @Test
+    public void testEnableDisable() throws Exception {
+        User user = getTestUser();
+        Tree userTree = root.getTree(user.getPath());
+
+        PasswordHistory history = new PasswordHistory(CONFIG);
+        for (String pw : PASSWORDS) {
+            assertTrue(history.updatePasswordHistory(userTree, pw));
+        }
+        assertEquals(10, getHistory(user).size());
+
+        // disable the password history : changing the pw now must not
+        // modify the rep:pwdHistory property.
+        history = new PasswordHistory(ConfigurationParameters.EMPTY);
+        history.updatePasswordHistory(userTree, PASSWORDS[8]);
+
+        assertEquals(10, getHistory(user).size());
+    }
+
+    @Test
+    public void testSingleTypeHistoryProperty() throws Exception {
+        Tree userTree = root.getTree(getTestUser().getPath());
+        Tree pwdNode = new NodeUtil(userTree).getOrAddChild(REP_PWD, NT_REP_PASSWORD).getTree();
+
+        pwdNode.setProperty(REP_PWD_HISTORY, "singleValuedProperty");
+        assertFalse(pwdNode.getProperty(REP_PWD_HISTORY).isArray());
+        assertFalse(pwdNode.getProperty(REP_PWD_HISTORY).getType().isArray());
+
+        PasswordHistory history = new PasswordHistory(CONFIG);
+        assertTrue(history.updatePasswordHistory(userTree, "anyOtherPassword"));
+
+        assertTrue(pwdNode.getProperty(REP_PWD_HISTORY).isArray());
+        assertTrue(pwdNode.getProperty(REP_PWD_HISTORY).getType().isArray());
+    }
+}
Index: ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java	(revision 1691506)
+++ ../oak/oak-core/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java	(revision )
@@ -25,8 +25,6 @@
 import org.junit.Before;
 import org.junit.Test;
 
-import static org.junit.Assert.fail;
-
 public class PasswordChangeActionTest extends AbstractSecurityTest {
 
     private PasswordChangeAction pwChangeAction;
@@ -38,27 +36,17 @@
         pwChangeAction.init(getSecurityProvider(), ConfigurationParameters.EMPTY);
     }
 
-    @Test
+    @Test(expected = ConstraintViolationException.class)
     public void testNullPassword() throws Exception {
-        try {
-            pwChangeAction.onPasswordChange(getTestUser(), null, root, getNamePathMapper());
+        pwChangeAction.onPasswordChange(getTestUser(), null, root, getNamePathMapper());
-            fail("ConstraintViolationException expected.");
-        } catch (ConstraintViolationException e) {
-            // success
-        }
+    }
-    }
 
-    @Test
+    @Test(expected = ConstraintViolationException.class)
     public void testSamePassword() throws Exception {
-        try {
-            User user = getTestUser();
-            String pw = user.getID();
-            pwChangeAction.onPasswordChange(user, pw, root, getNamePathMapper());
+        User user = getTestUser();
+        String pw = user.getID();
+        pwChangeAction.onPasswordChange(user, pw, root, getNamePathMapper());
-            fail("ConstraintViolationException expected.");
-        } catch (ConstraintViolationException e) {
-            // success
-        }
+    }
-    }
 
     @Test
     public void testPasswordChange() throws Exception {
@@ -69,7 +57,10 @@
     public void testUserWithoutPassword() throws Exception {
         String uid = "testUser" + UUID.randomUUID();
         User user = getUserManager(root).createUser(uid, null);
-
+        try {
-        pwChangeAction.onPasswordChange(user, "changedPassword", root, getNamePathMapper());
+            pwChangeAction.onPasswordChange(user, "changedPassword", root, getNamePathMapper());
+        } finally {
+            user.remove();
+        }
     }
 }
\ No newline at end of file
Index: ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserConstants.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserConstants.java	(revision 1691506)
+++ ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserConstants.java	(revision )
@@ -49,6 +49,7 @@
     String REP_MEMBERS_LIST = "rep:membersList";
     String REP_IMPERSONATORS = "rep:impersonators";
     String REP_PWD = "rep:pwd";
+    String REP_PWD_HISTORY = "rep:pwdHistory";
 
     Collection<String> GROUP_PROPERTY_NAMES = ImmutableSet.of(
             REP_PRINCIPAL_NAME,
@@ -228,4 +229,17 @@
      * This may be used change the password via the credentials object.
      */
     String CREDENTIALS_ATTRIBUTE_NEWPASSWORD = "user.newpassword";
+
+    /**
+     * Optional configuration parameter indicating the maximum number of passwords recorded for a user after
+     * password changes. If the value specified is > 0, password history checking during password change is implicitly
+     * enabled and the new password provided during a password change must not be found in the already recorded
+     * history.
+     */
+    String PARAM_PASSWORD_HISTORY_SIZE = "passwordHistorySize";
+
+    /**
+     * Constant to indicate disabled password history, which is the default.
+     */
+    int PASSWORD_HISTORY_DISABLED_SIZE = 0;
 }
Index: ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserImpl.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserImpl.java	(revision 1691506)
+++ ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserImpl.java	(revision )
@@ -41,11 +41,13 @@
 class UserImpl extends AuthorizableImpl implements User {
 
     private final boolean isAdmin;
+    private final PasswordHistory pwHistory;
 
     UserImpl(String id, Tree tree, UserManagerImpl userManager) throws RepositoryException {
         super(id, tree, userManager);
 
         isAdmin = UserUtil.isAdmin(userManager.getConfig(), id);
+        pwHistory = new PasswordHistory(userManager.getConfig());
     }
 
     //---------------------------------------------------< AuthorizableImpl >---
@@ -107,6 +109,9 @@
         }
         UserManagerImpl userManager = getUserManager();
         userManager.onPasswordChange(this, password);
+
+        pwHistory.updatePasswordHistory(getTree(), password);
+
         userManager.setPassword(getTree(), getID(),  password, true);
     }
 
\ No newline at end of file
Index: ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java	(revision 1691506)
+++ ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java	(revision )
@@ -105,7 +105,11 @@
         @Property(name = UserConstants.PARAM_PASSWORD_INITIAL_CHANGE,
                 label = "Change Password On First Login",
                 description = "When enabled, forces users to change their password upon first login.",
-                boolValue = UserConstants.DEFAULT_PASSWORD_INITIAL_CHANGE)
+                boolValue = UserConstants.DEFAULT_PASSWORD_INITIAL_CHANGE),
+        @Property(name = UserConstants.PARAM_PASSWORD_HISTORY_SIZE,
+                label = "Maximum Password History Size",
+                description = "Maximum number of passwords recorded for a user after changing her password (NOTE: upper limit is 1000). When changing the password the new password must not be present in the password history. A value of 0 indicates no password history is recorded.",
+                intValue = UserConstants.PASSWORD_HISTORY_DISABLED_SIZE)
 })
 public class UserConfigurationImpl extends ConfigurationBase implements UserConfiguration, SecurityConfiguration {
 
Index: ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/package-info.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/package-info.java	(revision 1691506)
+++ ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/package-info.java	(revision )
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-@Version("2.0")
+@Version("2.1.0")
 @Export(optional = "provide:=true")
 package org.apache.jackrabbit.oak.spi.security.user;
 
\ No newline at end of file
Index: ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/PasswordHistory.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/PasswordHistory.java	(revision )
+++ ../oak/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/PasswordHistory.java	(revision )
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.jackrabbit.oak.security.user;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nonnull;
+import javax.jcr.AccessDeniedException;
+import javax.jcr.nodetype.ConstraintViolationException;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
+import org.apache.jackrabbit.oak.spi.security.user.UserConstants;
+import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil;
+import org.apache.jackrabbit.oak.util.NodeUtil;
+import org.apache.jackrabbit.oak.util.TreeUtil;
+
+/**
+ * Helper class for the password history feature.
+ */
+final class PasswordHistory implements UserConstants {
+
+    private static final int HISTORY_MAX_SIZE = 1000;
+
+    private final int maxSize;
+    private final boolean isEnabled;
+
+    public PasswordHistory(@Nonnull ConfigurationParameters config) {
+        maxSize = Math.min(HISTORY_MAX_SIZE, config.getConfigValue(UserConstants.PARAM_PASSWORD_HISTORY_SIZE, UserConstants.PASSWORD_HISTORY_DISABLED_SIZE));
+        isEnabled = maxSize > UserConstants.PASSWORD_HISTORY_DISABLED_SIZE;
+    }
+
+    /**
+     * If password history is enabled this method validates the new password and
+     * updated the history; otherwise it returns {@code false}.
+     *
+     * @param userTree The user tree.
+     * @param password The new password to be validated.
+     * @return {@code true} if the history is enabled, the new password is not
+     * included in the history and the history was successfully updated;
+     * {@code false} otherwise.
+     * @throws javax.jcr.nodetype.ConstraintViolationException If the feature
+     * is enabled and the new password is found in the history.
+     * @throws javax.jcr.AccessDeniedException If the rep:pwd tree cannot be
+     * accessed.
+     */
+    boolean updatePasswordHistory(@Nonnull Tree userTree, @Nonnull String password) throws ConstraintViolationException, AccessDeniedException {
+        boolean updated = false;
+        if (isEnabled) {
+            checkPasswordInHistory(userTree, password);
+            shiftPasswordHistory(userTree);
+            updated = true;
+        }
+        return updated;
+    }
+
+    /**
+     * Update the history property with the current pw-hash stored in rep:password
+     * and trim the list of hashes in the list according to the configured maxSize.
+     *
+     * @param userTree The user tree.
+     * @throws AccessDeniedException If the editing session cannot access or
+     * create the rep:pwd node.
+     */
+    private void shiftPasswordHistory(@Nonnull Tree userTree) throws AccessDeniedException {
+        String currentPasswordHash = TreeUtil.getString(userTree, UserConstants.REP_PASSWORD);
+        if (currentPasswordHash != null) {
+            Tree passwordTree = getPasswordTree(userTree, true);
+            PropertyState historyProp = passwordTree.getProperty(UserConstants.REP_PWD_HISTORY);
+
+            // insert the current (old) password at the beginning of the password history
+            List<String> historyEntries = (historyProp == null) ? new ArrayList<String>() : Lists.newArrayList(historyProp.getValue(Type.STRINGS));
+            historyEntries.add(0, currentPasswordHash);
+
+            /* remove oldest history entries exceeding configured history max size (e.g. after
+             * a configuration change)
+             */
+            if (historyEntries.size() > maxSize) {
+                historyEntries = historyEntries.subList(0, maxSize);
+            }
+
+            passwordTree.setProperty(UserConstants.REP_PWD_HISTORY, historyEntries, Type.STRINGS);
+        }
+    }
+
+    /**
+     * Verify that the specified new password is not contained in the history.
+     *
+     * @param userTree The user tree.
+     * @param newPassword The new password
+     * @throws ConstraintViolationException If the passsword is found in the history
+     * @throws AccessDeniedException If the editing session cannot access the rep:pwd node.
+     */
+    private void checkPasswordInHistory(@Nonnull Tree userTree, @Nonnull String newPassword) throws ConstraintViolationException, AccessDeniedException {
+        if (PasswordUtil.isSame(TreeUtil.getString(userTree, UserConstants.REP_PASSWORD), newPassword)) {
+            throw new ConstraintViolationException("New password is identical to the current password.");
+        }
+        Tree pwTree = getPasswordTree(userTree, false);
+        if (pwTree.exists()) {
+            PropertyState pwHistoryProperty = pwTree.getProperty(UserConstants.REP_PWD_HISTORY);
+            if (pwHistoryProperty != null) {
+                for (String historyPwHash : Iterables.limit(pwHistoryProperty.getValue(Type.STRINGS), maxSize)) {
+                    if (PasswordUtil.isSame(historyPwHash, newPassword)) {
+                        throw new ConstraintViolationException("New password was found in password history.");
+                    }
+                }
+            }
+        }
+    }
+
+    @Nonnull
+    private static Tree getPasswordTree(@Nonnull Tree userTree, boolean doCreate) throws AccessDeniedException {
+        if (doCreate) {
+            return new NodeUtil(userTree).getOrAddChild(UserConstants.REP_PWD, UserConstants.NT_REP_PASSWORD).getTree();
+        } else {
+            return userTree.getChild(UserConstants.REP_PWD);
+        }
+    }
+}
\ No newline at end of file
Index: ../oak/oak-doc/src/site/markdown/security/user.md
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-doc/src/site/markdown/security/user.md	(revision 1691506)
+++ ../oak/oak-doc/src/site/markdown/security/user.md	(revision )
@@ -241,6 +241,17 @@
 See section [Password Expiry and Force Initial Password Change](user/expiry.html)
 for details.
 
+#### Password History
+
+Since Oak 1.3.3 the default user management implementation provides password
+history support.
+
+By default this feature is disabled. The corresponding configuration option is
+
+- `PARAM_PASSWORD_HISTORY_SIZE`: number of changed passwords to remember.
+
+See section [Password History](user/history.html) for details.
+
 #### Utilities
 
 `org.apache.jackrabbit.oak.spi.security.user.*`
@@ -283,6 +294,7 @@
 | `PARAM_IMPORT_BEHAVIOR`             | String ("abort", "ignore", "besteffort") | "ignore"    |
 | `PARAM_PASSWORD_MAX_AGE`            | int     | 0                                            |
 | `PARAM_PASSWORD_INITIAL_CHANGE`     | boolean | false                                        |
+| `PARAM_PASSWORD_HISTORY_SIZE`       | int (upper limit: 1000) | 0                            |
 | | | |
 
 The following configuration parameters present with the default implementation in Jackrabbit 2.x are no longer supported and will be ignored:
@@ -317,6 +329,7 @@
 - [Authorizable Node Name](user/authorizablenodename.html)
 - [Searching Users and Groups](user/query.html)
 - [Password Expiry and Force Initial Password Change](user/expiry.html)
+- [Password History](user/history.html)
 
 <!-- hidden references -->
 [everyone]: /oak/docs/apidocs/org/apache/jackrabbit/oak/spi/security/principal/EveryonePrincipal.html#NAME
Index: ../oak/oak-doc/src/site/markdown/security/user/expiry.md
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-doc/src/site/markdown/security/user/expiry.md	(revision 1691506)
+++ ../oak/oak-doc/src/site/markdown/security/user/expiry.md	(revision )
@@ -74,10 +74,10 @@
         - * (UNDEFINED) protected
         - * (UNDEFINED) protected multiple
 
-##### Node rep:passwords and Property rep:passwordLastModified
+##### Node rep:pwd and Property rep:passwordLastModified
 
     [rep:User]  > rep:Authorizable, rep:Impersonatable
-        + rep:pw (rep:Password) = rep:Password protected
+        + rep:pwd (rep:Password) = rep:Password protected
         ...
         
 The _rep:pw_ node and the _rep:passwordLastModified_ property are defined
Index: ../oak/oak-doc/src/site/markdown/security/user/history.md
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- ../oak/oak-doc/src/site/markdown/security/user/history.md	(revision )
+++ ../oak/oak-doc/src/site/markdown/security/user/history.md	(revision )
@@ -0,0 +1,85 @@
+<!--
+   Licensed to the Apache Software Foundation (ASF) under one or more
+   contributor license agreements.  See the NOTICE file distributed with
+   this work for additional information regarding copyright ownership.
+   The ASF licenses this file to You 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.
+-->
+
+Password History
+--------------------------------------------------------------------------------
+
+### General
+
+Oak provides functionality to remember a configurable number of
+passwords after password changes and to prevent a password to
+be set during changing a user's password if found in said history.
+
+### Configuration
+
+An administrator may enable password history via the
+_org.apache.jackrabbit.oak.security.user.UserConfigurationImpl_
+OSGi configuration. By default the history is disabled.
+
+The following configuration option is supported:
+
+- Maximum Password History Size (_passwordHistorySize_, number of passwords): When greater 0 enables password
+  history and sets feature to remember the specified number of passwords for a user.
+
+Note, that the current implementation has a limit of at most 1000 passwords
+remembered in the history.
+
+### How it works
+
+#### Recording of Passwords
+
+If the feature is enabled, during a user changing her password, the old password
+hash is recorded in the password history.
+
+The old password hash is only recorded if a password was set (non-empty).
+Therefore setting a password for a user for the first time (i.e. during creation
+or if the user doesn't have a password set before) does not result in a history
+record, as there is no old password.
+
+The old password hash is copied to the password history *after* the provided new
+password has been validated but *before* the new password hash is written to the
+user's _rep:password_ property.
+
+The history operates as a FIFO list. A new password history record exceeding the
+configured max history size, results in the oldest recorded password from being
+removed from the history.
+
+Also, if the configuration parameter for the history size is changed to a non-zero
+but smaller value than before, upon the next password change the oldest records
+exceeding the new history size are removed.
+
+History password hashes are recorded in a multi-value property _rep:pwdHistory_ on
+the user's _rep:pwd_ node.
+        
+The _rep:pwdHistory_ property is defined protected in order to guard against the 
+user modifying (overcoming) her password history limitations.
+
+
+#### Evaluation of Password History
+
+Upon a user changing her password and if the password history feature is enabled
+(configured password history size > 0), implementation checks if the current
+password or  any of the password hashes recorded in the history matches the new
+password.
+
+If any record is a match, a _ConstraintViolationException_ is thrown and the
+user's password is *NOT* changed.
+
+#### Oak JCR XML Import
+
+When users are imported via the Oak JCR XML importer, password history is imported
+irrespective on whether the password history feature is enabled or not.
