此版本仍在开发中,尚未被视为稳定版本。如需最新稳定版本,请使用 Spring Security 7.0.4spring-doc.cadn.net.cn

密码存储

Spring Security 的 PasswordEncoder 接口用于对密码执行单向转换,以便安全地存储密码。 由于 PasswordEncoder 是单向转换,因此在需要双向转换密码的场景下(例如存储用于向数据库进行身份验证的凭据)并不适用。 通常,PasswordEncoder 用于存储密码,该密码在身份验证时需与用户提供的密码进行比对。spring-doc.cadn.net.cn

密码存储历史

多年来,存储密码的标准机制不断演进。 最初,密码以明文形式存储。 当时人们认为密码是安全的,因为存储密码的数据存储库需要凭据才能访问。 然而,恶意用户通过 SQL 注入等攻击手段,找到了获取大量用户名和密码“数据转储”的方法。 随着越来越多的用户凭证被公开,安全专家意识到我们需要采取更多措施来保护用户的密码。spring-doc.cadn.net.cn

随后,开发人员被鼓励在对密码进行单向哈希(例如 SHA-256)处理后再存储。 当用户尝试进行身份验证时,系统会将存储的哈希密码与用户输入密码的哈希值进行比对。 这意味着系统只需存储密码的单向哈希值。 如果发生数据泄露,暴露的也只是密码的单向哈希值。 由于这些哈希是单向的,并且根据哈希值推测出原始密码在计算上非常困难,因此攻击者通常认为逐个破解系统中的每个密码并不值得投入精力。 为了攻破这种新机制,恶意用户创建了被称为彩虹表(Rainbow Tables)的查找表。 他们不再每次重复猜测每个密码,而是预先计算一次密码哈希值并将其存储在查找表中。spring-doc.cadn.net.cn

为了降低彩虹表(Rainbow Tables)的有效性,开发人员被鼓励使用加盐密码(salted passwords)。 具体做法是:不再仅将密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为“盐值”或 salt)。 将盐值与用户密码一起输入哈希函数,生成唯一的哈希值。 该盐值会以明文形式与用户的密码哈希值一同存储。 当用户尝试进行身份验证时,系统会将用户输入的密码与存储的盐值组合后进行哈希,并将结果与存储的哈希值进行比对。 由于每个盐值都是唯一的,因此对于不同的盐值和密码组合,所产生的哈希值也各不相同,从而使彩虹表攻击不再有效。spring-doc.cadn.net.cn

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

现在建议开发人员使用自适应单向函数来存储密码。使用自适应单向函数对密码进行验证是有意设计为资源密集型的(它们有意大量占用 CPU、内存或其他资源)。一种自适应的单向函数允许配置一个“工作因子”,该因子可随着硬件性能的提升而增大。我们建议将“工作因子”调整为在您的系统上验证一个密码大约需要一秒钟。这种权衡是为了增加攻击者破解密码的难度,但又不至于对您自己的系统造成过重负担或引起用户不满。Spring Security 已尝试为“工作因子”提供一个良好的起点,但我们鼓励用户根据自己的系统自定义“工作因子”,因为不同系统的性能差异极大。应使用的自适应单向函数示例包括bcryptPBKDF2scryptargon2spring-doc.cadn.net.cn

由于自适应单向函数有意设计为资源密集型,因此在每个请求中都验证用户名和密码会显著降低应用程序的性能。 Spring Security(或其他任何库)都无法加快密码验证的速度,因为正是通过使验证过程消耗大量资源才获得了安全性。 建议用户将长期凭证(即用户名和密码)交换为短期凭证(例如会话、OAuth Tokens等)。 短期凭证可以快速验证,且不会造成任何安全性的损失。spring-doc.cadn.net.cn

委托密码编码器

在 Spring Security 5.0 之前,默认的 PasswordEncoderNoOpPasswordEncoder,它要求使用明文密码。 根据密码历史一节的内容,你可能会认为现在的默认 PasswordEncoder 应该类似于 BCryptPasswordEncoder。 然而,这种想法忽略了三个现实世界中的问题:spring-doc.cadn.net.cn

相反,Spring Security 引入了 DelegatingPasswordEncoder,它通过以下方式解决了所有这些问题:spring-doc.cadn.net.cn

你可以通过使用 DelegatingPasswordEncoder 轻松构造一个 PasswordEncoderFactories 的实例:spring-doc.cadn.net.cn

创建默认的 DelegatingPasswordEncoder
PasswordEncoder passwordEncoder =
		PasswordEncoderFactories.createDelegatingPasswordEncoder();
