PasswordEncoder详解
原文很好,直接点击看原文原文链接
文章目录
一、概述
PasswordEncoder是一个密码解析器
Spring Security封装了如bcrypt, PBKDF2, scrypt, Argon2等主流适应性单向加密方法( adaptive one-way functions),用以进行密码存储和校验。单向校验安全性高,但开销很大,单次密码校验耗时可能高达1秒,故针对高并发性能要求较强的大型信息系统,Spring Security更推荐选择如:session, OAuth,Token等开销很小的短期加密策略(short term credential)实现系统信息安全。
二、BCryptPasswordEncoder(和原文区别应该就是版本问题)
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package org.springframework.security.crypto.bcrypt;
import java.security.SecureRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
public class BCryptPasswordEncoder implements PasswordEncoder {
private Pattern BCRYPT_PATTERN;
private final Log logger;
private final int strength;
private final BCryptPasswordEncoder.BCryptVersion version;
private final SecureRandom random;
public BCryptPasswordEncoder() {
this(-1);
}
public BCryptPasswordEncoder(int strength) {
this(strength, (SecureRandom)null);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version) {
this(version, (SecureRandom)null);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, SecureRandom random) {
this(version, -1, random);
}
public BCryptPasswordEncoder(int strength, SecureRandom random) {
this(BCryptPasswordEncoder.BCryptVersion.$2A, strength, random);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, int strength) {
this(version, strength, (SecureRandom)null);
}
public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, int strength, SecureRandom random) {
this.BCRYPT_PATTERN = Pattern.compile("\\A\\$2(a|y|b)?\\$(\\d\\d)\\$[./0-9A-Za-z]{53}");
this.logger = LogFactory.getLog(this.getClass());
if (strength == -1 || strength >= 4 && strength <= 31) {
this.version = version;
this.strength = strength == -1 ? 10 : strength;
this.random = random;
} else {
throw new IllegalArgumentException("Bad strength");
}
}
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
} else {
String salt = this.getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
}
private String getSalt() {
return this.random != null ? BCrypt.gensalt(this.version.getVersion(), this.strength, this.random) : BCrypt.gensalt(this.version.getVersion(), this.strength);
}
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
} else if (encodedPassword != null && encodedPassword.length() != 0) {
if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
this.logger.warn("Encoded password does not look like BCrypt");
return false;
} else {
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
} else {
this.logger.warn("Empty encoded password");
return false;
}
}
public boolean upgradeEncoding(String encodedPassword) {
if (encodedPassword != null && encodedPassword.length() != 0) {
Matcher matcher = this.BCRYPT_PATTERN.matcher(encodedPassword);
if (!matcher.matches()) {
throw new IllegalArgumentException("Encoded password does not look like BCrypt: " + encodedPassword);
} else {
int strength = Integer.parseInt(matcher.group(2));
return strength < this.strength;
}
} else {
this.logger.warn("Empty encoded password");
return false;
}
}
public static enum BCryptVersion {
$2A("$2a"),
$2Y("$2y"),
$2B("$2b");
private final String version;
private BCryptVersion(String version) {
this.version = version;
}
public String getVersion() {
return this.version;
}
}
}

三、BCryptPasswordEncoder测试
我这里直接项目里实验测试
前端新建用户(入参没有密码)

后台contreol
import org.springframework.security.crypto.password.PasswordEncoder;
@Api(tags = "系统:用户管理")
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final PasswordEncoder passwordEncoder;
@Log("新增用户")
@ApiOperation("新增用户")
@PostMapping
@PreAuthorize("@el.check('user:add')")
public ResponseEntity<Object> createUser(@Validated @RequestBody User resources){
checkLevel(resources);
// 默认密码 123456
resources.setPassword(passwordEncoder.encode("123456"));
// 默认密码失效日期
LocalDateTime now = LocalDateTime.now();
LocalDateTime threeMonthsLater = now.plus(3, ChronoUnit.MONTHS);
System.out.println("当前时间:" + now);
System.out.println("三个月后:" + threeMonthsLater);
Date oracleDate = DateUtil.toDate(threeMonthsLater);
// 设置默认密码失效日期
resources.setPwdExpiryDate(oracleDate);
userService.create(resources);
return new ResponseEntity<>(HttpStatus.CREATED);
}
}

