BCryptPasswordEncoder是Spring Security中推荐的加密器,我很好奇的是它如何验证前端密码的正确性,下面来分析分析。
从无参构造方法调用说起…
public BCryptPasswordEncoder() {
this(-1); //strength,密码强度,越大强度越高,范围在[4,31]之间
}
public BCryptPasswordEncoder(int strength) {
//this(-1, null);
this(strength, null); //第二个参数random,随机数生成器实例
}
public BCryptPasswordEncoder(int strength, SecureRandom random) {
//this(BCryptVersion.$2A, -1, null);
this(BCryptVersion.$2A, strength, random); //第一个参数,加密器的版本
}
public BCryptPasswordEncoder(BCryptVersion version, int strength, SecureRandom random) {
if (strength != -1 && (strength < BCrypt.MIN_LOG_ROUNDS || strength > BCrypt.MAX_LOG_ROUNDS)) {
//strength不等于-1时,验证strength的范围必须在[4,31]之间
throw new IllegalArgumentException("Bad strength");
}
//this.version = BCryptVersion.$2A
this.version = version;
//this.strength = 10; //给个默认值
this.strength = strength == -1 ? 10 : strength;
//this.random = null;
this.random = random;
}
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//等价于
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(BCryptVersion.$2A, 10, null);
public String encode(CharSequence rawPassword)
这个方法是对明文的加密方法,rawPassword是明文密码,返回的是密文
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
String salt;
if (random != null) {
salt = BCrypt.gensalt(version.getVersion(), strength, random);
} else {
salt = BCrypt.gensalt(version.getVersion(), strength);
}
return BCrypt.hashpw(rawPassword.toString(), salt);
}
可以看到它通过BCrypt.gensalt()获取了一个盐值,然后调用BCrypt.hashpw()加密并返回结果。
由于random参数是null,获取盐的方法为: BCrypt.gensalt(version.getVersion(), strength)
来康康这个方法:
public static String gensalt(String prefix, int log_rounds)
throws IllegalArgumentException {
//return gensalt("$2a", 10, new SecureRandom());
return gensalt(prefix, log_rounds, new SecureRandom());
}
它采用了默认的随机数生成器SecureRandom来参与盐值的生成。
log_rounds也就是strength,代表密码的强度,由此可见密码强度体现在盐的生成里。
具体怎么生成的咱就不看了,总之这个方法会随机生成一个盐值,参与到后续的加密中。
让我们回到encode方法中接着看,hashpw(String password, String salt)将明文和盐加密,看看源码:
public static String hashpw(String password, String salt) {
byte passwordb[];
//转换为byte
passwordb = password.getBytes(StandardCharsets.UTF_8);
//加密并返回
return hashpw(passwordb, salt);
}
public static String hashpw(byte passwordb[], String salt) {
BCrypt B;
String real_salt;
byte saltb[], hashed[];
char minor = (char) 0;
int rounds, off;
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' && minor != 'x' && minor != 'y' && minor != 'b')
|| salt.charAt(3) != '$')
throw new IllegalArgumentException ("Invalid salt revision");
off = 4;
}
// Extract number of rounds
if (salt.charAt(off + 2) > '$')
throw new IllegalArgumentException ("Missing salt rounds");
if (off == 4 && saltLength < 29) {
throw new IllegalArgumentException("Invalid salt");
}
rounds = Integer.parseInt(salt.substring(off, off + 2));
real_salt = salt.substring(off + 3, off + 25);
saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);
if (minor >= 'a') // add null terminator
passwordb = Arrays.copyOf(passwordb, passwordb.length + 1);
B = new BCrypt();
hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 0x10000 : 0);
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();
}
可以看到real_salt才是最终参与加密的盐,其中的算法咱不用关注,总之是一个Hash函数,类似MD5
加密的步骤大概分为:
(明文+salt)-> (明文+real_salt【来自salt】) -> hash(明文+real_salt) -> 密文
public boolean matches(CharSequence rawPassword, String encodedPassword)
rawPassword是前端输入的明文密码,encodedPassword是数据库存放的经过加密的密文。
返回是否匹配成功。
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}
//密文样式校验,检查密文的样式是不是BCrypt加密的密文
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
//进入匹配流程
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
public static boolean checkpw(String plaintext, String hashed) {
//可以看到,最终对比的是hashed和hashpw(plaintext, hashed)
//hashpw前面加密方法中已经出现过了,即:hashpw(String password, String salt)
return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
}
//类似与String.equals方法,对比两个字符串是否相等
static boolean equalsNoEarlyReturn(String a, String b) {
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
从以上的源码可知,matches对比的到底是什么?
答案是:encodedPassword 和 hashpw(rawPassword, encodedPassword)
由于:encodedPassword = hashpw(rawPassword, salt)
所以:hashpw(rawPassword, salt) = hashpw(rawPassword, encodedPassword)
等式成立的条件是什么呢?
可想而知,salt和encodedPassword必然有某种联系
下面做个测试:
package com.vz.test;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* @author visy.wang
* @description: 测试BCrypt
* @date 2023/6/1 11:16
*/
public class TestBCrypt {
public static void main(String[] args) {
String pass = "123456"; //明文密码
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//等价于: passwordEncoder.encode(pass);
String salt = BCrypt.gensalt(BCryptPasswordEncoder.BCryptVersion.$2A.getVersion(), 10);
String password = BCrypt.hashpw(pass, salt);
//等价于:passwordEncoder.matches(pass, password);
String newPassword = BCrypt.hashpw(pass, password);
boolean isMatch = password.equals(newPassword);
System.out.println("明文: "+ pass);
System.out.println("盐值: "+ salt);
System.out.println("旧密文(明文+盐): "+ password);
System.out.println("新密文(明文+旧密文): "+ newPassword);
System.out.println("是否匹配: "+ isMatch);
}
}
打印结果:
明文: 123456
盐值: $2a$10$5DGZIjRc27knvCWFyRa5de
旧密文(明文+盐): $2a$10$5DGZIjRc27knvCWFyRa5de1bHI.6bNW1HyHhU9guEqRpoXavAGC3C
新密文(明文+旧密文): $2a$10$5DGZIjRc27knvCWFyRa5de1bHI.6bNW1HyHhU9guEqRpoXavAGC3C
是否匹配: true
可以看到密文中的前缀已经包含了盐值(salt):$2a$10$5DGZIjRc27knvCWFyRa5de
通过Debug可以看到,真实的盐值(real_salt)为:5DGZIjRc27knvCWFyRa5de, 很明显是从salt提取出来的
那么 $2a$10$ 是什么呢?很明显2a是版本号,10是密码强度,$是分隔符。
而真正的密文是: 1bHI.6bNW1HyHhU9guEqRpoXavAGC3C ,由hash(明文+real_salt)得出
所以,现在可以回答上面的问题,salt和encodedPassword的联系就是:encodedPassword包含了salt
这也是为什么使用BCryptPasswordEncoder时我们数据库不用单独保存盐的原因,因为密文本身就包含了盐值
搞清楚原理后,我们也可以自己用MD5等哈希函数实现一个类似的加密器了。
直接上代码:
package com.vz.utils;
import org.apache.shiro.crypto.SecureRandomNumberGenerator;
import org.apache.shiro.crypto.hash.Md5Hash;
import org.apache.shiro.crypto.hash.Sha256Hash;
/**
* @author visy.wang
* @description: 密码工具
* @date 2023/5/30 18:16
*/
public class PwdUtil {
private static final String SALT_PREFIX = "PS";
private static final SecureRandomNumberGenerator secureRandomNumberGenerator = new SecureRandomNumberGenerator();
/**
* 随机获取一个盐值
* @return 盐值
*/
public static String getSalt(){
return secureRandomNumberGenerator.nextBytes().toHex();
}
/**
* 密码加密(自动加盐)
* @param rawPassword 原密码(明文)
* @return 密文
*/
public static String encode(String rawPassword){
return encode(rawPassword, 10); //默认强度:10
}
/**
* 密码加密(自动加盐)
* @param rawPassword 原密码(明文)
* @param strength 密码强度
* @return 密文
*/
public static String encode(String rawPassword, int strength){
if(strength<4 || strength>31){
throw new RuntimeException("密码强度参数范围:[4,31]");
}
String realSalt = getSalt();
int saltLen = realSalt.length();
if(saltLen<16){
throw new RuntimeException("盐值不低于16位");
}
String saltLenStr = (saltLen>9?"":"0") + saltLen;
String strengthStr = (strength>9?"":"0") + strength;
String salt = SALT_PREFIX + strengthStr + saltLenStr + realSalt;
return hashpwd(rawPassword, salt);
}
private static String hashpwd(String rawPassword, String salt){
String strengthStr = salt.substring(SALT_PREFIX.length(), SALT_PREFIX.length()+2);
String saltLenStr = salt.substring(SALT_PREFIX.length()+2, SALT_PREFIX.length()+4);
int strength = Integer.parseInt(strengthStr), saltLen = Integer.parseInt(saltLenStr);
String realSalt = salt.substring(SALT_PREFIX.length()+4, SALT_PREFIX.length()+4+saltLen);
/*System.out.println("salt: " + salt);
System.out.println("strength: " + strength);
System.out.println("realSalt: " + realSalt);*/
String realPass = new Sha256Hash(new Md5Hash(rawPassword), realSalt, strength).toHex();
return salt.substring(0, SALT_PREFIX.length()+4+saltLen) + realPass;
}
/**
* 密码对比,结合encrypt(String rawPassword, int strength)使用
* @param rawPassword 明文
* @param encodedPassword 密文
* @return 是否匹配
*/
public static boolean matches(String rawPassword, String encodedPassword){
return encodedPassword.equals(hashpwd(rawPassword, encodedPassword));
}
public static void main(String[] args) {
String pass = "123456";
String pass1 = encode(pass);
String pass2 = hashpwd(pass, pass1);
boolean matches = matches(pass, pass1);
System.out.println("pass: "+pass);
System.out.println("pass1: "+pass1);
System.out.println("pass2: "+pass2);
System.out.println("matches: "+matches);
}
}
打印结果:
pass: 123456
pass1: PS10321ab677ad4dfe0d9b61fb205211b96d6c1330c5e90383548059b20fcd1331af45b210232d86a5b23166008ec9c341664a
pass2: PS10321ab677ad4dfe0d9b61fb205211b96d6c1330c5e90383548059b20fcd1331af45b210232d86a5b23166008ec9c341664a
matches: true