Spring Security PasswordEncoder 密码校验和密码加密流程
- 本文使用的源码是 Spring Security 5.1.2
http://central.maven.org/maven2/org/springframework/security/spring-security-core/5.1.2.RELEASE/spring-security-core-5.1.2.RELEASE-sources.jar
PasswordEncoder 使用
首先我们先来看看一个创建密码编码器工厂方法
org/springframework/security/crypto/factory/PasswordEncoderFactories.java
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map 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());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
上述代码 encoders 的 Map 包含了很多种密码编码器,有 ldap 、MD4 、 MD5 、noop 、pbkdf2 、scrypt 、SHA-1 、SHA-256
上面静态工厂方法可以看出,默认是创建并返回一个 BCryptPasswordEncoder,同时该 BCryptPasswordEncoder( PasswordEncoder 子类)也是 Spring Security 推荐的默认密码编码器,其中 noop 就是不做处理默认保存原密码。
一般我们代码中 @Autowired 注入并使用 PasswordEncoder 接口的实例,然后调用其 matches 方法去匹配原密码和数据库中保存的“密码”;密码的校验方式有多种,从 PasswordEncoder 接口实现的类是可以知道。
业务代码中注入 PasswordEncoder
@Autowired
private PasswordEncoder passwordEncoder;
科普一些基本知识
BCrypt 密码散列函数的概念介绍可以看一下维基百科或百度百科的内容 (因为只是使用,暂时不需要对算法的了解和实现)
维基百科:BCrypt
百度百科:BCrypt
知识混淆点
加密/解密 与 Hash 这两个概念不能混淆,比如:SHA 系列是 Hash 算法,不是加密算法,加密意味着可以解密,但是 Hash 是不可逆的(无法通过 Hash 值还原得到密码,只能比对 Hash 值看看是否相等)。
安全性问题
目前很大一部分存在安全问题的系统一般仅仅使用密码的 MD5 值进行保存,可以通过 MD5 查询库去匹配对大部分的密码(可以直接从彩虹表里反推出来),而且 MD5 计算 Hash 值碰撞容易构造,安全性大大降低。MD5 加盐在本地计算速度也是很快,也是密码短也是极其容易破解;更好的选择是 SHA-256、BCrypt 等等等
密码匹配流程的源码解释
本文简单说一下 BCryptPasswordEncoder 密码匹配的一个简单流程或者过程。
重点
如果是使用 BCryptPasswordEncoder 调用 encode() 方法编码输入密码的话,其实这个编码后的“密码”并不是我们平时输入的真正密码,而是密码加盐后的通过单向 Hash 算法(BCrypt)得到值。
这里面细心的同学可能会发现一些问题:
同一个密码计算 Hash 不应该是一样的吗?每次使用 BCryptPasswordEncoder 编码同一个密码都是不一样的?
BCryptPasswordEncoder 编码同一个密码后结果都不一样,怎么进行匹配?
下面通过源码简单说一下这个匹配的流程:
matches(CharSequence rawPassword, String encodedPassword)
方法根据两个参数都可以知道
- 第一个参数是原密码
- 第二个参数就是用
PasswordEncoder
调用encode(CharSequence rawPassword)
编码过后保存在数据库的密码。
org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.java
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
上述代码解读:首先判断是否数据库保存的“密码”(后面简称:“密码”)是否为空或者 null
,在通过正则表达式匹配“密码”是否符合格式,最后通过 BCrypt
的 checkpw(String plaintext, String hashed)
方法进行密码匹配
再详细看看 BCrypt
的 checkpw(String plaintext, String hashed)
方法:
org/springframework/security/crypto/bcrypt/BCrypt.java
public static boolean checkpw(String plaintext, String hashed) {
return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
}
第二个参数 hashed
表明其实数据库查询出来的“密码”也就是 Hash 值;equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed))
代码中通过调用 hashpw
计算输入密码的 Hash 值(参数分别是输入的密码和保存在数据库的“密码”)
再继续看 hashpw
里面的部分代码(内容过长,省略部分代码,看看代码中的中文注释):
org/springframework/security/crypto/bcrypt/BCrypt.java
public static String hashpw(String password, String salt) throws IllegalArgumentException {
BCrypt B;
String real_salt;
byte passwordb[], saltb[], hashed[];
char minor = (char) 0;
int rounds, off = 0;
StringBuilder rs = new StringBuilder();
if (salt == null) {
throw new IllegalArgumentException("salt cannot be null");
}
int saltLength = salt.length();
if (saltLength < 28) {
throw new IllegalArgumentException("Invalid salt");
}
if (salt.charAt(0) != '$' || salt.charAt(1) != '2') {
throw new IllegalArgumentException("Invalid salt version");
}
if (salt.charAt(2) == '$') {
off = 3;
}
else {
minor = salt.charAt(2);
if (minor != 'a' || salt.charAt(3) != '$') {
throw new IllegalArgumentException("Invalid salt revision");
}
off = 4;
}
if (saltLength - off < 25) {
throw new IllegalArgumentException("Invalid salt");
}
// Extract number of rounds
if (salt.charAt(off + 2) > '$') {
throw new IllegalArgumentException("Missing salt rounds");
}
rounds = Integer.parseInt(salt.substring(off, off + 2));
// 关键点:上面***一大堆就是校验是否符合相应格式,然后下面这行就是取出密码的盐,real_salt就是 Hash 计算前的密码盐(关于盐的介绍:https://zh.wikipedia.org/wiki/%E7%9B%90_(%E5%AF%86%E7%A0%81%E5%AD%A6))
real_salt = salt.substring(off + 3, off + 25);
try {
passwordb = (password + (minor >= 'a' ? "\000" : "")).getBytes("UTF-8");
}
catch (UnsupportedEncodingException uee) {
throw new AssertionError("UTF-8 is not supported");
}
saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);
B = new BCrypt();
hashed = B.crypt_raw(passwordb, saltb, rounds);
rs.append("$2");
if (minor >= 'a') {
rs.append(minor);
}
rs.append("$");
if (rounds < 10) {
rs.append("0");
}
rs.append(rounds);
rs.append("$");
encode_base64(saltb, saltb.length, rs);
encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
return rs.toString();
}
其实上面代码就是从数据库得到的“密码”(参数: salt )进行一系列校验(长度校验等)并截取“密码”中相应的密码盐,利用这个密码盐进行同样的一系列计算 Hash 操作和 Base64 编码拼接一些标识符 生成所谓的“密码”,最后 equalsNoEarlyReturn
方法对同一个密码盐生成的两个“密码”进行匹配。
上述大致就是密码匹配流程了,对于问题“ BCryptPasswordEncoder 编码同一个密码后结果都不一样,怎么进行匹配”
的简单解答:
因为密码盐是随机生成的,但是可以根据数据库查询出来的“密码”拿到密码盐,同一个密码盐+原密码计算 Hash 结果值是能匹配的。
密码“加密”保存源码解释
看看加密的一个过程,
org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.java
public String encode(CharSequence rawPassword) {
String salt;
if (strength > 0) {
if (random != null) {
// 生成随机密码盐
salt = BCrypt.gensalt(strength, random);
}
else {
// 生成随机密码盐
salt = BCrypt.gensalt(strength);
}
}
else {
// 生成随机密码盐
salt = BCrypt.gensalt();
}
return BCrypt.hashpw(rawPassword.toString(), salt);
}
encode 方法传入是原密码,其中 int strength, SecureRandom random
这两个构造参数是 BCryptPasswordEncoder(int strength, SecureRandom random)
构造方法按需传入,如果不指定strength和random,默认执行 BCrypt.gensalt()
这行代码生成也相应密码随机盐。
先看看 gensalt(int log_rounds, SecureRandom random)
方法的代码(可以看看中文注释):
org/springframework/security/crypto/bcrypt/BCrypt.java
public static String gensalt(int log_rounds, SecureRandom random) {
// 一些检验
if (log_rounds < MIN_LOG_ROUNDS || log_rounds > MAX_LOG_ROUNDS) {
throw new IllegalArgumentException("Bad number of rounds");
}
StringBuilder rs = new StringBuilder();
byte rnd[] = new byte[BCRYPT_SALT_LEN];
// 生成随机字节并将其置于rnd字节数组
random.nextBytes(rnd);
rs.append("$2a$");
if (log_rounds < 10) {
// 不够长度补够
rs.append("0");
}
// 拼接字符串得到相应的格式
rs.append(log_rounds);
rs.append("$");
encode_base64(rnd, rnd.length, rs);
return rs.toString();
}
最终上面的 gensalt
方法得到一个 随机密码盐+无用字符串(这个字符串可以理解为你输入的密码) 计算 Hash 操作和 Base64 编码拼接一些标识符 生成假“密码”(这个假“密码”为了兼容方便调用 hashpw
方法),最后关键点就是调用 BCrypt.hashpw
方法取到密码盐生成相应的真实“密码”(这个得到的密码可以用于保存在数据库中了)。
对于问题“同一个密码计算 Hash 不应该是一样的吗?每次使用 BCryptPasswordEncoder 编码同一个密码都是不一样的?”
的简单解答:
因为用到的随机密码盐每次都是不一样的,同一个密码和不同的密码盐组合计算出来的 Hash 值肯定不一样啦,所以编码同一个密码得到的结果都是不一样。
建议和想法
本文主要讲解一些安全性防护的思想,学习的过程思想很重要。
登录注册是每个系统都具备的功能,开发的同学记住一定不能保存明文密码,否则被脱库就会造成严重的后果。如果是通过上述的方法进行密码保存,即便拿到“密码”也非常难还原密码。
上述在密码编码的过程中的思想还是需要掌握:
只是保存散列码是不安全的,但是我们可以为密码加盐再通过一些 Hash 值 低概率碰撞且计算速度慢 的散列算法计算 Hash 值保存。
Spring Security 每次 Hash 之前用的盐都是随机,盐可以保存在最终生成的“密码”中,这样每个密码都是用了相应不同的随机盐+原密码计算 Hash 值得到,暴力破解难度也变大了。