跳到主要内容
版本:7.0.2

密码存储

DeepSeek V3 中英对照 Password Storage

Spring Security 的 PasswordEncoder 接口用于对密码执行单向转换,以便安全地存储密码。由于 PasswordEncoder 是单向转换,当密码转换需要双向进行时(例如存储用于数据库身份验证的凭据),它并不适用。通常,PasswordEncoder 用于存储需要在身份验证时与用户提供的密码进行比较的密码。

密码存储历史

多年来,存储密码的标准机制不断演进。最初,密码以明文形式存储。人们认为密码是安全的,因为存储密码的数据库需要凭据才能访问。然而,恶意用户能够通过SQL注入等攻击手段获取包含用户名和密码的大规模"数据转储"。随着越来越多的用户凭证被公开,安全专家意识到我们需要采取更多措施来保护用户的密码。

随后,开发者被鼓励在密码经过单向哈希(如SHA-256)处理后存储。当用户尝试认证时,系统会将存储的哈希密码与用户输入密码的哈希值进行比较。这意味着系统只需存储密码的单向哈希值。一旦发生数据泄露,暴露的也只是密码的单向哈希值。由于哈希是单向的,且通过哈希值猜测密码在计算上非常困难,因此破解系统中每个密码的代价将得不偿失。为了攻破这一新系统,恶意用户决定创建一种称为彩虹表的查找表。他们不再每次费力猜测每个密码,而是预先计算密码哈希并存储在查找表中。

为了降低彩虹表的有效性,开发者被鼓励使用加盐密码。不再仅将密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码会通过哈希函数处理,生成唯一的哈希值。盐会以明文形式与用户的密码哈希值一同存储。当用户尝试进行身份验证时,系统会将存储的哈希密码与用户输入的密码和存储的盐组合计算出的哈希值进行比较。独特的盐意味着彩虹表不再有效,因为每个盐和密码组合的哈希值都不同。

在现代,我们意识到加密哈希(如SHA-256)已不再安全。原因在于,借助现代硬件,我们每秒可以进行数十亿次哈希计算。这意味着我们可以轻松地逐个破解每个密码。

现在鼓励开发者利用自适应单向函数来存储密码。使用自适应单向函数进行密码验证时,会特意消耗大量资源(即有意占用大量 CPU、内存或其他资源)。自适应单向函数允许配置一个“工作因子”,该因子可以随着硬件性能的提升而增加。我们建议将“工作因子”调整到在您的系统上验证密码大约需要一秒钟。这种权衡是为了增加攻击者破解密码的难度,同时又不至于给您的系统带来过重负担或引起用户不满。Spring Security 已尝试为“工作因子”提供一个良好的起点,但我们鼓励用户根据自身系统情况自定义“工作因子”,因为不同系统的性能差异巨大。推荐使用的自适应单向函数示例包括 bcryptPBKDF2scryptargon2

由于自适应单向函数被设计为资源密集型操作,每次请求都验证用户名和密码会显著降低应用程序的性能。Spring Security(或任何其他库)无法加快密码验证的速度,因为安全性正是通过使验证过程消耗大量资源来实现的。建议用户将长期凭证(即用户名和密码)交换为短期凭证(例如会话、OAuth 令牌等)。短期凭证可以快速验证,同时不会降低安全性。

DelegatingPasswordEncoder

在 Spring Security 5.0 之前,默认的 PasswordEncoderNoOpPasswordEncoder,它要求使用明文密码。根据密码历史部分的介绍,你可能会认为现在的默认 PasswordEncoder 应该是类似 BCryptPasswordEncoder 这样的实现。然而,这忽略了三个现实问题:

  • 许多应用程序使用旧的密码编码方式,难以迁移。

  • 密码存储的最佳实践将再次发生变化。

  • 作为框架,Spring Security 无法频繁进行破坏性更改。

相反,Spring Security 引入了 DelegatingPasswordEncoder,它通过以下方式解决了所有问题:

  • 确保使用当前推荐的密码存储方式进行密码编码
  • 支持验证现代和传统格式的密码
  • 允许未来升级编码方式

你可以通过使用 PasswordEncoderFactories 轻松构建 DelegatingPasswordEncoder 的实例:

PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();

或者,你也可以创建自己的自定义实例:

String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder =
new DelegatingPasswordEncoder(idForEncode, encoders);

密码存储格式

密码的一般格式为:

{id}encodedPassword

