原文很好,直接点击看原文原文链接

一、概述

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)” 的经典模式,将“定义”和“执行”解耦,让你能灵活地修改配置,而无需改动核心的认证逻辑

更多推荐