这里断点进入就是BCryptPasswordEncoder 实现 PasswordEncoder的源码
数据库新增一个用户

重复上面新增用户


四、总结
本文介绍了PasswordEncoder以及这个接口常见的实现类BCryptPasswordEncoder相关的知识与内容
五、多年以后(又一次验证)
项目v1和项目v2要共用一个用户中心认证服务,但是v1和v2 各自保留springsecurity,只是最后用户的增删改查都远程feign请求用户中心认证服务(所以各自系统创建用户后,各自系统加密后再让用户中心认证服务保存,登录也是各自springsecurity校验登录密码,只是从用户中心认证服务取用户数据回来验证)
v1项目用spring-boot-starter-security:2.6.8 和 spring-security-crypto 5.6.5
v2项目用spring-boot-starter-security:2.6.8 和 spring-security-crypto 5.6.5
在v1项目和v2项目都对test123加密,最后加密不一样(同上面实验一样:BCryptPasswordEncoder 每次 encode() 都会生成随机盐值,即使明文相同、版本相同、配置相同,两次加密结果也必然不同。这不是兼容性问题,而是 BCrypt 算法的核心安全特性),但是v1登录时候可以拿到v2创建用户的登录密码解密,完全可以校验通过(关键点:encode() 生成的密文中已经包含了盐值($2a$10$ 后面的部分)。所以虽然两次生成的密文不同,但 matches() 方法可以从任一密文中提取盐值,正确验证明文)
// 在v1项目执行下面
String rawPassword = "test123";
String encoded = SecurityUtils.encryptPassword(rawPassword);
System.out.println("项目名v1: " + encoded);
System.out.println("项目v1自检: " + SecurityUtils.matchesPassword(rawPassword, encoded));
// 在v2项目执行下面
String rawPassword = "test123";
String encoded = SecurityUtils.encryptPassword(rawPassword);
System.out.println("项目名v2: " + encoded);
System.out.println("项目v2自检: " + SecurityUtils.matchesPassword(rawPassword, encoded));
//结果日志:
//kms-admin | 项目名v1: $2a$10$iKpUlsgDNIVuHFuyEGG0oeL.v.i2hlNrIvZGJjdX6helA4dxF6jOa
//kms-admin | 项目v1自检: true
//kms-admin2 | 项目名v2: $2a$10$ENw9N2UiK8Wo/XHy9CFKaOYMne2SoqbZLt/0V18F.2jkQo7X5WPUq
//kms-admin2 | 项目v2自检: true
// 在v1项目执行下面代码
String v1Hash = "$2a$10$iKpUlsgDNIVuHFuyEGG0oeL.v.i2hlNrIvZGJjdX6helA4dxF6jOa";
System.out.println(SecurityUtils.matchesPassword("test123", v1Hash));
String v2Hash = "$2a$10$ENw9N2UiK8Wo/XHy9CFKaOYMne2SoqbZLt/0V18F.2jkQo7X5WPUq";
System.out.println(SecurityUtils.matchesPassword("test123", v2Hash));
System.out.println("项目名v2默认密码应该能解析才对:" );
String v2Hashdefault = "$2a$10$KCdfLevf.691Y3RVflgqd.kQ32LfYTbt8bP6AyeYfq0BbetElfuXe";
System.out.println(SecurityUtils.matchesPassword("abc123!@#", v2Hashdefault));
// 日志:
//kms-admin | true
//kms-admin | true
//kms-admin | 项目名v2默认密码应该能解析才对:
//kms-admin | true
关键点:encode() 生成的密文中已经包含了盐值($2a$10$ 后面的部分)。所以虽然两次生成的密文不同,但 matches() 方法可以从任一密文中提取盐值,正确验证明文
关键点:encode() 生成的密文中已经包含了盐值($2a$10$ 后面的部分)。所以虽然两次生成的密文不同,但 matches() 方法可以从任一密文中提取盐值,正确验证明文

