Index: ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java (revision 1690866) +++ ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java (revision ) @@ -16,23 +16,29 @@ */ package org.apache.jackrabbit.oak.spi.security.user.action; -import javax.annotation.CheckForNull; -import javax.annotation.Nonnull; -import javax.jcr.RepositoryException; -import javax.jcr.nodetype.ConstraintViolationException; - import org.apache.jackrabbit.api.security.user.User; +import org.apache.jackrabbit.oak.api.PropertyState; import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.api.Tree; +import org.apache.jackrabbit.oak.api.Type; import org.apache.jackrabbit.oak.namepath.NamePathMapper; +import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters; +import org.apache.jackrabbit.oak.spi.security.SecurityProvider; 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.TreeUtil; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import javax.jcr.RepositoryException; +import javax.jcr.nodetype.ConstraintViolationException; + /** * {@code PasswordChangeAction} asserts that the upon * {@link #onPasswordChange(org.apache.jackrabbit.api.security.user.User, String, * org.apache.jackrabbit.oak.api.Root, org.apache.jackrabbit.oak.namepath.NamePathMapper)} - * a different, non-null password is specified. + * a different, non-null password is specified, and that the new password is not present + * in the password history (if feature enabled and history exists). * * @see org.apache.jackrabbit.api.security.user.User#changePassword(String) * @see org.apache.jackrabbit.api.security.user.User#changePassword(String, String) @@ -41,8 +47,18 @@ */ public class PasswordChangeAction extends AbstractAuthorizableAction { + private boolean isPasswordHistoryEnabled; + //-------------------------------------------------< AuthorizableAction >--- @Override + public void init(SecurityProvider securityProvider, ConfigurationParameters config) { + isPasswordHistoryEnabled = config.getConfigValue( + UserConstants.PARAM_PASSWORD_HISTORY_SIZE, + UserConstants.DEFAULT_PASSWORD_HISTORY_SIZE + ) > 0; + } + + @Override public void onPasswordChange(@Nonnull User user, String newPassword, @Nonnull Root root, @Nonnull NamePathMapper namePathMapper) throws RepositoryException { if (newPassword == null) { throw new ConstraintViolationException("Expected a new password that is not null."); @@ -51,11 +67,37 @@ if (PasswordUtil.isSame(pwHash, newPassword)) { throw new ConstraintViolationException("New password is identical to the old password."); } + + if (isPasswordHistoryEnabled && passwordFoundInHistory(root, user, newPassword)) { + throw new ConstraintViolationException("New password was found in password history."); - } + } + } //------------------------------------------------------------< private >--- @CheckForNull private String getPasswordHash(@Nonnull Root root, @Nonnull User user) throws RepositoryException { return TreeUtil.getString(root.getTree(user.getPath()), UserConstants.REP_PASSWORD); + } + + private boolean passwordFoundInHistory(Root root, User user, String newPassword) throws RepositoryException { + Tree userTree = root.getTree(user.getPath()); + if (userTree.hasChild(UserConstants.REP_PWD)) { + Tree pwTree = userTree.getChild(UserConstants.REP_PWD); + PropertyState pwHistoryProperty = pwTree.getProperty(UserConstants.REP_PWD_HISTORY); + if (pwHistoryProperty != null) { + if (pwHistoryProperty.isArray()) { + Iterable historyPwHashes = pwHistoryProperty.getValue(Type.STRINGS); + for (String historyPwHash : historyPwHashes) { + if (PasswordUtil.isSame(historyPwHash, newPassword)) { + return true; + } + } + } else { + throw new ConstraintViolationException("Password history present but not multi-value"); + } + } + } + + return false; } } \ No newline at end of file Index: ../../oak/trunk/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/trunk/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/PasswordHistoryTest.java (revision ) +++ ../../oak/trunk/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/PasswordHistoryTest.java (revision ) @@ -0,0 +1,160 @@ +/* + * 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 com.google.common.base.Function; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.collect.Ordering; +import org.apache.jackrabbit.JcrConstants; +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.api.Type; +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.TreeUtil; +import org.junit.Before; +import org.junit.Test; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +/** + * @see OAK-xxxx + */ +public class PasswordHistoryTest extends AbstractSecurityTest { + + private static final String[] PASSWORDS = { + "abc", "def", "ghi", "jkl", "mno", "pqr", "stu", "vwx", "yz0", "123", "456", "789" + }; + + private UserManagerImpl userMgr; + + @Before + public void before() throws Exception { + super.before(); + userMgr = new UserManagerImpl(root, namePathMapper, getSecurityProvider()); + } + + @Override + protected ConfigurationParameters getSecurityConfigParameters() { + ConfigurationParameters parameters = ConfigurationParameters.of(new HashMap() {{ + put(UserConstants.PARAM_PASSWORD_HISTORY_SIZE, 10); + }}); + return ConfigurationParameters.of(ImmutableMap.of(UserConfiguration.NAME, parameters)); + } + + @Test + public void testCreateUser() throws Exception { + User user = createTestUser(); + assertFalse(root.getTree(user.getPath()).hasChild(UserConstants.REP_PWD)); + } + + @Test + public void testHistoryEmptyOnUserCreationWithPassword() throws Exception { + User user = createTestUser(); // 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(UserConstants.REP_PWD)); + } + + @Test + public void testHistoryWithSinglePasswordChange() throws Exception { + // the user must be able to change the password + User user = createTestUser(); + String oldPassword = TreeUtil.getString(root.getTree(user.getPath()), UserConstants.REP_PASSWORD); + user.changePassword(user.getID()); + 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(UserConstants.REP_PWD)); + + Tree pwTree = root.getTree(user.getPath()).getChild(UserConstants.REP_PWD); + assertTrue(pwTree.hasProperty(UserConstants.REP_PWD_HISTORY)); + + ArrayList history = Lists.newArrayList(TreeUtil.getStrings(pwTree, UserConstants.REP_PWD_HISTORY)); + assertEquals(history.size(), 1); + assertEquals(oldPassword, history.get(0)); + + String currentPw = TreeUtil.getString(root.getTree(user.getPath()), UserConstants.REP_PASSWORD); + assertNotSame(currentPw, oldPassword); + } + + @Test + public void testHistoryMaxSize() throws Exception { + User user = createTestUser(); + + // we're changing the password 12 times, history max is 10 + for (String pw : PASSWORDS) { + user.changePassword(pw); + root.commit(); + } + + ArrayList history = Lists.newArrayList( + TreeUtil.getStrings(root.getTree(user.getPath()).getChild(UserConstants.REP_PWD), + UserConstants.REP_PWD_HISTORY) + ); + assertEquals(history.size(), 10); + } + + @Test + public void testHistoryPasswordsAndOrder() throws Exception { + User user = createTestUser(); + + // we're changing the password 12 times, history max is 10 + for (String pw : PASSWORDS) { + user.changePassword(pw); + root.commit(); + } + + ArrayList history = Lists.newArrayList( + TreeUtil.getStrings(root.getTree(user.getPath()).getChild(UserConstants.REP_PWD), + UserConstants.REP_PWD_HISTORY) + ); + assertEquals(history.size(), 10); + + for (int i = 0; i < PASSWORDS.length; i++) { + // we skip the two first entries in the password list as it was shifted out + // due to max history size = 10. in total 13 passwords were set: + // pw from user creation + 12 from PASSWORD list. the initial password setting did not result in a history + // entry, as there was no old password to record. + if (i > 0 && i < 10) { + assertTrue(PasswordUtil.isSame(history.get(i), PASSWORDS[i + 1])); + } + } + } + + private User createTestUser() throws Exception { + String uid = "testUser" + UUID.randomUUID(); + User user = userMgr.createUser(uid, uid); + root.commit(); + return user; + } +} Index: ../../oak/trunk/oak-doc/src/site/markdown/security/user/history.md IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- ../../oak/trunk/oak-doc/src/site/markdown/security/user/history.md (revision ) +++ ../../oak/trunk/oak-doc/src/site/markdown/security/user/history.md (revision ) @@ -0,0 +1,79 @@ + + +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. + +### 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 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 (e.g. via the _PasswwordChangeAction_) 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), the _PasswordChangeAction_ checks whether +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 +currently not supported (ignored). \ No newline at end of file Index: ../../oak/trunk/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/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserConstants.java (revision 1690866) +++ ../../oak/trunk/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 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"; + + /** + * Default value for {@link #PARAM_PASSWORD_HISTORY_SIZE} + */ + int DEFAULT_PASSWORD_HISTORY_SIZE = 0; } Index: ../../oak/trunk/oak-doc/src/site/markdown/security/user.md IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- ../../oak/trunk/oak-doc/src/site/markdown/security/user.md (revision 1690866) +++ ../../oak/trunk/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.1.6 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 | 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) [everyone]: /oak/docs/apidocs/org/apache/jackrabbit/oak/spi/security/principal/EveryonePrincipal.html#NAME Index: ../../oak/trunk/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/trunk/oak-core/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java (revision 1690866) +++ ../../oak/trunk/oak-core/src/test/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeActionTest.java (revision ) @@ -16,12 +16,16 @@ */ package org.apache.jackrabbit.oak.spi.security.user.action; +import java.util.HashMap; import java.util.UUID; import javax.jcr.nodetype.ConstraintViolationException; +import com.google.common.collect.ImmutableMap; import org.apache.jackrabbit.api.security.user.User; import org.apache.jackrabbit.oak.AbstractSecurityTest; 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.junit.Before; import org.junit.Test; @@ -35,9 +39,17 @@ public void before() throws Exception { super.before(); pwChangeAction = new PasswordChangeAction(); - pwChangeAction.init(getSecurityProvider(), ConfigurationParameters.EMPTY); + pwChangeAction.init(getSecurityProvider(), getUserConfiguration().getParameters()); } + @Override + protected ConfigurationParameters getSecurityConfigParameters() { + ConfigurationParameters parameters = ConfigurationParameters.of(new HashMap() {{ + put(UserConstants.PARAM_PASSWORD_HISTORY_SIZE, 10); + }}); + return ConfigurationParameters.of(ImmutableMap.of(UserConfiguration.NAME, parameters)); + } + @Test public void testNullPassword() throws Exception { try { @@ -71,5 +83,22 @@ User user = getUserManager(root).createUser(uid, null); pwChangeAction.onPasswordChange(user, "changedPassword", root, getNamePathMapper()); + } + + @Test + public void testPasswordHistory() throws Exception { + String uid = "testUser" + UUID.randomUUID(); + User user = getUserManager(root).createUser(uid, null); + user.changePassword("abc"); + root.commit(); + user.changePassword("def"); + root.commit(); + + try { + pwChangeAction.onPasswordChange(user, "abc", root, getNamePathMapper()); + fail("expected constraint violation due password already in history"); + } catch (ConstraintViolationException e) { + // success + } } } \ No newline at end of file Index: ../../oak/trunk/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/trunk/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java (revision 1690866) +++ ../../oak/trunk/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.DEFAULT_PASSWORD_HISTORY_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/trunk/oak-doc/src/site/markdown/security/user/expiry.md IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- ../../oak/trunk/oak-doc/src/site/markdown/security/user/expiry.md (revision 1690866) +++ ../../oak/trunk/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/trunk/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/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java (revision 1690866) +++ ../../oak/trunk/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. 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.DEFAULT_PASSWORD_HISTORY_SIZE), }) public class UserConfigurationImpl extends ConfigurationBase implements UserConfiguration, SecurityConfiguration { Index: ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java (revision 1690866) +++ ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java (revision ) @@ -21,15 +21,19 @@ import java.io.UnsupportedEncodingException; import java.security.NoSuchAlgorithmException; import java.security.Principal; +import java.util.ArrayList; import java.util.Iterator; +import java.util.List; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import javax.jcr.AccessDeniedException; import javax.jcr.RepositoryException; import javax.jcr.UnsupportedRepositoryOperationException; import com.google.common.base.Strings; +import com.google.common.collect.Lists; import org.apache.jackrabbit.api.security.principal.PrincipalManager; import org.apache.jackrabbit.api.security.user.Authorizable; import org.apache.jackrabbit.api.security.user.AuthorizableExistsException; @@ -37,6 +41,7 @@ import org.apache.jackrabbit.api.security.user.Query; import org.apache.jackrabbit.api.security.user.User; import org.apache.jackrabbit.api.security.user.UserManager; +import org.apache.jackrabbit.oak.api.PropertyState; import org.apache.jackrabbit.oak.api.Root; import org.apache.jackrabbit.oak.api.Tree; import org.apache.jackrabbit.oak.api.Type; @@ -57,6 +62,7 @@ import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil; import org.apache.jackrabbit.oak.spi.security.user.util.UserUtil; import org.apache.jackrabbit.oak.util.NodeUtil; +import org.apache.jackrabbit.oak.util.TreeUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -387,6 +393,11 @@ } void setPassword(Tree userTree, String userId, String password, boolean forceHash) throws RepositoryException { + + // if pw history is enabled, shift all passwords in the history left and add new (old) password + // the check whether the new password has a matching history entry is done in PasswordChangeAction + shiftPasswordHistory(userTree); + String pwHash; if (forceHash || PasswordUtil.isPlainTextPassword(password)) { try { @@ -415,10 +426,58 @@ // irrespective of password expiry being enabled or not || (forceInitialPwChange && !isNewUser))) { - Tree pwdTree = new NodeUtil(userTree).getOrAddChild(UserConstants.REP_PWD, UserConstants.NT_REP_PASSWORD).getTree(); + Tree pwdTree = getPasswordTree(userTree); // System.currentTimeMillis() may be inaccurate on windows. This is accepted for this feature. pwdTree.setProperty(UserConstants.REP_PASSWORD_LAST_MODIFIED, System.currentTimeMillis(), Type.LONG); } + } + + private void shiftPasswordHistory(Tree userTree) throws AccessDeniedException { + + String currentPasswordHash = TreeUtil.getString(userTree, UserConstants.REP_PASSWORD); + + // only shift passwords if there's a current password defined and the feature is enabled + // if there's no current password, this corresponds to setting an initial password, so + // we don't record an empty password in the history. preventing empty passwords from being + // set is already checked in the PasswordChangeAction + if (null != currentPasswordHash && passwordHistoryEnabled()) { + + List historyEntries = getPasswordHistory(userTree); + + if (historyEntries.size() >= getPasswordHistoryMaxSize()) { + // remove earliest history entries exceeding configured history max size (e.g. after + // a configuration change), as well as on additional entry to make room for the new (old) + // password to be recorded at the end of the history + int trimSize = historyEntries.size() - (getPasswordHistoryMaxSize() - 1); + for (int i = 0; i < trimSize; i++) { + historyEntries.remove(0); + } + } + + // append new (old) password at the end of the password history + historyEntries.add(currentPasswordHash); + getPasswordTree(userTree).setProperty(UserConstants.REP_PWD_HISTORY, historyEntries, Type.STRINGS); + } + } + + private static List getPasswordHistory(Tree userTree) throws AccessDeniedException { + Tree pwTree = getPasswordTree(userTree); + PropertyState property = pwTree.getProperty(UserConstants.REP_PWD_HISTORY); + return property == null + ? new ArrayList() + : Lists.newArrayList(property.getValue(Type.STRINGS)); + } + + private static Tree getPasswordTree(Tree userTree) throws AccessDeniedException { + return new NodeUtil(userTree).getOrAddChild(UserConstants.REP_PWD, UserConstants.NT_REP_PASSWORD).getTree(); + } + + private boolean passwordHistoryEnabled() { + return getPasswordHistoryMaxSize() > 0; + } + + private int getPasswordHistoryMaxSize() { + return config.getConfigValue(UserConstants.PARAM_PASSWORD_HISTORY_SIZE, UserConstants.DEFAULT_PASSWORD_HISTORY_SIZE); } private boolean passwordExpiryEnabled() { \ No newline at end of file