密码加密
何为密码加密
在 Spring Security 中,密码加密是保障系统安全的核心环节之一。Spring
Security
通过PasswordEncoder
接口统一规范了密码加密与验证的行为,其核心思想是:存储加密后的密码而非明文,并通过标准化方法验证用户输入的密码与存储的加密密码是否匹配。
明文密码指的是用户输入的原始密码(如abc123
、password
),未经过任何加密或哈希处理,直接以字符串形式存储在数据库中。
但不是你加密了就不能破解,早期系统会使用哈希算法对密码进行处理:将明文密码通过哈希函数转换为一串不可逆的哈希值(如MD5("123456") = e10adc3949ba59abbe56e057f20f883e
),然后存储哈希值而非明文。
彩虹表
但是这样,是双向加密,是可以被解密的,彩虹表(Rainbow Table) 就是针对这个缺陷设计的破解工具。
彩虹表的原理:
- 哈希函数的特性是:相同明文必然产生相同哈希值(确定性)。例如,无论何时计算
MD5("123456")
,结果都是固定的e10adc3949ba59abbe56e057f20f883e
。 - 黑客利用这一特性,预先计算海量常见密码(如字典词、简单数字组合)的哈希值,并将 “明文 - 哈希值” 对应关系存储在一张巨大的表中,这就是彩虹表。
- 彩虹表的本质是:用存储空间换取破解时间—— 提前花时间计算并存储哈希值,破解时只需查表对比,无需重新计算。
彩虹表的破解流程
假设某系统用 MD5
存储密码,数据库中某用户的哈希值是e10adc3949ba59abbe56e057f20f883e
:
- 黑客获取该哈希值后,查询自己的彩虹表;
- 发现表中
e10adc3949ba59abbe56e057f20f883e
对应明文123456
; - 直接使用
123456
登录该用户账户,破解成功。
传统哈希(如 MD5 + 无盐值)容易被彩虹表破解,而现代密码加密方式(如 BCrypt、Argon2)通过两大核心技术解决了这一问题,实现 “难以破译”:
加盐密码
盐值(Salt):让彩虹表失效
盐值是一串随机生成的字符串,在加密时与明文密码混合后再进行哈希处理。加盐这一步就是让哈希算法具有单向性,但是其实是可以验证的,验证的逻辑根据后面不同的算法会详细的说
例如,对明文123456
加密时:
- 生成随机盐值(如
x9#k2p
); - 计算
哈希(明文 + 盐值)
(如哈希("123456x9#k2p")
); - 最终存储的加密串包含盐值 +
哈希结果(如
x9#k2p$abc123...
)。
为什么能抵御彩虹表?
彩虹表是预计算 “明文→哈希值” 的对应关系,而加入盐值后,相同明文的加密结果完全不同(因盐值随机)。例如:
- 明文
123456
+ 盐值x9#k2p
→ 哈希结果 A; - 明文
123456
+ 盐值y7!m5q
→ 哈希结果 B;
黑客无法预先计算所有可能的 “明文 + 盐值” 组合(盐值随机且长度可变,组合量趋近无穷),彩虹表彻底失效。
自适应单向函数
自适应哈希:让暴力破解成本极高
即使彩虹表失效,黑客仍可能尝试暴力破解:枚举所有可能的明文(如从a
到zzzzzzzz
),对每个明文加入盐值后计算哈希,与存储的加密串对比。
现代加密算法通过自适应哈希(调整计算复杂度)让暴力破解变得 “不现实”:
什么是自适应哈希?
算法允许通过参数控制哈希计算的 “难度”,例如:
- 迭代次数:对哈希结果重复哈希 N 次(如 PBKDF2 的默认 65536 次迭代);
- 内存成本:计算过程中消耗大量内存(如 Argon2 的内存成本参数);
- 并行度:控制计算时的并行处理量(增加 GPU 破解难度)。
这些参数越大,计算一个哈希值的时间越长(例如从 1ms 增加到 100ms)。
为何能抵御暴力破解?
假设黑客要尝试 10 亿个可能的密码:
- 若每个密码计算耗时 1ms,总时间约 115 天;
- 若通过参数将耗时提升到 100ms,总时间则增至 31 年。
对黑客而言,这样的时间成本和硬件资源消耗(如 GPU 集群运行数年)完全不可接受,从而放弃破解。
单向性:无法从密文反推明文
现代加密算法基于单向哈希函数:从明文→密文的过程不可逆,无法通过密文直接计算出明文。
黑客只能通过 “正向枚举 + 对比” 的方式尝试破解,而结合盐值和自适应哈希后,这种尝试的成本已远超收益,因此加密后的密码在实际场景中可视为 “不可破译”。
密码加密方式
其中,PasswordEncoder
是 Spring Security
密码加密的核心接口,定义了两个关键方法:
String encode(CharSequence rawPassword)
:对原始密码进行加密,返回加密后的字符串(通常包含算法标识、盐值、哈希结果等信息)。boolean matches(CharSequence rawPassword, String encodedPassword)
:验证原始密码与加密密码是否匹配(无需手动提取盐值或解析加密串,接口内部实现自动处理)。
而 Spring Security
提供了多种PasswordEncoder
的实现类,对应不同的加密算法或策略,安全性和适用场景各有不同。以下是常用的实现类及对应的加密方式:
- 不加密
NoOpPasswordEncoder
:不对密码进行任何加密,直接存储明文,仅通过字符串相等性验证密码。明文存储极度危险,一旦数据库泄露,所有用户密码直接暴露。貌似Spring Security 5.0 后标记为废弃,好像实际上你想用肯定也能用 - 基于哈希算法
MessageDigestPasswordEncoder
:- 支持算法:MD5、SHA-1、SHA-256、SHA-512 等传统哈希算法。
- 原理:通过哈希函数对原始密码进行单向哈希(如
MD5("123456")
),存储哈希结果。验证时对输入密码再次哈希,对比结果是否一致。 - 问题:
- 无盐值(salt):相同密码的哈希结果完全相同,容易通过 “彩虹表”(预计算的哈希值字典)破解。安全性极低,Spring Security 已不推荐使用
- 计算速度快:哈希算法设计初衷是快速计算,导致暴力破解(枚举密码 + 哈希对比)成本低。
- 带盐值的哈希算法:为解决无盐值哈希的缺陷,现代密码加密算法引入盐值(随机字符串)和自适应哈希(通过增加计算复杂度抵抗暴力破解)。Spring
Security 提供了多种实现:
- BCrypt 加密
BCryptPasswordEncoder
:- 算法:基于 BCrypt 算法(一种自适应密码哈希函数)。
- 特点:
- 自动生成盐值:每次加密时随机生成盐值(无需手动管理),并将盐值嵌入加密结果中(格式如
$2a$10$N9qo8uLOickgx2ZMRZo5MeVQ82i0t8w0
)。 - 自适应哈希:通过 “工作因子”(work factor,默认 10)控制计算复杂度,值越大,哈希计算时间越长(可抵御 GPU 暴力破解)。
- 单向不可逆:无法从加密结果反推原始密码。
- 自动生成盐值:每次加密时随机生成盐值(无需手动管理),并将盐值嵌入加密结果中(格式如
- 使用在了绝大多数 Web 应用,平衡了安全性和实现复杂度。
- PBKDF2 加密
Pbkdf2PasswordEncoder
:- 算法:基于 PBKDF2(Password-Based Key Derivation Function 2)算法,通过迭代哈希 + 盐值增强安全性。
- 特点:
- 手动配置参数:需指定哈希算法(如 HMAC-SHA256)、迭代次数(默认 65536)、盐值长度(默认 16 字节)、密钥长度(默认 256 位)。
- 标准化算法:被 NIST(美国国家标准与技术研究院)推荐,安全性有权威背书。
- 盐值管理:加密结果中包含盐值,验证时自动提取。
- 使用在对安全性要求较高且需要符合行业标准的场景(如金融、政务系统)。
- Argon2 加密
Argon2PasswordEncoder
:- 算法:基于 Argon2 算法(2015 年密码哈希竞赛冠军),专为抵抗 GPU/ASIC 暴力破解设计。
- 特点:
- 多维度复杂度控制:通过 “内存成本”“时间成本”“并行度” 三个参数调整计算难度(内存消耗 + 计算时间 + 并行处理),大幅提高暴力破解成本。
- 现代设计:针对密码哈希的最新攻击方式(如侧信道攻击)进行了优化。
- 盐值自动生成:加密结果包含盐值和参数信息,验证时自动解析。
- 使用场景:对安全性要求极高的系统(如支付、敏感数据存储),但实现复杂度略高于 BCrypt。
- SCrypt 加密
SCryptPasswordEncoder
:- 算法:基于 SCrypt 算法(一种密钥派生函数),结合了内存硬消耗和计算硬消耗。
- 特点:
- 内存密集型:通过消耗大量内存增加 GPU/ASIC 破解的难度(普通哈希算法主要消耗 CPU)。
- 参数可配置:需指定 CPU 成本、内存成本、并行度等参数,平衡安全性和性能。
- 使用场景:需要抵抗大规模并行破解的场景,适合高安全性需求。
- BCrypt 加密
详细讲解密码加密算法
BCrypt 加密源码分析
BCryptPasswordEncoder
的核心逻辑集中在
encode
(加密)和 matches
(验证)方法,对应
BCrypt 算法的两个核心阶段:
加密阶段(encode
方法)
加密的目标是将明文密码转换为包含版本、盐值、哈希结果的字符串,流程如下:
参数初始化
1
2
3
4
5
6
7
8
9
10public BCryptPasswordEncoder(BCryptVersion version, int strength, SecureRandom random) {
// 校验工作因子有效性(4-31之间,默认10)
if (strength == -1 || strength >= 4 && strength <= 31) {
this.version = version; // 版本(如$2a、$2b)
this.strength = strength == -1 ? 10 : strength; // 工作因子,默认10
this.random = random; // 随机数生成器(用于生成盐值)
} else {
throw new IllegalArgumentException("Bad strength");
}
}- 工作因子(strength):决定加密复杂度的核心参数,计算次数为
2^strength
(如 strength=10 对应 1024 次迭代)。 - 版本(BCryptVersion):源码中定义了
$2A
、$2Y
、$2B
三个版本,用于修复早期实现的漏洞(如$2B
修复了一个小的安全缺陷,推荐优先使用)。
- 工作因子(strength):决定加密复杂度的核心参数,计算次数为
生成盐值(
getSalt
方法)盐值是一串随机字符串,用于与明文密码混合后哈希,确保相同密码加密结果不同
1
2
3
4
5
6private String getSalt() {
// 调用BCrypt工具类生成盐值,包含版本、工作因子和随机字节
return this.random != null ?
BCrypt.gensalt(this.version.getVersion(), this.strength, this.random) :
BCrypt.gensalt(this.version.getVersion(), this.strength);
}盐值生成的具体细节:
- 生成 16 字节(128 位)的随机数(通过
SecureRandom
保证随机性); - 用 BCrypt 自定义的 Base64 编码(字符集:
./0-9A-Za-z
,共 64 个字符)将 16 字节随机数转换为 22 个字符; - 盐值字符串格式:
$<version>$<strength>$<encoded-salt>
(如$2b$10$N9qo8uLOickgx2ZMRZo5Me
)。
- 生成 16 字节(128 位)的随机数(通过
哈希计算(
BCrypt.hashpw
方法)将明文密码与盐值混合后,通过 BCrypt 核心算法计算哈希,核心是 EksBlowfish 密钥扩展算法:
1
2
3
4public String encode(CharSequence rawPassword) {
String salt = this.getSalt(); // 获取盐值
return BCrypt.hashpw(rawPassword.toString(), salt); // 混合密码与盐值,计算哈希
}其中,
hashpw
走到底就是直接涉及 EksBlowfish 算法的核心流程,EksBlowfish(Extended Key Schedule Blowfish,扩展密钥调度的 Blowfish)是 BCrypt 算法的底层核心,负责通过 “密钥扩展” 和 “加密迭代” 实现密码的高强度哈希。hashpw
方法的主要作用是解析盐值参数、准备输入数据,并最终调用 EksBlowfish 算法的核心实现(B.crypt_raw
方法)。解析盐值,提取 EksBlowfish 所需的核心参数
盐值是 EksBlowfish 算法的关键输入之一,BCrypt 的盐值格式包含算法版本、工作因子(迭代次数)和随机盐字节,这段代码首先解析这些参数:
1
2
3
4
5
6
7
8
9
10
11
12// 盐值格式示例:$2b$10$N9qo8uLOickgx2ZMRZo5Me(版本$2b,工作因子10,盐字节N9qo8uLOickgx2ZMRZo5Me)
int off;
char minor = 0;
if (salt.charAt(2) == '$') {
off = 3; // 处理无次要版本的盐值(如$2$)
} else {
minor = salt.charAt(2); // 提取次要版本(如$2b$中的'b')
off = 4; // 偏移到工作因子位置
}
int rounds = Integer.parseInt(salt.substring(off, off + 2)); // 提取工作因子(如10)
String real_salt = salt.substring(off + 3, off + 25); // 提取实际盐值字符串(22字符)
byte[] saltb = decode_base64(real_salt, 16); // 将盐值字符串解码为16字节的原始盐值(EksBlowfish的盐输入)- 工作因子(rounds):EksBlowfish 算法的迭代次数由
2^rounds
决定(如 rounds=10 对应 1024 次迭代),这是 “自适应复杂度” 的核心参数。 - 盐值字节(saltb):16 字节的随机盐值,是 EksBlowfish 密钥扩展的关键输入之一,用于确保相同密码的哈希结果不同。
- 工作因子(rounds):EksBlowfish 算法的迭代次数由
处理密码字节,适配 EksBlowfish 的输入要求
EksBlowfish 对密码的处理有特定要求(如某些版本需要额外填充),代码中针对不同版本(minor)调整密码字节数组:
1
2
3if (minor >= 'a') {
passwordb = Arrays.copyOf(passwordb, passwordb.length + 1); // 对特定版本(如$2a$)的密码进行额外填充
}这一步是为了适配 EksBlowfish 算法在不同版本中的实现细节(如早期版本的密码长度处理逻辑)。
调用
crypt_raw
方法,执行 EksBlowfish 核心流程代码的核心是通过
B.crypt_raw(...)
调用 EksBlowfish 算法的具体实现:1
2
3
4
5
6
7
8
9BCrypt B = new BCrypt();
byte[] hashed = B.crypt_raw(
passwordb, // 密码字节数组(原始密码)
saltb, // 16字节盐值
rounds, // 工作因子(决定迭代次数)
minor == 'x', // 兼容旧版本的标志(与密钥扩展逻辑相关)
minor == 'a' ? 65536 : 0, // 特定版本的密码长度限制(EksBlowfish的参数)
for_check // 是否为验证模式(影响加密流程的细节)
);crypt_raw
方法是 EksBlowfish 算法的 “实现载体”,内部包含 EksBlowfish 的两大核心步骤:密钥扩展(Key Expansion):
以密码和盐值为输入,通过
2^rounds
次迭代扩展生成 Blowfish 加密算法的子密钥。迭代次数越多,密钥扩展过程越耗时,这正是 BCrypt 抵御暴力破解的核心(EksBlowfish 的 “扩展” 特性由此体现)。加密固定明文:
用扩展后的子密钥对一个固定的明文(
"OrpheanBeholderScryDoubt"
)执行 Blowfish 加密,得到的密文经过处理后即为最终哈希结果的核心部分。
组装最终哈希串(包含 EksBlowfish 的输入参数)
EksBlowfish 的输入参数(版本、工作因子、盐值)会被嵌入最终的哈希串,以便验证时能重新执行相同的 EksBlowfish 流程:
这也是 BCrypt 无需单独存储盐值的原因 —— 所有 EksBlowfish 所需的参数都已包含在哈希串中。
1
2
3
4
5
6
7rs.append("$2"); // 主版本
if (minor >= 'a') rs.append(minor); // 次要版本
rs.append("$");
rs.append(rounds); // 工作因子(EksBlowfish的迭代次数参数)
rs.append("$");
encode_base64(saltb, saltb.length, rs); // 盐值(EksBlowfish的盐输入)
encode_base64(hashed, ..., rs); // EksBlowfish加密后的结果
EksBlowfish 算法的核心实现——
crypt_raw
方法这个方法负责将密码、盐值通过多轮密钥扩展和加密迭代,生成最终的哈希字节数组。其流程可拆解为 参数初始化、密钥扩展、迭代强化、固定明文加密、结果转换 五个核心阶段,每个阶段都服务于 “增强密码哈希安全性” 的目标。
参数初始化与校验
没啥多说的,该有的流程要有
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 1. 初始化固定明文(用于最终加密的基准数据)
int[] cdata = (int[])bf_crypt_ciphertext.clone();
int clen = cdata.length;
// 2. 计算实际迭代次数(rounds = 2^log_rounds)
long rounds;
if (log_rounds >= 4 && log_rounds <= 31) {
rounds = roundsForLogRounds(log_rounds); // 核心:rounds = 2^log_rounds(如log_rounds=10 → 1024)
if (rounds < 16L || rounds > 2147483648L) {
throw new IllegalArgumentException("Bad number of rounds");
}
} else {
// 验证模式下的特殊处理(略)
}
// 3. 校验盐值长度(必须16字节,EksBlowfish的硬性要求)
if (salt.length != 16) {
throw new IllegalArgumentException("Bad salt length");
}bf_crypt_ciphertext
:这是一个固定的整数数组(源码中预定义为{0x4f727068, 0x65616e42, 0x65686f6c, 0x64657253, 0x63727944, 0x6f756274}
),对应明文字符串"OrpheanBeholderScryDoubt"
。EksBlowfish 最终会用扩展后的密钥加密这个固定明文,确保哈希结果的一致性基准。rounds
计算:log_rounds
即 “工作因子”,rounds
是实际迭代次数(2^log_rounds
),这是 “自适应复杂度” 的核心 —— 次数越多,计算越耗时,暴力破解成本越高。- 盐值校验:强制盐值为 16 字节(128 位),确保随机性足够,避免相同密码产生相同哈希。
密钥初始化与扩展:
EksBlowfish(扩展密钥调度的 Blowfish)的核心是 “通过盐值和密码生成强密钥”,这一过程由
init_key
和ekskey
完成1
2this.init_key(); // 初始化Blowfish算法的S盒和P数组(初始密钥状态)
this.ekskey(salt, password, sign_ext_bug, safety); // 扩展密钥调度:结合盐和密码生成初始密钥init_key()
:初始化 Blowfish 算法的内部状态(包括 8 个 S 盒和 18 个 P 数组),这些是对称加密的核心参数(类似 “密码本”)。ekskey(...)
:EksBlowfish 的关键步骤(“Eks” 即 Extended Key Schedule),作用是:用盐值(salt
)和密码(password
)共同 “扰乱” 初始的 S 盒和 P 数组,生成与密码、盐值强关联的初始密钥。这一步确保密钥不仅依赖密码,还依赖随机盐值,避免相同密码在不同盐值下产生相同密钥。
迭代强化:
为进一步增强密钥安全性,算法会执行
rounds
次(2^log_rounds
)迭代,每次迭代用密码和盐值再次更新密钥:1
2
3
4for(int i = 0; (long)i < rounds; ++i) {
this.key(password, sign_ext_bug, safety); // 用密码更新密钥
this.key(salt, false, safety); // 用盐值更新密钥
}this.key(...)
:Blowfish 算法的标准密钥调度方法,作用是用输入的字节数组(密码或盐值)进一步 “扰乱” 当前的 S 盒和 P 数组(即更新密钥)。
固定明文加密:生成哈希核心数据
完成密钥强化后,算法用最终生成的密钥对固定明文(
cdata
)执行多轮加密,生成哈希的核心部分:1
2
3
4
5for(int i = 0; i < 64; ++i) { // 固定执行64轮加密
for(int j = 0; j < clen >> 1; ++j) { // 对cdata中的每对整数加密
this.encipher(cdata, j << 1); // Blowfish加密操作
}
}encipher(...)
:Blowfish 算法的加密函数,接收整数数组和索引,对指定位置的两个整数(64 位数据)执行加密(Blowfish 是 64 位块加密算法)。
最后,将加密后的
cdata
整数数组转换为字节数组,作为哈希结果返回:加密后的字符串将盐值和哈希结果合并,这一步是在上面的 hashpw 中尾部完成,组装最终哈希串,格式为:
总长度固定为 60 字符(版本 3 字符 + 强度 2 字符 + 盐 22 字符 + 哈希 31 字符);
1
$<version>$<strength>$<encoded-salt><encoded-hash>
1
2
3
4
5
6
7
8
9byte[] ret = new byte[clen * 4]; // clen是cdata的长度,每个int占4字节
int i = 0;
for(int j = 0; i < clen; ++i) {
ret[j++] = (byte)(cdata[i] >> 24 & 255); // 提取int的高8位
ret[j++] = (byte)(cdata[i] >> 16 & 255);
ret[j++] = (byte)(cdata[i] >> 8 & 255);
ret[j++] = (byte)(cdata[i] & 255); // 提取int的低8位
}
return ret;
验证阶段(matches 方法)
验证的目标是判断用户输入的明文密码与存储的加密串是否匹配,核心是 “用相同盐值和算法重新计算哈希并对比”:
1
2
3
4
5
6
7
8
9public boolean matches(CharSequence rawPassword, String encodedPassword) {
// 校验加密串格式是否符合BCrypt规范(通过正则表达式)
if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
// 调用BCrypt工具类验证:用加密串中的盐值重新计算哈希,对比结果
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
BCrypt.checkpw
内部逻辑只在这里简单说明:
- 从加密串中解析出版本、工作因子和盐值(如从
$2b$10$N9qo8uLOickgx2ZMRZo5MeVQ82i0t8w0
提取盐值$2b$10$N9qo8uLOickgx2ZMRZo5Me
); - 用解析出的盐值对用户输入的明文密码重新计算哈希;
- 对比新计算的哈希结果与加密串中的哈希部分是否一致,一致则验证通过。
PBKDF2 加密源码流程分析
PBKDF2 的核心目标是通过 “增强计算复杂度” 和 “随机盐值”
提升密码哈希的安全性,和 Bcrypt
略有区别,但是流程较为相似,核心逻辑集中在
参数初始化、encode
(加密)、matches
(验证)
三个部分。
参数初始化
PBKDF2 依赖 5 个核心参数(也是 Pbkdf2PasswordEncoder
的核心配置项):
- Password(原始密码):用户输入的明文密码(如
123456
)。 - Salt(盐值):随机生成的字节数组(默认 16 字节),确保相同密码生成不同哈希。
- Iterations(迭代次数):哈希函数的执行次数(是 Spring Security 5.8+ 的优化值 310000 次),次数越多,计算越耗时,暴力破解成本越高。
- PRF(伪随机函数):通常基于 HMAC 哈希算法(如 HMAC-SHA256、HMAC-SHA512),作为迭代计算的 “基础哈希工具”。Spring Security 默认使用的是PBKDF2WithHmacSHA256
- Key Length(密钥长度):最终生成的哈希(或密钥)的字节长度(如 256 位 = 32 字节)。
源码首先通过构造函数和静态变量定义 PBKDF2 的默认参数,确保算法安全性的 “基础配置”:
1 | private static final int DEFAULT_SALT_LENGTH = 16; // 默认盐值长度:16字节(128位) |
源码提供多个构造函数,支持自定义参数,以最常用的构造函数为例:
1 | public Pbkdf2PasswordEncoder(CharSequence secret, int saltLength, int iterations, SecretKeyFactoryAlgorithm secretKeyFactoryAlgorithm) { |
secret
参数:可选的 “额外秘密值”(如系统全局密钥),会与盐值拼接后参与计算,进一步增强哈希的唯一性(即使盐值和密码相同,secret
不同则哈希不同)。
setAlgorithm
方法就是对PBK2K算法进行一些设置,其中注意的就是
1 | // 自动匹配密钥长度(overrideHashWidth为true时) |
加密流程
encode
方法是 PBKDF2 算法的核心实现,对应
“生成盐值→派生密钥→组装结果” 的完整流程:
入口 encode
方法(生成盐值 +
调用核心加密)
1 | public String encode(CharSequence rawPassword) { |
核心加密 encode(rawPassword, salt)
方法
1 | private byte[] encode(CharSequence rawPassword, byte[] salt) { |
其中的 encode
方法是三层调用关系的
1 | // 1. 入口:生成盐值 + 调用核心加密 + 编码结果 |
第二步真正实现了加密,该方法的作用是:将 “原始密码 + 盐值 + 系统 secret” 封装为安全参数,调用 JCE 底层 PBKDF2 实现生成派生密钥,最终拼接 “盐值 + 派生密钥” 返回。
验证流程
主要核心是 matches
方法,验证的核心逻辑是
“用存储的盐值重新计算哈希,对比结果是否一致”,源码实现如下:
1 | public boolean matches(CharSequence rawPassword, String encodedPassword) { |
密码编码器PasswordEncoder
使用密码编码器
最简单的方式就是修改你的 Security 配置类
1 | /** |

那么你其实也可以自己写一个密码编码器,自定义一些密码的配置
1 | package hbnu.project.databasesecurity.config; |
密码编码器的工作流程
- 存储密码时:
- 接收用户输入的明文密码
- 通过
encode()
方法生成加密后的密码 - 将加密后的密码存储到用户存储系统(内存 / 数据库)
- 验证密码时:
- 用户登录时输入明文密码
- 系统获取存储的加密密码
- 通过
matches()
方法验证两者是否匹配 - 匹配成功则认证通过
当然这只是简单的流程概述,实际上 Spring Security 通过
PasswordEncoder
接口统一规范所有密码编码器的行为,接口定义如下:
1 | public interface PasswordEncoder { |
所有具体编码器(如
BCryptPasswordEncoder
、Pbkdf2PasswordEncoder
)均实现此接口,确保加密与验证逻辑的一致性。
密码编码器的工作流程贯穿用户 “注册” 和 “登录” 两个核心场景,具体步骤如下:
注册阶段:密码加密与存储
当用户注册时,系统需要将原始密码加密后存储到数据库,流程如下:
生成随机盐值(部分算法)
- 现代加密算法(如 BCrypt、PBKDF2)会自动生成随机盐值(Salt),例如:
BCryptPasswordEncoder
:生成 16 字节随机盐值,通过BCrypt.gensalt()
实现;Pbkdf2PasswordEncoder
:通过KeyGenerators.secureRandom(16)
生成 16 字节盐值。
- 盐值作用:确保相同密码加密结果不同,抵御彩虹表攻击。
执行加密算法
编码器使用指定算法(如 BCrypt、PBKDF2)对 “原始密码 + 盐值” 进行加密,核心逻辑包括:
- BCrypt:通过 EksBlowfish 算法执行
2^strength
次迭代(默认 1024 次),生成哈希结果; - PBKDF2:基于 HMAC 哈希(如 SHA256)执行指定次数迭代(默认 310000 次),生成派生密钥。
组装加密串
加密后的结果会包含 算法标识、盐值、哈希结果 等信息,例如:
- BCrypt
加密串:
$2b$10$N9qo8uLOickgx2ZMRZo5MeVQ82i0t8w0
(版本 + 工作因子 + 盐值 + 哈希); - PBKDF2 加密串(Hex
编码):
5f4dcc3b5aa765d61d8327deb882cf99...
(盐值 + 派生密钥)。
优势:无需单独存储盐值和算法参数,加密串本身包含验证所需的全部信息。
存储加密串
将组装后的加密串存入数据库(如用户表的 password
字段),替代明文密码。
登录阶段:密码验证
当用户登录时,系统需要验证输入密码与存储的加密串是否匹配,流程如下:
提取加密串信息
编码器从存储的加密串中解析出 盐值、算法参数 等信息,例如:
- BCrypt:通过正则表达式
\A\$2(a|y|b)?\$(\d\d)\$[./0-9A-Za-z]{53}
提取版本、工作因子和盐值; - PBKDF2:通过
EncodingUtils.subArray
从加密串头部提取盐值(默认前 16 字节)。
- BCrypt:通过正则表达式
重新加密输入密码
使用解析出的盐值和算法参数,对用户输入的原始密码执行 与注册时相同的加密流程,生成临时加密结果。
对比验证
将临时加密结果与存储的加密串进行对比,一致则验证通过,否则失败。
安全细节:对比时使用
MessageDigest.isEqual
等方法,避免时序攻击(Timing
Attack)。
可选阶段:密码升级
当加密算法参数(如迭代次数)需要更新时,通过
upgradeEncoding
方法判断是否需要重新加密:
- 例如,若存储的 BCrypt 加密串工作因子为 10,而当前配置为 12,则
upgradeEncoding
返回true
; - 系统可在用户登录成功后,使用新参数重新加密密码并更新数据库,实现平滑升级。
DelegatingPasswordEncoder
当系统从旧的密码加密方式升级到新方式时,DelegatingPasswordEncoder
是 Spring Security
提供的优雅解决方案。它解决了一个核心问题:如何在不强制所有用户立即修改密码的情况下,平滑过渡到新的加密算法。
假设你的系统原本使用:
- 明文存储密码(极不安全)
- 或 MD5 加密(已被破解)
- 或 SHA-1 加密(安全性不足)
现在要升级到 BCrypt 加密,但不能要求所有用户重新注册或立即修改密码,这时候就需要一种机制:
- 能验证旧算法加密的密码
- 新密码或修改后的密码使用新算法加密
- 逐步完成所有密码的算法升级
那么我们就可以这样使用 DelegatingPasswordEncoder
1 |
|
它的核心思想是在加密后的密码前添加算法标识,格式如下:
1 | {加密算法ID}加密后的密码 |
例如:
{noop}123456
- 明文密码(noop 表示无加密){md5}e10adc3949ba59abbe56e057f20f883e
- MD5 加密{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW
- BCrypt 加密
这样,Spring Security 就能根据前缀自动选择对应的编码器进行验证。
那么我们知道了,DelegatingPasswordEncoder
的核心设计是
“前缀标识算法”(比如 {bcrypt}$2a$10$xxx
表示用 BCrypt 加密,{md5}e10adc39xxx
表示用 MD5
加密)。它通过解析密码字符串的前缀,来决定用哪种具体的
PasswordEncoder
去验证密码。
但问题在于:迁移前的旧密码,是没有这个前缀的(比如数据库中存的是
$2a$10$xxx
,而不是
{bcrypt}$2a$10$xxx
;或者存的是
e10adc39xxx
,而不是 {md5}e10adc39xxx
)。
那很厉害了,DelegatingPasswordEncoder
提供了一个关键特性:如果密码字符串没有前缀,会自动使用你所配置的
“默认算法” 去验证。这正是迁移的核心 ——
你不需要手动修改旧密码或者手动加前缀,只需要告诉
DelegatingPasswordEncoder
:“所有没有前缀的密码,都默认是用【旧算法】加密的”。他就会去做好匹配。
举个具体迁移场景的例子:
假设你的系统迁移前:
- 用 BCrypt 加密密码(无前缀),数据库中存的是
$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
(纯 BCrypt 哈希,无{bcrypt}
前缀); - 现在要迁移到
DelegatingPasswordEncoder
,且新密码希望用 更强的 BCrypt 版本(或 Argon2) 存储(带前缀)。
迁移配置步骤(无需手动加前缀):
- 定义所有需要支持的算法(包括旧算法 BCrypt、新算法 BCrypt/Argon2);
- 指定 “默认算法” 为旧算法(BCrypt)—— 让无前缀的旧密码能被正确验证;
- 指定 “新密码加密算法” 为目标新算法—— 新用户注册 / 密码重置时,自动用新算法 + 前缀存储。
1 |
|
那么假如你的旧密码是明文存储?
- 只需将
DelegatingPasswordEncoder
的 “默认算法” 配置为NoOpPasswordEncoder
(明文验证器) - 旧用户登录时,
DelegatingPasswordEncoder
会用NoOpPasswordEncoder
验证明文密码,新用户 / 改密码时自动用新算法加密并加前缀。
注意,使用DelegatingPasswordEncoder
迁移完成后,务必删除旧算法配置(尤其是
NoOpPasswordEncoder
这类不安全的实现),避免安全风险。
对了,还可以添加一步,使得在用户成功登录进行验证通过之后,可以选择自动将密码更新为新算法,步骤也很简单,就是在用户登录成功后,检测其密码是否使用旧算法,若是则自动升级:
1 |
|