至此可以确定:你的两个项目 BCrypt 完全互通,
V1 登录时实际拿到的密码和 V2 生成的Hash 有一个没取到正确的值
打断点最后进入源码看
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider{
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
}
最终看到源码里
presentedPassword 不是 abc123!@# 这个明文,是 e4ce71d23141fa54209f9e47ad0d1540372088f784732ddd0b98e1556e672199
userDetails.getPassword() 是 $2a
10
10
10KCdfLevf.691Y3RVflgqd.kQ32LfYTbt8bP6AyeYfq0BbetElfuXe
前端登录时候入参password是加密了
{"username":"testv2again","rememberMe":false,"code":"0","uuid":"82b979e761494e55b78f8e3f7587aad1","password":"e4ce71d23141fa54209f9e47ad0d1540372088f784732ddd0b98e1556e672199"}
因此v1 在创建用户的时候是 前端加密,后端加密最后 远程请求落入用户中心认证数据库中
v2 在创建用户的时候 没有加密, 后端加密 最后 远程请求落入用户中心认证数据库中
最终导致v2创建用户在v1登录时候总是校验不通过,(因为v2的创建用户保存到用户中心认证数据库是明文的加密hash,而不是前端明文加密后再后端加密的hash)
罪魁祸首(前端v2创建用户没加密):
v1 创建用户

v2 创建用户

六、多年以后(补充一下为啥springSecurity的this.passwordEncoder 用的是BCryptPasswordEncoder)
之前项目保存用户密码加密(这里应该之前项目指定是BCryptPasswordEncoder,已经无从考证, 后来才知道后面补充了 Spring Security的BCryptPasswordEncoder验证)
resources.setPassword(passwordEncoder.encode(“123456”));
解密和加密都要使用一样的 PasswordEncoder
当前项目分v1项目和v2项目(因为单独抽取用户模块,认证还是各自认证,只是获取用户从公共用户模块远程feign请求获取) 都可以考证加密就是用 BCryptPasswordEncoder

