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 接口中都定义好了,不同的实现类将采用不同的密码加密方案对密码进行处理。

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、Md4PasswordEncoder、StandardPasswordEncoder 以及 NoOpPasswordEncoder(密码明文存储),这五种皆已废弃,这里对这些类也不做过多介绍。

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

DelegatingPasswordEncoder

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

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

  1. 兼容性:使用 DelegatingPasswordEncoder 可以帮助许多使用旧密码加密方式的系统顺利迁移到 Spring Security 中,它允许在同一个系统中同时存在多种不同的密码加密方案。

  2. 便捷性:密码存储的最佳方案不可能一直不变,如果使用 DelegatingPasswordEncoder 作为默认的密码加密方案,当需要修改加密方案时,只需要修改很小一部分代码就可以实现。

  3. 稳定性:作为一个框架,Spring Security 不能经常进行重大更改,而使用 DelegatingPasswordEncoder 可以方便地对密码进行升级(自动从一个加密方案升级到另外一个加密方案)。

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

public class PasswordEncoderFactories {

	@SuppressWarnings("deprecation")
	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 对应着 BcryptPasswordEncoder、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}$2as10$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);
    }
}

可以看到,如果当前加密字符串所采用的加密方案不是默认的加密方案(BcryptPasswordEncoder),就会自动进行密码升级,否则就调用默认加密方案的 upgradeEncoding 方法判断密码是否需要升级。

至此,我们将 Spring Security 中的整个加密体系向读者简单介绍了一遍,接下来我们通过几个实际的案例来看一下加密方案要怎么用。