密码存储

本站(springdoc.cn)中的内容来源于 spring.io ,原始版权归属于 spring.io。由 springdoc.cn 进行翻译,整理。可供个人学习、研究,未经许可,不得进行任何转载、商用或与之相关的行为。 商标声明:Spring 是 Pivotal Software, Inc. 在美国以及其他国家的商标。

Spring Security 的 PasswordEncoder 接口用于对密码进行单向转换,让密码安全地存储。鉴于 PasswordEncoder 是一个单向转换,当密码转换需要双向时(如存储用于验证数据库的凭证),它就没有用了。通常情况下,PasswordEncoder 用于存储在认证时需要与用户提供的密码进行比较的密码。

密码存储历史

多年来,存储密码的标准机制一直在演变。起初,密码是以明文形式存储的。这些密码被认为是安全的,因为密码所保存的数据存储需要凭证来访问它。然而,恶意用户能够通过使用SQL注入等攻击手段,找到获取大量用户名和密码的 “data dumps” 的方法。随着越来越多的用户凭证被公开,安全专家意识到,我们需要做更多的工作来保护用户的密码。

开发者们被鼓励在对密码进行单向哈希运算后进行存储,例如SHA-256。当用户试图验证时,哈希密码将与他们输入的密码的哈希值进行比较。这意味着,系统只需要存储密码的单向散列值。如果发生漏洞,只有密码的单向散列值被暴露。由于哈希值是单向的,而且根据哈希值猜测密码在计算上是很困难的,所以不值得花费精力去弄清系统中的每个密码。为了打败这个新系统,恶意的用户决定创建被称为 彩虹表 的查询表。与其每次猜测每个密码的工作,他们只计算一次密码并将其存储在一个查询表中。

为了减轻彩虹表的效力,鼓励开发者使用加盐的密码。与其只使用密码作为哈希函数的输入,不如为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将通过散列函数运行,产生一个唯一的散列。盐将与用户的密码一起以明文形式存储。然后,当用户试图验证时,哈希的密码将与存储的盐和他们输入的密码的哈希值进行比较。独特的盐意味着彩虹表不再有效,因为每个盐和密码组合的哈希值都是不同的。

在现代,我们意识到加密哈希值(如SHA-256)已经不再安全。原因是,利用现代硬件,我们可以在一秒钟内进行数十亿次的哈希计算。这意味着,我们可以轻松地逐一破解每个密码。

现在鼓励开发者利用自适应单向函数来存储密码。用自适应单向函数验证密码是有意的资源密集型(他们有意使用大量的CPU、内存或其他资源)。自适应单向函数允许配置一个 “work factor” ,这个系数可以随着硬件的改进而增长。我们建议将 “work factor” 调整为在你的系统上验证一个密码需要大约一秒钟。这种权衡是为了使攻击者难以破解密码,但又不至于使你自己的系统负担过重或刺激到用户。Spring Security试图为 “work factor” 提供一个良好的起点,但我们鼓励用户为自己的系统定制 “work factor” ,因为不同系统的性能有很大的差异。应该使用的自适应单向函数的例子包括bcrypt, PBKDF2, scrypt, 和 argon2

因为自适应单向函数是有意进行资源密集型的,为每个请求验证一个用户名和密码会大大降低应用程序的性能。Spring Security(或任何其他库)都无法加快密码验证的速度,因为安全是通过使验证资源密集来获得的。我们鼓励用户用长期凭证(即用户名和密码)交换短期凭证(如会话和 OAuth Token 等)。短期凭证可以被快速验证,而不会有任何安全上的损失。

DelegatingPasswordEncoder

在Spring Security 5.0之前,默认的 PasswordEncoderNoOpPasswordEncoder,它需要纯文本密码。根据密码历史部分,你可能期望现在默认的 PasswordEncoder 是类似 BCryptPasswordEncoder 的东西。然而,这忽略了三个现实世界的问题。

  • 许多应用程序使用旧的密码编码(password encode),不能轻易迁移。

  • 密码存储的最佳实践将再次改变。

  • 作为一个框架,Spring Security 不能频繁地进行破坏性的改变。

相反,Spring Security引入了 DelegatingPasswordEncoder,它通过以下方式解决了所有的问题。

  • 确保通过使用当前的密码存储建议对密码进行编码。

  • 允许验证现代和传统格式的密码。

  • 允许在未来升级编码。

你可以通过使用 PasswordEncoderFactories 轻松构建 DelegatingPasswordEncoder 的实例。

Create Default DelegatingPasswordEncoder
  • Java

  • Kotlin

PasswordEncoder passwordEncoder =
    PasswordEncoderFactories.createDelegatingPasswordEncoder();
val passwordEncoder: PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()

另外,你也可以创建自己的自定义实例。

Create Custom DelegatingPasswordEncoder
  • 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)

密码存储格式

