附彩蛋|Spring Security 竟然故意延长登录时间?知道真相的我惊呆了!

2011年12月21日,有人在网络上公开了一个包含600万个CSDN用户资料的数据库,数据全部为明文储存,包含用户名、密码以及注册邮箱。事件发生后CSDN在微博、官方网站等渠道发出了声明,解释说此数据库系2009年备份所用,因不明原因泄漏,已经向警方报案,后又在官网发出了公开道歉信。在接下来的十多天里,金山、网易、京东、当当、新浪等多家公司被卷入到这次事件中。整个事件中最触目惊心的莫过于CSDN把用户密码明文存储,由于很多用户是多个网站共用一个密码,因此一个网站密码泄漏就会造成很大的安全隐患。由于有了这么多前车之鉴,我们现在做系统时,密码都要加密处理。

1.密码加密方案进化史

最早我们使用类似SHA-256这样的单向Hash算法。用户注册成功后,保存在数据库中的不再是用户的明文密码,而是经过SHA-256加密计算的一个字符串,当用户进行登录时,将用户输入的明文密码用SHA-256进行加密,加密完成之后,再和存储在数据库中的密码进行比对,进而确定用户登录信息是否有效。如果系统遭遇攻击,最多也只是存储在数据库中的密文被泄漏。

这样就绝对安全了吗?当然不是的。彩虹表是一个用于加密Hash函数逆运算的表,通常用于破解加密过的Hash字符串。为了降低彩虹表对系统安全性的影响,人们又发明了密码加“盐”,之前是直接将密码作为明文进行加密,现在再添加一个随机数(即盐)和密码明文混合在一起进行加密,这样即使密码明文相同,生成的加密字符串也是不同的。当然,这个随机数也需要以明文形式和密码一起存储在数据库中。当用户需要登录时,拿到用户输入的明文密码和存储在数据库中的盐一起进行Hash运算,再将运算结果和存储在数据库中的密文进行比较,进而确定用户的登录信息是否有效。

密码加盐之后,彩虹表的作用就大打折扣了,因为唯一的盐和明文密码总会生成唯一的Hash字符。

然而,随着计算机硬件的发展,每秒执行数十亿次Hash计算已经变得轻轻松松,这意味着即使给密码加密加盐也不再安全。

在Spring Security中,我们现在是用一种自适应单向函数(Adaptive One-way Functions)来处理密码问题,这种自适应单向函数在进行密码匹配时,会有意占用大量系统资源(例如CPU、内存等),这样可以增加恶意用户攻击系统的难度。在Spring Security中,开发者可以通过bcrypt、PBKDF2、scrypt以及argon2来体验这种自适应单向函数加密。

由于自适应单向函数有意占用大量系统资源,因此每个登录认证请求都会大大降低应用程序的性能,但是Spring Security不会采取任何措施来提高密码验证速度,因为它正是通过这种方式来增强系统的安全性。当然,开发者也可以将用户名/密码这种长期凭证兑换为短期凭证,如会话、OAuth2令牌等,这样既可以快速验证用户凭证信息,又不会损失系统的安全性。

2.PasswordEncoder详解

Spring Security中通过PasswordEncoder接口定义了密码加密和比对的相关操作:

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);
    boolean matches(CharSequence rawPassword, String encodedPassword);
    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

可以看到,PasswordEncoder接口中一共有三个方法:

  1. encode:该方法用来对明文密码进行加密。
  2. matches:该方法用来进行密码比对。
  3. upgradeEncoding:该方法用来判断当前密码是否需要升级,默认返回false表示不需要升级。

针对密码的所有操作,PasswordEncoder接口中都定义好了,不同的实现类将采用不同的密码加密方案对密码进行处理。

2.1 PasswordEncoder常见实现类

BCryptPasswordEncoder

BCryptPasswordEncoder使用bcrypt算法对密码进行加密,为了提高密码的安全性,bcrypt算法故意降低运行速度,以增强密码破解的难度。同时BCryptPasswordEncoder “为自己带盐”,开发者不需要额外维护一个“盐”字段,使用BCryptPasswordEncoder加密后的字符串就已经“带盐”了,即使相同的明文每次生成的加密字符串都不相同。

BCryptPasswordEncoder的默认强度为10,开发者可以根据自己的服务器性能进行调整,以确保密码验证时间约为1秒钟(官方建议密码验证时间为1秒钟,这样既可以提高系统安全性,又不会过多影响系统运行性能)。

Argon2PasswordEncoder

Argon2PasswordEncoder使用Argon2算法对密码进行加密,Argon2曾在Password Hashing Competition竞赛中获胜。为了解决在定制硬件上密码容易被破解的问题,Argon2也是故意降低运算速度,同时需要大量内存,以确保系统的安全性。

Pbkdf2PasswordEncoder

