正确保存密码:bcrypt 和 argon2
1. 概述
保存密码方式不当是导致用户数据泄露的最常见漏洞之一。本文帮你理解为什么不应该用纯 MD5/SHA256 对密码进行哈希处理,以及如何在 Spring Security 中使用 BCryptPasswordEncoder 和 Argon2PasswordEncoder。读完后,你将能够配置一个安全的 PasswordEncoder,编写行为测试,并知道如何为自己的项目选择合适的算法。
2. 为什么不应该用纯 SHA-256 对密码进行哈希处理
密码学哈希函数如 SHA-256、SHA-512 的设计目标是运行速度越快越好。这在验证文件完整性时是优势,但用于密码时则是灾难。
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class NaiveHasher {
public static String sha256(String password) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(hash);
}
}
上面的函数每秒可以在一个中等水平的 GPU 上哈希数十亿个密码。攻击者只需拥有彩虹表或进行暴力破解,就能轻松破解原始密码,尤其是对于常见密码如 123456、password1 这样的。
两个核心问题:
- 没有 salt:两个用户相同的密码会生成相同的哈希值,容易被彩虹表攻击。
- 速度过快:无法“减慢”攻击者的速度。
注意: 不要自己手动添加 salt 然后将其附加到 SHA-256。这仍然是一个薄弱的解决方案,因为缺少“工作因子” — 计算难度不会随着硬件性能提高而增加。
3. 什么是密码哈希函数
像 bcrypt、scrypt、argon2 这样的算法是专门为密码哈希设计的,具有三个特点:
- 自动 Salt:每次哈希生成随机 salt,并直接嵌入到哈希输出字符串中。
- 故意变慢(工作因子):有参数来增加难度,防止随着硬件性能提升而被暴力破解。
- 内存硬化(argon2/scrypt):消耗大量 RAM,使得在 GPU/ASIC 上并行运行效率较低。
一条 bcrypt 哈希字符串的结果如下所示:
$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
结构:$<version>$<cost>$<salt+hash base64>。这里 10 是成本因子(2^10 次迭代)。
4. 在 Spring Security 中使用 bcrypt
4.1. 依赖项
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
BCryptPasswordEncoder 已包含在 spring-security-crypto 中,不需要添加任何额外内容。
4.2. 声明 Bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // 成本因子 = 12
}
}
参数 12 决定了迭代次数是 2^12。成本因子越高 → 越慢 → 越安全,但登录时消耗的 CPU 也越多。对于 2026 年的大多数应用程序,10-12 的值是合理的。
4.3. 在注册和登录时使用
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserRegistrationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public UserRegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
public void register(String email, String rawPassword) {
String hashed = passwordEncoder.encode(rawPassword);
userRepository.save(new Customer(email, hashed));
}
public boolean authenticate(String email, String rawPassword) {
Customer customer = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("邮箱不存在"));
return passwordEncoder.matches(rawPassword, customer.getPasswordHash());
}
}
encode()每次调用时生成随机 salt,因此相同的rawPassword每次生成的哈希值都不同 — 这是正确的行为,不是 bug。matches()自动从 DB 中的哈希值提取 salt,重新哈希输入的密码然后进行比较,无需你自己管理 salt。
4.4. 测试行为
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class BcryptPasswordEncoderTest {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
@Test
void shouldGenerateDifferentHashesForSamePassword() {
String hash1 = encoder.encode("S3cretPass!");
String hash2 = encoder.encode("S3cretPass!");
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches("S3cretPass!", hash1)).isTrue();
assertThat(encoder.matches("S3cretPass!", hash2)).isTrue();
}
@Test
void shouldRejectWrongPassword() {
String hash = encoder.encode("S3cretPass!");
assertThat(encoder.matches("WrongPass!", hash)).isFalse();
}
}
此测试确认了两个重要的事情:随机 salt 导致哈希值不同,以及 matches() 仍然能正确识别原始密码,尽管哈希值不同。
注意: bcrypt 的输入长度限制是 72 字节。如果密码更长,超出部分会被静默丢弃。对于普通密码(少于 72 个字符),没有问题,但如果你允许非常长的密码短语,请考虑使用 argon2。
5. 使用 argon2 — 更现代的选择
Argon2 赢得了 2015 年的密码哈希竞赛,并被 OWASP 推荐为当前的首选,因为它比 bcrypt 有更好的抗 GPU 破解能力(内存硬化)。
5.1. 依赖项
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
Spring Security 使用 Bouncy Castle 作为 Argon2PasswordEncoder 的提供者,因此需要添加此依赖项。
5.2. 声明 Bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// saltLength=16, hashLength=32, parallelism=1, memory=1<<16 (64MB), iterations=3
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}
}
工厂方法 defaultsForSpringSecurity_v5_8() 返回已经被 Spring Security 团队验证的参数配置,对大多数应用程序是安全的,你不需要自己计算 memory/iterations。
5.3. 比较输出
$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$5t1h3u7K3iC2rT0...
结构包括变体(argon2id)、版本、内存参数(m)、迭代次数(t)、并行度(p),然后是 salt 和哈希 — 比 bcrypt 更透明,因为清楚地暴露了成本。
5.4. 两个编码器的比较测试
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class Argon2PasswordEncoderTest {
private final Argon2PasswordEncoder encoder =
Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@Test
void shouldVerifyCorrectPassword() {
String hash = encoder.encode("MyP@ssw0rd123");
assertThat(encoder.matches("MyP@ssw0rd123", hash)).isTrue();
assertThat(encoder.matches("wrong", hash)).isFalse();
}
}
6. 使用 DelegatingPasswordEncoder 同时支持多个编码器
在实际中,你的系统可能需要随着时间推移更改算法(例如从 bcrypt 迁移到 argon2),而不破坏旧的哈希值。Spring Security 提供了现成的解决方案。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
createDelegatingPasswordEncoder() 创建一个编码器,能够读取哈希字符串中的前缀 {bcrypt}、{argon2}、{noop}… 以选择正确的解密算法。默认情况下,它使用 bcrypt 对新密码进行编码,但仍然可以正确地 matches() 用其他算法创建的旧哈希值。
{bcrypt}$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...
由于这个前缀,你可以逐步迁移:旧用户仍然可以登录,新注册或更改密码的用户将使用最新算法重新哈希。
7. bcrypt 和 argon2 快速对比
| 标准 | bcrypt | argon2 |
|---|---|---|
| 发布年份 | 1999 | 2015(PHC 赛事获胜者) |
| 抗 GPU/ASIC | 不错 | 更好(内存硬化) |
| 输入限制 | 72 字节 | 实际无限制 |
| 普遍程度 | 非常高,无处不支持 | 越来越流行 |
| OWASP 建议 | 第二选择 | 首选 |
如果是新项目且不受旧系统的限制,请选择 argon2id(argon2id 变体是 Spring Security 的默认值,在防止 side-channel 和 GPU 攻击之间找到平衡)。如果系统已经稳定使用 bcrypt 多年,不一定要立即迁移 — 将 bcrypt 的成本因子提高到 12-14 对大多数应用程序仍然足够安全。
8. 常见错误
- 自己实现 salt + SHA-256:如第 2 节所述,缺少工作因子,所以对现代暴力破解仍然很弱。
- 使用成本因子过低:
BCryptPasswordEncoder()无参数的默认成本因子是 10,但如果你为了在测试时“加快速度”而将其降低到 4-5,在部署到生产环境时容易忘记改回来。 - 用
String.equals()比较哈希值:永远不要自己手动比较哈希字符串 — 始终使用PasswordEncoder的matches()方法,因为这些库使用常数时间比较,避免时序攻击。 - 在 DB 中单独存储 salt:不必要,bcrypt 和 argon2 都已将 salt 嵌入到哈希输出字符串中。
- 没有登录速率限制:无论哈希有多强,缺少速率限制或账户锁定仍然使在线暴力破解成为可能。
注意: 永远不要在生产环境中使用
{noop}编码器(纯文本)。它应该只出现在测试或本地演示中。
9. 结论
正确地哈希密码意味着使用为此专门设计的算法 — bcrypt 或 argon2 — 而不是像 SHA-256 这样的快速哈希函数。Spring Security 使这一点变得简单:只需声明一个 PasswordEncoder bean,保存时调用 encode(),验证时调用 matches(),无需自己管理 salt。如果需要在算法之间灵活迁移,DelegatingPasswordEncoder 是一个安全且向后兼容的选择。无论选择 bcrypt 还是 argon2,记住强哈希只是一层防护 — 结合速率限制和 MFA 才能创建真正安全的身份验证系统。