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()方法传入客户端提供的明文密码,检查返回的CompromisedPasswordDecisionisCompromised()返回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获取。


原始标题:Detecting Compromised Passwords Using Spring Security | Baeldung