密码加密

密码必须经过单向哈希存储,永远不能以明文或可逆加密形式保存。Spring Security 提供了完整的 PasswordEncoder 体系。

为什么不能用 MD5 / SHA

MD5("123456") = "e10adc3949ba59abbe56e057f20f883e"
  • 彩虹表攻击:常见密码的 MD5 值已预先计算好,查表即可还原
  • 无盐:相同密码哈希相同,一旦泄露可批量破解
  • 速度太快:MD5 每秒可计算数十亿次,暴力破解成本极低

现代密码哈希算法(BCrypt / Argon2 / SCrypt)的设计目标正好相反:故意慢、内置盐、可调成本


BCrypt(Spring Security 默认)

BCrypt 每次哈希自动生成随机盐,相同密码每次输出不同,且内置成本因子(cost)控制计算速度。

基础用法

@Bean
public PasswordEncoder passwordEncoder() {
    // strength 是 log2(迭代次数),默认 10,范围 4~31
    // strength=10 → 2^10=1024 次迭代,约 100ms/次(适合大多数场景)
    // strength=12 → 约 400ms,安全性更高但登录稍慢
    return new BCryptPasswordEncoder(10);
}
@Service
@RequiredArgsConstructor
public class UserService {
 
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepo;
 
    // 注册:加密存储
    public void register(String username, String rawPassword) {
        String encoded = passwordEncoder.encode(rawPassword);
        // encoded 示例:$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
        userRepo.save(new User(username, encoded));
    }
 
    // 登录:校验密码
    public boolean login(String username, String rawPassword) {
        User user = userRepo.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
        // matches 内部提取盐值后重新哈希再比较,无需手动处理盐
        return passwordEncoder.matches(rawPassword, user.getPassword());
    }
}

BCrypt 输出格式:

$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
│  │  │                                                        │
│  │  └─ 盐(22位 base64)+ 哈希值(31位 base64)
│  └──── cost factor(10 = 2^10 次迭代)
└─────── BCrypt 版本号

算法对比

算法抗 GPU 暴力破解内存硬度Spring Security 支持推荐场景
BCrypt✓ 默认通用,新项目首选
Argon2极好强(可配置)高安全要求(金融、医疗)
SCrypt极好高安全要求
PBKDF2FIPS 合规要求
MD5 / SHA-1 / SHA-256✗(不应使用)

Argon2 配置

@Bean
public PasswordEncoder passwordEncoder() {
    // saltLength=16字节, hashLength=32字节, parallelism=1, memory=65536KB, iterations=3
    return new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
}

DelegatingPasswordEncoder(多算法迁移)

生产系统中历史用户可能使用旧算法(MD5 / SHA),DelegatingPasswordEncoder 支持同时兼容多种算法,无需强迫用户重置密码:

@Bean
public PasswordEncoder passwordEncoder() {
    // 默认编码器:新密码用 BCrypt
    PasswordEncoder defaultEncoder = new BCryptPasswordEncoder();
 
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put("bcrypt", defaultEncoder);
    encoders.put("argon2", new Argon2PasswordEncoder(16, 32, 1, 65536, 3));
    encoders.put("noop", NoOpPasswordEncoder.getInstance());  // 明文(测试用)
 
    // 新密码前缀为 {bcrypt},旧密码前缀为 {noop} 等
    return new DelegatingPasswordEncoder("bcrypt", encoders);
}

存储格式:{bcrypt}$2a$10$...,前缀标识算法。

迁移旧密码到 BCrypt

@Service
@RequiredArgsConstructor
public class PasswordMigrationService {
 
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepo;
 
    // 登录成功时,若密码是旧格式则自动升级
    public void upgradePasswordIfNeeded(User user, String rawPassword) {
        if (passwordEncoder instanceof DelegatingPasswordEncoder dp) {
            if (dp.upgradeEncoding(user.getPassword())) {
                // 旧算法,重新加密后保存
                user.setPassword(passwordEncoder.encode(rawPassword));
                userRepo.save(user);
                log.info("用户 {} 密码已升级到 BCrypt", user.getUsername());
            }
        }
    }
}

与 Spring Security 集成

Spring Security 的 DaoAuthenticationProvider 自动使用注册的 PasswordEncoder Bean 校验密码:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public AuthenticationManager authManager(
            UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) throws Exception {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);   // 注入加密器
        return new ProviderManager(provider);
    }
}

Spring Security 完整配置详见 安全,JWT 认证流程详见 OAuth2与JWT


修改密码

修改密码时必须先验证旧密码,防止会话劫持后直接改密:

@PutMapping("/users/me/password")
public void changePassword(@RequestBody ChangePasswordRequest req,
                           @AuthenticationPrincipal UserDetails currentUser) {
    User user = userRepo.findByUsername(currentUser.getUsername()).orElseThrow();
 
    // 1. 验证旧密码
    if (!passwordEncoder.matches(req.getOldPassword(), user.getPassword())) {
        throw new BusinessException("原密码错误");
    }
 
    // 2. 校验新密码强度
    if (req.getNewPassword().length() < 8) {
        throw new BusinessException("密码长度不能少于 8 位");
    }
 
    // 3. 加密并保存
    user.setPassword(passwordEncoder.encode(req.getNewPassword()));
    userRepo.save(user);
 
    // 4. 使旧 Token 失效(防止原 Token 继续使用)
    tokenService.invalidateAll(user.getId());
}

数据脱敏(密码字段序列化)

防止密码字段在日志或 API 响应中意外泄漏:

public class User {
 
    private String username;
 
    @JsonIgnore          // 序列化时永远不输出
    private String password;
 
    // 或者:只允许反序列化(接收),不允许序列化(输出)
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    private String password;
}

日志中避免打印用户对象时暴露密码:

@Override
public String toString() {
    // 不包含 password 字段
    return "User{id=" + id + ", username='" + username + "'}";
}

数据脱敏详见 数据脱敏


常见错误

错误后果正确做法
明文存储密码数据库泄露即全军覆没使用 PasswordEncoder.encode()
使用 MD5 / SHA彩虹表、GPU 暴力破解使用 BCrypt / Argon2
手动加盐后用 SHA盐存库里等于没有BCrypt 自动处理盐
忘记校验旧密码就改密会话劫持后可任意改密修改前调用 matches() 验证
在日志中打印密码日志文件即泄漏@JsonIgnore + 重写 toString()

相关链接

  • 安全 — Spring Security 完整配置,PasswordEncoder Bean 注册位置
  • OAuth2与JWT — 登录流程中的密码验证
  • 数据脱敏 — 敏感字段的序列化保护
  • Session管理 — 改密后使旧 Session/Token 失效