SecurityUtils工具类,可以看到加密就是BCryptPasswordEncoder
/**
* 虽然有单独的用户服务,但是这里security的上下文自己保存用户信息,在拦截器中也不断往上下文设置用户信息
* 安全服务工具类
*
* @author skms
*/
public class SecurityUtils {
/**
* 用户ID
**/
public static Long getUserId() {
try {
return getLoginUser().getUserId();
} catch (Exception e) {
throw new ServiceE("获取用户ID异常", HttpStatus.UNAUTHORIZED);
}
}
/**
* 获取用户账户
**/
public static String getUsername() {
try {
return getLoginUser().getUsername();
} catch (Exception e) {
throw new ServiceE("获取用户账户异常", HttpStatus.UNAUTHORIZED);
}
}
/**
* 获取用户
**/
public static LoginUser getLoginUser() {
try {
return (LoginUser) getAuthentication().getPrincipal();
} catch (Exception e) {
throw new ServiceE("获取用户信息异常", HttpStatus.UNAUTHORIZED);
}
}
/**
* 获取Authentication
*/
public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
/**
* 生成BCryptPasswordEncoder密码
*
* @param password 密码
* @return 加密字符串
*/
public static String encryptPassword(String password) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.encode(password);
}
/**
* 判断密码是否相同
*
* @param rawPassword 真实密码
* @param encodedPassword 加密后字符
* @return 结果
*/
public static boolean matchesPassword(String rawPassword, String encodedPassword) {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.matches(rawPassword, encodedPassword);
}
/**
* 是否为管理员
*
* @param userId 用户ID
* @return 结果
*/
public static boolean isAdmin(Long userId) {
return userId != null && 1L == userId;
}
}
v1和v2 项目得 SecurityConfig配置一样
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 自定义用户认证逻辑
*/
@Resource
@Qualifier("UserDetailsServiceImpl")
private UserDetailsService userDetailsService;
/**
* 解决 无法直接注入 AuthenticationManager
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
}
这里auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
点击进去源码 这个bCryptPasswordEncoder() 最终放到AbstractDaoAuthenticationConfigurer的DaoAuthenticationProvider provider里面
public abstract class AbstractDaoAuthenticationConfigurer<B extends ProviderManagerBuilder<B>, C extends AbstractDaoAuthenticationConfigurer<B, C, U>, U extends UserDetailsService> extends UserDetailsAwareConfigurer<B, U> {
private DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
private final U userDetailsService;
public C passwordEncoder(PasswordEncoder passwordEncoder) {
this.provider.setPasswordEncoder(passwordEncoder);
return (C)this;
}
}
疑问:
DaoAuthenticationProvider 里面的
this.passwordEncoder.matches 进行密码校验 和 刚才配置类里面注入bCryptPasswordEncode到AbstractDaoAuthenticationConfigurer 啥关系?
ai解惑(我也不用看源码怎么 AbstractDaoAuthenticationConfigurer配置 构建并初始化一个 DaoAuthenticationProvider 实例)
本质关系
通过配置类将 PasswordEncoder 实例注入到 Provider 中,供其内部逻辑使用
一、它们的定义和作用
-
DaoAuthenticationProvider
是 Spring Security 的核心认证逻辑执行者。当用户登录时,它的 authenticate 方法会执行密码比对,而比对的工具就是它持有的 this.passwordEncoder
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
ProviderManager是实现AuthenticationManager 一个类(AuthenticationManager接口:定义了认证Authentication的方法 ProviderManager实现这个接口, 下面的认证和多年前blog 用户登录:断点看流程认证 一致,参照之前blog,最终会走到 this.passwordEncoder.matches 进行密码校验)
-
AbstractDaoAuthenticationConfigurer(及其子类 DaoAuthenticationConfigurer)
是 Java 配置阶段的工具。它的职责是构建并初始化一个 DaoAuthenticationProvider 实例,并把外部配置(比如你设定的密码编码器)塞进 Provider 里
二、关系总结图
[ 你的代码配置 ]
||
|| 调用 .passwordEncoder(new BCryptPasswordEncoder())
\/
[ AbstractDaoAuthenticationConfigurer ] (配置类)
||
|| 持有配置好的 PasswordEncoder 实例
\/
[ 构建阶段 ] (当 Spring 容器启动时)
||
|| 执行 provider.setPasswordEncoder(配置类持有的实例)
\/
[ DaoAuthenticationProvider ] (实际执行认证的类)
||
|| 持有 this.passwordEncoder
\/
[ 用户登录时 ]
||
|| 执行 this.passwordEncoder.matches(前台密码, 库中密码哈希)
\/
[ 认证成功/失败 ]
三、通俗理解
-
AbstractDaoAuthenticationConfigurer 像一个“模具”,你告诉它你需要什么样的 PasswordEncoder(比如是 BCrypt、SCrypt 还是自定义)。
-
DaoAuthenticationProvider 像是模具生产出来的“产品”。产品一旦出厂,内部就固定好了你指定的那个密码编码器
-
两者不是“继承”或“组合”的静态关系,而是一个 “设置”和“被设置” 的动态流程。配置类负责组装,Provider 负责拿着组装好的工具干活
四、为什么要设计成两层?
这种设计遵循了 Spring Security 的 “配置 (Configurer) - 构建 (Builder) - 执行 (Provider)” 的经典模式,将“定义”和“执行”解耦,让你能灵活地修改配置,而无需改动核心的认证逻辑
更多推荐

所有评论(0)