val passwordEncoder: PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()

或者,您可以创建自己的自定义实例:spring-doc.cadn.net.cn

创建自定义的 DelegatingPasswordEncoder
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);
val idForEncode = "bcrypt"
val encoders: MutableMap<String, PasswordEncoder> = mutableMapOf()
encoders[idForEncode] = BCryptPasswordEncoder()
encoders["noop"] = org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance()
encoders["pbkdf2"] = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5()
encoders["pbkdf2@SpringSecurity_v5_8"] = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()
encoders["scrypt"] = SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1()
encoders["scrypt@SpringSecurity_v5_8"] = SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()
encoders["argon2"] = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()
encoders["argon2@SpringSecurity_v5_8"] = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
encoders["sha256"] = org.springframework.security.crypto.password.StandardPasswordEncoder()

val passwordEncoder: PasswordEncoder = DelegatingPasswordEncoder(idForEncode, encoders)

密码存储格式

密码的一般格式为:spring-doc.cadn.net.cn

DelegatingPasswordEncoder 存储格式
{id}encodedPassword

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

DelegatingPasswordEncoder 编码密码示例
{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)
1 第一个密码的 PasswordEncoder ID 为 bcrypt,其 encodedPassword 值为 $2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG。 在进行匹配时,它将委托给 BCryptPasswordEncoder
2 第二个密码的 PasswordEncoder ID 为 noopencodedPassword 值为 password。 在进行匹配时,它将委托给 NoOpPasswordEncoder
3 第三个密码的 PasswordEncoder ID 为 pbkdf2,其 encodedPassword 值为 5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc。 在进行匹配时,它将委托给 Pbkdf2PasswordEncoder
4 第四个密码的 PasswordEncoder ID 为 scrypt,其 encodedPassword 值为 $e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=。 在进行匹配时,它将委托给 SCryptPasswordEncoder
5 最终密码的 PasswordEncoder ID 为 sha256encodedPassword 值为 97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0。 在进行匹配时,它将委托给 StandardPasswordEncoder

一些用户可能会担心,提供存储格式会为潜在的黑客带来便利。 这其实无需担忧,因为密码的存储并不依赖于算法本身保密。 此外,即使没有前缀,攻击者通常也很容易推断出大多数格式。 例如,BCrypt 密码通常以 $2a$ 开头。spring-doc.cadn.net.cn

密码加密

传入构造函数的 idForEncode 决定了使用哪个 PasswordEncoder 来对密码进行编码。 在我们之前构造的 DelegatingPasswordEncoder 中,这意味着对 password 进行编码的结果将委托给 BCryptPasswordEncoder,并在前面加上前缀 {bcrypt}。 最终结果如下例所示:spring-doc.cadn.net.cn

DelegatingPasswordEncoder 编码示例
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

密码匹配

匹配基于构造函数中提供的 {id} 以及该 idPasswordEncoder 的映射。 密码存储格式 中的示例展示了如何实现这一机制。 默认情况下,当调用 matches(CharSequence, String) 方法传入一个未映射的 id(包括 null 的 id)和密码时,会抛出 IllegalArgumentException。 通过使用 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder) 可以自定义此行为。spring-doc.cadn.net.cn

通过使用 id,我们可以匹配任意密码编码方式,但会使用最新的密码编码方式对密码进行编码。 这一点非常重要,因为与加密不同,密码哈希的设计初衷就是无法简单地还原出明文。 由于无法还原明文,因此迁移密码就变得十分困难。 虽然用户可以轻松地迁移到 NoOpPasswordEncoder,但我们默认将其包含在内,以简化入门体验。spring-doc.cadn.net.cn

入门体验

如果你正在搭建一个演示或示例项目,花时间对用户密码进行哈希处理会有些麻烦。 虽然有一些便捷机制可以让这个过程更简单,但这些方法仍然不适用于生产环境。spring-doc.cadn.net.cn

使用默认密码编码器的示例
UserDetails user = User.withDefaultPasswordEncoder()
		.username("user")
		.password("password")
		.roles("user")
		.build();
System.out.println(user.getPassword());
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
val user = User.withDefaultPasswordEncoder()
    .username("user")
    .password("password")
    .roles("user")
    .build()
println(user.password)
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

如果你要创建多个用户,也可以重用该构建器:spring-doc.cadn.net.cn

使用默认密码编码器 重用构建器
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();
val users = User.withDefaultPasswordEncoder()
val user = users
    .username("user")
    .password("password")
    .roles("USER")
    .build()