id 是一个标识符,用于查找应使用哪个 PasswordEncoder,而 encodedPassword 则是所选 PasswordEncoder 对应的原始编码密码。id 必须位于密码的开头,以 { 开始,并以 } 结束。如果找不到对应的 id,则 id 将被设置为 null。例如,以下可能是使用不同 id 值编码的密码列表。所有原始密码均为 password

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG // <1>
{noop}password // <2>
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc // <3>
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc= // <4>
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 // <5>
  • 第一个密码的 PasswordEncoder id 为 bcrypt,其 encodedPassword 值为 $2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG。在匹配时,它将委托给 BCryptPasswordEncoder

  • 第二个密码的 PasswordEncoder id 为 noop,其 encodedPassword 值为 password。在匹配时,它将委托给 NoOpPasswordEncoder

  • 第三个密码的 PasswordEncoder id 为 pbkdf2,其 encodedPassword 值为 5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc。在匹配时,它将委托给 Pbkdf2PasswordEncoder

  • 第四个密码的 PasswordEncoder id 为 scrypt,其 encodedPassword 值为 $e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=。在匹配时,它将委托给 SCryptPasswordEncoder

  • 最后一个密码的 PasswordEncoder id 为 sha256,其 encodedPassword 值为 97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0。在匹配时,它将委托给 StandardPasswordEncoder

备注

一些用户可能会担心,存储格式的公开可能会为潜在的黑客提供便利。这种担忧是不必要的,因为密码的安全性并不依赖于算法的保密性。此外,即使没有前缀,攻击者通常也很容易推断出大多数格式。例如,BCrypt 密码通常以 $2a$ 开头。

密码编码

传入构造函数的 idForEncode 决定了使用哪个 PasswordEncoder 来编码密码。在我们之前构建的 DelegatingPasswordEncoder 中,这意味着对 password 的编码结果会委托给 BCryptPasswordEncoder 处理,并加上 {bcrypt} 前缀。最终结果如下例所示:

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

密码匹配

匹配基于 {id} 以及构造函数中提供的 idPasswordEncoder 的映射关系。我们在密码存储格式章节中的示例提供了一个关于如何实现此功能的有效示例。默认情况下,使用密码和一个未映射的 id(包括空 id)调用 matches(CharSequence, String) 会导致抛出 IllegalArgumentException。此行为可以通过使用 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder) 进行自定义。

通过使用 id,我们可以匹配任何密码编码方式,同时使用最现代的密码编码算法对密码进行编码。这一点很重要,因为与加密不同,密码哈希的设计使得无法通过简单方式恢复明文。由于无法恢复明文,迁移密码变得困难。虽然用户迁移 NoOpPasswordEncoder 很简单,但我们选择默认包含它,以便为入门体验提供便利。

入门体验

如果你正在准备演示或示例,花时间对用户密码进行哈希处理会有些繁琐。虽然有一些便捷机制可以让这个过程更容易,但这仍然不适用于生产环境。

UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("user")
.build();
System.out.println(user.getPassword());
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

如果你正在创建多个用户,也可以复用构建器:

UserBuilder users = User.withDefaultPasswordEncoder();
UserDetails user = users
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = users
.username("admin")
.password("password")
.roles("USER","ADMIN")
.build();

这确实对存储的密码进行了哈希处理,但密码在内存和编译后的源代码中仍然暴露。因此,在生产环境中,这仍然不被认为是安全的。对于生产环境,你应该在外部对密码进行哈希处理

使用 Spring Boot CLI 进行编码

正确编码密码的最简单方法是使用 Spring Boot CLI

例如,以下示例对密码 password 进行编码,以便与 DelegatingPasswordEncoder 配合使用:

spring encodepassword password
{bcrypt}$2a$10$X5wFBtLrL/kHcmrOGGTrGufsBX8CJ0WpQpF3pgeuxBB/H73BK1DW6

故障排除

当存储的密码中有一个没有 id 时,会发生以下错误,如密码存储格式中所述。

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:233)
at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:196)

解决此问题的最简单方法是弄清楚当前密码的存储方式,并明确提供正确的 PasswordEncoder

如果你正在从 Spring Security 4.2.x 迁移,可以通过暴露一个 NoOpPasswordEncoder bean 来恢复之前的行为。

或者,您也可以在密码前加上正确的 id 前缀,并继续使用 DelegatingPasswordEncoder。例如,如果您正在使用 BCrypt,您可以将密码从类似以下形式进行迁移:

$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

有关映射的完整列表,请参阅 PasswordEncoderFactories 的 Javadoc。

BCryptPasswordEncoder

BCryptPasswordEncoder 实现采用广泛支持的 bcrypt 算法对密码进行哈希处理。为了增强其抵抗密码破解的能力,bcrypt 被设计为故意缓慢。与其他自适应单向函数一样,应将其调整到在您的系统上验证密码大约需要 1 秒的时间。BCryptPasswordEncoder 的默认实现使用强度 10,如 BCryptPasswordEncoder 的 Javadoc 中所述。建议您在您自己的系统上调整和测试强度参数,使其验证密码大约需要 1 秒。

// Create an encoder with strength 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

Argon2PasswordEncoder

