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
  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
适用人群所有的IT从业者,尤其适合快速掌握新技术,快速增长工作经验人群,对教育公平,教育公益,教育爱心公益人士课程概述课程概述该互联网实战项目是基于腾讯开源Tdesign产品框架,前后端分离,开发项目实战,SpringBoot+SpringSecurity+Mybatisplus+MySQL+Knife4j中后台项目产品实战,包括图形展示、权限管理、用户管理等功能。【后端Spring Boot2 框架 开发的一站式解决方案Spring Security5 认证和授权框架MyBatisPlus3.3.1 基于 MyBatis 框架的快速研发框架MyBatisCode工具生成 MyBatis 相关代码Jackson提供了处理 JSON 数据的工具Lombok简化对象封装工具 Druid   数据库连接池 【前端技术】Vue       互联网最火的前端框架Vue Router路由框架Vuex全局状态管理框架Axios前端 HTTP 框架TDesign 前端模板TDesign 是腾讯各业务团队在服务业务过程中沉淀的一套企业级设计体系。TDesign 提供了开箱即用的 UI 组件库、设计指南 和相关 设计资产,以优雅高效的方式将设计和研发从重复劳动中解放出来,同时方便大家在 TDesign 的基础上扩展,更好的的贴近业务需求。在腾讯内部以开源协同的方式,共建一个完善、易用的设计体系和组件库产品。【开发工具】IntelliJ IDEA开发 IDESQLyog数据库连接客户端PostmanHTTP 请求工具【开发环境】工具版本JDK1.8MySQL5.7 
要在JS中设置密码强度校验,可以使用以下步骤: 1. 创建一个密码校验函数,可以参考引用中的示例代码。该函数接收一个密码作为参数,并返回一个Promise对象。在函数内部,可以使用正则表达式来检查密码是否符合要求,比如密码长度在6~16位、包含数字和字母、包含符号、包含字母大小写等规则。 2. 创建一个获取密码强度的函数,可以参考引用中的示例代码。该函数接收一个密码作为参数,并返回一个表示密码强度的级别值。在函数内部,可以根据密码的长度和特定字符的出现情况来确定密码的强度级别。 3. 在需要进行密码强度校验的地方,调用密码校验函数并传入密码作为参数。可以参考引用中的示例代码,根据获取到的密码强度级别进行判断,如果级别大于等于9,则表示是一个强密码;否则,可以给出相应的提示信息。 请注意,以上是一种基本的实现方法,具体的校验规则和级别可以根据实际需求进行调整和扩展。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [js实现强密码校验](https://blog.csdn.net/chhpearl/article/details/113939043)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] - *2* [js实现密码校验规则](https://blog.csdn.net/weixin_43784418/article/details/114000190)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值