对于最新的稳定版本,请使用 Spring Security 6.5.3! |
密码存储
Spring Security 的PasswordEncoder
接口用于对密码进行单向转换,让密码安全存储。
鉴于PasswordEncoder
是单向转换,当密码转换需要是双向的(例如存储用于向数据库进行身份验证的凭据)时,它没有用。
通常PasswordEncoder
用于存储需要与身份验证时用户提供的密码进行比较的密码。
密码存储历史
多年来,存储密码的标准机制不断发展。 一开始,密码以明文形式存储。 密码被认为是安全的,因为密码的数据存储保存在访问密码所需的凭据中。 但是,恶意用户能够找到使用 SQL 注入等攻击来获取用户名和密码的大量“数据转储”的方法。 随着越来越多的用户凭据公开,安全专家意识到我们需要采取更多措施来保护用户的密码。
然后鼓励开发人员在通过单向哈希(例如 SHA-256)运行密码后存储密码。 当用户尝试进行身份验证时,将哈希密码与他们键入的密码的哈希进行比较。 这意味着系统只需要存储密码的单向哈希值。 如果发生违规,则仅暴露密码的单向哈希值。 由于哈希值是单向的,并且在给定哈希值的情况下很难猜测密码,因此不值得费力找出系统中的每个密码。 为了击败这个新系统,恶意用户决定创建称为 Rainbow Tables 的查找表。 他们不是每次都猜测每个密码,而是计算一次密码并将其存储在查找表中。
为了降低彩虹表的有效性,鼓励开发人员使用加盐密码。 不会仅使用密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。 盐和用户密码将通过哈希函数运行以生成唯一的哈希。 盐将以明文形式与用户密码一起存储。 然后,当用户尝试进行身份验证时,将哈希密码与存储的盐的哈希值和他们键入的密码进行比较。 独特的盐意味着 Rainbow Tables 不再有效,因为每个盐和密码组合的哈希值都不同。
在现代,我们意识到加密哈希(如 SHA-256)不再安全。 原因是使用现代硬件,我们每秒可以执行数十亿次哈希计算。 这意味着我们可以轻松地单独破解每个密码。
现在鼓励开发人员利用自适应单向功能来存储密码。 使用自适应单向函数验证密码是故意占用资源的(它们故意使用大量 CPU、内存或其他资源)。 自适应单向功能允许配置一个“工作因子”,该因子可以随着硬件的改进而增长。 我们建议将“工作因素”调整为大约需要一秒钟来验证系统上的密码。 这种权衡是让攻击者难以破解密码,但又不会成本太高,以免给您自己的系统带来过大的负担或激怒用户。 Spring Security 试图为“工作因素”提供一个很好的起点,但我们鼓励用户为自己的系统自定义“工作因素”,因为性能因系统而异。 应使用的自适应单向函数示例包括 bcrypt、PBKDF2、scrypt 和 argon2。
由于自适应单向函数故意占用大量资源,因此验证每个请求的用户名和密码可能会显着降低应用程序的性能。 Spring Security(或任何其他库)无法加快密码的验证速度,因为通过大量使用验证资源来获得安全性。 鼓励用户将长期凭据(即用户名和密码)交换为短期凭据(例如会话和 OAuth Tokens等)。 短期凭证可以快速验证,而不会造成任何安全性损失。
委托密码编码器
在 Spring Security 5.0 之前,默认的PasswordEncoder
是NoOpPasswordEncoder
,这需要纯文本密码。
根据“密码历史记录”部分,您可能期望默认的PasswordEncoder
现在会像BCryptPasswordEncoder
.
然而,这忽略了三个现实世界的问题:
-
许多应用程序使用无法轻松迁移的旧密码编码。
-
密码存储的最佳做法将再次更改。
-
作为一个框架,Spring Security 不能频繁地进行重大更改。
相反,Spring Security 引入了DelegatingPasswordEncoder
,它通过以下方式解决所有问题:
-
确保使用当前密码存储建议对密码进行编码
-
允许以现代和传统格式验证密码
-
允许将来升级编码
您可以轻松构造DelegatingPasswordEncoder
通过使用PasswordEncoderFactories
:
-
Java
-
Kotlin
PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
val passwordEncoder: PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
或者,您可以创建自己的自定义实例:
-
Java
-
Kotlin
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"] = 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"] = StandardPasswordEncoder()
val passwordEncoder: PasswordEncoder = 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)
1 | 第一个密码有一个PasswordEncoder id 为bcrypt 和encodedPassword 值$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG .
匹配时,它会委托给BCryptPasswordEncoder |
2 | 第二个密码有一个PasswordEncoder id 为noop 和encodedPassword 值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 为sha256 和encodedPassword 值97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 .
匹配时,它会委托给StandardPasswordEncoder |
一些用户可能担心存储格式是为潜在的黑客提供的。
这不是问题,因为密码的存储不依赖于算法是秘密。
此外,大多数格式很容易被攻击者弄清楚,没有前缀。
例如,BCrypt 密码通常以 |
密码编码
这idForEncode
传递到构造函数中,确定哪个PasswordEncoder
用于编码密码。
在DelegatingPasswordEncoder
我们之前构造的,这意味着编码的结果password
被委托给BCryptPasswordEncoder
并以{bcrypt}
.
最终结果如下例所示:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
密码匹配
匹配基于{id}
以及id
到PasswordEncoder
在构造函数中提供。
我们在密码存储格式中的示例提供了如何完成此作的工作示例。
默认情况下,调用的结果matches(CharSequence, String)
使用密码和id
未映射(包括空 ID)会导致IllegalArgumentException
.
可以使用以下命令自定义此行为DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder)
.
通过使用id
,我们可以匹配任何密码编码,但使用最现代的密码编码来编码密码。
这很重要,因为与加密不同,密码哈希的设计使得没有简单的方法来恢复明文。
由于无法恢复明文,因此很难迁移密码。
虽然用户迁移很简单NoOpPasswordEncoder
,我们选择默认包含它,以简化入门体验。
入门体验
如果您正在制作演示或示例,则花时间对用户的密码进行哈希处理会有点麻烦。 有一些便利机制可以简化此作,但这仍然不适用于生产。
-
Java
-
Kotlin
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
如果您要创建多个用户,您还可以重复使用构建器:
-
Java
-
Kotlin
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 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
豆.
或者,您可以在所有密码前加上正确的id
并继续使用DelegatingPasswordEncoder
.
例如,如果您使用的是 BCrypt,则可以从以下内容迁移您的密码:
$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
自
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
有关映射的完整列表,请参阅 JavadocPasswordEncoderFactories
.
BCryptPasswordEncoder
这BCryptPasswordEncoder
实现使用广泛支持的 bcrypt 算法对密码进行哈希处理。
为了使其更能抵抗密码破解,bcrypt 故意变慢速度。
与其他自适应单向功能一样,应将其调整为大约需要 1 秒来验证系统上的密码。
默认实现BCryptPasswordEncoder
使用强度 10,如 Javadoc 中提到的BCryptPasswordEncoder
.我们鼓励您
在您自己的系统上调整和测试强度参数,以便验证密码大约需要 1 秒钟。
-
Java
-
Kotlin
// 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))
Argon2Password编码器
这Argon2PasswordEncoder
实现使用 Argon2 算法对密码进行哈希处理。
Argon2 是密码哈希竞赛的获胜者。
为了消除自定义硬件上的密码破解,Argon2 是一种故意缓慢的算法,需要大量内存。
与其他自适应单向功能一样,应将其调整为大约需要 1 秒来验证系统上的密码。
当前实现的Argon2PasswordEncoder
需要 BouncyCastle。
-
Java
-
Kotlin
// 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 认证时,此算法是一个不错的选择。
-
Java
-
Kotlin
// 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 秒来验证系统上的密码。
-
Java
-
Kotlin
// 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))
其他PasswordEncoder
s
还有相当多的其他PasswordEncoder
完全为了向后兼容性而存在的实现。
它们都已被弃用,以表明它们不再被视为安全。
但是,没有计划删除它们,因为很难迁移现有的遗留系统。
密码存储配置
默认情况下,Spring Security 使用 DelegatingPasswordEncoder。
但是,您可以通过公开PasswordEncoder
作为春豆。
如果您从 Spring Security 4.2.x 迁移,则可以通过公开NoOpPasswordEncoder
豆。
恢复为 |
-
Java
-
XML
-
Kotlin
@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 配置需要 |
更改密码配置
大多数允许用户指定密码的应用程序还需要更新该密码的功能。
用于更改密码的已知 URL 表示密码管理器可以发现给定应用程序的密码更新端点的机制。
您可以配置 Spring Security 以提供此发现端点。
例如,如果应用程序中的更改密码端点是/change-password
,那么您可以像这样配置 Spring Security:
-
Java
-
XML
-
Kotlin
http
.passwordManagement(Customizer.withDefaults())
<sec:password-management/>
http {
passwordManagement { }
}
然后,当密码管理器导航到/.well-known/change-password
然后 Spring Security 将重定向您的端点,/change-password
.
或者,如果您的端点是/change-password
,您还可以像这样指定:
-
Java
-
XML
-
Kotlin
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-password
.