Spring Security 检测密码是否泄露

1、概览

在构建处理敏感数据的 Web 应用时,确保用户密码的安全性非常重要。密码安全的一个重要方面是检查密码是否泄露,这通常是由于密码出现在 数据泄露事件 中。

Spring Security 6.3 引入了一项新功能,让我们可以轻松检查密码是否被已泄露。

本文将带你了解 Spring Security 中新的 CompromisedPasswordChecker API 以及如何将其集成到 Spring Boot 应用中。

2、密码泄露

密码泄露是指在数据泄露事件中暴露的密码,使其容易受到未经授权的访问。攻击者通常在凭证填充和密码填充攻击中使用这些泄露的密码,在多个网站上使用泄露的用户名-密码对,或在多个账户上使用通用密码。

要降低这种风险,关键是要在创建账户前检查用户密码是否泄露。

同样重要的是要注意,以前有效的密码可能会随着时间的推移而泄露,因此建议不仅在创建账户时,而且在登录过程中或任何允许用户更改密码的过程中都要检查密码是否泄露。如果登录尝试因检测到密码泄露而失败,可以提示用户重设密码。

3、CompromisedPasswordChecker

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 集成,后者维护着一个庞大的数据库,其中包含数据泄露事件中泄露的密码。

当调用此默认实现的 check() 方法时,它会对提供的密码进行安全哈希处理,并将哈希的前 5 个字符发送到 Have I Been Pwned API。API 会返回与该前缀匹配的哈希后缀列表。然后,该方法会将密码的完整哈希与该列表进行比较,并确定是否已泄露。整个检查过程无需通过网络发送明文密码。

3.2、自定义 CompromisedPasswordChecker Bean

如果应用使用了代理服务器,可以使用自定义 RestClient 配置 HaveIBeenPwnedRestApiPasswordChecker

@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() 方法时,它会将 API 请求连同自定义 HTTP Header 一起发送到自定义 baseUrl。

4、处理密码泄露

配置完了 CompromisedPasswordChecker Bean 后来看看如何在 Service 层中使用它来验证密码。

以常见的新用户注册为例:

@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 异常

当 Service 层抛出 CompromisedPasswordException 时,需要处理该异常。

一种方法是在 @RestControllerAdvice 类中定义全局异常处理 Handler:

@ExceptionHandler(CompromisedPasswordException.class)
public ProblemDetail handle(CompromisedPasswordException exception) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, exception.getMessage());
}

当该 Handler 捕获 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 实现是一个很好的解决方案,但在某些情况下,我们可能需要集成不同的提供商(Provider),甚至实现我们自己的 密码泄露 检查逻辑。

可以通过实现 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” 字符的密码视为 已泄露 的密码。当然,实际上 密码泄露 检查并不会那么简单,这只是为了演示如何简单地插入自己的自定义逻辑。

在测试时,一般是使用模拟实现,而不是通过 HTTP 调用外部 API。要在测试中使用自定义实现,可以将其定义为 @TestConfiguration 类中的一个 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、创建自定义 @NotCompromised 注解

如前所述,不仅要在用户注册时检查密码是否泄露,还要在更改密码或使用密码进行身份认证的所有 API(如登录 API)中检查密码是否泄露。

虽然可以在 Service 中对每个业务手动执行这种检查,但使用自定义验证注解(Validation Annotation)会更加优雅,重用性更高。

首先,定义一个自定义 @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 类的实例,用它来检查客户端的密码是否泄露。

现在,可以在请求体的 password 字段上使用自定义的 @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 实现,以及如何根据特定环境对其进行自定义,甚至实现自定义 泄露密码 的检查逻辑。


Ref:https://www.baeldung.com/spring-security-detect-compromised-passwords