|
对于最新的稳定版本,请使用 Spring Security 7.0.4! |
密码存储
Spring Security 的 PasswordEncoder 接口用于对密码执行单向转换,以便安全地存储密码。
由于 PasswordEncoder 是单向转换,因此在需要双向转换密码的场景下(例如存储用于向数据库进行身份验证的凭据)并不适用。
通常,PasswordEncoder 用于存储密码,该密码在身份验证时需与用户提供的密码进行比对。
密码存储历史
多年来,存储密码的标准机制不断演进。 最初,密码以明文形式存储。 当时人们认为密码是安全的,因为存储密码的数据存储库需要凭据才能访问。 然而,恶意用户通过 SQL 注入等攻击手段,找到了获取大量用户名和密码“数据转储”的方法。 随着越来越多的用户凭证被公开,安全专家意识到我们需要采取更多措施来保护用户的密码。
随后,开发人员被鼓励在对密码进行单向哈希(例如 SHA-256)处理后再存储。 当用户尝试进行身份验证时,系统会将存储的哈希密码与用户输入密码的哈希值进行比对。 这意味着系统只需存储密码的单向哈希值。 如果发生数据泄露,暴露的也只是密码的单向哈希值。 由于这些哈希是单向的,并且根据哈希值推测出原始密码在计算上非常困难,因此攻击者通常认为逐个破解系统中的每个密码并不值得投入精力。 为了攻破这种新机制,恶意用户创建了被称为彩虹表(Rainbow Tables)的查找表。 他们不再每次重复猜测每个密码,而是预先计算一次密码哈希值并将其存储在查找表中。
为了降低彩虹表(Rainbow Tables)的有效性,开发人员被鼓励使用加盐密码(salted passwords)。 具体做法是:不再仅将密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为“盐值”或 salt)。 将盐值与用户密码一起输入哈希函数,生成唯一的哈希值。 该盐值会以明文形式与用户的密码哈希值一同存储。 当用户尝试进行身份验证时,系统会将用户输入的密码与存储的盐值组合后进行哈希,并将结果与存储的哈希值进行比对。 由于每个盐值都是唯一的,因此对于不同的盐值和密码组合,所产生的哈希值也各不相同,从而使彩虹表攻击不再有效。
在现代,我们意识到加密哈希算法(如 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(包括 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 的默认实现使用了强度 10,如 BCryptPasswordEncoder 的 Javadoc 中所述。建议您
在自己的系统上调整和测试强度参数,以确保验证一个密码大约需要 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 是 密码哈希竞赛 的获胜者。
为了抵御在定制硬件上进行的密码破解,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))
其他PasswordEncoders
还有大量其他的 PasswordEncoder 实现,它们的存在完全是为了向后兼容。
这些实现都已被弃用,以表明它们不再被认为是安全的。
然而,目前没有计划将它们移除,因为迁移现有的遗留系统十分困难。
密码存储配置
Spring Security 默认使用 DelegatingPasswordEncoder。
不过,你可以通过将 PasswordEncoder 暴露为 Spring Bean 来自定义此行为。
如果您正在从 Spring Security 4.2.x 迁移,可以通过暴露一个 NoOpPasswordEncoder Bean 来恢复之前的行为。
|
回退到 |
-
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。
检查已泄露密码
在某些场景下,你需要检查密码是否已被泄露。例如,如果你正在开发一个处理敏感数据的应用程序,通常需要对用户的密码进行某种检查,以确保其可靠性。 其中一种检查方式就是确认该密码是否已被泄露,通常是因为它出现在数据泄露事件中。
为此,Spring Security 通过 Have I Been Pwned API 的 HaveIBeenPwnedRestApiPasswordChecker 实现与 CompromisedPasswordChecker 接口进行了集成。
您可以自行使用 CompromisedPasswordChecker API,或者如果您通过 Spring Security 认证机制 使用 DaoAuthenticationProvider,则可以提供一个 CompromisedPasswordChecker Bean,它将被 Spring Security 配置自动识别。
通过这种方式,当你尝试使用弱密码(例如 123456)通过表单登录进行身份验证时,你会收到一个 401 状态码,或者被重定向到 /login?error 页面(具体取决于你的用户代理)。
然而,在这种情况下,仅返回 401 或执行重定向并不太有用,因为这会造成一些困惑:用户输入了正确的密码,却仍然无法登录。
在这种情况下,你可以通过 CompromisedPasswordException 来处理 AuthenticationFailureHandler 异常,以执行你期望的逻辑,例如将用户代理重定向到 /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 {
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)
}
}