密码存储
Spring Security 的 PasswordEncoder
接口用于执行密码的单向转换,以便安全地存储密码。鉴于 PasswordEncoder
是单向转换,因此当密码转换需要双向进行时(例如存储用于向数据库进行身份验证的凭据)它并不适用。通常,PasswordEncoder
用于存储在身份验证时需要与用户提供的密码进行比较的密码。
密码存储历史
多年来,存储密码的标准机制一直在发展。最初,密码以明文形式存储。假设密码是安全的,因为存储密码的数据存储需要凭据才能访问。但是,恶意用户能够通过使用 SQL 注入等攻击来获取大量用户名和密码的“数据转储”。随着越来越多的用户凭据被公开,安全专家意识到我们需要采取更多措施来保护用户的密码。
然后鼓励开发人员在将密码通过单向哈希(例如 SHA-256)运行后存储它们。当用户尝试进行身份验证时,哈希后的密码将与他们输入的密码的哈希值进行比较。这意味着系统只需要存储密码的单向哈希值。如果发生泄露,只会公开密码的单向哈希值。由于哈希是单向的,并且给定哈希值猜测密码在计算上很困难,因此猜测系统中每个密码的努力将不值得。为了击败这个新系统,恶意用户决定创建称为彩虹表的查找表。他们不是每次都进行猜测每个密码的工作,而是计算一次密码并将其存储在查找表中。
为了降低彩虹表的有效性,鼓励开发人员使用加盐密码。不是仅将密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将通过哈希函数运行以生成唯一的哈希值。盐将与用户的密码一起以明文形式存储。然后,当用户尝试进行身份验证时,哈希后的密码将与存储的盐和他们输入的密码的哈希值进行比较。唯一的盐意味着彩虹表不再有效,因为每个盐和密码组合的哈希值都不同。
在现代,我们意识到加密哈希(如 SHA-256)不再安全。原因是使用现代硬件,我们每秒可以执行数十亿次哈希计算。这意味着我们可以轻松地单独破解每个密码。
现在鼓励开发人员利用自适应单向函数来存储密码。使用自适应单向函数验证密码的目的是为了占用大量资源(有意使用大量的 CPU、内存或其他资源)。自适应单向函数允许配置“工作因子”,随着硬件的改进,“工作因子”可以增长。我们建议将“工作因子”调整为在您的系统上大约需要 1 秒钟来验证密码。这种权衡是为了使攻击者难以破解密码,但又不至于给您的系统带来过大的负担或让用户感到恼火。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
,则将其设置为 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
的映射。我们在 密码存储格式 中的示例提供了此操作的工作示例。默认情况下,使用密码和未映射的 id
(包括 null id)调用 matches(CharSequence, String)
的结果将导致 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
迁移到
{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))
其他 PasswordEncoder
还有大量其他 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 通过HaveIBeenPwnedRestApiPasswordChecker
实现提供了与Have I Been Pwned API的集成CompromisedPasswordChecker
接口。
您可以自己使用CompromisedPasswordChecker
API,或者,如果您正在通过DaoAuthenticationProvider
使用Spring Security 身份验证机制,您可以提供一个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)
}
}