BCryptPasswordEncoder密码校验原理解析

一、简介

BCryptPasswordEncoder是Spring Security中推荐的加密器,我很好奇的是它如何验证前端密码的正确性,下面来分析分析。

二、源码分析

1.构造方法

从无参构造方法调用说起…

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);
2.核心方法之加密

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) -> 密文

3.核心方法之密码校验

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对比的到底是什么?
答案是:encodedPasswordhashpw(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

你可能感兴趣的:(java,spring,后端)