Spring Security密码加密

  • Post author:
  • Post category:其他


本文内容来自王松老师的《深入浅出Spring Security》,自己在学习的时候为了加深理解顺手抄录的,有时候还会写一些自己的想法。

在前面的学习中,凡是涉及到密码的地方我们都采用明文加密存储,实际项目中肯定是不可取的,因为这会带来极高的安全风险。在企业级应用中,密码不仅需要加密,还需要加盐,最大程度上保证密码的安全。学完这一篇之后,大家就会明白前面我们一直使用的{noop}是什么意思了。

密码为什么要加密

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

密码加密方案进化史

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

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

密码加了盐之后彩虹表就大打折扣了,因为盐和明文密码总会生成唯一的Hash字符。然而,随着计算机每秒数十亿次的Hash计算已经变的很轻松,这就意味着即使给密码加盐也不再安全。

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

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

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接口中一共三个方法:

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

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

PasswordEncoder常见实现类

  1. BCryptPasswordEncoder:BCryptPasswordEncoder 使用bcrypt算大对密码进行加密,为了提高密码的安全性,bcrypt算法故意降低了运算速度,以增强破解密码的难度。同时BCryptPasswordEncoder “为自己带盐”,开发者不需要额外维护一个“盐”字段,使用BCryptPasswordEncoder 加密后的字符串就已经“带盐”了,实际相同的明文每次加密生成的密文字符串都不相同。BCryptPasswordEncoder 默认的强度为10,开发者可以根据自己服务器的性能进行调整,以确保验证密码时间约为1秒钟(官方建密码验证时间为1秒钟,这样既可以提高系统的安全性,又不会过多的影响系统的运行性能)。

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

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

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

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

DelegatingPasswordEncoder

除了上面学习的几种加密方式,还有一个非常重要的加密工具类,那就是:DelegatingPasswordEncoder。

从名字上看DelegatingPasswordEncoder是一个代理类,而并非一种全新的密码加密方案。DelegatingPasswordEncoder主要是用来代理前面学习的密码加密方案的。为什么采用DelegatingPasswordEncoder而不是具体某一种加密方式作为默认的加密方案呢?主要是考虑了如下因素:

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

那么DelegatingPasswordEncoder到底是如何代理其他密码加密方案的呢?我们就从PasswordEncoderFactories来看起,应为正式由它的静态方法createDelegatingPasswordEncoder提供了默认的DelegatingPasswordEncoder实例:

public final class PasswordEncoderFactories {
    private PasswordEncoderFactories() {
    }

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

这里的代码非常明确,声明了一个Map,用来装各种加密方案的实例和它的id。例如BCryptPasswordEncoder示例对应的id为bcrypt。创建好encoders这个Map对象后,最终会创建一个DelegatingPasswordEncoder的实例,将encoders这个装着各种加密方案实例的Map传给DelegatingPasswordEncoder,并且DelegatingPasswordEncoder的默认加密方案为:BCryptPasswordEncoder。

我们来学习下DelegatingPasswordEncoder的源码,由于DelegatingPasswordEncoder的源码比较长,我们先来看看他的属性:

public class DelegatingPasswordEncoder implements PasswordEncoder {
    private static final String DEFAULT_ID_PREFIX = "{";
    private static final String DEFAULT_ID_SUFFIX = "}";
    private final String idPrefix;
    private final String idSuffix;
    private final String idForEncode;
    private final PasswordEncoder passwordEncoderForEncode;
    private final Map<String, PasswordEncoder> idToPasswordEncoder;
    private PasswordEncoder defaultPasswordEncoderForMatches;
}
  • 首先定义了前缀PREFIX和SUFFIX,用来包裹将来生成的加密方案的id
  • idForEncode表示默认的加密方案的id
  • passwordEncoderForEncode表示系统默认的加密方案(BCryptPasswordEncoder),它的值是根据idForEncode从idToPasswordEncoder集合中提取出来的
  • idToPasswordEncoder用来保存id和加密方案之间的映射

  • defaultPasswordEncoderForMatches


    是指默认的密码比对器,

    当根据密码加密方案的


    id


    无法找到对应的加密方案时,就会使用默认的

    密码比对器。


    defaultPasswordEncoderForMatches


    的默认类型是


    UnmappedIdPasswordEncoder


    ,在


    UnmappedIdPasswordEncoder





    matches

    方法中并不会做任何密码比对操作,直接抛出异常

  • 最后我们看到

    DelegatingPasswordEncoder也是PasswordEncoder接口的实现类。所以我们重点来看看DelegatingPasswordEncoder的encode方法和matches方法

首先来看encode方法:

  public String encode(CharSequence rawPassword) {
        return this.idPrefix + this.idForEncode + this.idSuffix + this.passwordEncoderForEncode.encode(rawPassword);
    }

这里比较简单,具体的加密工作还是又加密类来完成,只不过在加密完成之后,在给密文前面加上一个前缀+id+后缀,形如:{id}。用来描述具体的加密方案,因此,encode方法加密出来的字符串格式形如:

{bcrypt}$2a$10$cfuvD57gmX6lWE2W1ztUcev7TwngI90N8hMFxpPI6.sIPlWzr6bJ.
{noop}123

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

我们再来看看matches方法:

 public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
        if (rawPassword == null && prefixEncodedPassword == null) {
            return true;
        } else {
            String id = this.extractId(prefixEncodedPassword);
            PasswordEncoder delegate = (PasswordEncoder)this.idToPasswordEncoder.get(id);
            if (delegate == null) {
                return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
            } else {
                String encodedPassword = this.extractEncodedPassword(prefixEncodedPassword);
                return delegate.matches(rawPassword, encodedPassword);
            }
        }
    }

matches方法也是比较简单的,首次用extractId方法从加密的字符串里面提取出加密方案的id,也就是{}中间包裹的加密方案的id。拿到id之后从idToPasswordEncoder集合中获取到加密方案的实例,如果没有获取到就调用默认defaultPasswordEncoderForMatches的matches方法,如果获取到对应的实例,则调用其matches方法完成密码校验。

到此,Spring Security中的大部分加密体系已经学习的差不多了。下一篇文章我们来学习下PasswordEncoder如何使用。



版权声明:本文为qq_27062249原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。