Argon2PasswordEncoder 实现采用 Argon2 算法对密码进行哈希处理。Argon2 是 密码哈希竞赛 的优胜算法。为了抵御定制硬件上的密码破解攻击,Argon2 是一种刻意设计为缓慢且需要大量内存的算法。与其他自适应单向函数类似,应将其参数调整至在您的系统上验证密码大约需要 1 秒钟。当前 Argon2PasswordEncoder 的实现需要 BouncyCastle 库支持。

// Create an encoder with all the defaults
Argon2PasswordEncoder encoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

Pbkdf2PasswordEncoder

Pbkdf2PasswordEncoder 实现使用 PBKDF2 算法对密码进行哈希处理。为了抵御密码破解,PBKDF2 是一种刻意设计为缓慢的算法。与其他自适应单向函数类似,应将其调整到在您的系统上验证密码大约需要 1 秒钟。当需要 FIPS 认证时,此算法是一个不错的选择。

// Create an encoder with all the defaults
Pbkdf2PasswordEncoder encoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

SCryptPasswordEncoder

SCryptPasswordEncoder 实现采用 scrypt 算法对密码进行哈希处理。为抵御定制硬件的密码破解攻击,scrypt 是一种刻意设计为缓慢且需要大量内存的算法。与其他自适应单向函数类似,应将其参数调整至在您的系统上验证密码大约需要 1 秒钟。

// Create an encoder with all the defaults
SCryptPasswordEncoder encoder = SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

其他 PasswordEncoder 实现

还存在大量其他 PasswordEncoder 实现,这些实现完全是为了向后兼容而保留的。它们都被标记为已弃用,表明不再被视为安全。然而,目前没有计划移除它们,因为迁移现有的遗留系统非常困难。

基于 Password4j 的密码编码器

Spring Security 7.0 引入了基于 Password4j 库的替代密码编码器实现。这些编码器为流行的哈希算法提供了更多选择,可作为现有 Spring Security 实现的替代方案使用。

Password4j 库是一个专注于密码哈希的 Java 加密库,支持多种算法。当您需要特定的算法配置或希望利用 Password4j 的优化时,这些编码器尤其有用。

所有基于Password4j的编码器都是线程安全的,可以在多个线程之间共享。

Argon2Password4jPasswordEncoder

Argon2Password4jPasswordEncoder 实现通过 Password4j 库使用 Argon2 算法对密码进行哈希处理。这为 Spring Security 内置的 Argon2PasswordEncoder 提供了一种替代方案,具有不同的配置选项和潜在的性能特性。

Argon2 是 密码哈希竞赛 的获胜者,推荐用于新应用。此实现利用了 Password4j 的 Argon2 支持,该支持在输出哈希中正确包含了盐值。

创建一个具有默认设置的编码器:

PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

创建一个具有自定义 Argon2 参数的编码器:

