Skip to content

Commit 763aebc

Browse files
author
Keith Hudnall
committed
Add configurable system-wide password policy (regex + description)
1 parent c8afe48 commit 763aebc

14 files changed

Lines changed: 213 additions & 9 deletions

File tree

common-util/src/main/java/dev/getelements/elements/config/CommonModuleDefaults.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public Properties get() {
2323
defaultProperties.setProperty(ASYNC_TIMEOUT_LIMIT, Integer.toString(0));
2424
defaultProperties.setProperty(GENERATED_PASSWORD_LENGTH, "24");
2525
defaultProperties.setProperty(GLOBAL_SECRET, "");
26+
defaultProperties.setProperty(PASSWORD_POLICY_REGEX, ".{4,}");
27+
defaultProperties.setProperty(PASSWORD_POLICY_DESCRIPTION, "Password must be at least 4 characters.");
2628

2729
// The way the dependent code uses this is a little weird and should be reworked to eliminate some redundancy.
2830
defaultProperties.setProperty(APP_OUTSIDE_URL, "http://localhost:8080/");

sdk-model/src/main/java/dev/getelements/elements/sdk/model/Constants.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ public interface Constants {
118118
*/
119119
String GENERATED_PASSWORD_LENGTH = "dev.getelements.elements.mock.generated.password.length";
120120

121+
/**
122+
* The system-wide password policy, expressed as a regex a submitted password must fully match.
123+
* Enforced everywhere a password is accepted or changed (signup, password reset, admin-set
124+
* password, email/username-password linking). Does not apply to server-generated passwords.
125+
*/
126+
String PASSWORD_POLICY_REGEX = "dev.getelements.elements.password.policy.regex";
127+
128+
/**
129+
* A plain-text, human-readable description of {@link #PASSWORD_POLICY_REGEX}, so clients can
130+
* display the requirement to end users without parsing the regex themselves. Also included in
131+
* the validation error message when a submitted password fails the policy. Operators are
132+
* responsible for keeping this in sync with the regex.
133+
*/
134+
String PASSWORD_POLICY_DESCRIPTION = "dev.getelements.elements.password.policy.description";
135+
121136
/**
122137
* Defines some useful regex patterns.
123138
*/

service-test/src/test/java/dev/getelements/elements/service/user/EmailPasswordLinkServiceTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
import org.testng.annotations.Test;
1414

1515
import static com.google.inject.Guice.createInjector;
16+
import static com.google.inject.name.Names.named;
1617
import static dev.getelements.elements.sdk.dao.UserUidDao.SCHEME_EMAIL;
18+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_DESCRIPTION;
19+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_REGEX;
1720
import static org.mockito.Mockito.*;
1821
import static org.testng.Assert.assertEquals;
1922

@@ -99,6 +102,8 @@ protected void configure() {
99102
bind(User.class).toInstance(currentUser);
100103
bind(UserDao.class).toInstance(mock(UserDao.class));
101104
bind(UserUidDao.class).toInstance(mock(UserUidDao.class));
105+
bindConstant().annotatedWith(named(PASSWORD_POLICY_REGEX)).to(".*");
106+
bindConstant().annotatedWith(named(PASSWORD_POLICY_DESCRIPTION)).to("");
102107
}
103108
}
104109

service-test/src/test/java/dev/getelements/elements/service/user/PasswordResetServiceTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import static com.google.inject.Guice.createInjector;
2424
import static com.google.inject.name.Names.named;
2525
import static dev.getelements.elements.sdk.dao.UserUidDao.SCHEME_EMAIL;
26+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_DESCRIPTION;
27+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_REGEX;
2628
import static dev.getelements.elements.sdk.service.user.PasswordResetService.*;
2729
import static org.mockito.ArgumentMatchers.*;
2830
import static org.mockito.Mockito.*;
@@ -239,6 +241,8 @@ protected void configure() {
239241
bind(PasswordResetTokenDao.class).toInstance(mock(PasswordResetTokenDao.class));
240242
bind(EmailService.class).toInstance(mock(EmailService.class));
241243
bind(ElementRegistry.class).toInstance(mock(ElementRegistry.class));
244+
bindConstant().annotatedWith(named(PASSWORD_POLICY_REGEX)).to(".*");
245+
bindConstant().annotatedWith(named(PASSWORD_POLICY_DESCRIPTION)).to("");
242246

243247
bindConstant().annotatedWith(named(RESET_EMAIL_SUBJECT)).to(SUBJECT);
244248
bindConstant().annotatedWith(named(RESET_EMAIL_TEMPLATE)).to(TEMPLATE);

service-test/src/test/java/dev/getelements/elements/service/user/UsernamePasswordLinkServiceTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
import java.util.function.Function;
1717

1818
import static com.google.inject.Guice.createInjector;
19+
import static com.google.inject.name.Names.named;
1920
import static dev.getelements.elements.sdk.dao.UserUidDao.SCHEME_NAME;
21+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_DESCRIPTION;
22+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_REGEX;
2023
import static org.mockito.ArgumentMatchers.any;
2124
import static org.mockito.ArgumentMatchers.eq;
2225
import static org.mockito.Mockito.*;
@@ -135,6 +138,8 @@ protected void configure() {
135138
bind(User.class).toInstance(currentUser);
136139
bind(UserDao.class).toInstance(mock(UserDao.class));
137140
bind(UserUidDao.class).toInstance(mock(UserUidDao.class));
141+
bindConstant().annotatedWith(named(PASSWORD_POLICY_REGEX)).to(".*");
142+
bindConstant().annotatedWith(named(PASSWORD_POLICY_DESCRIPTION)).to("");
138143
// Binding Transaction also satisfies Provider<Transaction> injection automatically.
139144
bind(Transaction.class).toInstance(tx);
140145
}

service/src/main/java/dev/getelements/elements/service/user/AbstractPasswordResetService.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ abstract class AbstractPasswordResetService implements PasswordResetService {
4949

5050
private String expiryHours;
5151

52+
private PasswordPolicyValidator passwordPolicyValidator;
53+
5254
// -------------------------------------------------------------------------
5355
// Shared core operations
5456
// -------------------------------------------------------------------------
@@ -103,6 +105,7 @@ void doCompleteReset(final String token, final String newPassword) {
103105
final var tokenData = getTokenDao().findToken(token)
104106
.orElseThrow(() -> new InvalidParameterException("Invalid or expired reset token."));
105107

108+
getPasswordPolicyValidator().validate(newPassword);
106109
getUserDao().setPassword(tokenData.getUser().getId(), newPassword);
107110
getTokenDao().deleteToken(token);
108111

@@ -195,4 +198,13 @@ private long getTokenValidityMs() {
195198
}
196199
}
197200

201+
public PasswordPolicyValidator getPasswordPolicyValidator() {
202+
return passwordPolicyValidator;
203+
}
204+
205+
@Inject
206+
public void setPasswordPolicyValidator(PasswordPolicyValidator passwordPolicyValidator) {
207+
this.passwordPolicyValidator = passwordPolicyValidator;
208+
}
209+
198210
}

service/src/main/java/dev/getelements/elements/service/user/AnonUserService.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public class AnonUserService extends AbstractUserService implements UserService
2424

2525
private PasswordGenerator passwordGenerator;
2626

27+
private PasswordPolicyValidator passwordPolicyValidator;
28+
2729
@Override
2830
public User getUser(String userId) {
2931
throw new ForbiddenException();
@@ -49,9 +51,14 @@ public UserCreateResponse createUser(final UserCreateRequest userCreateRequest)
4951

5052
getNameService().assignNameAndEmailIfNecessary(user);
5153

52-
final var password = isNullOrEmpty(userCreateRequest.getPassword())
53-
? getPasswordGenerator().generate()
54-
: userCreateRequest.getPassword();
54+
final String password;
55+
56+
if (isNullOrEmpty(userCreateRequest.getPassword())) {
57+
password = getPasswordGenerator().generate();
58+
} else {
59+
getPasswordPolicyValidator().validate(userCreateRequest.getPassword());
60+
password = userCreateRequest.getPassword();
61+
}
5562

5663
final var created = getUserDao().createUserWithPasswordStrict(user, password);
5764

@@ -118,4 +125,13 @@ public void setPasswordGenerator(PasswordGenerator passwordGenerator) {
118125
this.passwordGenerator = passwordGenerator;
119126
}
120127

128+
public PasswordPolicyValidator getPasswordPolicyValidator() {
129+
return passwordPolicyValidator;
130+
}
131+
132+
@Inject
133+
public void setPasswordPolicyValidator(PasswordPolicyValidator passwordPolicyValidator) {
134+
this.passwordPolicyValidator = passwordPolicyValidator;
135+
}
136+
121137
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package dev.getelements.elements.service.user;
2+
3+
import dev.getelements.elements.sdk.model.exception.InvalidParameterException;
4+
import jakarta.inject.Inject;
5+
import jakarta.inject.Named;
6+
import jakarta.inject.Singleton;
7+
8+
import java.util.regex.Pattern;
9+
import java.util.regex.PatternSyntaxException;
10+
11+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_DESCRIPTION;
12+
import static dev.getelements.elements.sdk.model.Constants.PASSWORD_POLICY_REGEX;
13+
14+
/**
15+
* Enforces the system-wide, deploy-time-configured password policy against a raw, client-submitted
16+
* password. Not applied to server-generated passwords (e.g. mock/bootstrap accounts), which are not
17+
* subject to end-user policy requirements.
18+
*/
19+
@Singleton
20+
public class PasswordPolicyValidator {
21+
22+
private String policyRegex;
23+
24+
private String policyDescription;
25+
26+
/**
27+
* Validates the supplied raw password against the configured policy regex.
28+
*
29+
* @param rawPassword the raw, plain-text password to validate
30+
* @throws InvalidParameterException if the password does not match the configured policy, with a
31+
* message including the configured policy description
32+
*/
33+
public void validate(final String rawPassword) {
34+
35+
final Pattern pattern;
36+
37+
try {
38+
pattern = Pattern.compile(getPolicyRegex());
39+
} catch (final PatternSyntaxException ex) {
40+
// A misconfigured operator-supplied regex should not silently disable the policy nor
41+
// reject every password; surface the misconfiguration instead.
42+
throw new IllegalStateException(
43+
"Configured password policy regex is invalid: " + getPolicyRegex(), ex);
44+
}
45+
46+
if (rawPassword == null || !pattern.matcher(rawPassword).matches()) {
47+
throw new InvalidParameterException(getPolicyDescription());
48+
}
49+
50+
}
51+
52+
public String getPolicyRegex() {
53+
return policyRegex;
54+
}
55+
56+
@Inject
57+
public void setPolicyRegex(@Named(PASSWORD_POLICY_REGEX) String policyRegex) {
58+
this.policyRegex = policyRegex;
59+
}
60+
61+
public String getPolicyDescription() {
62+
return policyDescription;
63+
}
64+
65+
@Inject
66+
public void setPolicyDescription(@Named(PASSWORD_POLICY_DESCRIPTION) String policyDescription) {
67+
this.policyDescription = policyDescription;
68+
}
69+
70+
}

service/src/main/java/dev/getelements/elements/service/user/SuperUserEmailPasswordLinkService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public class SuperUserEmailPasswordLinkService implements EmailPasswordLinkServi
2323

2424
private UserUidDao userUidDao;
2525

26+
private PasswordPolicyValidator passwordPolicyValidator;
27+
2628
@Override
2729
public User linkEmailPassword(final String email, final String password) {
2830
final var normalizedEmail = email.trim().toLowerCase();
@@ -34,6 +36,8 @@ public User linkEmailPassword(final String email, final String password) {
3436
"Call POST /user/me/email/verify first.");
3537
}
3638

39+
getPasswordPolicyValidator().validate(password);
40+
3741
return getUserDao().setPassword(uid.getUserId(), password);
3842
}
3943

@@ -55,4 +59,13 @@ public void setUserUidDao(UserUidDao userUidDao) {
5559
this.userUidDao = userUidDao;
5660
}
5761

62+
public PasswordPolicyValidator getPasswordPolicyValidator() {
63+
return passwordPolicyValidator;
64+
}
65+
66+
@Inject
67+
public void setPasswordPolicyValidator(PasswordPolicyValidator passwordPolicyValidator) {
68+
this.passwordPolicyValidator = passwordPolicyValidator;
69+
}
70+
5871
}

service/src/main/java/dev/getelements/elements/service/user/SuperUserUsernamePasswordLinkService.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ public class SuperUserUsernamePasswordLinkService implements UsernamePasswordLin
2222

2323
private UserUidDao userUidDao;
2424

25+
private PasswordPolicyValidator passwordPolicyValidator;
26+
2527
@Override
2628
public User linkUsernamePassword(final String username, final String password) {
2729
final var normalizedUsername = username.trim();
2830
final var uid = getUserUidDao().getUserUid(normalizedUsername, SCHEME_NAME);
31+
getPasswordPolicyValidator().validate(password);
2932
return getUserDao().setPassword(uid.getUserId(), password);
3033
}
3134

@@ -47,4 +50,13 @@ public void setUserUidDao(UserUidDao userUidDao) {
4750
this.userUidDao = userUidDao;
4851
}
4952

53+
public PasswordPolicyValidator getPasswordPolicyValidator() {
54+
return passwordPolicyValidator;
55+
}
56+
57+
@Inject
58+
public void setPasswordPolicyValidator(PasswordPolicyValidator passwordPolicyValidator) {
59+
this.passwordPolicyValidator = passwordPolicyValidator;
60+
}
61+
5062
}

0 commit comments

Comments
 (0)