乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

PasswordEncoder

定义与作用

PasswordEncoder 是 Spring Security 定义密码哈希和比对规范的接口,只有两个方法:

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);           // 明文 → 密文
    boolean matches(CharSequence rawPassword, String encodedPassword);  // 明文与密文比对
}

Spring Security 5.0 起强制要求使用 PasswordEncoder,不允许明文存储密码。DaoAuthenticationProvider 在认证阶段调用 matches() 验证用户输入的密码是否与数据库中的哈希值匹配。

手写过滤器的痛点

手写登录时,密码处理通常极其随意:

// 痛点1:明文存储,数据库泄露等于密码全泄露
// 痛点2:简单 MD5,彩虹表秒破
// 痛点3:没有统一盐值,相同密码哈希结果相同,易被批量撞库
// 痛点4:换算法时全部密码要重新生成,无法平滑迁移
// 痛点5:没有标准接口,每个项目写一套密码工具类
String storedPassword = user.getPassword();  // 可能是明文、MD5、SHA256...
if (storedPassword.equals(inputPassword)) {  // 明文比对!
    // 登录成功
}

PasswordEncoder 将密码编码抽象为标准接口,使算法选择、强度升级、多算法共存都有了统一方案。


核心原理

接口设计哲学

主要实现对比

实现类算法是否含盐是否可配置强度安全性推荐场景
BCryptPasswordEncoderBCrypt(Blowfish)自动随机盐可配置 cost(默认10)高通用首选
SCryptPasswordEncoderSCrypt自动随机盐可配置高内存密集型抗 GPU
Argon2PasswordEncoderArgon2自动随机盐可配置最高高安全场景
Pbkdf2PasswordEncoderPBKDF2可配置盐可配置迭代次数中高合规/金融
DelegatingPasswordEncoder多算法委托依子编码器依子编码器—算法迁移期
NoOpPasswordEncoder明文(无编码)无无无仅测试,禁止生产

升级编码机制

PasswordEncoder 接口提供 upgradeEncoding() 方法,用于判断当前密码是否需要重新编码(如升级算法或强度):

// DelegatingPasswordEncoder 源码简化
public boolean upgradeEncoding(String encodedPassword) {
    String id = extractId(encodedPassword);  // 提取 {bcrypt} 前缀
    PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
    return this.idForEncode.equals(id) && delegate.upgradeEncoding(encodedPassword);
}

当 upgradeEncoding() 返回 true 时,DaoAuthenticationProvider 会在认证成功后调用 UserDetailsPasswordService.updatePassword() 自动升级密码。


示例一:从无编码到 BCrypt 的强制迁移

场景说明

旧系统使用 NoOpPasswordEncoder(明文或简单编码),需升级到 BCryptPasswordEncoder。

操作前配置

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .passwordEncoder(NoOpPasswordEncoder.getInstance())  // 明文!危险!
            .withUser("admin").password("admin123").roles("ADMIN");
    }
}

操作后配置

切换到 BCryptPasswordEncoder,并预生成测试用户密码:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .passwordEncoder(passwordEncoder())
            .withUser("admin")
                .password(passwordEncoder().encode("admin123"))  // 存储的是哈希值
                .roles("ADMIN")
            .and()
            .withUser("user")
                .password(passwordEncoder().encode("user123"))
                .roles("USER");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authorize -> authorize
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(Customizer.withDefaults());
    }
}

结果分析

  • 数据库中存储的密码从 admin123 变为 $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
  • 即使数据库泄露,攻击者拿到的是哈希值,无法反推明文
  • 同一明文每次 encode() 结果不同,因为 BCrypt 内置随机盐值

示例二:使用 DelegatingPasswordEncoder 实现多算法共存

场景说明

系统历史数据库中同时存在旧 MD5 密码和新 BCrypt 密码,需要一种编码器能自动识别并分别处理。

操作前配置

只能配置一种编码器,旧 MD5 密码无法验证:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();  // 旧 {MD5}xxx 密码无法匹配
}

操作后配置

使用 DelegatingPasswordEncoder,通过密码前缀自动识别算法:

@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

数据库中的密码格式示例:

-- 老用户:MD5 编码(已废弃但暂时保留)
INSERT INTO users (username, password) VALUES ('old_user', '{MD5}5f4dcc3b5aa765d61d8327deb882cf99');

-- 新用户:BCrypt 编码
INSERT INTO users (username, password) VALUES ('new_user', '{bcrypt}$2a$10$...');

-- 测试用户:明文(不推荐)
INSERT INTO users (username, password) VALUES ('test_user', '{noop}password');

UserDetailsService 无需改动,直接返回数据库中的密码字符串:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        UserEntity entity = userMapper.findByUsername(username);
        return User.builder()
            .username(entity.getUsername())
            .password(entity.getPassword())  // 直接返回 {bcrypt}... 或 {MD5}...
            .roles("USER")
            .build();
    }
}

结果分析

  • DelegatingPasswordEncoder 从密码中提取 {bcrypt}、{MD5}、{noop} 前缀
  • 根据前缀路由到对应的子编码器执行 matches()
  • 默认编码新密码时使用 BCrypt(idForEncode = "bcrypt")
  • 逐步引导用户重新登录,将旧 MD5 密码升级为 BCrypt

易错场景:encode() 的返回值不能直接用于 matches() 的第二次比对

问题描述

开发者误以为 encode() 返回的字符串是固定的,试图将其缓存后多次使用:

// 错误:BCrypt 每次 encode 结果不同
String encoded1 = passwordEncoder.encode("password");  // $2a$10$abc...
String encoded2 = passwordEncoder.encode("password");  // $2a$10$xyz...

// 错误认知:
System.out.println(encoded1.equals(encoded2));  // false!但两次都是有效的

正确理解

BCryptPasswordEncoder 每次 encode() 生成不同的随机盐,所以相同明文的结果不同。验证时必须使用 matches() 而不是字符串比对:

// 正确:将 encode 的结果存入数据库
String storedPassword = passwordEncoder.encode(rawPassword);

// 正确:验证时用 matches
boolean match = passwordEncoder.matches(rawPassword, storedPassword);  // true

面试陷阱题

问:为什么 BCryptPasswordEncoder.encode("123") 调用两次,结果不一样?

答:因为每次生成不同的随机盐值(salt)。盐值与明文一起参与哈希,所以结果不同,但两者都能通过 matches("123", result) 验证。

面试考点:

  • Spring Security 5.x 为什么强制要求 PasswordEncoder?
  • DelegatingPasswordEncoder 的密码前缀格式是什么?如何实现平滑迁移?
  • upgradeEncoding() 的触发条件是什么?DaoAuthenticationProvider 如何利用它自动升级密码?
  • 生产环境为什么绝对不能用 NoOpPasswordEncoder?
上一页
UserDetailsService
下一页
BCryptPasswordEncoder