Argon2Function argon2Fn = Argon2Function.getInstance(65536, 3, 4, 32,
Argon2.ID);
PasswordEncoder encoder = new Argon2Password4jPasswordEncoder(argon2Fn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

BcryptPassword4jPasswordEncoder

BcryptPassword4jPasswordEncoder 实现通过 Password4j 库使用 BCrypt 算法对密码进行哈希处理。这为 Spring Security 内置的 BCryptPasswordEncoder 提供了另一种选择,并具备 Password4j 的实现特性。

BCrypt 是一种成熟的密码哈希算法,它内置了盐值生成机制,并能有效抵御彩虹表攻击。此实现利用了 Password4j 库对 BCrypt 的支持,该库会在输出的哈希值中正确包含盐值。

创建一个具有默认设置的编码器:

PasswordEncoder encoder = new BCryptPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

创建一个具有自定义bcrypt参数的编码器:

BcryptFunction bcryptFn = BcryptFunction.getInstance(12);
PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(bcryptFn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

ScryptPassword4jPasswordEncoder

ScryptPassword4jPasswordEncoder 实现通过 Password4j 库使用 SCrypt 算法对密码进行哈希处理。这为 Spring Security 内置的 SCryptPasswordEncoder 提供了另一种选择,并具有 Password4j 的实现特性。

SCrypt是一种内存密集型密码哈希算法,旨在抵御硬件暴力破解攻击。此实现利用了Password4j的SCrypt支持功能,该功能会在输出哈希值中正确包含盐值。

创建一个使用默认设置的编码器:

PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

创建一个带有自定义 scrypt 参数的编码器:

ScryptFunction scryptFn = ScryptFunction.getInstance(32768, 8, 1, 32);
PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(scryptFn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

Pbkdf2Password4jPasswordEncoder

Pbkdf2Password4jPasswordEncoder 实现通过 Password4j 库使用 PBKDF2 算法对密码进行哈希处理。这为 Spring Security 内置的 Pbkdf2PasswordEncoder 提供了一种替代方案,并支持显式的盐值管理。

PBKDF2是一种密钥派生函数,其设计目标是计算成本高昂,以抵御字典攻击和暴力破解攻击。由于Password4j的PBKDF2实现不会在输出的哈希值中包含盐值,因此本实现显式地处理盐值管理。编码后的密码格式为:{salt}:{hash},其中盐值和哈希值均采用Base64编码。

创建一个具有默认设置的编码器:

PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

创建具有自定义PBKDF2参数的编码器:

PBKDF2Function pbkdf2Fn = PBKDF2Function.getInstance(Hmac.SHA256, 100000, 256);
PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(pbkdf2Fn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

BalloonHashingPassword4jPasswordEncoder

BalloonHashingPassword4jPasswordEncoder 实现通过 Password4j 库使用 Balloon 哈希算法对密码进行哈希处理。Balloon 哈希是一种内存密集型密码哈希算法,旨在同时抵抗时间-内存权衡攻击和侧信道攻击。

此实现显式处理盐值管理,因为Password4j的Balloon哈希实现不会在输出哈希中包含盐值。编码后的密码格式为:{salt}:{hash},其中盐值和哈希值均采用Base64编码。

创建一个具有默认设置的编码器:

PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

创建一个具有自定义参数的编码器:

BalloonHashingFunction ballooningHashingFn =
BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3);
PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();

密码存储配置

Spring Security 默认使用 DelegatingPasswordEncoder。不过,您可以通过将 PasswordEncoder 暴露为 Spring bean 来自定义此行为。

如果你正在从 Spring Security 4.2.x 迁移,可以通过暴露一个 NoOpPasswordEncoder bean 来恢复之前的行为。

注意

回退到 NoOpPasswordEncoder 不被认为是安全的。你应该迁移到使用 DelegatingPasswordEncoder 来支持安全的密码编码。

@Bean
public static NoOpPasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
备注

XML 配置要求 NoOpPasswordEncoder 的 bean 名称必须为 passwordEncoder

修改密码配置

大多数允许用户设置密码的应用程序,也需要提供更新密码的功能。

用于更改密码的知名URL 提出了一种机制,通过该机制,密码管理器可以发现给定应用程序的密码更新端点。

你可以配置 Spring Security 来提供这个发现端点。例如,如果你的应用程序中的修改密码端点是 /change-password,那么你可以像这样配置 Spring Security:

http
.passwordManagement(Customizer.withDefaults())

那么,当密码管理器导航到 /.well-known/change-password 时,Spring Security 会将请求重定向到你的端点 /change-password

或者,如果你的端点不是 /change-password,也可以像这样指定:

http
.passwordManagement((management) -> management
.changePasswordPage("/update-password")
)

通过上述配置,当密码管理器访问 /.well-known/change-password 时,Spring Security 将重定向到 /update-password

受损密码检查

在某些场景下,你需要检查密码是否已被泄露。例如,如果你正在开发一个处理敏感数据的应用程序,通常需要对用户密码进行一些检查,以确保其可靠性。其中一项检查可以是确认密码是否已被泄露,通常是因为该密码在数据泄露事件中被发现。

为此,Spring Security 通过 CompromisedPasswordChecker 接口的 HaveIBeenPwnedRestApiPasswordChecker 实现,提供了与 Have I Been Pwned API 的集成。

你可以直接使用 CompromisedPasswordChecker API,或者,如果你通过 Spring Security 认证机制 使用 DaoAuthenticationProvider,你可以提供一个 CompromisedPasswordChecker bean,Spring Security 配置将自动识别并使用它。

这样做之后,当你尝试使用弱密码(例如 123456)通过表单登录进行身份验证时,你将收到 401 状态码或被重定向到 /login?error 页面(具体取决于你的用户代理)。然而,仅仅返回 401 或进行重定向在这种情况下并不那么有用,它会引起一些困惑,因为用户提供了正确的密码却仍然无法登录。在这种情况下,你可以通过 AuthenticationFailureHandler 来处理 CompromisedPasswordException,以执行你期望的逻辑,例如将用户代理重定向到 /reset-password

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin((login) -> login
.failureHandler(new CompromisedPasswordAuthenticationFailureHandler())
);
return http.build();
}

@Bean
public CompromisedPasswordChecker compromisedPasswordChecker() {
return new HaveIBeenPwnedRestApiPasswordChecker();
}

static class CompromisedPasswordAuthenticationFailureHandler implements AuthenticationFailureHandler {

private final SimpleUrlAuthenticationFailureHandler defaultFailureHandler = new SimpleUrlAuthenticationFailureHandler(
"/login?error");

private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
if (exception instanceof CompromisedPasswordException) {
this.redirectStrategy.sendRedirect(request, response, "/reset-password");
return;
}
this.defaultFailureHandler.onAuthenticationFailure(request, response, exception);
}

}