Spring Security的PasswordEncoder接口用于执行密码单向的转换,以允许安全地存储密码。假定PasswordEncoder是单向转换,那么当密码转换需要双向转换时(例如,存储用于向数据库进行身份验证的凭证),就不需要使用它。通常,PasswordEncoder用于存储需要在身份验证时与用户提供的密码进行比较的密码。
密码存储历史
经过多年的发展,存储密码的标准机制已经形成。一开始密码是以明文形式存储的。密码被认为是安全的,因为数据存储的密码保存在访问它所需的凭证中。然而,恶意用户能够通过使用SQL注入等攻击找到获取大量用户名和密码“转储”的方法。随着越来越多的用户凭证成为公共安全事件,专家意识到我们需要做更多的工作来保护用户的密码。
然后,开发人员被鼓励通过单向哈希(例如SHA-256)来存储密码。当用户试图进行身份验证时,将哈希密码与他们键入的密码的哈希进行比较。这意味着系统只需要存储密码的单向哈希。如果发生了入侵,那么只有密码哈希被暴露了。由于哈希是一种算法,而且根据哈希来猜测密码在计算上非常困难,因此不值得花力气去找出系统中的每个密码。为了破解这种方式,恶意用户决定创建称为彩虹表(https://en.wikipedia.org/wiki/Rainbow_table)的查找表。他们不是每次都猜测每个密码,而是计算一次密码并将其存储在一个查找表中。
为了降低彩虹表的有效性,开发者被鼓励使用盐值密码。与仅仅使用密码作为哈希函数的输入不同,它将为每个用户的密码生成随机字节(称为盐值)。盐值和用户密码将通过哈希函数运行,该函数生成一个唯一的盐值。盐值将以明文形式与用户密码一起存储。然后,当用户试图进行身份验证时,将哈希密码与存储的盐值的哈希值和用户输入的密码进行比较。独特的盐值意味着彩虹表不再有效,因为每种盐值和密码组合的哈希值是不同的。
在现代,我们意识到加密散列(如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的实例。
示例:创建默认DelegatingPasswordEncoder
PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
或者,您可以创建自己的自定义实例。例如:
创建自定义DelegatingPasswordEncoder
String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());
PasswordEncoder passwordEncoder =
new 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
{noop}password
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
- 第一个密码的密码编码id为bcrypt,编码密码为$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG。匹配时,它将委托给BCryptPasswordEncode
- 第二个密码将有一个PasswordEncoder id为noop和一个编码密码为password。匹配时,它将委托给NoOpPasswordEncoder
- 第三个密码的密码编码器id为pbkdf2,编码密码为5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc。匹配时,它将委托给Pbkdf2PasswordEncoder
- 第四个密码的密码编码id为scrypt,编码密码为$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=。匹配时,它将委托给SCryptPasswordEncoder
- 最终密码的密码编码id为sha256,编码密码为97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfff8410849f27605abcbc0。匹配时,它将委托给StandardPasswordEncoder
一些用户可能会担心存储格式是为潜在的黑客提供的。这不是一个问题,因为密码的存储并不依赖于算法是秘密的。此外,对于攻击者来说,如果没有前缀,大多数格式都很容易识别。例如,BCrypt密码通常以$2a$开头。
密码编码
传入构造函数的idForEncode确定了将使用哪个PasswordEncoder对密码进行编码。在我们上面构造的DelegatingPasswordEncoder中,这意味着编码密码的结果将被委托给BCryptPasswordEncoder,并以{bcrypt}作为前缀。
最终结果如下:
DelegatingPasswordEncoder编码的例子
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
密码匹配
匹配是基于{id}和id到构造函数中提供的PasswordEncoder的映射来完成的。我们的密码存储格式示例提供了如何实现此功能的示例。默认情况下,调用带有密码和没有映射的id(包括null id)的匹配(CharSequence, String)的结果将导致IllegalArgumentException。可以使用DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder)自定义此行为。
通过使用id,我们可以匹配任何密码编码,但是使用最现代的密码编码编码密码。这一点很重要,因为与加密不同,密码哈希被设计成没有简单的方法来恢复明文。由于没有办法恢复明文,这使得迁移密码变得困难。虽然迁移NoOpPasswordEncoder对用户来说很简单,但我们选择在默认情况下包含它,以简化入门体验。
开始体验
如果您正在制作一个演示或示例,那么花时间对用户的密码进行哈希会有点麻烦。有一些方便的机制可以使这变得更容易,但这仍然不是用于生产的。
示例:withDefaultPasswordEncoder
User user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("user")
.build();
System.out.println(user.getPassword());
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
如果要创建多个用户,还可以重用构建器。
示例:withdefaultpasswordencoder重用构建器
UserBuilder users = User.withDefaultPasswordEncoder();
User user = users
.username("user")
.password("password")
.roles("USER")
.build();
User admin = users
.username("admin")
.password("password")
.roles("USER","ADMIN")
.build();
这将哈希存储的密码,但密码仍然暴露在内存和已编译的源代码中。
因此,对于生产环境来说,它仍然不安全。对于生产,您应该在外部散列您的密码。
用Spring Boot CLI编码
正确编码密码的最简单方法是使用Spring Boot CLI(https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-cli.html)。
例如,下面的代码将对password的密码进行编码,以便与DelegatingPasswordEncoder一起使用:
Spring Boot CLI 编码密码使用实例
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(https://docs.spring.io/spring-security/site/docs/5.0.x/api/org/springframework/security/crypto/factory/PasswordEncoderFactories.html)。
BCryptPasswordEncoder
BCryptPasswordEncoder实现使用广泛支持的bcrypt算法对密码进行哈希。为了使其更能抵抗密码破解,bcrypt故意放慢速度。与其他自适应单向函数一样,应该将其调整为在系统上花费大约1秒的时间验证密码。BCryptPasswordEncoder的默认实现使用强度10。建议您在自己的系统上调优和测试强度参数,以便验证密码大约需要1秒。
示例:BCryptPasswordEncoder
// Create an encoder with strength 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
Argon2PasswordEncoder
Argon2PasswordEncoder实现使用Argon2(https://en.wikipedia.org/wiki/Argon2)算法对密码进行哈希。Argon2是密码哈希竞赛的获胜者(https://en.wikipedia.org/wiki/Password_Hashing_Competition)。为了在自定义硬件上破解密码,Argon2是一个需要大量内存的缓慢算法。与其他自适应单向函数一样,应该将其调整为在系统上花费大约1秒的时间验证密码。Argon2PasswordEncoder的当前实现需要BouncyCastle。
示例:Argon2PasswordEncoder
// Create an encoder with all the defaults
Argon2PasswordEncoder encoder = new Argon2PasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
Pbkdf2PasswordEncoder
Pbkdf2PasswordEncoder实现使用PBKDF2(https://en.wikipedia.org/wiki/PBKDF2)算法对密码进行哈希。阻止密码破解的,PBKDF2是一种故意很慢的算法。与其他自适应单向函数一样,应该将其调整为在系统上花费大约1秒的时间验证密码。当需要FIPS认证时,这种算法是一个很好的选择。
示例:Pbkdf2PasswordEncoder
// Create an encoder with all the defaults
Pbkdf2PasswordEncoder encoder = new Pbkdf2PasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
SCryptPasswordEncoder
SCryptPasswordEncoder实现使用scrypt(https://en.wikipedia.org/wiki/Scrypt)算法对密码进行哈希。为了阻止在定制硬件scrypt上的密码破解,这是一个故意慢的算法,需要大量的内存。与其他自适应单向函数一样,应该将其调整为在系统上花费大约1秒的时间验证密码。
示例:SCryptPasswordEncoder
// Create an encoder with all the defaults
SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
其他PasswordEncoders
还有很多其他的PasswordEncoder实现完全是为了向后兼容而存在的。它们都已弃用,以表明它们不再被认为是安全的。然而,由于很难迁移现有的遗留系统,因此没有删除它们的计划。
密码存储配置
Spring Security默认使用DelegatingPasswordEncoder。但是,这可以通过将PasswordEncoder公开为Spring bean来进行定制。
如果您从Spring Security 4.2.x迁移。您可以通过公开NoOpPasswordEncoder bean恢复到以前的行为。
恢复到NoOpPasswordEncoder被认为是不安全的。相反,您应该迁移到使用DelegatingPasswordEncoder来支持安全的密码编码。
示例:NoOpPasswordEncoder
@Bean
public static NoOpPasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
XML配置要求NoOpPasswordEncoder bean名称为passwordEncoder。