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,171 @@ +/* + * 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.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.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)); + assertTrue(root.getTree(user.getPath()).getChild(UserConstants.REP_PWD).hasChild(UserConstants.REP_PWD_HISTORY)); + assertEquals( + root.getTree(user.getPath()).getChild(UserConstants.REP_PWD).getChild(UserConstants.REP_PWD_HISTORY).getChildrenCount(0), + 1 + ); + Tree historyEntry = root.getTree(user.getPath()).getChild(UserConstants.REP_PWD).getChild(UserConstants.REP_PWD_HISTORY).getChildren().iterator().next(); + String historyPassword = TreeUtil.getString(historyEntry, UserConstants.REP_PASSWORD); + assertEquals(oldPassword, historyPassword); + + 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(); + } + + assertEquals( + root.getTree(user.getPath()).getChild(UserConstants.REP_PWD).getChild(UserConstants.REP_PWD_HISTORY).getChildrenCount(0), + 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(); + } + + assertEquals( + root.getTree(user.getPath()).getChild(UserConstants.REP_PWD).getChild(UserConstants.REP_PWD_HISTORY).getChildrenCount(0), + 10 + ); + + // history records are naturally unordered (no nodetype ordering due performance considerations) + // as such order is established via the record's JCR_CREATED date + List historyEntries = Ordering.natural().onResultOf(new Function() { + @Nullable + @Override + public Comparable apply(@Nullable Tree input) { + return null != input ? input.getProperty(JcrConstants.JCR_CREATED).getValue(Type.DATE) : null; + } + }).immutableSortedCopy(root.getTree(user.getPath()).getChild(UserConstants.REP_PWD).getChild(UserConstants.REP_PWD_HISTORY).getChildren()); + + for (int i = 0; i < PASSWORDS.length; i++) { + // we skip the first entry 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 13th password is the active one, + // so in total 12 passwords (1 from user creation and 11 from PASSWORDS) have been pushed + // through the history. therefore the two oldest passwords were pushed out of the history, + // including the first from PASSWORDS, therefore we start comparing at index 1 and stop + // when reaching the end of the password history + if (i > 0 && i < 10) { + String historicPwHash = TreeUtil.getString(historyEntries.get(i - 1), UserConstants.REP_PASSWORD); + assertTrue(PasswordUtil.isSame(historicPwHash, PASSWORDS[i])); + } + } + } + + private User createTestUser() throws Exception { + String uid = "testUser" + UUID.randomUUID(); + User user = userMgr.createUser(uid, uid); + root.commit(); + return user; + } +} 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 1654765) +++ ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/UserConstants.java (revision ) @@ -33,6 +33,8 @@ String NT_REP_GROUP = "rep:Group"; String NT_REP_SYSTEM_USER = "rep:SystemUser"; String NT_REP_PASSWORD = "rep:Password"; + String NT_REP_PASSWORD_HISTORY = "rep:PasswordHistory"; + String NT_REP_PASSWORD_HISTORY_ENTRY = "rep:PasswordHistoryEntry"; @Deprecated String NT_REP_MEMBERS = "rep:Members"; String NT_REP_MEMBER_REFERENCES_LIST = "rep:MemberReferencesList"; @@ -49,6 +51,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 +231,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-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 1654765) +++ ../../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 1654765) +++ ../../oak/trunk/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java (revision ) @@ -42,6 +42,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() { @@ -72,6 +73,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() { @@ -86,6 +88,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 1653459) +++ ../../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 1654765) +++ ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java (revision ) @@ -103,7 +103,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/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 1654765) +++ ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/spi/security/user/action/PasswordChangeAction.java (revision ) @@ -23,7 +23,10 @@ import org.apache.jackrabbit.api.security.user.User; import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.api.Tree; 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; @@ -32,7 +35,8 @@ * {@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 +45,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(User user, String newPassword, Root root, NamePathMapper namePathMapper) throws RepositoryException { if (newPassword == null) { throw new ConstraintViolationException("Expected a new password that is not null."); @@ -51,11 +65,32 @@ 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); + if (pwTree.hasChild(UserConstants.REP_PWD_HISTORY)) { + for (Tree historyEntry : pwTree.getChild(UserConstants.REP_PWD_HISTORY).getChildren()) { + String historyPwHash = TreeUtil.getString(historyEntry, UserConstants.REP_PASSWORD); + if (PasswordUtil.isSame(historyPwHash, newPassword)) { + return true; + } + } + } + } + + return false; } } \ No newline at end of file Index: ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserContext.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/UserContext.java (revision 1654765) +++ ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserContext.java (revision ) @@ -80,7 +80,8 @@ || GROUP_PROPERTY_NAMES.contains(name) || path.contains(REP_MEMBERS) || path.contains(REP_MEMBERS_LIST) - || path.contains(REP_PWD)) { + || path.contains(REP_PWD) + || path.contains(REP_PWD_HISTORY)) { return true; } else { // undefined: unable to determine if the specified location \ No newline at end of file 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,101 @@ + + +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 as child nodes of a user's _rep:pwd/rep:pwdHistory_ +node. The _rep:pwdHistory_ node is governed by the _rep:PasswordHistory_ node type, +its children by the _rep:PasswordHistoryEntry_ node type, allowing setting of the +_rep:password_ property and its record date via _jcr:created_. + +##### Node Type rep:PasswordHistory and rep:PasswordHistoryEntry + + [rep:PasswordHistory] + + * (rep:PasswordHistoryEntry) = rep:PasswordHistoryEntry protected + + [rep:PasswordHistoryEntry] > nt:hierarchyNode + - rep:password (STRING) protected + +##### Nodes rep:pwd and rep:pwdHistory + + [rep:Password] + + rep:pwdHistory (rep:PasswordHistory) = rep:PasswordHistory protected + ... + + [rep:User] > rep:Authorizable, rep:Impersonatable + + rep:pwd (rep:Password) = rep:Password protected + ... + +The _rep:pwdHistory_ and history entry nodes and the _rep:password_ property +are defined protected in order to guard against the user modifying (overcoming) her +password history limitations. The new sub-nodes also has the advantage of allowing +repository consumers to e.g. register specific commit hooks / actions on such a node. + +#### 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-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 1653459) +++ ../../oak/trunk/oak-doc/src/site/markdown/security/user.md (revision ) @@ -218,6 +218,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.*` @@ -260,6 +271,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: @@ -294,6 +306,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/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 1654765) +++ ../../oak/trunk/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserManagerImpl.java (revision ) @@ -21,15 +21,22 @@ import java.io.UnsupportedEncodingException; import java.security.NoSuchAlgorithmException; import java.security.Principal; +import java.util.Calendar; import java.util.Iterator; +import java.util.List; +import java.util.UUID; 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.Function; import com.google.common.base.Strings; +import com.google.common.collect.Ordering; +import org.apache.jackrabbit.JcrConstants; import org.apache.jackrabbit.api.security.principal.PrincipalManager; import org.apache.jackrabbit.api.security.user.Authorizable; import org.apache.jackrabbit.api.security.user.AuthorizableExistsException; @@ -57,6 +64,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; @@ -382,6 +390,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 { @@ -410,10 +423,72 @@ // 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()) { + + Tree historyTree = getPasswordHistoryTree(userTree); + + // history records are naturally unordered (no nodetype ordering due performance considerations) + // as such order is established via the record's JCR_CREATED date + List historyEntries = Ordering.natural().onResultOf(new Function() { + @Nullable + @Override + public Comparable apply(@Nullable Tree input) { + return null != input ? input.getProperty(JcrConstants.JCR_CREATED).getValue(Type.DATE) : null; + } + }).immutableSortedCopy(historyTree.getChildren()); + + 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); + List orphans = historyEntries.subList(0, trimSize); + for (Tree orphan : orphans) { + orphan.remove(); + } + } + + // append new (old) password at the end of the password history + NodeUtil newEntry = new NodeUtil(historyTree).addChild( + UUID.randomUUID().toString(), + UserConstants.NT_REP_PASSWORD_HISTORY_ENTRY + ); + newEntry.getTree().setProperty(UserConstants.REP_PASSWORD, currentPasswordHash); + newEntry.getTree().setProperty(JcrConstants.JCR_CREATED, Calendar.getInstance()); + } + } + + private static Tree getPasswordHistoryTree(Tree userTree) throws AccessDeniedException { + return new NodeUtil(getPasswordTree(userTree)).getOrAddChild( + UserConstants.REP_PWD_HISTORY, + UserConstants.NT_REP_PASSWORD_HISTORY + ).getTree(); + } + + 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 Index: ../../oak/trunk/oak-core/src/main/resources/org/apache/jackrabbit/oak/plugins/nodetype/write/builtin_nodetypes.cnd IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- ../../oak/trunk/oak-core/src/main/resources/org/apache/jackrabbit/oak/plugins/nodetype/write/builtin_nodetypes.cnd (revision 1654765) +++ ../../oak/trunk/oak-core/src/main/resources/org/apache/jackrabbit/oak/plugins/nodetype/write/builtin_nodetypes.cnd (revision ) @@ -707,7 +707,14 @@ mixin - rep:impersonators (STRING) protected multiple +[rep:PasswordHistoryEntry] > nt:hierarchyNode + - rep:password (STRING) protected + +[rep:PasswordHistory] + + * (rep:PasswordHistoryEntry) = rep:PasswordHistoryEntry protected + [rep:Password] + + rep:pwdHistory (rep:PasswordHistory) = rep:PasswordHistory protected - * (UNDEFINED) protected - * (UNDEFINED) protected multiple