Skip to content

Commit

Permalink
Add PasswordChecker
Browse files Browse the repository at this point in the history
  • Loading branch information
marcusdacoregio committed Mar 5, 2024
1 parent bade66e commit 78023c4
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 0 deletions.
2 changes: 2 additions & 0 deletions core/spring-security-core.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies {
optional 'org.aspectj:aspectjrt'
optional 'org.springframework:spring-jdbc'
optional 'org.springframework:spring-tx'
optional 'org.springframework:spring-web'
optional 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor'

testImplementation 'commons-collections:commons-collections'
Expand All @@ -31,6 +32,7 @@ dependencies {
testImplementation "org.springframework:spring-test"
testImplementation 'org.skyscreamer:jsonassert'
testImplementation 'org.springframework:spring-test'
testImplementation 'com.squareup.okhttp3:mockwebserver'

testRuntimeOnly 'org.hsqldb:hsqldb'
}
Expand Down
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());
}
}

}
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);
}

}
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) {

}

}
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);

}
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);
}

}
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));
}

}

0 comments on commit 78023c4

Please sign in to comment.