Pbkdf2PasswordEncoder使用PBKDF2算法对密码进行加密,和前面几种类似,PBKDF2算法也是一种故意降低运算速度的算法,当需要FIPS(Federal Information Processing Standard,美国联邦信息处理标准)认证时,PBKDF2算法是一个很好的选择。

SCryptPasswordEncoder

SCryptPasswordEncoder使用scrypt算法对密码进行加密,和前面的几种类似,scrypt也是一种故意降低运算速度的算法,而且需要大量内存。

这四种就是我们前面所说的自适应单向函数加密。除了这几种,还有一些基于消息摘要算法的加密方案,这些方案都已经不再安全,但是出于兼容性考虑,Spring Security并未移除相关类,主要有LdapShaPasswordEncoder、MessageDigestPasswordEncoder、Md4Password Encoder、StandardPasswordEncoder以及NoOpPasswordEncoder(密码明文存储),这五种皆已废弃,这里对这些类也不做过多介绍。

除了上面介绍的这几种之外,还有一个非常重要的密码加密工具类,那就是DelegatingPasswordEncoder。

2.2 DelegatingPasswordEncoder

根据前文的介绍,读者可能会认为Spring Security中默认的密码加密方案应该是四种自适应单向加密函数中的一种,其实不然,在Spring Security 5.0之后,默认的密码加密方案其实是DelegatingPasswordEncoder。

从名字上来看,DelegatingPasswordEncoder是一个代理类,而并非一种全新的密码加密方案。

DelegatingPasswordEncoder主要用来代理上面介绍的不同的密码加密方案。为什么采用DelegatingPasswordEncoder而不是某一个具体加密方式作为默认的密码加密方案呢?主要考虑了如下三方面的因素:

  1. 兼容性:使用DelegatingPasswordEncoder可以帮助许多使用旧密码加密方式的系统顺利迁移到Spring Security中,它允许在同一个系统中同时存在多种不同的密码加密方案。
  2. 便捷性:密码存储的最佳方案不可能一直不变,如果使用DelegatingPasswordEncoder作为默认的密码加密方案,当需要修改加密方案时,只需要修改很小一部分代码就可以实现。
  3. 稳定性:作为一个框架,Spring Security不能经常进行重大更改,而使用Delegating PasswordEncoder可以方便地对密码进行升级(自动从一个加密方案升级到另外一个加密方案)。

那么DelegatingPasswordEncoder到底是如何代理其他密码加密方案的?又是如何对加密方案进行升级的?我们就从PasswordEncoderFactories类开始看起,因为正是由它里边的静态方法createDelegatingPasswordEncoder提供了默认的DelegatingPasswordEncoder实例:

public class PasswordEncoderFactories {
    public static PasswordEncoder createDelegatingPasswordEncoder() {
        String encodingId = "bcrypt";
        Map<String, PasswordEncoder> encoders = new HashMap<>();
        encoders.put(encodingId, new BCryptPasswordEncoder());
        encoders.put("ldap"new org.springframework.security.crypto
                                           .password.LdapShaPasswordEncoder());
        encoders.put("MD4"new org.springframework.security.crypto
                                                .password.Md4PasswordEncoder());
        encoders.put("MD5"new org.springframework.security.crypto
                              .password.MessageDigestPasswordEncoder("MD5"));
        encoders.put("noop", org.springframework.security.crypto.password
                                           .NoOpPasswordEncoder.getInstance());
        encoders.put("pbkdf2"new Pbkdf2PasswordEncoder());
        encoders.put("scrypt"new SCryptPasswordEncoder());
        encoders.put("SHA-1"new org.springframework.security.crypto
                            .password.MessageDigestPasswordEncoder("SHA-1"));
        encoders.put("SHA-256"new org.springframework.security.crypto
                         .password.MessageDigestPasswordEncoder("SHA-256"));
        encoders.put("sha256"new org.springframework.security.crypto
                                          .password.StandardPasswordEncoder());
        encoders.put("argon2"new Argon2PasswordEncoder());
        return new DelegatingPasswordEncoder(encodingId, encoders);
    }
    private PasswordEncoderFactories() {}
}

可以看到,在createDelegatingPasswordEncoder方法中,首先定义了encoders变量,encoders中存储了每一种密码加密方案的id和所对应的加密类,例如bcrypt对应着BcryptPassword Encoder、argon2对应着Argon2PasswordEncoder、noop对应着NoOpPasswordEncoder。

encoders创建完成后,最终新建一个DelegatingPasswordEncoder实例,并传入encodingId和encoders变量,其中encodingId默认值为bcrypt,相当于代理类中默认使用的加密方案是BCryptPasswordEncoder。

我们来分析一下DelegatingPasswordEncoder类的源码,由于源码比较长,我们就先从它的属性开始看起:

public class DelegatingPasswordEncoder implements PasswordEncoder {
    private static final String PREFIX = "{";
    private static final String SUFFIX = "}";
    private final String idForEncode;
    private final PasswordEncoder passwordEncoderForEncode;
    private final Map<String, PasswordEncoder> idToPasswordEncoder;
    private PasswordEncoder defaultPasswordEncoderForMatches = 
                                                new UnmappedIdPasswordEncoder();
}
  1. 首先定义了前缀PREFIX和后缀SUFFIX,用来包裹将来生成的加密方案的id。
  2. idForEncode表示默认的加密方案id。
  3. passwordEncoderForEncode表示默认的加密方案(BCryptPasswordEncoder),它的值是根据idForEncode从idToPasswordEncoder集合中提取出来的。
  4. idToPasswordEncoder用来保存id和加密方案之间的映射。
  5. defaultPasswordEncoderForMatches是指默认的密码比对器,当根据密码加密方案的id无法找到对应的加密方案时,就会使用默认的密码比对器。defaultPasswordEncoderForMatches的默认类型是UnmappedIdPasswordEncoder,在UnmappedIdPasswordEncoder的matches方法中并不会做任何密码比对操作,直接抛出异常。
  6. 最后看到的DelegatingPasswordEncoder也是PasswordEncoder接口的子类,所以接下来我们就来重点分析PasswordEncoder接口中三个方法在DelegatingPasswordEncoder中的具体实现。首先来看encode方法:
@Override
public String encode(CharSequence rawPassword) {
    return PREFIX + this.idForEncode + SUFFIX 
                         + this.passwordEncoderForEncode.encode(rawPassword);
}

encode方法的实现逻辑很简单,具体的加密工作还是由加密类来完成,只不过在密码加密完成后,给加密后的字符串加上一个前缀{id},用来描述所采用的具体加密方案。因此,encode方法加密出来的字符串格式类似如下形式:

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{noop}123
{pbkdf2}23b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4

不同的前缀代表了后面的字符串采用了不同的加密方案。

再来看密码比对方法matches:

@Override
public boolean matches(CharSequence rawPassword, 
                            String prefixEncodedPassword)
 
{
    if (rawPassword == null && prefixEncodedPassword == null) {
        return true;
    }
    String id = extractId(prefixEncodedPassword);
    PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
    if (delegate == null) {
        return this.defaultPasswordEncoderForMatches
            .matches(rawPassword, prefixEncodedPassword);
    }
    String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
    return delegate.matches(rawPassword, encodedPassword);
}
private String extractId(String prefixEncodedPassword) {
    if (prefixEncodedPassword == null) {
        return null;
    }
    int start = prefixEncodedPassword.indexOf(PREFIX);
    if (start != 0) {
        return null;
    }
    int end = prefixEncodedPassword.indexOf(SUFFIX, start);
    if (end < 0) {
        return null;
    }
    return prefixEncodedPassword.substring(start + 1, end);
}

在matches方法中,首先调用extractId方法从加密字符串中提取出具体的加密方案id,也就是{}中的字符,具体的提取方式就是字符串截取。拿到id之后,再去idToPasswordEncoder集合中获取对应的加密方案,如果获取到的为null,说明不存在对应的加密实例,那么就会采用默认的密码匹配器defaultPasswordEncoderForMatches;如果根据id获取到了对应的加密实例,则调用其matches方法完成密码校验。

可以看到,这里的matches方法非常灵活,可以根据加密字符串的前缀,去查找到不同的加密方案,进而完成密码校验。同一个系统中,加密字符串可以使用不同的前缀而互不影响。

最后,我们再来看一下DelegatingPasswordEncoder中的密码升级方法upgradeEncoding:

@Override
public boolean upgradeEncoding(String prefixEncodedPassword) {
    String id = extractId(prefixEncodedPassword);
    if (!this.idForEncode.equalsIgnoreCase(id)) {
        return true;
    }
    else {
        String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
        return this.idToPasswordEncoder.get(id)
                                              .upgradeEncoding(encodedPassword);
    }
}

可以看到,如果当前加密字符串所采用的加密方案不是默认的加密方案(BcryptPassword Encoder),就会自动进行密码升级,否则就调用默认加密方案的upgradeEncoding方法判断密码是否需要升级。至此,我们将Spring Security中的整个加密体系向读者简单介绍了一遍,接下来我们通过几个实际的案例来看一下加密方案要怎么用。


以上内容节选自松哥的新书《深入浅出 Spring Security》,他和磊哥是老乡,也是认识很久的朋友了,同时他也是《Spring Boot+Vue全栈开发实战》一书的作者,非常低调和务实的技术大佬 ,最后推荐一波他的新书,非常值得一读。

彩蛋

为了感谢各位读者朋友的长期支持,此评论区下留言,磊哥送 5 本松哥的新书《深入浅出 Spring Security》,需要的小伙伴赶紧留言吧,当然,脸熟和经常留言的朋友中奖机率更大。

本文分享自微信公众号 - Java中文社群(javacn666)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章