Skip to content

Commit 847f977

Browse files
feat(security): add pwned password validation
1 parent 9bffe68 commit 847f977

7 files changed

Lines changed: 254 additions & 1 deletion

File tree

authme-core/src/main/java/fr/xephi/authme/message/MessageKey.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ public enum MessageKey {
8080
/** The chosen password isn't safe, please choose another one... */
8181
PASSWORD_UNSAFE_ERROR("password.unsafe_password"),
8282

83+
/** Your chosen password is not secure. It has been seen %pwned_count times before! */
84+
PASSWORD_PWNED_ERROR("password.pwned_password", "%pwned_count"),
85+
8386
/** Your password contains illegal characters. Allowed chars: %valid_chars */
8487
PASSWORD_CHARACTERS_ERROR("password.forbidden_characters", "%valid_chars"),
8588

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package fr.xephi.authme.service;
2+
3+
import com.google.common.annotations.VisibleForTesting;
4+
import fr.xephi.authme.ConsoleLogger;
5+
import fr.xephi.authme.output.ConsoleLoggerFactory;
6+
import fr.xephi.authme.security.HashUtils;
7+
8+
import java.io.BufferedReader;
9+
import java.io.IOException;
10+
import java.io.InputStreamReader;
11+
import java.net.HttpURLConnection;
12+
import java.net.URI;
13+
import java.nio.charset.StandardCharsets;
14+
import java.util.Locale;
15+
import java.util.OptionalLong;
16+
import java.util.stream.Collectors;
17+
18+
/**
19+
* Queries the Have I Been Pwned Pwned Passwords range API.
20+
*/
21+
public class PwnedPasswordService {
22+
23+
private static final String RANGE_API_URL = "https://api.pwnedpasswords.com/range/";
24+
private static final String USER_AGENT = "AuthMeReloaded";
25+
private static final int HASH_PREFIX_LENGTH = 5;
26+
private static final int CONNECT_TIMEOUT_MILLIS = 5_000;
27+
private static final int READ_TIMEOUT_MILLIS = 5_000;
28+
29+
private final ConsoleLogger logger = ConsoleLoggerFactory.get(PwnedPasswordService.class);
30+
31+
/**
32+
* Returns how many times the password appears in the Pwned Passwords database.
33+
*
34+
* @param password the password to check
35+
* @return the count, 0 when absent, or empty if the API could not be queried
36+
*/
37+
public OptionalLong getPwnedCount(String password) {
38+
String hash = HashUtils.sha1(password).toUpperCase(Locale.ROOT);
39+
String hashPrefix = hash.substring(0, HASH_PREFIX_LENGTH);
40+
String hashSuffix = hash.substring(HASH_PREFIX_LENGTH);
41+
42+
try {
43+
return parsePwnedCount(hashSuffix, requestHashRange(hashPrefix));
44+
} catch (IOException e) {
45+
logger.debug("Could not query the Pwned Passwords API: {0}", e.getMessage());
46+
return OptionalLong.empty();
47+
}
48+
}
49+
50+
@VisibleForTesting
51+
protected String requestHashRange(String hashPrefix) throws IOException {
52+
HttpURLConnection connection = (HttpURLConnection) URI.create(RANGE_API_URL + hashPrefix)
53+
.toURL()
54+
.openConnection();
55+
connection.setRequestMethod("GET");
56+
connection.setRequestProperty("User-Agent", USER_AGENT);
57+
connection.setRequestProperty("Add-Padding", "true");
58+
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
59+
connection.setReadTimeout(READ_TIMEOUT_MILLIS);
60+
61+
try {
62+
int responseCode = connection.getResponseCode();
63+
if (responseCode != HttpURLConnection.HTTP_OK) {
64+
throw new IOException("HTTP " + responseCode);
65+
}
66+
67+
try (BufferedReader reader = new BufferedReader(
68+
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
69+
return reader.lines().collect(Collectors.joining("\n"));
70+
}
71+
} finally {
72+
connection.disconnect();
73+
}
74+
}
75+
76+
private OptionalLong parsePwnedCount(String searchedHashSuffix, String response) {
77+
String[] entries = response.split("\\R");
78+
for (String entry : entries) {
79+
int delimiterIndex = entry.indexOf(':');
80+
if (delimiterIndex < 0) {
81+
continue;
82+
}
83+
84+
String hashSuffix = entry.substring(0, delimiterIndex);
85+
if (hashSuffix.equalsIgnoreCase(searchedHashSuffix)) {
86+
return parseCount(entry.substring(delimiterIndex + 1));
87+
}
88+
}
89+
return OptionalLong.of(0);
90+
}
91+
92+
private OptionalLong parseCount(String count) {
93+
try {
94+
return OptionalLong.of(Long.parseLong(count.trim()));
95+
} catch (NumberFormatException e) {
96+
logger.debug("Could not parse Pwned Passwords count: {0}", count);
97+
return OptionalLong.empty();
98+
}
99+
}
100+
}

authme-core/src/main/java/fr/xephi/authme/service/ValidationService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.Collection;
2929
import java.util.List;
3030
import java.util.Locale;
31+
import java.util.OptionalLong;
3132
import java.util.Set;
3233
import java.util.regex.Pattern;
3334

@@ -48,6 +49,8 @@ public class ValidationService implements Reloadable {
4849
private PermissionsManager permissionsManager;
4950
@Inject
5051
private GeoIpService geoIpService;
52+
@Inject
53+
private PwnedPasswordService pwnedPasswordService;
5154

5255
private Pattern passwordRegex;
5356
private Multimap<String, String> restrictedNames;
@@ -82,6 +85,12 @@ public ValidationResult validatePassword(String password, String username) {
8285
return new ValidationResult(MessageKey.INVALID_PASSWORD_LENGTH);
8386
} else if (settings.getProperty(SecuritySettings.UNSAFE_PASSWORDS).contains(passLow)) {
8487
return new ValidationResult(MessageKey.PASSWORD_UNSAFE_ERROR);
88+
} else if (settings.getProperty(SecuritySettings.ENABLE_PWNED_PASSWORD_CHECK)) {
89+
OptionalLong pwnedCount = pwnedPasswordService.getPwnedCount(password);
90+
int threshold = settings.getProperty(SecuritySettings.PWNED_PASSWORD_CHECK_THRESHOLD);
91+
if (pwnedCount.isPresent() && pwnedCount.getAsLong() > threshold) {
92+
return new ValidationResult(MessageKey.PASSWORD_PWNED_ERROR, Long.toString(pwnedCount.getAsLong()));
93+
}
8594
}
8695
return new ValidationResult();
8796
}

authme-core/src/main/java/fr/xephi/authme/settings/properties/SecuritySettings.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ public final class SecuritySettings implements SettingsHolder {
8989
newLowercaseStringSetProperty("settings.security.unsafePasswords",
9090
"123456", "password", "qwerty", "12345", "54321", "123456789", "help");
9191

92+
@Comment({
93+
"Reject passwords found in the Have I Been Pwned Pwned Passwords database.",
94+
"Only the first 5 SHA-1 hash characters are sent; the full hash and password stay local."
95+
})
96+
public static final Property<Boolean> ENABLE_PWNED_PASSWORD_CHECK =
97+
newProperty("settings.security.pwnedPasswords.enabled", false);
98+
99+
@Comment("Reject passwords found more than this many times in the Pwned Passwords database")
100+
public static final Property<Integer> PWNED_PASSWORD_CHECK_THRESHOLD =
101+
newProperty("settings.security.pwnedPasswords.threshold", 0);
102+
92103
@Comment("Tempban a user's IP address if they enter the wrong password too many times")
93104
public static final Property<Boolean> TEMPBAN_ON_MAX_LOGINS =
94105
newProperty("Security.tempban.enableTempban", false);

authme-core/src/main/resources/messages/messages_en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ password:
1818
match_error: '&cPasswords didn''t match, check them again!'
1919
name_in_password: '&cYou can''t use your name as password, please choose another one...'
2020
unsafe_password: '&cThe chosen password isn''t safe, please choose another one...'
21+
pwned_password: '&cYour chosen password is not secure. It has been seen %pwned_count times before! Please use a stronger password...'
2122
forbidden_characters: '&4Your password contains illegal characters. Allowed chars: %valid_chars'
2223
wrong_length: '&cYour password is too short or too long! Please try with another one!'
2324

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package fr.xephi.authme.service;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.IOException;
6+
import java.util.OptionalLong;
7+
8+
import static org.hamcrest.MatcherAssert.assertThat;
9+
import static org.hamcrest.Matchers.equalTo;
10+
11+
/**
12+
* Test for {@link PwnedPasswordService}.
13+
*/
14+
class PwnedPasswordServiceTest {
15+
16+
@Test
17+
void shouldReturnPwnedCountForMatchingPasswordHash() {
18+
// given
19+
TestPwnedPasswordService service = new TestPwnedPasswordService(
20+
"003D68EB55068C33ACE09247EE4C639306B:1\n"
21+
+ "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\n"
22+
+ "FFFFF0AC487871FEEC1891C490136E006E2:0");
23+
24+
// when
25+
OptionalLong count = service.getPwnedCount("password");
26+
27+
// then
28+
assertThat(count.isPresent(), equalTo(true));
29+
assertThat(count.getAsLong(), equalTo(12345L));
30+
assertThat(service.requestedHashPrefix, equalTo("5BAA6"));
31+
}
32+
33+
@Test
34+
void shouldReturnZeroWhenPasswordHashIsAbsent() {
35+
// given
36+
TestPwnedPasswordService service = new TestPwnedPasswordService(
37+
"003D68EB55068C33ACE09247EE4C639306B:1\n"
38+
+ "FFFFF0AC487871FEEC1891C490136E006E2:0");
39+
40+
// when
41+
OptionalLong count = service.getPwnedCount("password");
42+
43+
// then
44+
assertThat(count.isPresent(), equalTo(true));
45+
assertThat(count.getAsLong(), equalTo(0L));
46+
}
47+
48+
@Test
49+
void shouldReturnEmptyWhenRangeRequestFails() {
50+
// given
51+
TestPwnedPasswordService service = new TestPwnedPasswordService(new IOException("boom"));
52+
53+
// when
54+
OptionalLong count = service.getPwnedCount("password");
55+
56+
// then
57+
assertThat(count.isPresent(), equalTo(false));
58+
}
59+
60+
private static final class TestPwnedPasswordService extends PwnedPasswordService {
61+
private final String response;
62+
private final IOException exception;
63+
private String requestedHashPrefix;
64+
65+
private TestPwnedPasswordService(String response) {
66+
this.response = response;
67+
this.exception = null;
68+
}
69+
70+
private TestPwnedPasswordService(IOException exception) {
71+
this.response = null;
72+
this.exception = exception;
73+
}
74+
75+
@Override
76+
protected String requestHashRange(String hashPrefix) throws IOException {
77+
requestedHashPrefix = hashPrefix;
78+
if (exception != null) {
79+
throw exception;
80+
}
81+
return response;
82+
}
83+
}
84+
}

authme-core/src/test/java/fr/xephi/authme/service/ValidationServiceTest.java

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import java.net.InetSocketAddress;
2828
import java.util.Collections;
29+
import java.util.OptionalLong;
2930
import java.util.logging.Logger;
3031

3132
import static com.google.common.collect.Sets.newHashSet;
@@ -56,6 +57,8 @@ public class ValidationServiceTest {
5657
private PermissionsManager permissionsManager;
5758
@Mock
5859
private GeoIpService geoIpService;
60+
@Mock
61+
private PwnedPasswordService pwnedPasswordService;
5962
@Captor
6063
private ArgumentCaptor<String> stringCaptor;
6164

@@ -65,6 +68,7 @@ public void createService() {
6568
given(settings.getProperty(SecuritySettings.MIN_PASSWORD_LENGTH)).willReturn(3);
6669
given(settings.getProperty(SecuritySettings.MAX_PASSWORD_LENGTH)).willReturn(20);
6770
given(settings.getProperty(SecuritySettings.UNSAFE_PASSWORDS)).willReturn(newHashSet("unsafe", "other-unsafe"));
71+
given(settings.getProperty(SecuritySettings.ENABLE_PWNED_PASSWORD_CHECK)).willReturn(false);
6872
given(settings.getProperty(EmailSettings.MAX_REG_PER_EMAIL)).willReturn(3);
6973
given(settings.getProperty(RestrictionSettings.UNRESTRICTED_NAMES)).willReturn(newHashSet("name01", "npc"));
7074
given(settings.getProperty(RestrictionSettings.ENABLE_RESTRICTED_USERS)).willReturn(false);
@@ -116,6 +120,48 @@ public void shouldRejectUnsafePassword() {
116120
assertErrorEquals(error, MessageKey.PASSWORD_UNSAFE_ERROR);
117121
}
118122

123+
@Test
124+
public void shouldRejectPwnedPasswordOverThreshold() {
125+
// given
126+
given(settings.getProperty(SecuritySettings.ENABLE_PWNED_PASSWORD_CHECK)).willReturn(true);
127+
given(settings.getProperty(SecuritySettings.PWNED_PASSWORD_CHECK_THRESHOLD)).willReturn(10);
128+
given(pwnedPasswordService.getPwnedCount("safePass")).willReturn(OptionalLong.of(42));
129+
130+
// when
131+
ValidationResult error = validationService.validatePassword("safePass", "some_user");
132+
133+
// then
134+
assertErrorEquals(error, MessageKey.PASSWORD_PWNED_ERROR, "42");
135+
}
136+
137+
@Test
138+
public void shouldAcceptPwnedPasswordAtThreshold() {
139+
// given
140+
given(settings.getProperty(SecuritySettings.ENABLE_PWNED_PASSWORD_CHECK)).willReturn(true);
141+
given(settings.getProperty(SecuritySettings.PWNED_PASSWORD_CHECK_THRESHOLD)).willReturn(10);
142+
given(pwnedPasswordService.getPwnedCount("safePass")).willReturn(OptionalLong.of(10));
143+
144+
// when
145+
ValidationResult error = validationService.validatePassword("safePass", "some_user");
146+
147+
// then
148+
assertThat(error.hasError(), equalTo(false));
149+
}
150+
151+
@Test
152+
public void shouldAcceptPasswordWhenPwnedCheckIsUnavailable() {
153+
// given
154+
given(settings.getProperty(SecuritySettings.ENABLE_PWNED_PASSWORD_CHECK)).willReturn(true);
155+
given(settings.getProperty(SecuritySettings.PWNED_PASSWORD_CHECK_THRESHOLD)).willReturn(10);
156+
given(pwnedPasswordService.getPwnedCount("safePass")).willReturn(OptionalLong.empty());
157+
158+
// when
159+
ValidationResult error = validationService.validatePassword("safePass", "some_user");
160+
161+
// then
162+
assertThat(error.hasError(), equalTo(false));
163+
}
164+
119165
@Test
120166
public void shouldAcceptValidPassword() {
121167
// given/when
@@ -424,4 +470,3 @@ private static void assertErrorEquals(ValidationResult validationResult, Message
424470
}
425471
}
426472

427-

0 commit comments

Comments
 (0)