密码的一般格式是:

DelegatingPasswordEncoder Storage Format
{id}encodedPassword

id 是一个标识符,用于查询应该使用哪个 PasswordEncoderencodedPassword 是所选 PasswordEncoder 的原始编码密码。id 必须在密码的开头,以 { 开始,以 } 结束。如果找不到 idid 将被设置为null。例如,下面可能是一个使用不同 id 值编码的密码列表。所有的原始密码都是 password

DelegatingPasswordEncoder Encoded Passwords Example
{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为 bcryptencodedPassword 值为 $2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG。匹配时,它将委托给 BCryptPasswordEncoder
2 第二个密码的 PasswordEncoder id为 noopencodedPassword 值为 password。匹配时,它将委托给 NoOpPasswordEncoder
3 第三个密码的 PasswordEncoder id为 pbkdf2encodedPassword 值为 5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc。匹配时,它将委托给 Pbkdf2PasswordEncoder
4 第四个密码的 PasswordEncoder id为 scryptencodedPassword 值为 $e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc= 。匹配时,它将委托给 SCryptPasswordEncoder
5 最后一个密码的 PasswordEncoder id为 sha256encodedPassword 值为 97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0。匹配时,它将委托给 StandardPasswordEncoder

一些用户可能会担心,存储格式是为潜在的黑客提供的。这不是一个问题,因为密码的存储并不依赖于算法是一个秘密。此外,大多数格式在没有前缀的情况下,攻击者很容易搞清楚。例如,BCrypt密码经常以 $2a$ 开始。

密码编码

传递给构造函数的 idForEncode 决定了哪一个 PasswordEncoder 被用于编码密码。在我们之前构建的 DelegatingPasswordEncoder 中,这意味着编码密码的结果被委托给 BCryptPasswordEncoder,并以 {bcrypt} 为前缀。最终的结果看起来像下面的例子。

DelegatingPasswordEncoder Encode Example
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

密码匹配(对比)

匹配是基于 {id} 和构造函数中提供的 idPasswordEncoder 的映射。我们在密码存储格式中的例子提供了一个如何实现的工作实例。默认情况下,用一个密码和一个没有映射的id(包括空id)调用 matches(CharSequence, String) 的结果是 IllegalArgumentException。这个行为可以通过使用 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder) 来定制。

通过使用 id,我们可以在任何密码编码上进行匹配,但通过使用最现代的密码编码对密码进行编码。这一点很重要,因为与加密不同,密码散列(Hash)的设计使我们没有简单的方法来恢复明文。既然没有办法恢复明文,那么就很难迁移密码了。虽然用户迁移 NoOpPasswordEncoder 很简单,但我们选择默认包含它,以使它的入门体验更简单。

入门体验

如果你正在制作一个演示或样本,花时间对用户的密码进行哈希处理是有点麻烦的。有一些方便的机制可以使之更容易,但这仍然不是为生产准备的。

withDefaultPasswordEncoder Example
  • 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

如果你要创建多个用户,你也可以重复使用builder。

withDefaultPasswordEncoder Reusing the Builder
  • 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)

用Spring Boot CLI进行编码

对密码进行正确编码的最简单方法是使用 Spring Boot CLI

例如,下面的例子对 password 的密码进行编码,以便与 DelegatingPasswordEncoder 一起使用。

Spring Boot CLI encodepassword Example
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 的默认实现使用 BCryptPasswordEncoder 的 Javadoc 中提到的强度10。我们鼓励你在自己的系统上调整和测试强度参数,使其大约需要1秒钟来验证一个密码。

BCryptPasswordEncoder
  • 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。

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认证时,这种算法是一个不错的选择。

Pbkdf2PasswordEncoder
  • 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秒左右。

SCryptPasswordEncoder
  • 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 来恢复到以前的行为。

恢复到 NoOpPasswordEncoder 被认为是不安全的。你应该转而使用 DelegatingPasswordEncoder 来支持安全的密码编码。

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 配置下,要求 NoOpPasswordEncoder Bean 的名称为 passwordEncoder

更改密码配置

大多数允许用户指定密码的应用程序也需要一个更新密码的功能。

用于更改密码的 Well-Known URL 表示一种机制,密码管理器可以通过该机制发现特定应用程序的密码更新端点。

你可以配置 Spring Security 来提供这个发现端点。例如,如果你的应用程序中更改密码的端点是 /change-password,那么你可以这样配置 Spring Security。

Default Change Password Endpoint
  • Java

  • XML

  • Kotlin

http
    .passwordManagement(Customizer.withDefaults())
<sec:password-management/>
http {
    passwordManagement { }
}

然后,当密码管理器导航到 /.well-known/change-password 时,Spring Security 将重定向你的端点,/change-password

或者,如果你的端点是 /change-password 以外的东西,你也可以像这样指定。

Change Password Endpoint
  • 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