密码存储
Spring Security 的 PasswordEncoder
接口用于对密码进行单向转换,以便安全地存储密码。由于 PasswordEncoder
是单向转换,当密码转换需要是双向时(例如存储用于身份验证数据库的凭据)就不太有用。通常,PasswordEncoder
用于存储在身份验证时需要与用户提供的密码进行比较的密码。
密码存储历史
随着时间的推移,存储密码的标准机制不断发展。最初,密码以明文存储。由于存储密码的数据存储需要凭证才能访问,因此假设密码是安全的。然而,恶意用户通过使用 SQL 注入等攻击方式,能够获取大量包含用户名和密码的“数据转储”。随着越来越多的用户凭证被公开,安全专家意识到我们需要采取更多措施来保护用户的密码。
开发者们被鼓励在将密码通过单向哈希(如 SHA-256)处理后再进行存储。当用户尝试进行身份验证时,哈希密码将与他们输入的密码的哈希进行比较。这意味着系统只需要存储密码的单向哈希。如果发生泄露,只有密码的单向哈希被暴露。由于哈希是单向的,并且根据哈希猜测密码在计算上是困难的,因此没有必要费力去破解系统中的每个密码。为了打破这个新系统,恶意用户决定创建称为 Rainbow Tables 的查找表。他们不再每次都进行猜测每个密码的工作,而是一次性计算密码并将其存储在查找表中。
为了减缓彩虹表的有效性,开发人员被鼓励使用加盐密码。与其仅使用密码作为哈希函数的输入,不如为每个用户的密码生成随机字节(称为盐)。盐和用户的密码会一起通过哈希函数生成一个唯一的哈希值。盐会以明文的形式与用户的密码一起存储。然后,当用户尝试进行身份验证时,哈希密码将与存储的盐和用户输入的密码的哈希值进行比较。唯一的盐意味着彩虹表不再有效,因为每个盐和密码组合生成的哈希值都是不同的。
在现代,我们意识到加密哈希(如 SHA-256)已经不再安全。原因是,凭借现代硬件,我们可以每秒进行数十亿次哈希计算。这意味着我们可以轻松地破解每一个密码。
现在鼓励开发人员利用自适应单向函数来存储密码。使用自适应单向函数验证密码时,故意增加了资源消耗(故意使用大量的 CPU、内存或其他资源)。自适应单向函数允许配置一个“工作因子”,该因子可以随着硬件的提升而增长。我们建议将“工作因子”调整到在您的系统上验证密码大约需要一秒钟的时间。这个折衷的目的是使攻击者很难破解密码,但又不会让您的系统承受过多的负担或使用户感到烦扰。Spring Security 尝试为“工作因子”提供一个很好的起点,但我们鼓励用户根据自己的系统自定义“工作因子”,因为性能在不同系统间差异很大。应使用的自适应单向函数的示例包括 bcrypt、PBKDF2、scrypt 和 argon2。
因为自适应单向函数故意消耗资源,对于每个请求验证用户名和密码可能会显著降低应用程序的性能。Spring Security(或其他任何库)无法加速密码的验证,因为安全性是通过使验证过程消耗资源来获得的。鼓励用户将长期凭证(即用户名和密码)交换为短期凭证(例如会话、OAuth 令牌等)。短期凭证可以快速验证,而不会损失安全性。
DelegatingPasswordEncoder
在 Spring Security 5.0 之前,默认的 PasswordEncoder
是 NoOpPasswordEncoder
,它要求使用明文密码。根据密码历史部分,你可能会认为默认的 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)
密码存储格式
密码的一般格式是:
{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}
和构造函数中提供的 PasswordEncoder
的 id
映射。我们在 密码存储格式 中的示例提供了一个如何实现这一点的工作示例。默认情况下,调用 matches(CharSequence, String)
方法时,如果密码和未映射的 id
(包括 null 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 bean 来恢复之前的行为。
另外,您可以在所有密码前加上正确的 id
,并继续使用 DelegatingPasswordEncoder
。例如,如果您使用 BCrypt,您可以将密码从类似以下内容迁移:
$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
to
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
有关映射的完整列表,请参阅 PasswordEncoderFactories 的 Javadoc。
BCryptPasswordEncoder
BCryptPasswordEncoder
实现使用广泛支持的 bcrypt 算法来对密码进行哈希处理。为了增加密码破解的难度,bcrypt 被故意设计得很慢。像其他自适应单向函数一样,它应该被调优为在您的系统上大约需要 1 秒钟的时间来验证密码。BCryptPasswordEncoder
的默认实现使用了 Javadoc 中提到的强度 10,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))
Argon2PasswordEncoder
- 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 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 配置要求 NoOpPasswordEncoder
bean 名称为 passwordEncoder
。
更改密码配置
大多数允许用户指定密码的应用程序也需要一个更新密码的功能。
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
。
被泄露密码检查
在某些情况下,您需要检查密码是否已被泄露,例如,如果您正在创建一个处理敏感数据的应用程序,通常需要对用户的密码进行一些检查,以确保其可靠性。这些检查之一可以是密码是否已被泄露,通常是因为它在 数据泄露 中被发现。
为方便实现,Spring Security 通过 Have I Been Pwned API 提供了与 HaveIBeenPwnedRestApiPasswordChecker 的集成,该接口实现了 CompromisedPasswordChecker。
您可以自己使用 CompromisedPasswordChecker
API,或者如果您通过 Spring Security 认证机制 使用 DaoAuthenticationProvider,您可以提供一个 CompromisedPasswordChecker
bean,它将被 Spring Security 配置自动识别。
通过这样做,当您尝试使用弱密码进行表单登录时,比如说 123456
,您将收到 401 或被重定向到 /login?error
页面(具体取决于您的用户代理)。然而,仅仅返回 401 或重定向在这种情况下并不是很有用,这会导致一些困惑,因为用户提供了正确的密码,但仍然无法登录。在这种情况下,您可以通过 AuthenticationFailureHandler
处理 CompromisedPasswordException
,以执行您想要的逻辑,比如将用户代理重定向到 /reset-password
,例如:
- Java
- Kotlin
@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 {
failureHandler = 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)
}
}