Skip to content

Commit 4e69a2f

Browse files
committed
Add score based password verification
Signed-off-by: Andrey Pleskach <ples@aiven.io>
1 parent fa33fc5 commit 4e69a2f

14 files changed

Lines changed: 627 additions & 149 deletions

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ dependencies {
335335
implementation ('org.opensaml:opensaml-saml-impl:3.4.5') {
336336
exclude(group: 'org.apache.velocity', module: 'velocity')
337337
}
338+
implementation "com.nulab-inc:zxcvbn:1.7.0"
338339
testImplementation 'org.opensaml:opensaml-messaging-impl:3.4.5'
339340
implementation 'org.opensaml:opensaml-messaging-api:3.4.5'
340341
runtimeOnly 'org.opensaml:opensaml-profile-api:3.4.5'

src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
import org.opensearch.security.configuration.Salt;
138138
import org.opensearch.security.configuration.SecurityFlsDlsIndexSearcherWrapper;
139139
import org.opensearch.security.dlic.rest.api.SecurityRestApiActions;
140+
import org.opensearch.security.dlic.rest.validation.PasswordValidator;
140141
import org.opensearch.security.filter.SecurityFilter;
141142
import org.opensearch.security.filter.SecurityRestFilter;
142143
import org.opensearch.security.http.SecurityHttpServerTransport;
@@ -1048,6 +1049,19 @@ public List<Setting<?>> getSettings() {
10481049
settings.add(Setting.simpleString(ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX, Property.NodeScope, Property.Filtered));
10491050
settings.add(Setting.simpleString(ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE, Property.NodeScope, Property.Filtered));
10501051

1052+
settings.add(
1053+
Setting.intSetting(
1054+
ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH,
1055+
-1, -1, Property.NodeScope, Property.Filtered)
1056+
);
1057+
settings.add(
1058+
Setting.simpleString(
1059+
ConfigConstants.SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH,
1060+
PasswordValidator.ScoreStrength.STRONG.name(),
1061+
PasswordValidator.ScoreStrength::fromConfiguration,
1062+
Property.NodeScope, Property.Filtered
1063+
)
1064+
);
10511065

10521066
// Compliance
10531067
settings.add(Setting.listSetting(ConfigConstants.OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_WRITE_WATCHED_INDICES, Collections.emptyList(), Function.identity(), Property.NodeScope)); //not filtered here

src/main/java/org/opensearch/security/dlic/rest/validation/AbstractConfigurationValidator.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,19 @@ public XContentBuilder errorsAsXContent(RestChannel channel) {
249249
break;
250250
case INVALID_PASSWORD:
251251
builder.field("status", "error");
252-
builder.field("reason", opensearchSettings.get(ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE,
252+
builder.field("reason", opensearchSettings.get(
253+
ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE,
253254
"Password does not match minimum criteria"));
254255
break;
256+
case WEAK_PASSWORD:
257+
case SIMILAR_PASSWORD:
258+
builder.field("status", "error");
259+
builder.field(
260+
"reason",
261+
opensearchSettings.get(
262+
ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE,
263+
errorType.message));
264+
break;
255265
case WRONG_DATATYPE:
256266
builder.field("status", "error");
257267
builder.field("reason", ErrorType.WRONG_DATATYPE.getMessage());
@@ -289,8 +299,14 @@ public static enum DataType {
289299
}
290300

291301
public static enum ErrorType {
292-
NONE("ok"), INVALID_CONFIGURATION("Invalid configuration"), INVALID_PASSWORD("Invalid password"), WRONG_DATATYPE("Wrong datatype"),
293-
BODY_NOT_PARSEABLE("Could not parse content of request."), PAYLOAD_NOT_ALLOWED("Request body not allowed for this action."),
302+
NONE("ok"),
303+
INVALID_CONFIGURATION("Invalid configuration"),
304+
INVALID_PASSWORD("Invalid password"),
305+
WEAK_PASSWORD("Weak password"),
306+
SIMILAR_PASSWORD("Password is similar to user name"),
307+
WRONG_DATATYPE("Wrong datatype"),
308+
BODY_NOT_PARSEABLE("Could not parse content of request."),
309+
PAYLOAD_NOT_ALLOWED("Request body not allowed for this action."),
294310
PAYLOAD_MANDATORY("Request body required for this action."), SECURITY_NOT_INITIALIZED("Security index not initialized"),
295311
NULL_ARRAY_ELEMENT("`null` is not allowed as json array element");
296312

src/main/java/org/opensearch/security/dlic/rest/validation/CredentialsValidator.java

Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
package org.opensearch.security.dlic.rest.validation;
1313

1414
import java.util.Map;
15-
import java.util.regex.Pattern;
1615

1716
import org.opensearch.common.Strings;
1817
import org.opensearch.common.bytes.BytesReference;
@@ -22,17 +21,21 @@
2221
import org.opensearch.common.xcontent.XContentType;
2322
import org.opensearch.rest.RestRequest;
2423
import org.opensearch.security.ssl.util.Utils;
25-
import org.opensearch.security.support.ConfigConstants;
2624

2725
/**
2826
* Validator for validating password and hash present in the payload
2927
*/
3028
public class CredentialsValidator extends AbstractConfigurationValidator {
3129

32-
public CredentialsValidator(final RestRequest request, BytesReference ref, final Settings opensearchSettings,
30+
private final PasswordValidator passwordValidator;
31+
32+
public CredentialsValidator(final RestRequest request,
33+
final BytesReference ref,
34+
final Settings opensearchSettings,
3335
Object... param) {
3436
super(request, ref, opensearchSettings, param);
3537
this.payloadMandatory = true;
38+
this.passwordValidator = PasswordValidator.of(opensearchSettings);
3639
allowedKeys.put("hash", DataType.STRING);
3740
allowedKeys.put("password", DataType.STRING);
3841
}
@@ -46,49 +49,29 @@ public boolean validate() {
4649
if (!super.validate()) {
4750
return false;
4851
}
49-
50-
final String regex = this.opensearchSettings.get(ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX, null);
51-
5252
if ((request.method() == RestRequest.Method.PUT || request.method() == RestRequest.Method.PATCH)
5353
&& this.content != null
5454
&& this.content.length() > 1) {
5555
try {
5656
final Map<String, Object> contentAsMap = XContentHelper.convertToMap(this.content, false, XContentType.JSON).v2();
57-
String password = (String) contentAsMap.get("password");
57+
final String password = (String) contentAsMap.get("password");
5858
if (password != null) {
5959
// Password is not allowed to be empty if present.
6060
if (password.isEmpty()) {
6161
this.errorType = ErrorType.INVALID_PASSWORD;
6262
return false;
6363
}
64-
65-
if (!Strings.isNullOrEmpty(regex)) {
66-
// Password can be null for an existing user. Regex will validate password if present
67-
if (!Pattern.compile("^"+regex+"$").matcher(password).matches()) {
68-
if(log.isDebugEnabled()) {
69-
log.debug("Regex does not match password");
70-
}
71-
this.errorType = ErrorType.INVALID_PASSWORD;
72-
return false;
73-
}
74-
75-
final String username = Utils.coalesce(request.param("name"), hasParams() ? (String) param[0] : null);
76-
final boolean isDebugEnabled = log.isDebugEnabled();
77-
78-
if (username == null || username.isEmpty()) {
79-
if (isDebugEnabled) {
80-
log.debug("Unable to validate username because no user is given");
81-
}
82-
return false;
83-
}
84-
85-
if (username.toLowerCase().equals(password.toLowerCase())) {
86-
if (isDebugEnabled) {
87-
log.debug("Username must not match password");
88-
}
89-
this.errorType = ErrorType.INVALID_PASSWORD;
90-
return false;
64+
final String username = Utils.coalesce(request.param("name"), hasParams() ? (String) param[0] : null);
65+
if (Strings.isNullOrEmpty(username)) {
66+
if (log.isDebugEnabled()) {
67+
log.debug("Unable to validate username because no user is given");
9168
}
69+
return false;
70+
}
71+
final ErrorType passwordValidationResult = passwordValidator.validate(username, password);
72+
if (passwordValidationResult != ErrorType.NONE) {
73+
this.errorType = passwordValidationResult;
74+
return false;
9275
}
9376
}
9477
} catch (NotXContentException e) {
@@ -99,4 +82,5 @@ public boolean validate() {
9982
}
10083
return true;
10184
}
85+
10286
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*
8+
* Modifications Copyright OpenSearch Contributors. See
9+
* GitHub history for details.
10+
*/
11+
12+
package org.opensearch.security.dlic.rest.validation;
13+
14+
import java.util.List;
15+
import java.util.Locale;
16+
import java.util.Objects;
17+
import java.util.StringJoiner;
18+
import java.util.function.Predicate;
19+
import java.util.regex.Pattern;
20+
21+
import com.google.common.collect.ImmutableList;
22+
import com.nulabinc.zxcvbn.Strength;
23+
import com.nulabinc.zxcvbn.Zxcvbn;
24+
import com.nulabinc.zxcvbn.matchers.Match;
25+
import org.apache.logging.log4j.LogManager;
26+
import org.apache.logging.log4j.Logger;
27+
28+
import org.opensearch.common.Strings;
29+
import org.opensearch.common.settings.Settings;
30+
import org.opensearch.security.dlic.rest.validation.AbstractConfigurationValidator.ErrorType;
31+
32+
import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_PASSWORD_MIN_LENGTH;
33+
import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH;
34+
import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX;
35+
36+
public class PasswordValidator {
37+
38+
private static final int MAX_LENGTH = 100;
39+
40+
/**
41+
* Checks a username similarity and a password
42+
* names and passwords like:
43+
* - some_user_name/456Some_uSer_Name_1234
44+
* - some_user_name/some_user_name_Ydfge
45+
* - some_user_name/eman_resu_emos
46+
* are similar
47+
* "user_inputs" - is a default dictionary zxcvbn creates for checking similarity
48+
*/
49+
private final static Predicate<Match> USERNAME_SIMILARITY_CHECK = m ->
50+
m.pattern == com.nulabinc.zxcvbn.Pattern.Dictionary && "user_inputs".equals(m.dictionaryName);
51+
52+
private final Logger logger = LogManager.getLogger(this.getClass());
53+
54+
private final int minPasswordLength;
55+
56+
private final Pattern passwordRegexpPattern;
57+
58+
private final ScoreStrength scoreStrength;
59+
60+
private final Zxcvbn zxcvbn;
61+
62+
private PasswordValidator(final int minPasswordLength,
63+
final Pattern passwordRegexpPattern,
64+
final ScoreStrength scoreStrength) {
65+
this.minPasswordLength = minPasswordLength;
66+
this.passwordRegexpPattern = passwordRegexpPattern;
67+
this.scoreStrength = scoreStrength;
68+
this.zxcvbn = new Zxcvbn();
69+
}
70+
71+
public static PasswordValidator of(final Settings settings) {
72+
final String passwordRegex = settings.get(SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX, null);
73+
final ScoreStrength scoreStrength = ScoreStrength.fromConfiguration(
74+
settings.get(SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH, ScoreStrength.STRONG.name())
75+
);
76+
final int minPasswordLength = settings.getAsInt(SECURITY_RESTAPI_PASSWORD_MIN_LENGTH, -1);
77+
return new PasswordValidator(
78+
minPasswordLength,
79+
!Strings.isNullOrEmpty(passwordRegex) ? Pattern.compile(String.format("^%s$", passwordRegex)) : null,
80+
scoreStrength);
81+
}
82+
83+
ErrorType validate(final String username, final String password) {
84+
if (minPasswordLength > 0 && password.length() < minPasswordLength) {
85+
logger.debug(
86+
"Password is too short, the minimum required length is {}, but current length is {}",
87+
minPasswordLength,
88+
password.length()
89+
);
90+
return ErrorType.INVALID_PASSWORD;
91+
}
92+
if (password.length() > MAX_LENGTH) {
93+
logger.debug(
94+
"Password is too long, the maximum required length is {}, but current length is {}",
95+
MAX_LENGTH,
96+
password.length()
97+
);
98+
return ErrorType.INVALID_PASSWORD;
99+
}
100+
if (Objects.nonNull(passwordRegexpPattern)
101+
&& !passwordRegexpPattern.matcher(password).matches()) {
102+
logger.debug("Regex does not match password");
103+
return ErrorType.INVALID_PASSWORD;
104+
}
105+
final Strength strength = zxcvbn.measure(password, ImmutableList.of(username));
106+
if (strength.getScore() < scoreStrength.score()) {
107+
logger.debug(
108+
"Password is weak the required score is {}, but current is {}",
109+
scoreStrength,
110+
ScoreStrength.fromScore(strength.getScore())
111+
);
112+
return ErrorType.WEAK_PASSWORD;
113+
}
114+
final boolean similar = strength.getSequence()
115+
.stream()
116+
.anyMatch(USERNAME_SIMILARITY_CHECK);
117+
if (similar) {
118+
logger.debug("Password is too similar to the user name {}", username);
119+
return ErrorType.SIMILAR_PASSWORD;
120+
}
121+
return ErrorType.NONE;
122+
}
123+
124+
public enum ScoreStrength {
125+
126+
// The weak score defines here only for debugging information
127+
// and doesn't use as a configuration setting value.
128+
WEAK(0, "too guessable: risky password"),
129+
FAIR(1, "very guessable: protection from throttled online attacks"),
130+
GOOD(2, "somewhat guessable: protection from unthrottled online attacks"),
131+
STRONG(3, "safely unguessable: moderate protection from offline slow-hash scenario"),
132+
VERY_STRONG(4, "very unguessable: strong protection from offline slow-hash scenario");
133+
134+
private final int score;
135+
136+
private final String description;
137+
138+
static final List<ScoreStrength> CONFIGURATION_VALUES = ImmutableList.of(FAIR, STRONG, VERY_STRONG);
139+
140+
static final String EXPECTED_CONFIGURATION_VALUES =
141+
new StringJoiner(",")
142+
.add(FAIR.name().toLowerCase(Locale.ROOT))
143+
.add(STRONG.name().toLowerCase(Locale.ROOT))
144+
.add(VERY_STRONG.name().toLowerCase(Locale.ROOT))
145+
.toString();
146+
147+
private ScoreStrength(final int score, final String description) {
148+
this.score = score;
149+
this.description = description;
150+
}
151+
152+
public static ScoreStrength fromScore(final int score) {
153+
for (final ScoreStrength strength : values()) {
154+
if (strength.score == score)
155+
return strength;
156+
}
157+
throw new IllegalArgumentException("Unknown score " + score);
158+
}
159+
160+
public static ScoreStrength fromConfiguration(final String value) {
161+
for (final ScoreStrength strength : CONFIGURATION_VALUES) {
162+
if (strength.name().equalsIgnoreCase(value))
163+
return strength;
164+
}
165+
throw new IllegalArgumentException(
166+
String.format(
167+
"Setting [%s] cannot be used with the configured: %s. Expected one of [%s]",
168+
SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH,
169+
value,
170+
EXPECTED_CONFIGURATION_VALUES
171+
)
172+
);
173+
}
174+
175+
@Override
176+
public String toString() {
177+
return String.format("Password strength score %s. %s", score, description);
178+
}
179+
180+
public int score() {
181+
return this.score;
182+
}
183+
184+
}
185+
}

src/main/java/org/opensearch/security/support/ConfigConstants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ public enum RolesMappingResolution {
253253
public static final String SECURITY_RESTAPI_ENDPOINTS_DISABLED = "plugins.security.restapi.endpoints_disabled";
254254
public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX = "plugins.security.restapi.password_validation_regex";
255255
public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE = "plugins.security.restapi.password_validation_error_message";
256+
public static final String SECURITY_RESTAPI_PASSWORD_MIN_LENGTH = "plugins.security.restapi.password_min_length";
257+
public static final String SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH = "plugins.security.restapi.password_score_based_validation_strength";
256258

257259
// Illegal Opcodes from here on
258260
public static final String SECURITY_UNSUPPORTED_DISABLE_REST_AUTH_INITIALLY = "plugins.security.unsupported.disable_rest_auth_initially";

0 commit comments

Comments
 (0)