forked from spring-projects/spring-security
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
bade66e
commit 78023c4
Showing
7 changed files
with
334 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
core/src/main/java/org/springframework/security/core/password/LeakedPasswordChecker.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
/* | ||
* Copyright 2002-2024 the original author or authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* https://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.springframework.security.core.password; | ||
|
||
import java.nio.charset.StandardCharsets; | ||
import java.security.MessageDigest; | ||
import java.security.NoSuchAlgorithmException; | ||
import java.util.Collections; | ||
import java.util.List; | ||
|
||
import org.apache.commons.logging.Log; | ||
import org.apache.commons.logging.LogFactory; | ||
|
||
import org.springframework.lang.Nullable; | ||
import org.springframework.security.crypto.codec.Hex; | ||
import org.springframework.util.Assert; | ||
import org.springframework.util.StringUtils; | ||
import org.springframework.web.client.RestClient; | ||
import org.springframework.web.client.RestClientException; | ||
|
||
/** | ||
* @see <a href="https://www.haveibeenpwned.com/API/v3#PwnedPasswords">Have I Been Pwned | ||
* API Docs</a> | ||
*/ | ||
public final class LeakedPasswordChecker implements PasswordChecker { | ||
|
||
private final Log logger = LogFactory.getLog(getClass()); | ||
|
||
private static final String API_URL = "https://api.pwnedpasswords.com/range/"; | ||
|
||
private final static int PREFIX_LENGTH = 5; | ||
|
||
private final MessageDigest sha1Digest; | ||
|
||
private RestClient restClient = RestClient.builder().baseUrl(API_URL).build(); | ||
|
||
public LeakedPasswordChecker() { | ||
this.sha1Digest = getSha1Digest(); | ||
} | ||
|
||
@Override | ||
public void check(String password, @Nullable String username) { | ||
byte[] hash = this.sha1Digest.digest(password.getBytes(StandardCharsets.UTF_8)); | ||
String encoded = new String(Hex.encode(hash)).toUpperCase(); | ||
String prefix = encoded.substring(0, PREFIX_LENGTH); | ||
String suffix = encoded.substring(PREFIX_LENGTH); | ||
|
||
List<String> passwords = getLeakedPasswordsForPrefix(prefix); | ||
LeakedPassword leakedPassword = findLeakedPassword(passwords, suffix); | ||
if (leakedPassword != null) { | ||
throw new LeakedPasswordException("The provided password has appeared " + leakedPassword.leakCount() | ||
+ " times in previous data breaches"); | ||
} | ||
} | ||
|
||
public void setRestClient(RestClient restClient) { | ||
Assert.notNull(restClient, "restClient cannot be null"); | ||
this.restClient = restClient; | ||
} | ||
|
||
private LeakedPassword findLeakedPassword(List<String> passwords, String suffix) { | ||
for (String pw : passwords) { | ||
if (pw.startsWith(suffix)) { | ||
return LeakedPassword.from(pw); | ||
} | ||
} | ||
return null; | ||
} | ||
|
||
private List<String> getLeakedPasswordsForPrefix(String prefix) { | ||
try { | ||
String response = this.restClient.get().uri(prefix).retrieve().body(String.class); | ||
if (!StringUtils.hasText(response)) { | ||
return Collections.emptyList(); | ||
} | ||
return response.lines().toList(); | ||
} | ||
catch (RestClientException ex) { | ||
this.logger.error("Request for leaked passwords failed", ex); | ||
return Collections.emptyList(); | ||
} | ||
} | ||
|
||
private record LeakedPassword(String suffix, long leakCount) { | ||
|
||
static LeakedPassword from(String passwordLine) { | ||
String[] parts = passwordLine.split(":"); | ||
return new LeakedPassword(parts[0], Long.parseLong(parts[1])); | ||
} | ||
|
||
} | ||
|
||
private static MessageDigest getSha1Digest() { | ||
try { | ||
return MessageDigest.getInstance("SHA-1"); | ||
} | ||
catch (NoSuchAlgorithmException ex) { | ||
throw new RuntimeException(ex.getMessage()); | ||
} | ||
} | ||
|
||
} |
31 changes: 31 additions & 0 deletions
31
core/src/main/java/org/springframework/security/core/password/LeakedPasswordException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
/* | ||
* Copyright 2002-2024 the original author or authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* https://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.springframework.security.core.password; | ||
|
||
/** | ||
* Indicates that the password has been leaked | ||
* | ||
* @author Marcus da Coregio | ||
* @since 6.3 | ||
*/ | ||
public class LeakedPasswordException extends PasswordException { | ||
|
||
public LeakedPasswordException(String message) { | ||
super(message); | ||
} | ||
|
||
} |
26 changes: 26 additions & 0 deletions
26
core/src/main/java/org/springframework/security/core/password/NoOpPasswordChecker.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
/* | ||
* Copyright 2002-2024 the original author or authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* https://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.springframework.security.core.password; | ||
|
||
public class NoOpPasswordChecker implements PasswordChecker { | ||
|
||
@Override | ||
public void check(String password, String username) { | ||
|
||
} | ||
|
||
} |
25 changes: 25 additions & 0 deletions
25
core/src/main/java/org/springframework/security/core/password/PasswordChecker.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
/* | ||
* Copyright 2002-2024 the original author or authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* https://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.springframework.security.core.password; | ||
|
||
import org.springframework.lang.Nullable; | ||
|
||
public interface PasswordChecker { | ||
|
||
void check(String password, @Nullable String username); | ||
|
||
} |
36 changes: 36 additions & 0 deletions
36
core/src/main/java/org/springframework/security/core/password/PasswordException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
/* | ||
* Copyright 2002-2024 the original author or authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* https://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.springframework.security.core.password; | ||
|
||
/** | ||
* Base class for exceptions related to passwords. Some common reasons for the exception | ||
* is if the password has been reused, compromised, leaked, etc. | ||
* | ||
* @author Marcus da Coregio | ||
* @since 6.3 | ||
*/ | ||
public abstract class PasswordException extends RuntimeException { | ||
|
||
public PasswordException(String message) { | ||
super(message); | ||
} | ||
|
||
public PasswordException(String message, Throwable cause) { | ||
super(message, cause); | ||
} | ||
|
||
} |
98 changes: 98 additions & 0 deletions
98
.../src/test/java/org/springframework/security/core/password/LeakedPasswordCheckerTests.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
/* | ||
* Copyright 2002-2024 the original author or authors. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* https://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package org.springframework.security.core.password; | ||
|
||
import java.io.IOException; | ||
|
||
import okhttp3.HttpUrl; | ||
import okhttp3.mockwebserver.MockResponse; | ||
import okhttp3.mockwebserver.MockWebServer; | ||
import org.junit.jupiter.api.AfterEach; | ||
import org.junit.jupiter.api.BeforeEach; | ||
import org.junit.jupiter.api.Test; | ||
|
||
import org.springframework.web.client.RestClient; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType; | ||
import static org.assertj.core.api.Assertions.assertThatNoException; | ||
|
||
class LeakedPasswordCheckerTests { | ||
|
||
private String pwnedPasswords = """ | ||
2CDE4CDCFA5AD7D223BD1800338FBEAA04E:1 | ||
2CF90F92EE1941547BB13DFC7D0E0AFE504:1 | ||
2D10A6654B6D75908AE572559542245CBFA:6 | ||
2D4FCF535FE92B8B950424E16E65EFBFED3:1 | ||
2D6980B9098804E7A83DC5831BFBAF3927F:1 | ||
2D8D1B3FAACCA6A3C6A91617B2FA32E2F57:1 | ||
2DC183F740EE76F27B78EB39C8AD972A757:300185 | ||
2DE4C0087846D223DBBCCF071614590F300:3 | ||
2DEA2B1D02714099E4B7A874B4364D518F6:1 | ||
2E750AE8C4756A20CE040BF3DDF094FA7EC:1 | ||
2E90B7B3C5C1181D16C48E273D9AC7F3C16:5 | ||
2E991A9162F24F01826D8AF73CA20F2B430:1 | ||
2EAE5EA981BFAF29A8869A40BDDADF3879B:2 | ||
2F1AC09E3846595E436BBDDDD2189358AF9:1 | ||
"""; | ||
|
||
private final MockWebServer server = new MockWebServer(); | ||
|
||
private final LeakedPasswordChecker passwordChecker = new LeakedPasswordChecker(); | ||
|
||
@BeforeEach | ||
void setup() throws IOException { | ||
this.server.start(); | ||
HttpUrl url = this.server.url("/range/"); | ||
this.passwordChecker.setRestClient(RestClient.builder().baseUrl(url.toString()).build()); | ||
} | ||
|
||
@AfterEach | ||
void tearDown() throws IOException { | ||
this.server.shutdown(); | ||
} | ||
|
||
@Test | ||
void checkWhenPasswordIsLeakedThenLeakedPasswordException() throws InterruptedException { | ||
this.server.enqueue(new MockResponse().setBody(this.pwnedPasswords).setResponseCode(200)); | ||
assertThatExceptionOfType(LeakedPasswordException.class) | ||
.isThrownBy(() -> this.passwordChecker.check("P@ssw0rd", null)) | ||
.withMessage("The provided password has appeared 300185 times in previous data breaches"); | ||
assertThat(this.server.takeRequest().getPath()).isEqualTo("/range/21BD1"); | ||
} | ||
|
||
@Test | ||
void checkWhenPasswordNotLeakedThenNoException() { | ||
this.server.enqueue(new MockResponse().setBody(this.pwnedPasswords).setResponseCode(200)); | ||
assertThatNoException().isThrownBy(() -> this.passwordChecker.check("My1nCr3d!bL3P@SS0W0RD", null)); | ||
} | ||
|
||
@Test | ||
void checkWhenNoPasswordsReturnedFromApiCallThenNoException() { | ||
this.server.enqueue(new MockResponse().setResponseCode(200)); | ||
assertThatNoException().isThrownBy(() -> this.passwordChecker.check("123456", null)); | ||
} | ||
|
||
@Test | ||
void checkWhenResponseStatusNot200ThenNoException() { | ||
this.server.enqueue(new MockResponse().setResponseCode(503)); | ||
assertThatNoException().isThrownBy(() -> this.passwordChecker.check("123456", null)); | ||
this.server.enqueue(new MockResponse().setResponseCode(302)); | ||
assertThatNoException().isThrownBy(() -> this.passwordChecker.check("123456", null)); | ||
} | ||
|
||
} |