密码存储

Spring Security 的 PasswordEncoder 接口用于对密码执行单向转换,以便安全地存储密码。由于 PasswordEncoder 是单向转换,因此当密码转换需要双向时(例如存储用于验证数据库的凭据),它并不适用。通常,PasswordEncoder 用于存储需要在认证时与用户提供的密码进行比较的密码。

密码存储历史

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

开发者随后被鼓励在使用 SHA-256 等单向哈希函数处理密码后存储密码。当用户尝试认证时,哈希后的密码将与他们输入的密码的哈希值进行比较。这意味着系统只需要存储密码的单向哈希值。如果发生泄露,只会暴露密码的单向哈希值。由于哈希是单向的,并且根据哈希值猜测密码在计算上很困难,因此找出系统中每个密码的努力是不值得的。为了攻破这个新系统,恶意用户决定创建称为彩虹表(Rainbow Tables)的查找表。他们不再每次都尝试猜测每个密码,而是一次计算出密码并将其存储在查找表中。

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

在现代,我们意识到加密哈希(如 SHA-256)已不再安全。原因是凭借现代硬件,我们可以每秒执行数十亿次哈希计算。这意味着我们可以轻松地单独破解每个密码。

现在,开发者被鼓励利用自适应单向函数来存储密码。使用自适应单向函数验证密码是故意资源密集型的(它们故意使用大量的 CPU、内存或其他资源)。自适应单向函数允许配置一个“工作因子”,该因子可以随着硬件的改进而增长。我们建议调整“工作因子”,使其在您的系统上验证一个密码大约需要一秒钟。这种权衡是为了使攻击者难以破解密码,但又不会过于昂贵以至于给您自己的系统带来过度负担或惹恼用户。Spring Security 试图为“工作因子”提供一个良好的起点,但我们鼓励用户根据自己的系统定制“工作因子”,因为不同系统之间的性能差异很大。应该使用的自适应单向函数的例子包括 bcryptPBKDF2scryptargon2

由于自适应单向函数是故意资源密集型的,因此对每个请求验证用户名和密码会显著降低应用程序的性能。Spring Security(或任何其他库)都无法加快密码验证速度,因为安全性是通过使验证资源密集型来实现的。建议用户将长期凭据(即用户名和密码)交换为短期凭据(例如会话、OAuth 令牌等)。短期凭据可以快速验证,而不会损失安全性。

DelegatingPasswordEncoder

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

  • 许多应用程序使用无法轻易迁移的旧密码编码。

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

  • 作为框架,Spring Security 不能频繁进行破坏性更改。

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

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

  • 允许验证现代和旧格式的密码

  • 允许将来升级编码

您可以使用 PasswordEncoderFactories 轻松构建 DelegatingPasswordEncoder 实例:

创建默认 DelegatingPasswordEncoder
  • Java

  • Kotlin

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

或者,您可以创建自己的自定义实例:

创建自定义 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 存储格式
{id}encodedPassword

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

DelegatingPasswordEncoder 编码密码示例
{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 中,这意味着对 password 进行编码的结果被委托给 BCryptPasswordEncoder 并以 {bcrypt} 为前缀。最终结果如下例所示:

DelegatingPasswordEncoder 编码示例
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

密码匹配

匹配是基于 {id} 以及构造函数中提供的 idPasswordEncoder 的映射。我们在密码存储格式中的示例提供了一个关于如何完成此操作的可行示例。默认情况下,使用未映射的 id(包括 null id)调用 matches(CharSequence, String) 的结果将引发 IllegalArgumentException。可以通过使用 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder) 来自定义此行为。

通过使用 id,我们可以匹配任何密码编码,但使用最现代的密码编码来编码密码。这很重要,因为与加密不同,密码哈希的设计使得无法轻易恢复纯文本。由于无法恢复纯文本,因此很难迁移密码。虽然用户迁移 NoOpPasswordEncoder 很简单,但我们选择默认包含它,以便简化入门体验。

入门体验

如果您正在准备一个演示或示例,花费时间哈希用户密码会有点麻烦。有一些便捷机制可以使这更容易,但这仍然不适用于生产环境。

withDefaultPasswordEncoder 示例
  • 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

如果您正在创建多个用户,您也可以重用构建器:

withDefaultPasswordEncoder 重用构建器
  • 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 Boot CLI encodepassword 示例
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 秒钟。

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 是 密码哈希竞赛(Password Hashing Competition)的获胜者。为了对抗自定义硬件上的密码破解,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

修改密码配置

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

“用于修改密码的知名 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 通过 CompromisedPasswordChecker 接口的 HaveIBeenPwnedRestApiPasswordChecker 实现,提供了与 Have I Been Pwned API 的集成。

您可以自行使用 CompromisedPasswordChecker API,或者,如果您通过 Spring Security 认证机制使用 DaoAuthenticationProvider,您可以提供一个 CompromisedPasswordChecker bean,Spring Security 配置将自动加载它。

这样做后,当您尝试使用弱密码(例如 123456)通过表单登录进行认证时,您将收到 401 错误或被重定向到 /login?error 页面(取决于您的用户代理)。然而,在这种情况下,仅仅是 401 或重定向用处不大,这会导致一些困惑,因为用户提供了正确的密码但仍然无法登录。在这种情况下,您可以通过 AuthenticationFailureHandler 处理 CompromisedPasswordException 来执行您想要的逻辑,例如将用户代理重定向到 /reset-password

使用 CompromisedPasswordChecker
  • 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)
    }
}