-
Notifications
You must be signed in to change notification settings - Fork 559
feat(security): add pwned password validation #2959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| package fr.xephi.authme.service; | ||
|
|
||
| import com.google.common.annotations.VisibleForTesting; | ||
| import fr.xephi.authme.ConsoleLogger; | ||
| import fr.xephi.authme.output.ConsoleLoggerFactory; | ||
| import fr.xephi.authme.security.HashUtils; | ||
|
|
||
| import java.io.BufferedReader; | ||
| import java.io.IOException; | ||
| import java.io.InputStreamReader; | ||
| import java.net.HttpURLConnection; | ||
| import java.net.URI; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.Locale; | ||
| import java.util.OptionalLong; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| /** | ||
| * Queries the Have I Been Pwned Pwned Passwords range API. | ||
| */ | ||
| public class PwnedPasswordService { | ||
|
|
||
| private static final String RANGE_API_URL = "https://api.pwnedpasswords.com/range/"; | ||
| private static final String USER_AGENT = "AuthMeReloaded"; | ||
| private static final int HASH_PREFIX_LENGTH = 5; | ||
| private static final int CONNECT_TIMEOUT_MILLIS = 5_000; | ||
| private static final int READ_TIMEOUT_MILLIS = 5_000; | ||
|
|
||
| private final ConsoleLogger logger = ConsoleLoggerFactory.get(PwnedPasswordService.class); | ||
|
|
||
| /** | ||
| * Returns how many times the password appears in the Pwned Passwords database. | ||
| * | ||
| * @param password the password to check | ||
| * @return the count, 0 when absent, or empty if the API could not be queried | ||
| */ | ||
| public OptionalLong getPwnedCount(String password) { | ||
| String hash = HashUtils.sha1(password).toUpperCase(Locale.ROOT); | ||
| String hashPrefix = hash.substring(0, HASH_PREFIX_LENGTH); | ||
| String hashSuffix = hash.substring(HASH_PREFIX_LENGTH); | ||
|
|
||
| try { | ||
| return parsePwnedCount(hashSuffix, requestHashRange(hashPrefix)); | ||
| } catch (IOException e) { | ||
| logger.debug("Could not query the Pwned Passwords API: {0}", e.getMessage()); | ||
| return OptionalLong.empty(); | ||
| } | ||
| } | ||
|
|
||
| @VisibleForTesting | ||
| protected String requestHashRange(String hashPrefix) throws IOException { | ||
| HttpURLConnection connection = (HttpURLConnection) URI.create(RANGE_API_URL + hashPrefix) | ||
| .toURL() | ||
| .openConnection(); | ||
| connection.setRequestMethod("GET"); | ||
| connection.setRequestProperty("User-Agent", USER_AGENT); | ||
| connection.setRequestProperty("Add-Padding", "true"); | ||
| connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); | ||
| connection.setReadTimeout(READ_TIMEOUT_MILLIS); | ||
|
|
||
| try { | ||
| int responseCode = connection.getResponseCode(); | ||
| if (responseCode != HttpURLConnection.HTTP_OK) { | ||
| throw new IOException("HTTP " + responseCode); | ||
| } | ||
|
|
||
| try (BufferedReader reader = new BufferedReader( | ||
| new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { | ||
| return reader.lines().collect(Collectors.joining("\n")); | ||
| } | ||
| } finally { | ||
| connection.disconnect(); | ||
| } | ||
| } | ||
|
|
||
| private OptionalLong parsePwnedCount(String searchedHashSuffix, String response) { | ||
| String[] entries = response.split("\\R"); | ||
| for (String entry : entries) { | ||
| int delimiterIndex = entry.indexOf(':'); | ||
| if (delimiterIndex < 0) { | ||
| continue; | ||
| } | ||
|
|
||
| String hashSuffix = entry.substring(0, delimiterIndex); | ||
| if (hashSuffix.equalsIgnoreCase(searchedHashSuffix)) { | ||
| return parseCount(entry.substring(delimiterIndex + 1)); | ||
| } | ||
| } | ||
| return OptionalLong.of(0); | ||
| } | ||
|
|
||
| private OptionalLong parseCount(String count) { | ||
| try { | ||
| return OptionalLong.of(Long.parseLong(count.trim())); | ||
| } catch (NumberFormatException e) { | ||
| logger.debug("Could not parse Pwned Passwords count: {0}", count); | ||
| return OptionalLong.empty(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ | |
| import java.util.Collection; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.OptionalLong; | ||
| import java.util.Set; | ||
| import java.util.regex.Pattern; | ||
|
|
||
|
|
@@ -48,6 +49,8 @@ public class ValidationService implements Reloadable { | |
| private PermissionsManager permissionsManager; | ||
| @Inject | ||
| private GeoIpService geoIpService; | ||
| @Inject | ||
| private PwnedPasswordService pwnedPasswordService; | ||
|
|
||
| private Pattern passwordRegex; | ||
| private Multimap<String, String> restrictedNames; | ||
|
|
@@ -82,6 +85,12 @@ public ValidationResult validatePassword(String password, String username) { | |
| return new ValidationResult(MessageKey.INVALID_PASSWORD_LENGTH); | ||
| } else if (settings.getProperty(SecuritySettings.UNSAFE_PASSWORDS).contains(passLow)) { | ||
| return new ValidationResult(MessageKey.PASSWORD_UNSAFE_ERROR); | ||
| } else if (settings.getProperty(SecuritySettings.ENABLE_PWNED_PASSWORD_CHECK)) { | ||
| OptionalLong pwnedCount = pwnedPasswordService.getPwnedCount(password); | ||
| int threshold = settings.getProperty(SecuritySettings.PWNED_PASSWORD_CHECK_THRESHOLD); | ||
| if (pwnedCount.isPresent() && pwnedCount.getAsLong() > threshold) { | ||
| return new ValidationResult(MessageKey.PASSWORD_PWNED_ERROR, Long.toString(pwnedCount.getAsLong())); | ||
|
Comment on lines
+88
to
+92
|
||
| } | ||
| } | ||
| return new ValidationResult(); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package fr.xephi.authme.service; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.OptionalLong; | ||
|
|
||
| import static org.hamcrest.MatcherAssert.assertThat; | ||
| import static org.hamcrest.Matchers.equalTo; | ||
|
|
||
| /** | ||
| * Test for {@link PwnedPasswordService}. | ||
| */ | ||
| class PwnedPasswordServiceTest { | ||
|
|
||
| @Test | ||
| void shouldReturnPwnedCountForMatchingPasswordHash() { | ||
| // given | ||
| TestPwnedPasswordService service = new TestPwnedPasswordService( | ||
| "003D68EB55068C33ACE09247EE4C639306B:1\n" | ||
| + "1E4C9B93F3F0682250B6CF8331B7EE68FD8:12345\n" | ||
| + "FFFFF0AC487871FEEC1891C490136E006E2:0"); | ||
|
|
||
| // when | ||
| OptionalLong count = service.getPwnedCount("password"); | ||
|
|
||
| // then | ||
| assertThat(count.isPresent(), equalTo(true)); | ||
| assertThat(count.getAsLong(), equalTo(12345L)); | ||
| assertThat(service.requestedHashPrefix, equalTo("5BAA6")); | ||
| } | ||
|
|
||
| @Test | ||
| void shouldReturnZeroWhenPasswordHashIsAbsent() { | ||
| // given | ||
| TestPwnedPasswordService service = new TestPwnedPasswordService( | ||
| "003D68EB55068C33ACE09247EE4C639306B:1\n" | ||
| + "FFFFF0AC487871FEEC1891C490136E006E2:0"); | ||
|
|
||
| // when | ||
| OptionalLong count = service.getPwnedCount("password"); | ||
|
|
||
| // then | ||
| assertThat(count.isPresent(), equalTo(true)); | ||
| assertThat(count.getAsLong(), equalTo(0L)); | ||
| } | ||
|
|
||
| @Test | ||
| void shouldReturnEmptyWhenRangeRequestFails() { | ||
| // given | ||
| TestPwnedPasswordService service = new TestPwnedPasswordService(new IOException("boom")); | ||
|
|
||
| // when | ||
| OptionalLong count = service.getPwnedCount("password"); | ||
|
|
||
| // then | ||
| assertThat(count.isPresent(), equalTo(false)); | ||
| } | ||
|
|
||
| private static final class TestPwnedPasswordService extends PwnedPasswordService { | ||
| private final String response; | ||
| private final IOException exception; | ||
| private String requestedHashPrefix; | ||
|
|
||
| private TestPwnedPasswordService(String response) { | ||
| this.response = response; | ||
| this.exception = null; | ||
| } | ||
|
|
||
| private TestPwnedPasswordService(IOException exception) { | ||
| this.response = null; | ||
| this.exception = exception; | ||
| } | ||
|
|
||
| @Override | ||
| protected String requestHashRange(String hashPrefix) throws IOException { | ||
| requestedHashPrefix = hashPrefix; | ||
| if (exception != null) { | ||
| throw exception; | ||
| } | ||
| return response; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getPwnedCountrelies onHashUtils.sha1(password), butHashUtils.hashusesmessage.getBytes()without an explicit charset, so the SHA-1 can differ across platforms for non-ASCII passwords. For interoperability with the HIBP API and consistent behavior across JVMs/OSes, ensure the SHA-1 is computed over a fixed charset (typically UTF-8), either by updating HashUtils or by hashing with an explicit charset here.