1. 概述
在处理敏感数据的Web应用开发中,确保用户密码安全至关重要。密码安全的核心环节之一是检测密码是否已被泄露(通常出现在数据泄露事件中)。
Spring Security 6.3引入了新功能,让我们能轻松检测密码是否被泄露。
本教程将探索Spring Security中的CompromisedPasswordChecker
API,以及如何将其集成到Spring Boot应用中。
2. 理解泄露密码
泄露密码是指因数据泄露而暴露的密码,易导致未授权访问。攻击者常利用这些密码进行凭证填充攻击和密码喷洒攻击,在多个站点或账户上使用泄露的用户名密码对或常见密码。
要降低此风险,必须在创建账户前检查用户密码是否被泄露。
需注意:原本安全的密码可能随时间变得不安全,因此建议不仅在账户创建时检查泄露密码,在登录过程或允许用户修改密码的流程中也应检查。若因检测到泄露密码导致登录失败,可提示用户重置密码。
3. CompromisedPasswordChecker
API详解
Spring Security提供了简单的CompromisedPasswordChecker
接口用于检测泄露密码:
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
Bean的check()
方法时,将向自定义基础URL发送API请求并附带自定义HTTP头。
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.");
}
这里简单调用check()
方法传入客户端提供的明文密码,检查返回的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
时,返回符合RFC 9457规范的ProblemDetail
错误响应:
{
"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。要在测试中使用自定义实现,可在@TestConfiguration
类中定义Bean:
@TestConfiguration
public class TestSecurityConfiguration {
@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
return new PasswordCheckerSimulator();
}
}
在测试类中使用@Import(TestSecurityConfiguration.class)
注解启用此实现。
为避免测试时出现BeanDefinitionOverrideException
,需在主CompromisedPasswordChecker
Bean上添加[@ConditionalOnMissingBean](/spring-boot-custom-auto-configuration#2-bean-conditions)
注解。
最后编写测试用例验证自定义实现行为:
@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. 创建自定义@NotCompromised
注解
如前所述,不仅应在用户注册时检查泄露密码,在所有允许用户修改密码或使用密码认证的API(如登录接口)中也应检查。
虽然可在每个流程的服务层执行此检查,但使用自定义验证注解提供了更声明式和可重用的方案。
首先定义自定义@NotCompromised
注解:
@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
实现,如何针对特定环境自定义,甚至实现自定义泄露密码检测逻辑。
总之,检查泄露密码为用户账户增加了额外保护层,抵御潜在安全攻击。
本文所有代码示例可在GitHub获取。