1. 概述
當構建處理敏感數據(如用户密碼)的Web應用程序時,確保用户密碼的安全至關重要。 密碼安全的一個重要方面是檢查密碼是否被泄露,這通常由於密碼出現在數據泄露事件中。
Spring Security 6.3 引入了一個新功能,允許我們輕鬆檢查密碼是否被泄露。
在本教程中,我們將探索 Spring Security 中的 CompromisedPasswordChecker API 以及如何將其集成到我們的 Spring Boot 應用程序中。
2. 理解被盜密碼
一個被盜密碼是指在數據泄露事件中暴露的密碼,使其容易受到未經授權的訪問。攻擊者經常使用這些被盜密碼在憑據填充攻擊和密碼填充攻擊中,利用泄露的用户名-密碼組合在多個網站或通用密碼對多個賬户進行嘗試。
為了減輕這種風險,在創建賬户之前,檢查用户密碼是否被盜至關重要。
同時,一個先前有效的密碼可能會隨着時間的推移而變得被盜,因此,建議不僅在賬户創建過程中,還應在登錄過程或允許用户更改密碼的任何過程中檢查密碼是否被盜。如果登錄嘗試由於檢測到被盜密碼而失敗,我們可以提示用户重置密碼。
3. 受損密碼檢查器 API
Spring Security 提供了一個簡單的 受損密碼檢查器 接口,用於檢查密碼是否已被泄露:
public interface CompromisedPasswordChecker {
CompromisedPasswordDecision check(String password);
}此接口暴露了一個名為 check() 的單方法,該方法接受密碼作為輸入,並返回一個 CompromisedPasswordDecision 實例,指示密碼是否被泄露。
check() 方法期望接收明文密碼,因此在通過 PasswordEncoder 加密密碼之前,我們需要調用它。
3.1. 配置 CompromisedPasswordChecker Bean
為了在我們的應用程序中啓用已泄露密碼檢查,我們需要聲明一個類型為 CompromisedPasswordChecker 的 Bean:
@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
return new HaveIBeenPwnedRestApiPasswordChecker();
}HaveIBeenPwnedRestApiPasswordChecker 是 Spring Security 提供的一致實現,它是 CompromisedPasswordChecker 的默認實現。
該默認實現與流行的 Have I Been Pwned API 集成,該 API 維護着來自數據泄露的大量已泄露密碼數據庫。
當調用該默認實現中的 check() 方法時,它會安全地哈希提供的密碼,並將哈希的前 5 個字符發送到 Have I Been Pwned API。API 會響應一個包含與此前綴匹配的哈希後綴的列表。然後該方法將密碼的完整哈希與此列表進行比較,以確定其是否已被泄露。整個檢查過程在不發送未加密的密碼到網絡上進行。
3.2. 自定義 CompromisedPasswordChecker Bean
@Bean
public CompromisedPasswordChecker customCompromisedPasswordChecker() {
RestClient customRestClient = RestClient.builder()
.baseUrl("https://api.proxy.com/password-check")
.defaultHeader("X-API-KEY", "api-key")
.build();
HaveIBeenPwnedRestApiPasswordChecker compromisedPasswordChecker = new HaveIBeenPwnedRestApiPasswordChecker();
compromisedPasswordChecker.setRestClient(customRestClient);
return compromisedPasswordChecker;
}現在,當我們調用應用程序中 CompromisedPasswordChecker 組件的 check() 方法時,它將向我們定義的基 URL 發送帶有自定義 HTTP 標頭的 API 請求。
4. 處理泄露密碼
現在我們已經配置了 CompromisedPasswordChecker Bean,接下來讓我們看看如何在我們的服務層中使用它來驗證密碼。 讓我們以一個常見的用例:新用户註冊為例:
@Autowired
private CompromisedPasswordChecker compromisedPasswordChecker;
String password = userCreationRequest.getPassword();
CompromisedPasswordDecision decision = compromisedPasswordChecker.check(password);
if (decision.isCompromised()) {
throw new CompromisedPasswordException("The provided password is compromised and cannot be used.");
}在這裏,我們只需調用提供的客户端文本密碼,並檢查返回的 CompromisedPasswordDecision。 如果 isCompromised() 方法返回 true,則拋出 CompromisedPasswordException 以終止註冊過程。
5. 處理 CompromisedPasswordException 異常
當我們的服務層拋出 CompromisedPasswordException 異常時,我們希望能夠優雅地處理它並向客户端提供反饋。
一種方法是定義一個全局異常處理程序,該程序位於一個 @RestControllerAdvice 類中:
@ExceptionHandler(CompromisedPasswordException.class)
public ProblemDetail handle(CompromisedPasswordException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exception.getMessage());
}當此處理方法捕獲到 CompromisedPasswordException 異常時,它會返回 ProblemDetail 類的一個實例,該類會構建符合 RFC 9457 規範的錯誤響應:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "The provided password is compromised and cannot be used.",
"instance": "/api/v1/users"
}6. 自定義 CompromisedPasswordChecker 實現
雖然 HaveIBeenPwnedRestApiPasswordChecker 實現是一個不錯的解決方案,但在某些情況下,我們可能希望與不同的提供商集成,甚至實現自己的已泄露密碼檢查邏輯。
我們可以通過實現 CompromisedPasswordChecker 接口來完成:
public class PasswordCheckerSimulator implements CompromisedPasswordChecker {
public static final String FAILURE_KEYWORD = "compromised";
@Override
public CompromisedPasswordDecision check(String password) {
boolean isPasswordCompromised = false;
if (password.contains(FAILURE_KEYWORD)) {
isPasswordCompromised = true;
}
return new CompromisedPasswordDecision(isPasswordCompromised);
}
}我們的示例實現認為密碼被泄露如果它包含單詞“compromised”。雖然在實際場景中這種方法不太實用,但它展示瞭如何簡單地插入我們自己的自定義邏輯。
在我們的測試用例中,通常最好使用這種模擬實現,而不是向外部API發出HTTP調用。要使用我們的自定義實現在測試中,我們可以將其定義為在<em>@TestConfiguration</em>類中的一個Bean:
@TestConfiguration
public class TestSecurityConfiguration {
@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
return new PasswordCheckerSimulator();
}
}在我們的測試類中,我們希望使用此自定義實現,我們將使用 @Import(TestSecurityConfiguration.class) 進行標註。
為了避免在運行測試時出現 BeanDefinitionOverrideException,我們將使用 @ConditionalOnMissingBean 標註我們的主 CompromisedPasswordChecker bean。
最後,為了驗證我們自定義實現的行為,我們將編寫一個測試用例:
@Test
void whenPasswordCompromised_thenExceptionThrown() {
String emailId = RandomString.make() + "@baeldung.it";
String password = PasswordCheckerSimulator.FAILURE_KEYWORD + RandomString.make();
String requestBody = String.format("""
{
"emailId" : "%s",
"password" : "%s"
}
""", emailId, password);
String apiPath = "/users";
mockMvc.perform(post(apiPath).contentType(MediaType.APPLICATION_JSON).content(requestBody))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath("$.detail").value("The provided password is compromised and cannot be used."));
}7. 創建自定義 <em @NotCompromised> 註解
如前所述,我們應該在用户註冊期間以及所有允許用户更改密碼或使用密碼進行身份驗證的 API 中檢查密碼是否受損,例如登錄 API。
雖然我們可以為這些過程中的每個步驟在服務層中執行此檢查,但使用自定義驗證註解提供了一種更聲明式和可重用的方法。
首先,讓我們定義自定義 > 註解:
@Documented
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CompromisedPasswordValidator.class)
public @interface NotCompromised {
String message() default "The provided password is compromised and cannot be used.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}接下來,讓我們實現 ConstraintValidator 接口:
public class CompromisedPasswordValidator implements ConstraintValidator<NotCompromised, String> {
@Autowired
private CompromisedPasswordChecker compromisedPasswordChecker;
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
CompromisedPasswordDecision decision = compromisedPasswordChecker.check(password);
return !decision.isCompromised();
}
}我們自動注入 CompromisedPasswordChecker 類的一個實例,並使用它來檢查客户端密碼是否被泄露。
現在,我們可以使用自定義的 @NotCompromised 註解來驗證請求體中密碼字段的值。
@NotCompromised
private String password;@Autowired
private Validator validator;
UserCreationRequestDto request = new UserCreationRequestDto();
request.setEmailId(RandomString.make() + "@baeldung.it");
request.setPassword(PasswordCheckerSimulator.FAILURE_KEYWORD + RandomString.make());
Set<ConstraintViolation<UserCreationRequestDto>> violations = validator.validate(request);
assertThat(violations).isNotEmpty();
assertThat(violations)
.extracting(ConstraintViolation::getMessage)
.contains("The provided password is compromised and cannot be used.");8. 結論
在本文中,我們探討了如何利用 Spring Security 的 CompromisedPasswordChecker API 來增強應用程序的安全性,通過檢測和防止使用已泄露密碼。
我們討論瞭如何配置默認的 HaveIBeenPwnedRestApiPasswordChecker 實現。我們還探討了如何針對特定環境進行自定義,甚至實現自定義的已泄露密碼檢查邏輯。
總之,檢查已泄露密碼可以為用户賬户提供額外的安全保護,抵禦潛在的安全攻擊。