Password Storage
Spring Security 的 PasswordEncoder
接口用于对密码执行单向转换,使密码可以安全地存储。鉴于 PasswordEncoder
是单向转换,因此当密码转换需要是双向的(例如存储用于对数据库进行身份验证的凭据)时,它将没有用。通常,PasswordEncoder
用于存储在身份验证时需要与用户提供的密码进行比较的密码。
Password Storage History
多年来,存储密码的标准机制一直在不断发展。最初,密码以纯文本形式存储。人们认为密码是安全的,因为存储密码的数据存储需要访问该信息。然而,恶意用户能够使用 SQL 注入等攻击找到包含大量 “data dumps” 的用户名和密码的方法。随着越来越多的用户凭据公开,安全专家意识到我们需要采取更多措施来保护用户的密码。
然后鼓励开发人员在通过单向哈希(如SHA-256)运行密码后将其存储起来。当用户尝试进行身份验证时,将哈希密码与他们键入的密码的哈希进行比较。这意味着该系统只需要存储密码的单向哈希。如果发生泄露,则仅泄露密码的单向哈希。由于哈希是单向的,并且根据哈希猜测密码在计算上很困难,因此不值得费力去找出系统中的每个密码。为了击败这个新系统,恶意用户决定创建称为 Rainbow Tables的查找表。他们并没有每次猜测每个密码,而是计算一次密码并将其存储在查找表中。
为了减轻彩虹表的有效性,鼓励开发人员使用加盐密码。不是仅将密码用作哈希函数的输入,而是为每位用户的密码生成随机字节(称为盐)。盐和用户密码将通过哈希函数运行以生成唯一的哈希。该盐将与用户的密码一起以明文形式存储。然后,当用户尝试进行身份验证时,哈希密码将与所存储的盐和他们键入的密码的哈希进行比较。唯一的盐意味着彩虹表不再有效,因为每个盐和密码组合的哈希不同。
在现代,我们意识到加密哈希(如 SHA-256)不再安全。原因是,使用现代硬件,我们每秒可以执行数十亿次哈希计算。这意味着我们可以轻松地破解每个密码。
现在鼓励开发人员利用自适应单向函数存储密码。通过自适应单向函数验证密码有意地资源密集(它们有意识地使用了大量的 CPU、内存或其他资源)。自适应单向函数允许配置 “work factor”,该配置可以随着硬件的改进而增长。我们建议将 “work factor” 调整为在您的系统上验证密码大约需要一秒钟。这种权衡是为了让攻击者难以破解密码,但不要造成太大的成本,以免对您自己的系统造成过大的负担或激怒用户。Spring Security 已尝试为 “work factor” 提供一个良好的起点,但我们鼓励用户为自己的系统定制 “work factor”,因为性能因系统而异很大。应该使用的自适应单向函数的示例包括 bcrypt、PBKDF2、scrypt 和 argon2。
因为自适应单向函数有意地资源密集,因此为每个请求验证用户名和密码会显着降低应用程序的性能。Spring Security(或任何其他库)无法加快密码验证速度,因为安全性是通过使验证资源密集来获得的。鼓励用户将长期凭据(即用户名和密码)换成短期凭据(如会话和 OAuth 令牌等)。短期凭据可以快速验证,而不会损失安全性。
DelegatingPasswordEncoder
在 Spring Security 5.0 之前,默认 PasswordEncoder
是 NoOpPasswordEncoder
,它需要纯文本密码。根据 Password History 部分,您可能希望默认 PasswordEncoder
现在类似 BCryptPasswordEncoder
。但是,这忽略了三个现实世界的问题:
-
许多应用程序都使用难以轻松迁移的旧密码编码。
-
存储密码的最佳做法将再次更改。
-
作为一个框架,Spring Security 不能频繁进行重大更改。
相反,Spring Security 引入了 DelegatingPasswordEncoder
,它通过以下方式解决了所有问题:
-
确保使用当前密码存储建议对密码进行编码
-
允许使用当前和旧式格式验证密码
-
允许在未来升级编码
您可以通过使用 PasswordEncoderFactories
轻松构建 DelegatingPasswordEncoder
的实例:
-
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)
Password Storage Format
密码的一般格式为:
{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 密码通常以 |
Password Encoding
传入构造函数的 idForEncode
确定哪个 PasswordEncoder
用于编码密码。在我们之前构建的 DelegatingPasswordEncoder
中,这意味着对 password
进行编码的结果委托给 BCryptPasswordEncoder
,并以 {bcrypt}
为前缀。最终结果如下例所示:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
Password Matching
匹配基于 {id}
以及在构造函数中提供的 id
到 PasswordEncoder
的映射。我们在 Password Storage Format 中的示例提供了一个关于如何完成此操作的工作示例。默认情况下,使用未映射的密码和 id
(包括 null id)调用 matches(CharSequence, String)
的结果将导致 IllegalArgumentException
。可以使用 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder)
自定义此行为。
通过使用 id
,我们可以匹配任何密码编码,但使用最现代的密码编码对密码进行编码。这一点很重要,因为与加密不同,密码哈希被设计为没有简单的方法可以恢复明文。由于没有办法恢复明文,因此难以迁移密码。虽然用户可以轻松地迁移 NoOpPasswordEncoder
,但我们选择默认包含它,以简化入门体验。
Getting Started Experience
如果您正在制作演示或示例,则花时间为用户密码进行哈希有点麻烦。有一些方便的机制可以简化此操作,但这仍然不适用于生产。
-
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()
这会对存储的密码进行哈希处理,但密码仍暴露在内存和已编译的源代码中。因此,对于生产环境而言,它仍然不被认为是安全的。对于生产,您应该 hash your passwords externally。
Encode with Spring Boot CLI
正确编码密码的最简单的方法是使用 Spring Boot CLI。
例如,以下示例对 password
的密码进行编码以用于 DelegatingPasswordEncoder:
spring encodepassword password
{bcrypt}$2a$10$X5wFBtLrL/kHcmrOGGTrGufsBX8CJ0WpQpF3pgeuxBB/H73BK1DW6
Troubleshooting
如果所存储的密码之一没有 id
,则会发生以下错误,如 Password Storage Format 中所述。
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 迁移过来,你可以通过 exposing a NoOpPasswordEncoder
bean 来恢复到之前的行为。
或者,你可以用正确的 id
给所有密码加前缀,然后继续使用 DelegatingPasswordEncoder
。例如,如果你使用 BCrypt,你可以将你的密码从类似这样的字符串迁移过来:
$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
更改为
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
有关映射的完整列表,请参阅 link:https://docs.spring.io/spring-security/site/docs/5.0.x/api/org/springframework/security/crypto/factory/PasswordEncoderFactories.html[PasswordEncoderFactories
的 Javadoc。
BCryptPasswordEncoder
BCryptPasswordEncoder`实现使用广泛支持的 bcrypt算法对密码进行哈希处理。为了增强其抗密码破解能力,bcrypt 故意设计的较慢。与其他自适应单向函数类似,它应该调整为在大约 1 秒钟内完成系统上密码的验证。`BCryptPasswordEncoder`的默认实现使用 `BCryptPasswordEncoder
的 Javadoc 中提到的强度 10。建议您对自己的系统微调并测试强度参数,以便在大约 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))
Argon2PasswordEncoder
`Argon2PasswordEncoder`实现使用 Argon2算法对密码进行哈希处理。Argon2 是 Password Hashing Competition的获胜者。为了在定制硬件上阻止密码破解,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))
Other `PasswordEncoder`s
还有大量其他 PasswordEncoder
实现完全是为了向后兼容。它们都被标记为已弃用,以表明它们不再被认为是安全的。但是,我们没有计划删除它们,因为迁移现有的旧系统很困难。
Password Storage Configuration
默认情况下,Spring Security 使用 DelegatingPasswordEncoder。但是,你可以通过将 PasswordEncoder
公开为 Spring bean 来自定义它。
如果你从 Spring Security 4.2.x 迁移过来,你可以通过公开一个 NoOpPasswordEncoder
bean 来恢复到之前的行为。
恢复到 NoOpPasswordEncoder
被认为是不安全的。你应该迁移到使用 DelegatingPasswordEncoder
以支持安全的密码编码。
-
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 配置要求 |
Change Password Configuration
大多数允许用户指定密码的应用程序还需要一个用于更新该密码的功能。
A Well-Known URL for Changing Passwords表示密码管理器可以发现给定应用程序的密码更新端点的机制。
你可以配置 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
。