val admin = users
    .username("admin")
    .password("password")
    .roles("USER", "ADMIN")
    .build()

这确实会对存储的密码进行哈希处理,但密码仍然会以明文形式暴露在内存和编译后的源代码中。 因此,在生产环境中仍被认为不够安全。 对于生产环境,您应在外部对密码进行哈希处理spring-doc.cadn.net.cn

使用 Spring Boot CLI 进行编码

正确编码密码最简单的方法是使用Spring Boot CLIspring-doc.cadn.net.cn

例如,以下示例对密码 password 进行编码,以便与 DelegatingPasswordEncoder 一起使用:spring-doc.cadn.net.cn

Spring Boot CLI encodepassword 示例
spring encodepassword password
{bcrypt}$2a$10$X5wFBtLrL/kHcmrOGGTrGufsBX8CJ0WpQpF3pgeuxBB/H73BK1DW6

故障排除

当存储的密码之一没有 id 时,就会发生以下错误,如密码存储格式中所述。spring-doc.cadn.net.cn

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)

最简单的解决方法是弄清楚当前密码是如何存储的,并显式提供正确的PasswordEncoderspring-doc.cadn.net.cn

如果您正在从 Spring Security 4.2.x 迁移,可以通过暴露一个 NoOpPasswordEncoder bean来恢复到之前的行为。spring-doc.cadn.net.cn

或者,你可以为所有密码加上正确的 id 前缀,并继续使用 DelegatingPasswordEncoder。 例如,如果你正在使用 BCrypt,则可以将你的密码从类似以下内容进行迁移:spring-doc.cadn.net.cn

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

有关映射的完整列表,请参阅 PasswordEncoderFactories 的 Javadoc。spring-doc.cadn.net.cn

BCryptPasswordEncoder

BCryptPasswordEncoder 的实现使用广泛支持的 bcrypt 算法对密码进行哈希。 为了提高抗密码破解能力,bcrypt 被设计得较慢。 与其他自适应单向函数一样,应将其调整至在您的系统上验证一个密码大约需要 1 秒。 BCryptPasswordEncoder 的默认实现使用了强度 10,如 BCryptPasswordEncoder 的 Javadoc 中所述。建议您 在自己的系统上调整和测试强度参数,以确保验证一个密码大约需要 1 秒。spring-doc.cadn.net.cn

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

Argon2PasswordEncoder

Argon2PasswordEncoder 实现使用 Argon2 算法对密码进行哈希。 Argon2 是 密码哈希竞赛 的获胜者。 为了抵御在定制硬件上进行的密码破解,Argon2 是一种刻意设计得较慢且需要大量内存的算法。 与其他自适应单向函数一样,应对其进行调优,使其在您的系统上验证密码大约耗时 1 秒。 当前 Argon2PasswordEncoder 的实现需要 BouncyCastle。spring-doc.cadn.net.cn

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

Pbkdf2PasswordEncoder

Pbkdf2PasswordEncoder 实现使用 PBKDF2 算法对密码进行哈希。 为了抵御密码破解,PBKDF2 是一种刻意设计得较慢的算法。 与其他自适应单向函数一样,应对其进行调整,使其在您的系统上验证一个密码大约需要 1 秒钟。 当需要 FIPS 认证时,该算法是一个很好的选择。spring-doc.cadn.net.cn

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

SCryptPasswordEncoder

SCryptPasswordEncoder 实现使用 scrypt 算法对密码进行哈希。 为了抵御在定制硬件上进行的密码破解,scrypt 是一种刻意设计得较慢的算法,并且需要大量内存。 与其他自适应单向函数一样,应对其进行调优,使其在您的系统上验证一个密码大约耗时 1 秒。spring-doc.cadn.net.cn

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

其他PasswordEncoders

还有大量其他的 PasswordEncoder 实现,它们的存在完全是为了向后兼容。 这些实现都已被弃用,以表明它们不再被认为是安全的。 然而,目前没有计划将它们移除,因为迁移现有的遗留系统十分困难。spring-doc.cadn.net.cn

基于 Password4j 的密码编码器

Spring Security 7.0 引入了基于 Password4j 库的替代密码编码器实现。 这些编码器为流行的哈希算法提供了额外的选项,可作为现有 Spring Security 实现的替代方案。spring-doc.cadn.net.cn

Password4j 库是一个 Java 加密库,专注于密码哈希,并支持多种算法。 当你需要特定的算法配置或希望利用 Password4j 的优化功能时,这些编码器尤其有用。spring-doc.cadn.net.cn

