diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.RC1.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.RC1.xml index 34771683a6..aa78acbdd6 100755 --- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.RC1.xml +++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.2.0.RC1.xml @@ -32,6 +32,9 @@ + + + diff --git a/docbook/reference/en/en-US/modules/security-vulnerabilities.xml b/docbook/reference/en/en-US/modules/security-vulnerabilities.xml index 359c55e5f3..0756ebdfc4 100755 --- a/docbook/reference/en/en-US/modules/security-vulnerabilities.xml +++ b/docbook/reference/en/en-US/modules/security-vulnerabilities.xml @@ -128,7 +128,8 @@ In the admin console, per realm, you can set up a password policy to enforce that users pick hard to guess passwords. A password has to match all policies. The password policies that can be configured are hash iterations, length, digits, - lowercase, uppercase, special characters, not username and regex patterns. Multiple regex patterns, separated by comma, + lowercase, uppercase, special characters, not username, regex patterns and expired passwords. Expired Passwords policy + restricts a user from resetting his password to N old expired passwords. Multiple regex patterns, separated by comma, can be specified. If there's more than one regex added, password has to match all fully. Increasing number of Hash Iterations (n) does not worsen anything (and certainly not the cipher) and it greatly increases the resistance to dictionary attacks. However the drawback to increasing n is that it has some cost (CPU usage, energy, delay) for diff --git a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_de.properties b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_de.properties index 61456927eb..3dbc156513 100644 --- a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_de.properties +++ b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_de.properties @@ -89,12 +89,14 @@ identityProviderRemovedMessage=Identity Provider erfolgreich entfernt. accountDisabledMessage=Benutzerkonto ist gesperrt, bitte kontaktieren Sie den Admin. accountTemporarilyDisabledMessage=Benutzerkonto ist tempor\u00E4r gesperrt, bitte kontaktieren Sie den Admin oder versuchen Sie es sp\u00E4ter noch einmal. -invalidPasswordMinLengthMessage=Ung\u00FCltiges Passwort: minimum l\u00E4nge {0}. -invalidPasswordMinDigitsMessage=Ung\u00FCltiges Passwort: muss mindestens {0} Zahl(en) beinhalten. -invalidPasswordMinLowerCaseCharsMessage=Ung\u00FCltiges Passwort: muss mindestens {0} Kleinbuchstaben beinhalten. -invalidPasswordMinUpperCaseCharsMessage=Ung\u00FCltiges Passwort: muss mindestens {0} Grossbuchstaben beinhalten. +invalidPasswordMinLengthMessage=Ung\u00FCltiges Passwort\: minimum l\u00E4nge {0}. +invalidPasswordMinDigitsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Zahl(en) beinhalten. +invalidPasswordMinLowerCaseCharsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Kleinbuchstaben beinhalten. +invalidPasswordMinUpperCaseCharsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Grossbuchstaben beinhalten. invalidPasswordMinSpecialCharsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Spezialzeichen beinhalten. invalidPasswordNotUsernameMessage=Ung\u00FCltiges Passwort\: darf nicht gleich sein wie Benutzername. +invalidPasswordRegexPatternMessage=Ung\u00FCltiges Passwort\: nicht Regex-Muster (n) entsprechen. +invalidPasswordHistoryMessage=Ung\u00FCltiges Passwort {0}: darf nicht gleich einem der letzten Passwortgeschichte. locale_de=Deutsch locale_en=Englisch diff --git a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties index 48a51aa807..248f2ad252 100755 --- a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties +++ b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_en.properties @@ -93,7 +93,9 @@ invalidPasswordMinLowerCaseCharsMessage=Invalid password: must contain at least invalidPasswordMinDigitsMessage=Invalid password: must contain at least {0} numerical digits. invalidPasswordMinUpperCaseCharsMessage=Invalid password: must contain at least {0} upper case characters. invalidPasswordMinSpecialCharsMessage=Invalid password: must contain at least {0} special characters. -invalidPasswordNotUsernameMessage=Invalid password\: must not be equal to the username. +invalidPasswordNotUsernameMessage=Invalid password: must not be equal to the username. +invalidPasswordRegexPatternMessage=Invalid password: fails to match regex pattern(s). +invalidPasswordHistoryMessage=Invalid password {0}: must not be equal to any of last password history. locale_de=German locale_en=English diff --git a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_pt_BR.properties b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_pt_BR.properties index a4e249fac3..5e387745a6 100644 --- a/forms/common-themes/src/main/resources/theme/base/account/messages/messages_pt_BR.properties +++ b/forms/common-themes/src/main/resources/theme/base/account/messages/messages_pt_BR.properties @@ -89,12 +89,14 @@ identityProviderRemovedMessage=Provedor de identidade removido com sucesso accountDisabledMessage=Conta desativada, contate o administrador accountTemporarilyDisabledMessage=A conta est\u00E1 temporariamente indispon\u00EDvel, contate administrador ou tente novamente mais tarde -invalidPasswordMinLengthMessage=Senha inv\u00E1lida: comprimento m\u00EDnimo {0} -invalidPasswordMinLowerCaseCharsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} caracteres min\u00FAsculos -invalidPasswordMinDigitsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} d\u00EDgitos num\u00E9ricos -invalidPasswordMinUpperCaseCharsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} caracteres mai\u00FAsculos -invalidPasswordMinSpecialCharsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} caracteres especiais +invalidPasswordMinLengthMessage=Senha inv\u00E1lida\: comprimento m\u00EDnimo {0} +invalidPasswordMinLowerCaseCharsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} caracteres min\u00FAsculos +invalidPasswordMinDigitsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} d\u00EDgitos num\u00E9ricos +invalidPasswordMinUpperCaseCharsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} caracteres mai\u00FAsculos +invalidPasswordMinSpecialCharsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} caracteres especiais invalidPasswordNotUsernameMessage=Senha inv\u00E1lida\: n\u00E3o deve ser igual ao nome de usu\u00E1rio +invalidPasswordRegexPatternMessage=Senha inv\u00E1lida\: n\u00E3o correspondem ao padr\u00E3o regex(s). +invalidPasswordHistoryMessage=Senha inv\u00E1lida {0}\: n\u00E3o deve ser igual a qualquer uma \u00FAltima hist\u00F3ria senha. locale_de=Deutsch locale_en=English diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js index da0b2eb1a7..df05a13ffc 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -900,14 +900,15 @@ module.factory('PasswordPolicy', function() { var p = {}; p.policyMessages = { - hashIterations: "Number of hashing iterations. Default is 1. Recommended is 50000.", - length: "Minimal password length (integer type). Default value is 8.", - digits: "Minimal number (integer type) of digits in password. Default value is 1.", - lowerCase: "Minimal number (integer type) of lowercase characters in password. Default value is 1.", - upperCase: "Minimal number (integer type) of uppercase characters in password. Default value is 1.", - specialChars: "Minimal number (integer type) of special characters in password. Default value is 1.", - notUsername: "Block passwords that are equal to the username", - regexPatterns: "Block passwords that do not match all of the regex patterns (string type)." + hashIterations: "Number of hashing iterations. Default is 1. Recommended is 50000.", + length: "Minimal password length (integer type). Default value is 8.", + digits: "Minimal number (integer type) of digits in password. Default value is 1.", + lowerCase: "Minimal number (integer type) of lowercase characters in password. Default value is 1.", + upperCase: "Minimal number (integer type) of uppercase characters in password. Default value is 1.", + specialChars: "Minimal number (integer type) of special characters in password. Default value is 1.", + notUsername: "Block passwords that are equal to the username", + regexPatterns: "Block passwords that do not match all of the regex patterns (string type).", + passwordHistory: "Block passwords that are equal to previous passwords. Default value is 3." } p.allPolicies = [ @@ -918,7 +919,8 @@ module.factory('PasswordPolicy', function() { { name: 'upperCase', value: 1 }, { name: 'specialChars', value: 1 }, { name: 'notUsername', value: 1 }, - { name: 'regexPatterns', value: ''} + { name: 'regexPatterns', value: ''}, + { name: 'passwordHistory', value: 3 } ]; p.parse = function(policyString) { diff --git a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_de.properties b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_de.properties index 75ffd3ee9b..3ed399dfcb 100644 --- a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_de.properties +++ b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_de.properties @@ -125,12 +125,14 @@ accountPasswordUpdatedMessage=Ihr Passwort wurde aktualisiert. noAccessMessage=Kein Zugriff -invalidPasswordMinLengthMessage=Ung\u00FCltiges Passwort: minimum l\u00E4nge {0}. -invalidPasswordMinDigitsMessage=Ung\u00FCltiges Passwort: muss mindestens {0} Zahl(en) beinhalten. -invalidPasswordMinLowerCaseCharsMessage=Ung\u00FCltiges Passwort: muss mindestens {0} Kleinbuchstaben beinhalten. -invalidPasswordMinUpperCaseCharsMessage=Ung\u00FCltiges Passwort: muss mindestens {0} Grossbuchstaben beinhalten. -invalidPasswordMinSpecialCharsMessage=Ung\u00FCltiges Passwort: muss mindestens {0} Spezialzeichen beinhalten. +invalidPasswordMinLengthMessage=Ung\u00FCltiges Passwort\: minimum l\u00E4nge {0}. +invalidPasswordMinDigitsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Zahl(en) beinhalten. +invalidPasswordMinLowerCaseCharsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Kleinbuchstaben beinhalten. +invalidPasswordMinUpperCaseCharsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Grossbuchstaben beinhalten. +invalidPasswordMinSpecialCharsMessage=Ung\u00FCltiges Passwort\: muss mindestens {0} Spezialzeichen beinhalten. invalidPasswordNotUsernameMessage=Ung\u00FCltiges Passwort\: darf nicht gleich sein wie Benutzername. +invalidPasswordRegexPatternMessage=Ung\u00FCltiges Passwort\: nicht Regex-Muster (n) entsprechen. +invalidPasswordHistoryMessage=Ung\u00FCltiges Passwort {0}\: darf nicht gleich einem der letzten Passwortgeschichte. failedToProcessResponseMessage=Konnte Response nicht verarbeiten. httpsRequiredMessage=HTTPS erforderlich. diff --git a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties index 0a3d6497db..ea61f5fb28 100755 --- a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -127,7 +127,9 @@ invalidPasswordMinDigitsMessage=Invalid password: must contain at least {0} nume invalidPasswordMinLowerCaseCharsMessage=Invalid password: must contain at least {0} lower case characters. invalidPasswordMinUpperCaseCharsMessage=Invalid password: must contain at least {0} upper case characters. invalidPasswordMinSpecialCharsMessage=Invalid password: must contain at least {0} special characters. -invalidPasswordNotUsernameMessage=Invalid password\: must not be equal to the username. +invalidPasswordNotUsernameMessage=Invalid password: must not be equal to the username. +invalidPasswordRegexPatternMessage=Invalid password: fails to match regex pattern(s). +invalidPasswordHistoryMessage=Invalid password {0}: must not be equal to any of last password history. failedToProcessResponseMessage=Failed to process response httpsRequiredMessage=HTTPS required diff --git a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_pt_BR.properties b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_pt_BR.properties index c7ba2bc016..e011159452 100644 --- a/forms/common-themes/src/main/resources/theme/base/login/messages/messages_pt_BR.properties +++ b/forms/common-themes/src/main/resources/theme/base/login/messages/messages_pt_BR.properties @@ -122,12 +122,14 @@ accountPasswordUpdatedMessage=Sua senha foi atualizada noAccessMessage=Sem acesso -invalidPasswordMinLengthMessage=Senha inv\u00E1lida: comprimento m\u00EDnimo {0} -invalidPasswordMinDigitsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} d\u00EDgitos num\u00E9ricos -invalidPasswordMinLowerCaseCharsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} caracteres min\u00FAsculos -invalidPasswordMinUpperCaseCharsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} caracteres mai\u00FAsculos -invalidPasswordMinSpecialCharsMessage=Senha inv\u00E1lida: deve conter pelo menos {0} caracteres especiais -invalidPasswordNotUsernameMessage=Senha inv\u00E1lida: n\u00E3o deve ser igual ao nome de usu\u00E1rio +invalidPasswordMinLengthMessage=Senha inv\u00E1lida\: comprimento m\u00EDnimo {0} +invalidPasswordMinDigitsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} d\u00EDgitos num\u00E9ricos +invalidPasswordMinLowerCaseCharsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} caracteres min\u00FAsculos +invalidPasswordMinUpperCaseCharsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} caracteres mai\u00FAsculos +invalidPasswordMinSpecialCharsMessage=Senha inv\u00E1lida\: deve conter pelo menos {0} caracteres especiais +invalidPasswordNotUsernameMessage=Senha inv\u00E1lida\: n\u00E3o deve ser igual ao nome de usu\u00E1rio +invalidPasswordRegexPatternMessage=Senha inv\u00E1lida\: n\u00E3o correspondem ao padr\u00E3o regex(s). +invalidPasswordHistoryMessage=Senha inv\u00E1lida {0}\: n\u00E3o deve ser igual a qualquer uma \u00FAltima hist\u00F3ria senha. failedToProcessResponseMessage=Falha ao processar a resposta httpsRequiredMessage=HTTPS requerido diff --git a/model/api/src/main/java/org/keycloak/models/PasswordPolicy.java b/model/api/src/main/java/org/keycloak/models/PasswordPolicy.java index 703e9c6593..a47aa9b0c8 100755 --- a/model/api/src/main/java/org/keycloak/models/PasswordPolicy.java +++ b/model/api/src/main/java/org/keycloak/models/PasswordPolicy.java @@ -1,11 +1,15 @@ package org.keycloak.models; +import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.keycloak.models.utils.Pbkdf2PasswordEncoder; + /** * @author Stian Thorgersen */ @@ -18,6 +22,7 @@ public class PasswordPolicy { public static final String INVALID_PASSWORD_MIN_SPECIAL_CHARS_MESSAGE = "invalidPasswordMinSpecialCharsMessage"; public static final String INVALID_PASSWORD_NOT_USERNAME = "invalidPasswordNotUsernameMessage"; public static final String INVALID_PASSWORD_REGEX_PATTERN = "invalidPasswordRegexPatternMessage"; + public static final String INVALID_PASSWORD_HISTORY = "invalidPasswordHistoryMessage"; private List policies; private String policyString; @@ -67,10 +72,12 @@ public class PasswordPolicy { } else if (name.equals(HashIterations.NAME)) { list.add(new HashIterations(args)); } else if (name.equals(RegexPatterns.NAME)) { - for(String regexPattern : args) { + for (String regexPattern : args) { Pattern.compile(regexPattern); } list.add(new RegexPatterns(args)); + } else if (name.equals(PasswordHistory.NAME)) { + list.add(new PasswordHistory(args)); } } return list; @@ -92,9 +99,35 @@ public class PasswordPolicy { return -1; } - public Error validate(String username, String password) { + /** + * + * @return -1 if no expired passwords setting + */ + public int getExpiredPasswords() { + if (policies == null) + return -1; for (Policy p : policies) { - Error error = p.validate(username, password); + if (p instanceof PasswordHistory) { + return ((PasswordHistory) p).passwordHistoryPolicyValue; + } + + } + return -1; + } + + public Error validate(UserModel user, String password) { + for (Policy p : policies) { + Error error = p.validate(user, password); + if (error != null) { + return error; + } + } + return null; + } + + public Error validate(String user, String password) { + for (Policy p : policies) { + Error error = p.validate(user, password); if (error != null) { return error; } @@ -103,7 +136,8 @@ public class PasswordPolicy { } private static interface Policy { - public Error validate(String username, String password); + public Error validate(UserModel user, String password); + public Error validate(String user, String password); } public static class Error { @@ -131,9 +165,15 @@ public class PasswordPolicy { public HashIterations(String[] args) { iterations = intArg(NAME, 1, args); } + @Override - public Error validate(String username, String password) { + public Error validate(String user, String password) { + return null; + } + + @Override + public Error validate(UserModel user, String password) { return null; } } @@ -148,6 +188,11 @@ public class PasswordPolicy { public Error validate(String username, String password) { return username.equals(password) ? new Error(INVALID_PASSWORD_NOT_USERNAME) : null; } + + @Override + public Error validate(UserModel user, String password) { + return validate(user.getUsername(), password); + } } private static class Length implements Policy { @@ -157,11 +202,17 @@ public class PasswordPolicy { public Length(String[] args) { min = intArg(NAME, 8, args); } + @Override public Error validate(String username, String password) { return password.length() < min ? new Error(INVALID_PASSWORD_MIN_LENGTH_MESSAGE, min) : null; } + + @Override + public Error validate(UserModel user, String password) { + return validate(user.getUsername(), password); + } } private static class Digits implements Policy { @@ -171,6 +222,7 @@ public class PasswordPolicy { public Digits(String[] args) { min = intArg(NAME, 1, args); } + @Override public Error validate(String username, String password) { @@ -182,6 +234,11 @@ public class PasswordPolicy { } return count < min ? new Error(INVALID_PASSWORD_MIN_DIGITS_MESSAGE, min) : null; } + + @Override + public Error validate(UserModel user, String password) { + return validate(user.getUsername(), password); + } } private static class LowerCase implements Policy { @@ -191,7 +248,7 @@ public class PasswordPolicy { public LowerCase(String[] args) { min = intArg(NAME, 1, args); } - + @Override public Error validate(String username, String password) { int count = 0; @@ -202,6 +259,11 @@ public class PasswordPolicy { } return count < min ? new Error(INVALID_PASSWORD_MIN_LOWER_CASE_CHARS_MESSAGE, min) : null; } + + @Override + public Error validate(UserModel user, String password) { + return validate(user.getUsername(), password); + } } private static class UpperCase implements Policy { @@ -222,6 +284,11 @@ public class PasswordPolicy { } return count < min ? new Error(INVALID_PASSWORD_MIN_UPPER_CASE_CHARS_MESSAGE, min) : null; } + + @Override + public Error validate(UserModel user, String password) { + return validate(user.getUsername(), password); + } } private static class SpecialChars implements Policy { @@ -231,7 +298,7 @@ public class PasswordPolicy { public SpecialChars(String[] args) { min = intArg(NAME, 1, args); } - + @Override public Error validate(String username, String password) { int count = 0; @@ -242,6 +309,11 @@ public class PasswordPolicy { } return count < min ? new Error(INVALID_PASSWORD_MIN_SPECIAL_CHARS_MESSAGE, min) : null; } + + @Override + public Error validate(UserModel user, String password) { + return validate(user.getUsername(), password); + } } private static class RegexPatterns implements Policy { @@ -256,17 +328,96 @@ public class PasswordPolicy { public Error validate(String username, String password) { Pattern pattern = null; Matcher matcher = null; - for(String regexPattern : regexPatterns) { + for (String regexPattern : regexPatterns) { pattern = Pattern.compile(regexPattern); matcher = pattern.matcher(password); if (!matcher.matches()) { - return new Error(INVALID_PASSWORD_REGEX_PATTERN, (Object)regexPatterns); + return new Error(INVALID_PASSWORD_REGEX_PATTERN, (Object) regexPatterns); } } return null; } + + @Override + public Error validate(UserModel user, String password) { + return validate(user.getUsername(), password); + } } + private static class PasswordHistory implements Policy { + private static final String NAME = "passwordHistory"; + private int passwordHistoryPolicyValue; + + public PasswordHistory(String[] args) { + passwordHistoryPolicyValue = intArg(NAME, 3, args); + } + + @Override + public Error validate(String user, String password) { + return null; + } + + @Override + public Error validate(UserModel user, String password) { + + if (passwordHistoryPolicyValue != -1) { + + UserCredentialValueModel cred = getCredentialValueModel(user, UserCredentialModel.PASSWORD); + if (cred != null) { + if(new Pbkdf2PasswordEncoder(cred.getSalt()).verify(password, cred.getValue(), cred.getHashIterations())) { + return new Error(INVALID_PASSWORD_HISTORY, password); + } + } + + List passwordExpiredCredentials = getCredentialValueModels(user, passwordHistoryPolicyValue - 1, + UserCredentialModel.PASSWORD_HISTORY); + for (UserCredentialValueModel credential : passwordExpiredCredentials) { + if (new Pbkdf2PasswordEncoder(credential.getSalt()).verify(password, credential.getValue(), credential.getHashIterations())) { + return new Error(INVALID_PASSWORD_HISTORY, password); + } + } + } + return null; + } + + private UserCredentialValueModel getCredentialValueModel(UserModel user, String credType) { + for (UserCredentialValueModel model : user.getCredentialsDirectly()) { + if (model.getType().equals(credType)) { + return model; + } + } + + return null; + } + + private List getCredentialValueModels(UserModel user, int expiredPasswordsPolicyValue, + String credType) { + List credentialModels = new ArrayList(); + for (UserCredentialValueModel model : user.getCredentialsDirectly()) { + if (model.getType().equals(credType)) { + credentialModels.add(model); + } + } + + Collections.sort(credentialModels, new Comparator() { + public int compare(UserCredentialValueModel credFirst, UserCredentialValueModel credSecond) { + if (credFirst.getCreatedDate() > credSecond.getCreatedDate()) { + return -1; + } else if (credFirst.getCreatedDate() < credSecond.getCreatedDate()) { + return 1; + } else { + return 0; + } + } + }); + + if (credentialModels.size() > expiredPasswordsPolicyValue) { + return credentialModels.subList(0, expiredPasswordsPolicyValue); + } + return credentialModels; + } + } + private static int intArg(String policy, int defaultValue, String... args) { if (args == null || args.length == 0) { return defaultValue; diff --git a/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java b/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java index 5fb60050eb..083eea85e0 100755 --- a/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java +++ b/model/api/src/main/java/org/keycloak/models/UserCredentialModel.java @@ -8,6 +8,7 @@ import java.util.UUID; */ public class UserCredentialModel { public static final String PASSWORD = "password"; + public static final String PASSWORD_HISTORY = "password-history"; public static final String PASSWORD_TOKEN = "password-token"; // Secret is same as password but it is not hashed diff --git a/model/api/src/main/java/org/keycloak/models/UserCredentialValueModel.java b/model/api/src/main/java/org/keycloak/models/UserCredentialValueModel.java index bd1213f32b..663eaeca19 100755 --- a/model/api/src/main/java/org/keycloak/models/UserCredentialValueModel.java +++ b/model/api/src/main/java/org/keycloak/models/UserCredentialValueModel.java @@ -12,6 +12,7 @@ public class UserCredentialValueModel { private String device; private byte[] salt; private int hashIterations; + private long createdDate; public String getType() { return type; @@ -52,4 +53,13 @@ public class UserCredentialValueModel { public void setHashIterations(int iterations) { this.hashIterations = iterations; } + + public long getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(long createdDate) { + this.createdDate = createdDate; + } + } diff --git a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java index 1016341e3a..94d0226aca 100755 --- a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java +++ b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java @@ -323,7 +323,7 @@ public class UserFederationManager implements UserProvider { public void updateCredential(RealmModel realm, UserModel user, UserCredentialModel credential) { if (credential.getType().equals(UserCredentialModel.PASSWORD)) { if (realm.getPasswordPolicy() != null) { - PasswordPolicy.Error error = realm.getPasswordPolicy().validate(user.getUsername(), credential.getValue()); + PasswordPolicy.Error error = realm.getPasswordPolicy().validate(user, credential.getValue()); if (error != null) throw new ModelException(error.getMessage(), error.getParameters()); } } diff --git a/model/api/src/main/java/org/keycloak/models/entities/CredentialEntity.java b/model/api/src/main/java/org/keycloak/models/entities/CredentialEntity.java index 6255836a40..5493fe24d5 100755 --- a/model/api/src/main/java/org/keycloak/models/entities/CredentialEntity.java +++ b/model/api/src/main/java/org/keycloak/models/entities/CredentialEntity.java @@ -5,11 +5,23 @@ package org.keycloak.models.entities; */ public class CredentialEntity { + private String id; private String type; private String value; private String device; private byte[] salt; private int hashIterations; + private long createdDate; + private UserEntity user; + + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } public String getType() { return type; @@ -50,4 +62,21 @@ public class CredentialEntity { public void setHashIterations(int hashIterations) { this.hashIterations = hashIterations; } + + public long getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(long createdDate) { + this.createdDate = createdDate; + } + + public UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + } diff --git a/model/api/src/test/java/org/keycloak/models/PasswordPolicyTest.java b/model/api/src/test/java/org/keycloak/models/PasswordPolicyTest.java index 3bde361113..14f28fb0de 100644 --- a/model/api/src/test/java/org/keycloak/models/PasswordPolicyTest.java +++ b/model/api/src/test/java/org/keycloak/models/PasswordPolicyTest.java @@ -1,5 +1,9 @@ package org.keycloak.models; +import static org.junit.Assert.fail; + +import java.util.regex.PatternSyntaxException; + import org.junit.Assert; import org.junit.Test; @@ -79,6 +83,48 @@ public class PasswordPolicyTest { Assert.assertEquals("invalidPasswordNotUsernameMessage", policy.validate("jdoe", "jdoe").getMessage()); Assert.assertNull(policy.validate("jdoe", "ab&d1234")); } + + @Test + public void testRegexPatterns() { + PasswordPolicy policy = null; + try { + policy = new PasswordPolicy("regexPatterns"); + fail("Expected NullPointerEXception: Regex Pattern cannot be null."); + } catch (NullPointerException e) { + // Expected NPE as regex pattern is null. + } + + try { + policy = new PasswordPolicy("regexPatterns(*)"); + fail("Expected PatternSyntaxException: Regex Pattern cannot be null."); + } catch (PatternSyntaxException e) { + // Expected PSE as regex pattern(or any of its token) is not quantifiable. + } + + try { + policy = new PasswordPolicy("regexPatterns(*,**)"); + fail("Expected PatternSyntaxException: Regex Pattern cannot be null."); + } catch (PatternSyntaxException e) { + // Expected PSE as regex pattern(or any of its token) is not quantifiable. + } + + //Fails to match one of the regex pattern + policy = new PasswordPolicy("regexPatterns(jdoe,j*d)"); + Assert.assertEquals("invalidPasswordRegexPatternMessage", policy.validate("jdoe", "jdoe").getMessage()); + + ////Fails to match all of the regex patterns + policy = new PasswordPolicy("regexPatterns(j*p,j*d,adoe)"); + Assert.assertEquals("invalidPasswordRegexPatternMessage", policy.validate("jdoe", "jdoe").getMessage()); + + policy = new PasswordPolicy("regexPatterns([a-z][a-z][a-z][a-z][0-9])"); + Assert.assertEquals("invalidPasswordRegexPatternMessage", policy.validate("jdoe", "jdoe").getMessage()); + + policy = new PasswordPolicy("regexPatterns(jdoe)"); + Assert.assertNull(policy.validate("jdoe", "jdoe")); + + policy = new PasswordPolicy("regexPatterns([a-z][a-z][a-z][a-z][0-9])"); + Assert.assertNull(policy.validate("jdoe", "jdoe0")); + } @Test public void testComplex() { diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java index e9ba84ab01..236f95c2a2 100755 --- a/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java +++ b/model/file/src/main/java/org/keycloak/models/file/adapter/UserAdapter.java @@ -16,7 +16,9 @@ */ package org.keycloak.models.file.adapter; -import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientModel; +import static org.keycloak.models.utils.Pbkdf2PasswordEncoder.getSalt; + import org.keycloak.models.PasswordPolicy; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; @@ -28,11 +30,14 @@ import org.keycloak.models.utils.Pbkdf2PasswordEncoder; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; + import org.keycloak.connections.file.InMemoryModel; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.entities.FederatedIdentityEntity; @@ -209,29 +214,80 @@ public class UserAdapter implements UserModel, Comparable { @Override public void updateCredential(UserCredentialModel cred) { + + if (cred.getType().equals(UserCredentialModel.PASSWORD)) { + updatePasswordCredential(cred); + } else { + CredentialEntity credentialEntity = getCredentialEntity(user, cred.getType()); + + if (credentialEntity == null) { + credentialEntity = setCredentials(user, cred); + credentialEntity.setValue(cred.getValue()); + user.getCredentials().add(credentialEntity); + } else { + credentialEntity.setValue(cred.getValue()); + } + } + } + + private void updatePasswordCredential(UserCredentialModel cred) { CredentialEntity credentialEntity = getCredentialEntity(user, cred.getType()); if (credentialEntity == null) { - credentialEntity = new CredentialEntity(); - credentialEntity.setType(cred.getType()); - credentialEntity.setDevice(cred.getDevice()); + credentialEntity = setCredentials(user, cred); + setValue(credentialEntity, cred); user.getCredentials().add(credentialEntity); - } - if (cred.getType().equals(UserCredentialModel.PASSWORD)) { - byte[] salt = Pbkdf2PasswordEncoder.getSalt(); - int hashIterations = 1; - PasswordPolicy policy = realm.getPasswordPolicy(); - if (policy != null) { - hashIterations = policy.getHashIterations(); - if (hashIterations == -1) hashIterations = 1; - } - credentialEntity.setValue(new Pbkdf2PasswordEncoder(salt).encode(cred.getValue(), hashIterations)); - credentialEntity.setSalt(salt); - credentialEntity.setHashIterations(hashIterations); } else { - credentialEntity.setValue(cred.getValue()); + + int expiredPasswordsPolicyValue = -1; + PasswordPolicy policy = realm.getPasswordPolicy(); + if(policy != null) { + expiredPasswordsPolicyValue = policy.getExpiredPasswords(); + } + + if (expiredPasswordsPolicyValue != -1) { + user.getCredentials().remove(credentialEntity); + credentialEntity.setType(UserCredentialModel.PASSWORD_HISTORY); + user.getCredentials().add(credentialEntity); + + List credentialEntities = getCredentialEntities(user, UserCredentialModel.PASSWORD_HISTORY); + if (credentialEntities.size() > expiredPasswordsPolicyValue - 1) { + user.getCredentials().removeAll(credentialEntities.subList(expiredPasswordsPolicyValue - 1, credentialEntities.size())); + } + + credentialEntity = setCredentials(user, cred); + setValue(credentialEntity, cred); + user.getCredentials().add(credentialEntity); + } else { + List credentialEntities = getCredentialEntities(user, UserCredentialModel.PASSWORD_HISTORY); + if (credentialEntities != null && credentialEntities.size() > 0) { + user.getCredentials().removeAll(credentialEntities); + } + setValue(credentialEntity, cred); + } } + } + + private CredentialEntity setCredentials(UserEntity user, UserCredentialModel cred) { + CredentialEntity credentialEntity = new CredentialEntity(); + credentialEntity.setType(cred.getType()); + credentialEntity.setCreatedDate(new Date().getTime()); credentialEntity.setDevice(cred.getDevice()); + return credentialEntity; + } + + private void setValue(CredentialEntity credentialEntity, UserCredentialModel cred) { + byte[] salt = getSalt(); + int hashIterations = 1; + PasswordPolicy policy = realm.getPasswordPolicy(); + if (policy != null) { + hashIterations = policy.getHashIterations(); + if (hashIterations == -1) + hashIterations = 1; + } + credentialEntity.setValue(new Pbkdf2PasswordEncoder(salt).encode(cred.getValue(), hashIterations)); + credentialEntity.setSalt(salt); + credentialEntity.setHashIterations(hashIterations); } private CredentialEntity getCredentialEntity(UserEntity userEntity, String credType) { @@ -244,6 +300,30 @@ public class UserAdapter implements UserModel, Comparable { return null; } + private List getCredentialEntities(UserEntity userEntity, String credType) { + List credentialEntities = new ArrayList(); + for (CredentialEntity entity : userEntity.getCredentials()) { + if (entity.getType().equals(credType)) { + credentialEntities.add(entity); + } + } + + // Avoiding direct use of credSecond.getCreatedDate() - credFirst.getCreatedDate() to prevent Integer Overflow + // Orders from most recent to least recent + Collections.sort(credentialEntities, new Comparator() { + public int compare(CredentialEntity credFirst, CredentialEntity credSecond) { + if (credFirst.getCreatedDate() > credSecond.getCreatedDate()) { + return -1; + } else if (credFirst.getCreatedDate() < credSecond.getCreatedDate()) { + return 1; + } else { + return 0; + } + } + }); + return credentialEntities; + } + @Override public List getCredentialsDirectly() { List credentials = new ArrayList(user.getCredentials()); @@ -253,6 +333,7 @@ public class UserAdapter implements UserModel, Comparable { UserCredentialValueModel credModel = new UserCredentialValueModel(); credModel.setType(credEntity.getType()); credModel.setDevice(credEntity.getDevice()); + credModel.setCreatedDate(credEntity.getCreatedDate()); credModel.setValue(credEntity.getValue()); credModel.setSalt(credEntity.getSalt()); credModel.setHashIterations(credEntity.getHashIterations()); @@ -272,6 +353,7 @@ public class UserAdapter implements UserModel, Comparable { // credentialEntity.setId(KeycloakModelUtils.generateId()); credentialEntity.setType(credModel.getType()); // credentialEntity.setUser(user); + credModel.setCreatedDate(credModel.getCreatedDate()); user.getCredentials().add(credentialEntity); } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java index a51d0de21b..2216d224b7 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/UserAdapter.java @@ -18,7 +18,11 @@ import org.keycloak.models.utils.Pbkdf2PasswordEncoder; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; + import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -160,7 +164,6 @@ public class UserAdapter implements UserModel { } } - @Override public String getFirstName() { return user.getFirstName(); @@ -208,33 +211,86 @@ public class UserAdapter implements UserModel { @Override public void updateCredential(UserCredentialModel cred) { + + if (cred.getType().equals(UserCredentialModel.PASSWORD)) { + updatePasswordCredential(cred); + } else { + CredentialEntity credentialEntity = getCredentialEntity(user, cred.getType()); + + if (credentialEntity == null) { + credentialEntity = setCredentials(user, cred); + credentialEntity.setValue(cred.getValue()); + em.persist(credentialEntity); + user.getCredentials().add(credentialEntity); + } else { + credentialEntity.setValue(cred.getValue()); + } + } + em.flush(); + } + + private void updatePasswordCredential(UserCredentialModel cred) { CredentialEntity credentialEntity = getCredentialEntity(user, cred.getType()); if (credentialEntity == null) { - credentialEntity = new CredentialEntity(); - credentialEntity.setId(KeycloakModelUtils.generateId()); - credentialEntity.setType(cred.getType()); - credentialEntity.setDevice(cred.getDevice()); - credentialEntity.setUser(user); + credentialEntity = setCredentials(user, cred); + setValue(credentialEntity, cred); em.persist(credentialEntity); user.getCredentials().add(credentialEntity); - } - if (cred.getType().equals(UserCredentialModel.PASSWORD)) { - byte[] salt = getSalt(); - int hashIterations = 1; - PasswordPolicy policy = realm.getPasswordPolicy(); - if (policy != null) { - hashIterations = policy.getHashIterations(); - if (hashIterations == -1) hashIterations = 1; - } - credentialEntity.setValue(new Pbkdf2PasswordEncoder(salt).encode(cred.getValue(), hashIterations)); - credentialEntity.setSalt(salt); - credentialEntity.setHashIterations(hashIterations); } else { - credentialEntity.setValue(cred.getValue()); + + int expiredPasswordsPolicyValue = -1; + PasswordPolicy policy = realm.getPasswordPolicy(); + if(policy != null) { + expiredPasswordsPolicyValue = policy.getExpiredPasswords(); + } + + if (expiredPasswordsPolicyValue != -1) { + user.getCredentials().remove(credentialEntity); + credentialEntity.setType(UserCredentialModel.PASSWORD_HISTORY); + user.getCredentials().add(credentialEntity); + + List credentialEntities = getCredentialEntities(user, UserCredentialModel.PASSWORD_HISTORY); + if (credentialEntities.size() > expiredPasswordsPolicyValue - 1) { + user.getCredentials().removeAll(credentialEntities.subList(expiredPasswordsPolicyValue - 1, credentialEntities.size())); + } + + credentialEntity = setCredentials(user, cred); + setValue(credentialEntity, cred); + em.persist(credentialEntity); + user.getCredentials().add(credentialEntity); + } else { + List credentialEntities = getCredentialEntities(user, UserCredentialModel.PASSWORD_HISTORY); + if (credentialEntities != null && credentialEntities.size() > 0) { + user.getCredentials().removeAll(credentialEntities); + } + setValue(credentialEntity, cred); + } } + } + + private CredentialEntity setCredentials(UserEntity user, UserCredentialModel cred) { + CredentialEntity credentialEntity = new CredentialEntity(); + credentialEntity.setId(KeycloakModelUtils.generateId()); + credentialEntity.setType(cred.getType()); + credentialEntity.setCreatedDate(new Date().getTime()); credentialEntity.setDevice(cred.getDevice()); - em.flush(); + credentialEntity.setUser(user); + return credentialEntity; + } + + private void setValue(CredentialEntity credentialEntity, UserCredentialModel cred) { + byte[] salt = getSalt(); + int hashIterations = 1; + PasswordPolicy policy = realm.getPasswordPolicy(); + if (policy != null) { + hashIterations = policy.getHashIterations(); + if (hashIterations == -1) + hashIterations = 1; + } + credentialEntity.setValue(new Pbkdf2PasswordEncoder(salt).encode(cred.getValue(), hashIterations)); + credentialEntity.setSalt(salt); + credentialEntity.setHashIterations(hashIterations); } private CredentialEntity getCredentialEntity(UserEntity userEntity, String credType) { @@ -247,6 +303,30 @@ public class UserAdapter implements UserModel { return null; } + private List getCredentialEntities(UserEntity userEntity, String credType) { + List credentialEntities = new ArrayList(); + for (CredentialEntity entity : userEntity.getCredentials()) { + if (entity.getType().equals(credType)) { + credentialEntities.add(entity); + } + } + + // Avoiding direct use of credSecond.getCreatedDate() - credFirst.getCreatedDate() to prevent Integer Overflow + // Orders from most recent to least recent + Collections.sort(credentialEntities, new Comparator() { + public int compare(CredentialEntity credFirst, CredentialEntity credSecond) { + if (credFirst.getCreatedDate() > credSecond.getCreatedDate()) { + return -1; + } else if (credFirst.getCreatedDate() < credSecond.getCreatedDate()) { + return 1; + } else { + return 0; + } + } + }); + return credentialEntities; + } + @Override public List getCredentialsDirectly() { List credentials = new ArrayList(user.getCredentials()); @@ -258,6 +338,7 @@ public class UserAdapter implements UserModel { credModel.setType(credEntity.getType()); credModel.setDevice(credEntity.getDevice()); credModel.setValue(credEntity.getValue()); + credModel.setCreatedDate(credEntity.getCreatedDate()); credModel.setSalt(credEntity.getSalt()); credModel.setHashIterations(credEntity.getHashIterations()); @@ -276,6 +357,7 @@ public class UserAdapter implements UserModel { credentialEntity = new CredentialEntity(); credentialEntity.setId(KeycloakModelUtils.generateId()); credentialEntity.setType(credModel.getType()); + credentialEntity.setCreatedDate(credModel.getCreatedDate()); credentialEntity.setUser(user); em.persist(credentialEntity); user.getCredentials().add(credentialEntity); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/CredentialEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/CredentialEntity.java index de4039c9f4..bdbfe84f42 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/CredentialEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/CredentialEntity.java @@ -37,7 +37,9 @@ public class CredentialEntity { protected byte[] salt; @Column(name="HASH_ITERATIONS") protected int hashIterations; - + @Column(name="CREATED_DATE") + protected long createdDate; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="USER_ID") protected UserEntity user; @@ -97,4 +99,13 @@ public class CredentialEntity { public void setHashIterations(int hashIterations) { this.hashIterations = hashIterations; } + + public long getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(long createdDate) { + this.createdDate = createdDate; + } + } diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java index 874f7d8159..1214323ed2 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/UserAdapter.java @@ -1,5 +1,7 @@ package org.keycloak.models.mongo.keycloak.adapters; +import static org.keycloak.models.utils.Pbkdf2PasswordEncoder.getSalt; + import org.keycloak.connections.mongo.api.context.MongoStoreInvocationContext; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; @@ -17,6 +19,8 @@ import org.keycloak.models.utils.Pbkdf2PasswordEncoder; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -29,7 +33,7 @@ import java.util.Set; * @author Marek Posolda */ public class UserAdapter extends AbstractMongoAdapter implements UserModel { - + private final MongoUserEntity user; private final RealmModel realm; private final KeycloakSession session; @@ -177,31 +181,81 @@ public class UserAdapter extends AbstractMongoAdapter implement @Override public void updateCredential(UserCredentialModel cred) { + + if (cred.getType().equals(UserCredentialModel.PASSWORD)) { + updatePasswordCredential(cred); + } else { + CredentialEntity credentialEntity = getCredentialEntity(user, cred.getType()); + + if (credentialEntity == null) { + credentialEntity = setCredentials(user, cred); + credentialEntity.setValue(cred.getValue()); + user.getCredentials().add(credentialEntity); + } else { + credentialEntity.setValue(cred.getValue()); + } + } + getMongoStore().updateEntity(user, invocationContext); + } + + private void updatePasswordCredential(UserCredentialModel cred) { CredentialEntity credentialEntity = getCredentialEntity(user, cred.getType()); if (credentialEntity == null) { - credentialEntity = new CredentialEntity(); - credentialEntity.setType(cred.getType()); - credentialEntity.setDevice(cred.getDevice()); + credentialEntity = setCredentials(user, cred); + setValue(credentialEntity, cred); user.getCredentials().add(credentialEntity); - } - if (cred.getType().equals(UserCredentialModel.PASSWORD)) { - byte[] salt = Pbkdf2PasswordEncoder.getSalt(); - int hashIterations = 1; - PasswordPolicy policy = realm.getPasswordPolicy(); - if (policy != null) { - hashIterations = policy.getHashIterations(); - if (hashIterations == -1) hashIterations = 1; - } - credentialEntity.setValue(new Pbkdf2PasswordEncoder(salt).encode(cred.getValue(), hashIterations)); - credentialEntity.setSalt(salt); - credentialEntity.setHashIterations(hashIterations); } else { - credentialEntity.setValue(cred.getValue()); - } - credentialEntity.setDevice(cred.getDevice()); - getMongoStore().updateEntity(user, invocationContext); + int expiredPasswordsPolicyValue = -1; + PasswordPolicy policy = realm.getPasswordPolicy(); + if(policy != null) { + expiredPasswordsPolicyValue = policy.getExpiredPasswords(); + } + + if (expiredPasswordsPolicyValue != -1) { + user.getCredentials().remove(credentialEntity); + credentialEntity.setType(UserCredentialModel.PASSWORD_HISTORY); + user.getCredentials().add(credentialEntity); + + List credentialEntities = getCredentialEntities(user, UserCredentialModel.PASSWORD_HISTORY); + if (credentialEntities.size() > expiredPasswordsPolicyValue - 1) { + user.getCredentials().removeAll(credentialEntities.subList(expiredPasswordsPolicyValue - 1, credentialEntities.size())); + } + + credentialEntity = setCredentials(user, cred); + setValue(credentialEntity, cred); + user.getCredentials().add(credentialEntity); + } else { + List credentialEntities = getCredentialEntities(user, UserCredentialModel.PASSWORD_HISTORY); + if (credentialEntities != null && credentialEntities.size() > 0) { + user.getCredentials().removeAll(credentialEntities); + } + setValue(credentialEntity, cred); + } + } + } + + private CredentialEntity setCredentials(MongoUserEntity user, UserCredentialModel cred) { + CredentialEntity credentialEntity = new CredentialEntity(); + credentialEntity.setType(cred.getType()); + credentialEntity.setCreatedDate(new Date().getTime()); + credentialEntity.setDevice(cred.getDevice()); + return credentialEntity; + } + + private void setValue(CredentialEntity credentialEntity, UserCredentialModel cred) { + byte[] salt = getSalt(); + int hashIterations = 1; + PasswordPolicy policy = realm.getPasswordPolicy(); + if (policy != null) { + hashIterations = policy.getHashIterations(); + if (hashIterations == -1) + hashIterations = 1; + } + credentialEntity.setValue(new Pbkdf2PasswordEncoder(salt).encode(cred.getValue(), hashIterations)); + credentialEntity.setSalt(salt); + credentialEntity.setHashIterations(hashIterations); } private CredentialEntity getCredentialEntity(MongoUserEntity userEntity, String credType) { @@ -214,6 +268,30 @@ public class UserAdapter extends AbstractMongoAdapter implement return null; } + private List getCredentialEntities(MongoUserEntity userEntity, String credType) { + List credentialEntities = new ArrayList(); + for (CredentialEntity entity : userEntity.getCredentials()) { + if (entity.getType().equals(credType)) { + credentialEntities.add(entity); + } + } + + // Avoiding direct use of credSecond.getCreatedDate() - credFirst.getCreatedDate() to prevent Integer Overflow + // Orders from most recent to least recent + Collections.sort(credentialEntities, new Comparator() { + public int compare(CredentialEntity credFirst, CredentialEntity credSecond) { + if (credFirst.getCreatedDate() > credSecond.getCreatedDate()) { + return -1; + } else if (credFirst.getCreatedDate() < credSecond.getCreatedDate()) { + return 1; + } else { + return 0; + } + } + }); + return credentialEntities; + } + @Override public List getCredentialsDirectly() { List credentials = user.getCredentials(); @@ -222,6 +300,7 @@ public class UserAdapter extends AbstractMongoAdapter implement UserCredentialValueModel credModel = new UserCredentialValueModel(); credModel.setType(credEntity.getType()); credModel.setDevice(credEntity.getDevice()); + credModel.setCreatedDate(credEntity.getCreatedDate()); credModel.setValue(credEntity.getValue()); credModel.setSalt(credEntity.getSalt()); credModel.setHashIterations(credEntity.getHashIterations()); @@ -239,6 +318,7 @@ public class UserAdapter extends AbstractMongoAdapter implement if (credentialEntity == null) { credentialEntity = new CredentialEntity(); credentialEntity.setType(credModel.getType()); + credModel.setCreatedDate(credModel.getCreatedDate()); user.getCredentials().add(credentialEntity); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 8bad8982ef..bbf202cd82 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -657,6 +657,8 @@ public class UsersResource { UserCredentialModel cred = RepresentationToModel.convertCredential(pass); try { session.users().updateCredential(realm, user, cred); + } catch (IllegalStateException ise) { + throw new BadRequestException("Resetting to N old passwords is not allowed."); } catch (ModelReadOnlyException mre) { throw new BadRequestException("Can't reset password as account is read only"); } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java index 999fdb875f..77a4687a4e 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/account/AccountTest.java @@ -229,7 +229,7 @@ public class AccountTest { } @Test - public void changePasswordWithPasswordPolicy() { + public void changePasswordWithLengthPasswordPolicy() { keycloakRule.update(new KeycloakRule.KeycloakSetup() { @Override public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { @@ -262,6 +262,55 @@ public class AccountTest { }); } } + + @Test + public void changePasswordWithPasswordHistoryPolicy() { + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + appRealm.setPasswordPolicy(new PasswordPolicy("passwordHistory(2)")); + } + }); + + try { + changePasswordPage.open(); + loginPage.login("test-user@localhost", "password"); + + events.expectLogin().client("account").detail(Details.REDIRECT_URI, ACCOUNT_REDIRECT + "?path=password").assertEvent(); + + changePasswordPage.changePassword("password", "password", "password"); + + Assert.assertEquals("Invalid password password: must not be equal to any of last password history.", profilePage.getError()); + + changePasswordPage.changePassword("password", "password1", "password1"); + + Assert.assertEquals("Your password has been updated.", profilePage.getSuccess()); + + events.expectAccount(EventType.UPDATE_PASSWORD).assertEvent(); + + changePasswordPage.changePassword("password1", "password", "password"); + + Assert.assertEquals("Invalid password password: must not be equal to any of last password history.", profilePage.getError()); + + changePasswordPage.changePassword("password1", "password1", "password1"); + + Assert.assertEquals("Invalid password password1: must not be equal to any of last password history.", profilePage.getError()); + + changePasswordPage.changePassword("password1", "password2", "password2"); + + Assert.assertEquals("Your password has been updated.", profilePage.getSuccess()); + + events.expectAccount(EventType.UPDATE_PASSWORD).assertEvent(); + + } finally { + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + appRealm.setPasswordPolicy(new PasswordPolicy(null)); + } + }); + } + } @Test public void changeProfile() { diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java index 55e6ed3711..fb2a3fe569 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java @@ -37,7 +37,6 @@ import org.keycloak.services.managers.RealmManager; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.MailUtil; import org.keycloak.testsuite.OAuthClient; -import org.keycloak.testsuite.Retry; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage.RequestType; import org.keycloak.testsuite.pages.ErrorPage; @@ -57,8 +56,7 @@ import javax.mail.internet.MimeMessage; import java.io.IOException; import java.util.Collections; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; /** * @author Stian Thorgersen @@ -241,6 +239,44 @@ public class ResetPasswordTest { assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); } + private void resetPassword(String username, String password) throws IOException, MessagingException { + loginPage.open(); + loginPage.resetPassword(); + + resetPasswordPage.assertCurrent(); + + resetPasswordPage.changePassword(username); + + resetPasswordPage.assertCurrent(); + + String sessionId = events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId) + .detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId(); + + assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage()); + + MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1]; + + String body = (String) message.getContent(); + String changePasswordUrl = MailUtil.getLink(body); + + driver.navigate().to(changePasswordUrl.trim()); + + updatePasswordPage.assertCurrent(); + + updatePasswordPage.changePassword(password, password); + + events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).session(sessionId) + .detail(Details.USERNAME, username).assertEvent(); + + assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); + + events.expectLogin().user(userId).detail(Details.USERNAME, username).session(sessionId).assertEvent(); + + oauth.openLogout(); + + events.expectLogout(sessionId).user(userId).session(sessionId).assertEvent(); + } + @Test public void resetPasswordWrongEmail() throws IOException, MessagingException, InterruptedException { loginPage.open(); @@ -405,7 +441,7 @@ public class ResetPasswordTest { } @Test - public void resetPasswordWithPasswordPolicy() throws IOException, MessagingException { + public void resetPasswordWithLengthPasswordPolicy() throws IOException, MessagingException { keycloakRule.update(new KeycloakRule.KeycloakSetup() { @Override public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { @@ -462,6 +498,65 @@ public class ResetPasswordTest { events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent(); } + @Test + public void resetPasswordWithPasswordHisoryPolicy() throws IOException, MessagingException { + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + //Block passwords that are equal to previous passwords. Default value is 3. + appRealm.setPasswordPolicy(new PasswordPolicy("passwordHistory")); + } + }); + + // try-catch blocks have been commented out to reduce execution time for this test case(30s->15s). + // TODO : Comment out any other piece of code, if applicable, in order to reduce execution time. + + resetPassword("login-test", "password1"); + /*try { + resetPassword("login-test", "password1"); + fail("Expected NullPointerException: Block passwords that are equal to previous passwords."); + } catch (Exception e) { + // Expected NPE as "password1" matches with password history + }*/ + + resetPassword("login-test", "password2"); + /*try { + resetPassword("login-test", "password1"); + fail("Expected NullPointerException: Block passwords that are equal to previous passwords."); + } catch (Exception e) { + // Expected NPE as "password1" matches with password history + } + try { + resetPassword("login-test", "password2"); + fail("Expected NullPointerException: Block passwords that are equal to previous passwords."); + } catch (Exception e) { + // Expected NPE as "password2" matches with password history + }*/ + + resetPassword("login-test", "password3"); + try { + resetPassword("login-test", "password1"); + fail("Expected NullPointerException: Block passwords that are equal to previous passwords."); + } catch (Exception e) { + // Expected NPE as "password1" matches with password history + } + try { + resetPassword("login-test", "password2"); + fail("Expected NullPointerException: Block passwords that are equal to previous passwords."); + } catch (Exception e) { + // Expected NPE as "password2" matches with password history + } + try { + resetPassword("login-test", "password3"); + fail("Expected NullPointerException: Block passwords that are equal to previous passwords."); + } catch (Exception e) { + // Expected NPE as "password3" matches with password history + } + + resetPassword("login-test", "password"); + + } + @Test public void resetPasswordNewBrowserSession() throws IOException, MessagingException { String username = "login-test";