所有基于 Password4j 的编码器都是线程安全的,可以在多个线程之间共享。spring-doc.cadn.net.cn

Argon2Password4jPasswordEncoder

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

Argon2 是密码哈希竞赛的获胜者,推荐用于新应用程序。 此实现利用了 Password4j 的 Argon2 支持,该支持会将盐值正确地包含在输出的哈希值中。spring-doc.cadn.net.cn

使用默认设置创建一个编码器:spring-doc.cadn.net.cn

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

使用自定义的 Argon2 参数创建一个编码器:spring-doc.cadn.net.cn

Argon2Password4jPasswordEncoder 自定义
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();
val argon2Fn = Argon2Function.getInstance(
    65536, 3, 4, 32,
    Argon2.ID
)
val encoder: PasswordEncoder = Argon2Password4jPasswordEncoder(argon2Fn)
val result = encoder.encode("myPassword")
assertThat(encoder.matches("myPassword", result)).isTrue()

BcryptPassword4jPasswordEncoder

BcryptPassword4jPasswordEncoder 实现通过 Password4j 库使用 BCrypt 算法对密码进行哈希。 这为 Spring Security 内置的 BCryptPasswordEncoder 提供了一种替代方案,并具有 Password4j 实现的特性。spring-doc.cadn.net.cn

BCrypt 是一种成熟可靠的密码哈希算法,内置盐值生成功能,并能有效抵御彩虹表攻击。 此实现利用了 Password4j 提供的 BCrypt 支持,其生成的哈希输出中已正确包含了盐值。spring-doc.cadn.net.cn

使用默认设置创建一个编码器:spring-doc.cadn.net.cn

BcryptPassword4jPasswordEncoder
PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
val encoder: PasswordEncoder = BcryptPassword4jPasswordEncoder()
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()

使用自定义的 bcrypt 参数创建一个编码器:spring-doc.cadn.net.cn

BcryptPassword4jPasswordEncoder 自定义
BcryptFunction bcryptFn = BcryptFunction.getInstance(12);
PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(bcryptFn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
val bcryptFunction = BcryptFunction.getInstance(12)
val encoder: PasswordEncoder = BcryptPassword4jPasswordEncoder(bcryptFunction)
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()

ScryptPassword4jPasswordEncoder

ScryptPassword4jPasswordEncoder 实现通过 Password4j 库使用 SCrypt 算法对密码进行哈希。 这为 Spring Security 内置的 SCryptPasswordEncoder 提供了一种替代方案,并具有 Password4j 实现的特性。spring-doc.cadn.net.cn

SCrypt 是一种内存密集型密码哈希算法,旨在抵御硬件暴力破解攻击。 此实现利用了 Password4j 的 SCrypt 支持,该支持会将盐值(salt)正确地包含在输出的哈希值中。spring-doc.cadn.net.cn

使用默认设置创建一个编码器:spring-doc.cadn.net.cn

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

使用自定义的 scrypt 参数创建一个编码器:spring-doc.cadn.net.cn

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

Pbkdf2Password4jPasswordEncoder

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

PBKDF2 是一种密钥派生函数,其设计在计算上代价高昂,以抵御字典攻击和暴力破解攻击。 此实现显式处理盐值(salt)管理,因为 Password4j 的 PBKDF2 实现不会将盐值包含在输出的哈希值中。 编码后的密码格式为:{salt}:{hash},其中 salt 和 hash 均采用 Base64 编码。spring-doc.cadn.net.cn

使用默认设置创建一个编码器:spring-doc.cadn.net.cn

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

使用自定义 PBKDF2 参数创建一个编码器:spring-doc.cadn.net.cn

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

BalloonHashingPassword4jPasswordEncoder

BalloonHashingPassword4jPasswordEncoder 实现通过 Password4j 库使用 Balloon 哈希算法对密码进行哈希。 Balloon 哈希是一种内存密集型密码哈希算法,旨在抵御时间和内存权衡攻击(time-memory trade-off attacks)以及旁路攻击(side-channel attacks)。spring-doc.cadn.net.cn

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

使用默认设置创建一个编码器:spring-doc.cadn.net.cn

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

使用自定义参数创建一个编码器:spring-doc.cadn.net.cn

BalloonHashingPassword4jPasswordEncoder 自定义
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();
val ballooningHashingFn =
    BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3)
val encoder: PasswordEncoder = BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn)
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()

密码存储配置

Spring Security 默认使用 DelegatingPasswordEncoder。 不过,你可以通过将 PasswordEncoder 暴露为 Spring Bean 来自定义此行为。spring-doc.cadn.net.cn

如果您正在从 Spring Security 4.2.x 迁移,可以通过暴露一个 NoOpPasswordEncoder Bean 来恢复之前的行为。spring-doc.cadn.net.cn

回退到 NoOpPasswordEncoder 被认为是不安全的。 您应改为迁移到使用 DelegatingPasswordEncoder,以支持安全的密码编码。spring-doc.cadn.net.cn

NoOpPasswordEncoder
@Bean
public static NoOpPasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}
<b:bean id="passwordEncoder"
        class="org.springframework.security.crypto.password.NoOpPasswordEncoder" factory-method="getInstance"/>
@Bean
fun passwordEncoder(): PasswordEncoder {
    return NoOpPasswordEncoder.getInstance();
}

XML 配置要求 NoOpPasswordEncoder 的 bean 名称为 passwordEncoderspring-doc.cadn.net.cn

更改密码配置

大多数允许用户指定密码的应用程序还需要提供更新该密码的功能。spring-doc.cadn.net.cn

用于更改密码的已知 URL 指明了一种机制,通过该机制,密码管理器可以发现给定应用程序的密码更新端点。spring-doc.cadn.net.cn

你可以配置 Spring Security 来提供此发现端点。 例如,如果你应用程序中的更改密码端点是 /change-password,那么你可以按如下方式配置 Spring Security:spring-doc.cadn.net.cn

默认更改密码端点
http
    .passwordManagement(Customizer.withDefaults())
<sec:password-management/>
http {
    passwordManagement { }
}

然后,当密码管理器导航到 /.well-known/change-password 时,Spring Security 会将请求重定向到您的端点 /change-passwordspring-doc.cadn.net.cn

或者,如果你的端点不是 /change-password,你也可以像这样指定它:spring-doc.cadn.net.cn

修改密码端点
http
    .passwordManagement((management) -> management
        .changePasswordPage("/update-password")
    )
<sec:password-management change-password-page="/update-password"/>
http {
    passwordManagement {
        changePasswordPage = "/update-password"
    }
}

通过上述配置,当密码管理器导航到 /.well-known/change-password 时,Spring Security 将重定向到 /update-passwordspring-doc.cadn.net.cn

检查已泄露密码

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

为此,Spring Security 通过 Have I Been Pwned APIHaveIBeenPwnedRestApiPasswordChecker 实现与 CompromisedPasswordChecker 接口进行了集成。spring-doc.cadn.net.cn

您可以自行使用 CompromisedPasswordChecker API,或者如果您通过 Spring Security 认证机制 使用 DaoAuthenticationProvider,则可以提供一个 CompromisedPasswordChecker Bean,它将被 Spring Security 配置自动识别。spring-doc.cadn.net.cn

通过这种方式,当你尝试使用弱密码(例如 123456)通过表单登录进行身份验证时,你会收到一个 401 状态码,或者被重定向到 /login?error 页面(具体取决于你的用户代理)。 然而,在这种情况下,仅返回 401 或执行重定向并不太有用,因为这会造成一些困惑:用户输入了正确的密码,却仍然无法登录。 在这种情况下,你可以通过 CompromisedPasswordException 来处理 AuthenticationFailureHandler 异常,以执行你期望的逻辑,例如将用户代理重定向到 /reset-password 页面:spring-doc.cadn.net.cn

使用 CompromisedPasswordChecker
@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);
	}

}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        authorizeHttpRequests {
            authorize(anyRequest, authenticated)
        }
        formLogin {
            authenticationFailureHandler = CompromisedPasswordAuthenticationFailureHandler()
        }
    }
    return http.build()
}

@Bean
open fun compromisedPasswordChecker(): CompromisedPasswordChecker {
    return HaveIBeenPwnedRestApiPasswordChecker()
}

class CompromisedPasswordAuthenticationFailureHandler : AuthenticationFailureHandler {
    private val defaultFailureHandler = SimpleUrlAuthenticationFailureHandler("/login?error")
    private val redirectStrategy = DefaultRedirectStrategy()

    override fun onAuthenticationFailure(
        request: HttpServletRequest,
        response: HttpServletResponse,
        exception: AuthenticationException
    ) {
        if (exception is CompromisedPasswordException) {
            redirectStrategy.sendRedirect(request, response, "/reset-password")
            return
        }
        defaultFailureHandler.onAuthenticationFailure(request, response, exception)
    }
}