先说个我印象很深的场景:有开发者拿着生产环境的用户表截图问我,说后台导出数据时发现密码字段直接是明文,项目还上线好几年了,问我怎么抢救。这种问题我这些年见了不止一次,好在现在补课的人越来越多。如果你也在做的Java项目里准备存用户密码,或者正打算把代码里的MD5改成更安全的方案,这篇就是顺着完整流程写的实操记录,从思路到选型再到能直接贴进项目的代码,一次说透。
1. 密码加密的核心思路:先搞清楚你到底在防什么
1.1 明文存储为什么不能接受
简单说,只要数据库里存的是明文,用户密码的安全就完全等同于数据库本身的安全。数据库一旦被拖库、被误导出、被内部人员翻阅,所有账号密码直接裸奔。更麻烦的是,很多人喜欢一个密码走天下,你的系统里明文泄露,用户的其他账号也跟着遭殃,这时候责任就大了。
网上总有人反驳:我数据库安全做得很好,不会被入侵。但现实是数据库泄露不只有被攻击这一条路,备份文件外流、测试环境拷贝、离职人员带库、日志误打印,哪条都可能导致数据脱离你的控制。密码加密的意义不在于“数据库永远不会被拿走”,而在于“即使数据被拿走,攻击者也没法直接得到可用的密码”,这才是纵深防御里非常关键的一道防线。
一旦密码是明文,任何上层权限控制都形同虚设。哪怕你只给运营人员开了一个只读查询工单,对方把整张用户表拉到本地看,这一瞬间所有密码都暴露了。我见过不止一起因为明文密码导致公司被索赔的案例,说句难听的,出事之后技术人员完全站不住脚,因为这是最基础的合规问题。
1.2 哈希、加密、加盐:三个概念一次说清
很多人把“加密”和“哈希”混在一起,代码里写着“对密码进行MD5加密”,这说法严格讲是错的。MD5、SHA-256、BCrypt这些属于哈希算法,它们的关键特征有三点:
- 单向性:从哈希值没法反推出原文;
- 定长输出:输入再长,输出固定长度;
- 雪崩效应:原文改一个字符,哈希值完全变样。
而真正的“加密算法”,比如AES、RSA,是可逆的,加密得到的密文能通过密钥解回原文。可逆加密用于密码存储是反模式,因为持有密钥的人就能还原所有密码,本质上和明文没区别。密码场景要的是“验证”,而不是“解密”。
那“加盐”又是什么?为了对付预计算好的彩虹表,攻击者把常用密码、字典组合全都预先算成哈希值存下来,一旦拿到你的哈希值,直接在表里查就能还原明文。盐就是一段随机数据,拼在密码后面一起做哈希,让相同密码在不同用户、不同盐值下产生完全不同的哈希值,攻击者的预计算表彻底失效。
1.3 一张表看主流密码算法的定位差异
处理Java项目时,我习惯先按“哈希速度快慢”和“是否内置盐”来给算法分类,别小看这个维度,选错方向会带来完全不同的结果。
| 算法 | 输出长度 | 是否内置盐 | 计算速度 | 适合存密码吗 |
|---|---|---|---|---|
| MD5 | 32位十六进制 | 否 | 极快 | 不适合 |
| SHA-1 | 40位十六进制 | 否 | 极快 | 不适合 |
| SHA-256 | 64位十六进制 | 否 | 快 | 可作中间形态,不建议直接用 |
| 加盐MD5/SHA | 自定义 | 需自己实现 | 快 | 勉强可用但很脆弱 |
| BCrypt | 60字符 | 是 | 可调节慢 | 适合 |
| PBKDF2 | 自定义 | 需传入盐 | 可调节慢 | 适合,但需要额外保存盐值 |
| Argon2 | 自定义 | 是 | 可调节慢 | 新一代推荐方案 |
判断标准并不复杂:密码哈希算法的核心价值就是“让攻击者在拿到哈希后无法快速暴力破解”,所以能抵抗GPU并行、能调节计算成本的算法才适合。MD5、SHA系列算得太快,一秒钟能跑几亿次,加再长的盐也挡不住暴力测试。
2. 算法选型:MD5、SHA、BCrypt、PBKDF2、Argon2到底选谁
2.1 为什么MD5和SHA-256“看起来能用”但迟早出事
MD5在很多老项目里特别常见,代码就一句话“DigestUtils.md5Hex(password)”,看起来省事。但它的问题是两个方向叠加的:一是碰撞攻击、长度扩展攻击等理论弱点早就被确认;二是算得太快,普通显卡跑MD5轻松做到每秒十亿次以上,这意味着只要你的用户量超过几万,攻击者拿哈希库做离线字典攻击的成本基本可以忽略。
有人会说“我加盐总行了吧”。加盐能挡住彩虹表,但挡不住暴力破解,因为你依然用的是高速哈希。盐是随机串,密码字典也是有限的,加盐后的MD5输出照样可以每秒算很多次。很多开发者自己拼字符串“密码+盐”,再用MD5转一遍,以为安全了,实际情况是稍微有点经验的攻击者看到这种哈希格式,用GPU几分钟就能试穿一个小型密码字典。
SHA-256同理,NIST虽然推荐SHA-2家族用于签名和完整性校验,但并没有推荐它直接用于密码存储,原因不在于算法弱,而在于“太快”。如果你的老系统里已经用了MD5或SHA,不需要立即推倒重来,但必须列入改造计划,具体迁移方法我放在后面。
2.2 加盐后就能高枕无忧了吗
加盐解决的是“预计算”问题,但解决不了“GPU暴力破解”问题。盐值本身不是密钥,不需要保密,它只需要做到随机且每个用户唯一。正确做法是每次生成一个16字节以上、由加密安全随机数发生器生成的盐,而不是拿用户名当盐,更不是写死一个固定盐。
为什么不能拿用户名当盐?第一,用户名可能不变,两个用户撞密码时哈希值完全一样,照样能看出规律;第二,用户名的字符集太小,可预测的盐值会让攻击者更容易构造针对性彩虹表;第三,注册后用户名一旦不允许改还好,很多系统允许改昵称,会导致校验逻辑混乱。每用户独立随机盐才是标准做法。
加了随机盐之后,即便是相同密码,哈希值也完全不同。攻击者唯一能做的就是拿到数据库后,针对每个哈希单独跑暴力破解,而且每个哈希都要重新算一遍,这大大拉高了成本。但前提是你用的算法本身是“慢哈希”,如果底层是MD5,GPU照样能秒过。
2.3 专业密码哈希算法才是正解
BCrypt是目前Java生态里最成熟的方案,它的核心设计是“可调节的成本因子”。成本因子每提高1,计算时间翻倍。Spring Security里的BCryptPasswordEncoder默认成本值是10,在普通服务器上一次哈希大约要60到100毫秒,对正常登录体验来说完全无感,但对攻击者来说,每秒只能测试十几次,和MD5的十亿次形成鲜明对比。
PBKDF2则是把哈希迭代执行很多遍,标准库自带支持,不需要额外引包。它属于“可调节慢哈希”,迭代次数可以配置。Java里用 PBEKeySpec + SecretKeyFactory 就能实现。缺点是盐值需要自己存,哈希结果的格式也需要自己设计。
Argon2是2022年前后多个密码竞赛中胜出的算法,设计上对GPU和专用硬件的抗性更强,也是目前业界公认的新标杆。但Java生态里的成熟库相对少一些,如果团队比较保守,从BCrypt入手更稳妥。BCrypt也不是没有劣势,它上限是72字节,如果用户密码超长,会静默截断,实战中要注意校验密码长度。
2.4 我的选型建议
新项目直接上BCrypt,理由有三个:一是Spring生态默认集成,几乎零成本接入;二是存进数据库的就是一个字符串,不需要额外管理盐字段;三是算法自身把盐、成本参数、哈希值全打包在一条字符串里,既便于校验也便于未来升级。
如果公司有明确的安全标准或者团队熟悉密码学,PBKDF2也能用,但坑在于要自己处理盐值和迭代参数的存储,做不好容易留下隐患。Argon2则适合上线新系统且愿意引入额外依赖的团队。
我自己的项目里现在统一用BCrypt,成本因子根据服务器性能在10到12之间调整。低于10太轻,攻击者跑得太快;高于12开始感觉到登录变慢,并发高时CPU容易被打满。具体的成本选择,后面实战部分会给出测试方法。
3. Java代码实操:把加密方案落到项目里
3.1 基于Spring Security的BCrypt加密实现
如果你的项目已经引入了Spring Security,密码加密基本是“开箱即用”。最简单的方式是这样:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class PasswordService { private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(10); public String encode(String rawPassword) { return ENCODER.encode(rawPassword); } public boolean matches(String rawPassword, String encodedPassword) { return ENCODER.matches(rawPassword, encodedPassword); } }注册用户时调用encode方法,得到的字符串长这样:
$2a$10$7EqJtq98hPqEX7fNZaFWoOHIaR1x0YJjMlNl3pXaDiRsVvU0Bmq这个字符串内部是有结构的:开头的$2a$表示BCrypt版本,10就是成本因子,后面22个字符是盐,再往后才是真正的哈希值。正因为这些信息都打包在一起了,匹配时才能用同一段逻辑处理不同成本因子的老数据。
登录校验时调用matches即可,它内部会解析之前存储的版本、成本因子、盐值,再用同样的参数计算一次,最后通过安全比较得出结果。整个过程不需要你手动提取盐,也不需要用存储的盐再算一次,框架全帮你处理了。
有人会问:每次登录都做一次慢哈希,性能扛得住吗?以一个成本因子10的BCrypt为例,单次约70毫秒,对用户来说毫无感知。真正需要注意的是并发场景,比如登录接口被脚本高频调用,此时BCrypt的高CPU成本反而变成一个放大器,所以后面必须配合限流和防爆破措施。
3.2 如果不引入框架,手写一个加盐工具类
如果不打算引入Spring Security,自己手写也没有想象中那么复杂。这里我提供一个基于PBKDF2的实现,用到的全是JDK原生类,零第三方依赖:
import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.security.spec.KeySpec; import java.util.Base64; public class Pbkdf2PasswordUtil { private static final int ITERATIONS = 120_000; private static final int KEY_LENGTH = 256; private static final int SALT_LENGTH = 16; private static final String ALGORITHM = "PBKDF2WithHmacSHA256"; public static String hash(String rawPassword) { byte[] salt = new byte[SALT_LENGTH]; new SecureRandom().nextBytes(salt); byte[] hash = pbkdf2(rawPassword.toCharArray(), salt, ITERATIONS, KEY_LENGTH); return "pbkdf2$sha256$" + ITERATIONS + "$" + Base64.getEncoder().encodeToString(salt) + "$" + Base64.getEncoder().encodeToString(hash); } public static boolean verify(String rawPassword, String stored) { String[] parts = stored.split("\\$"); if (parts.length != 5 || !"pbkdf2".equals(parts[0]) || !"sha256".equals(parts[1])) { return false; } int iterations = Integer.parseInt(parts[2]); byte[] salt = Base64.getDecoder().decode(parts[3]); byte[] expectedHash = Base64.getDecoder().decode(parts[4]); byte[] actualHash = pbkdf2(rawPassword.toCharArray(), salt, iterations, KEY_LENGTH); return MessageDigest.isEqual(expectedHash, actualHash); } private static byte[] pbkdf2(char[] password, byte[] salt, int iterations, int keyLength) { try { KeySpec spec = new PBEKeySpec(password, salt, iterations, keyLength); return SecretKeyFactory.getInstance(ALGORITHM).generateSecret(spec).getEncoded(); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { throw new IllegalStateException("PBKDF2 hash failed", e); } } }这个方案把算法标识、迭代次数、盐值、哈希值全部拼进一个字符串,好处是以后调整迭代次数时,老数据仍然可以通过自身携带的参数完成校验,这是工程化存储格式的常规设计思路。
有人会问:为什么不自己写一个简单的SHA-256加盐工具类?原因还是那句话,SHA-256算得太快,不适合密码存储。PBKDF2通过迭代把单次计算时间拉到可控的慢,才是正确的思路。迭代次数120000在当前硬件上大约几十毫秒,如果未来硬件更快了,可以再把迭代次数调大,而老用户老数据因为格式里带了旧参数,不会受影响。
3.3 登录校验:恒定时长比较不能省
校验哈希值的时候,如果直接把两个字符串用equals比较,会引入一个很微妙的时序攻击。攻击者可以通过反复测试,观察响应时间差,逐字符猜测哈希值,虽然这个攻击在本地网络场景下难度较大,但安全编码的习惯就是不留这种隐患。
Java里推荐使用MessageDigest.isEqual(byte[], byte[])做比较,它不会在第一个不匹配的字节处提前返回,而是会完整遍历所有字节后才给出结果。上面的PBKDF2示例里已经用了这个方法。
如果用的是Spring Security的BCryptPasswordEncoder,它内部实现本身就是安全比较,你不需要额外做处理。但如果你从老项目里拿了一段哈希比较工具类,看到“先比较长度,再双循环比较,发现一位不同直接return false”这种实现,建议立刻替换成上面这种恒定时间方案。
提示:哈希比较时不要把两个字符串都转成十六进制再比较,也不要用HashMap或集合的equals,这些都没有恒定时间保证。最稳妥的做法是解码成字节数组后用MessageDigest.isEqual。
4. 常见问题排查与避坑实录
4.1 编码不一致导致的校验失败
这是我在项目里见过频率最高的问题。同样的密码,加密和校验时如果用了不同的字符编码,结果一定对不上。比如注册时是password.getBytes(),登录时是password.getBytes(StandardCharsets.UTF_8),在纯ASCII密码时看不出问题,一旦用户密码包含中文、特殊符号,就会踩坑。
解决方案很简单,统一在入口处把密码转成StandardCharsets.UTF_8的字节数组。同时注意数据库连接的字符集设置,特别是老项目里如果出现过乱码,密码在进入数据库前可能已经被破坏了,此时务必检查Jdbc连接参数是否包含characterEncoding=utf8。
另外,密码本身的长度限制也要提前想好。BCrypt对输入长度上限是72字节,超过的部分会被静默截断。这意味着用户注册时输入100位的密码,实际参与哈希的只有前72字节,攻击者只要知道前72字节相同,先后输入的密码都能通过校验,这显然不是我们希望的行为。解决方案是在业务层统一限制密码长度,比如最长64个字符,输入框和接口同时做校验。
4.2 数据库字段长度不够
把密码字段从32位的MD5升级为60位的BCrypt时,最直接的报错是数据库字段超长。很多老表设计成varchar(32),直接写BCrypt字符串会报Data too long for column。
升级时建议直接改成varchar(255)。为什么留这么大余量?因为以后可能切换到Argon2,或者未来某个算法存储格式里携带更多参数信息,如果每次换算法都改表结构,运维成本太高。一次性留够255位,十年内基本不用再操心。
另外提醒一个细节:如果密码字段原来有唯一索引,要赶紧去掉。相同密码在两个用户那里会产生完全不同的BCrypt字符串,加唯一索引除了制造注册失败之外毫无意义。同样,也不要用哈希后的密码做业务主键,哪怕概率再低,撞到重复值会引发线上事故。
4.3 成本因子太高导致登录卡顿
有次我接手一个Spring Boot项目,登录接口平均耗时超过1.2秒,排查下来发现是BCrypt的成本因子被设置成了15。单次哈希就要600多毫秒,并发一高,CPU直接被打满。成本因子不是越大越好,它必须在安全和性能之间做平衡。
我的建议是:先定一个标准硬件环境,比如2核4G的云服务器,然后用一段压测脚本模拟真实登录,从cost=10开始逐级上调,看每个档位的耗时和CPU占用。一般来说,让单次BCrypt耗时控制在100毫秒以内,同时预留30%以上CPU余量,是比较合理的。正常项目用10或11即可,如果用的是企业级密码策略审计,可能会要求12,但不要盲目往15上冲。
此外,登录之外还有一类容易忽略的场景:密码重置、后台管理员登录、用户注销确认,凡是涉及matches调用的地方,都会产生一次慢哈希消耗。建议全局统一使用同一个成本因子配置,不要出现注册和登录各用各的情况。
4.4 历史数据迁移:老账号怎么平滑升级
老项目改造最大的难点不是写出新代码,而是怎么处理已经存在的MD5存量数据。常见的做法是“登录时自动升级”,具体流程:
- 用户提交用户名和密码;
- 查询用户信息,拿到当前存储的密码哈希;
- 根据哈希格式判断算法类型(比如看是不是以
$2a$开头的BCrypt格式); - 如果是旧算法,先用旧算法校验;
- 校验通过后,立刻用新算法重新生成哈希,并写回数据库;
- 下次该用户再登录时,走的就是新算法的分支了。
这个方案的好处是无感迁移,不需要让用户重置密码,也不会在某一个时间点产生巨大的数据库更新压力。注意事项是:旧算法模块不能马上从代码里删除,要等线上几乎不再出现旧格式数据后,再清理相关代码。
迁移期间也可以加一个统计逻辑:每天汇报还有多少用户是旧算法,当比例降到个位数时,主动给剩余用户推送“密码安全升级”通知。我这里曾经做过一个类似迁移,前后花了大概一个多月才清完,中间遇到过一些一次都没再登录过的“僵尸账号”,它们会一直留在旧算法里,这类账号占比不多,可以后续批量重置或忽略。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 注册后登录报密码错误 | 编码不一致、盐值格式损坏、字段截断 | 统一UTF-8编码,检查存储字符串完整性 |
| 插入用户表报字段超长 | 密码列长度不足 | 改为varchar(255) |
| 登录接口耗时暴涨 | BCrypt成本因子过高 | 压测后下调,建议10或11 |
| 老用户无法登录 | 旧算法校验逻辑被删 | 迁移保留期不要删旧代码 |
| 两个用户密码哈希相同 | 固定盐或拿用户名当盐 | 改用随机盐,每用户独立生成 |
| 数据库中密码可被反查 | 使用了可逆加密或明文 | 切换为哈希算法,杜绝可逆加密 |
这里特别提醒:网上有些教程教你先用AES加密密码,再在代码里解密校验,这绝对是错误示范。可逆加密保存密码,等于把钥匙和锁放在同一个口袋里,数据库一旦泄露,攻击者只要拿到密钥就能批量还原所有密码,比明文强的有限。
5. 密码安全不只是加密:这几件事同样重要
5.1 登录接口防暴力破解
即使你用了BCrypt,密码哈希本身很安全,但登录接口如果裸奔,攻击者可以直接在线上做密码喷洒,用常用密码去撞账号。BCrypt慢哈希在此时反而成了放大CPU压力的工具,一道简单的脚本就能把服务拖卡。
常见的缓解手段包括:同一IP短时间登录失败次数超过阈值时锁定一段时间;同一用户名连续失败就触发验证码,防止对指定账号的定向爆破;大规模失败后由风控介入,限制整个IP段。实现上可以用Redis做计数器,TTL按分钟设置,比如一个IP 5分钟内失败超过10次就临时封禁15分钟。
不要把这些防御写在应用内线程里,也不要用数据库做计数,高并发下这种逻辑本身就是性能瓶颈。用Redis的INCR加EXPIRE就能很轻量地实现限流。登录日志也要保持完整,记录请求来源和失败原因,方便事后审计。
5.2 密码策略与重置流程
加密强度再高,也挡不住用户把密码设置成“123456”。密码策略要在入口就控制:最小长度不少于8位,推荐12位以上;不能是连续数字、键盘序列、常见弱口令,这类检测可以借助黑名单字典实现;密码中最好同时包含字母和数字,但对符号的要求不必过于苛刻,强制过多反而促使小白用户把规则变成固定套路。
密码重置流程里最容易出问题的是两个点:一是找回密码接口暴露了“账号是否存在”,攻击者可以通过响应差异撞库枚举用户名;二是重置链接的时间窗口太长、权限校验太弱。实践中建议重置链接有效期控制在30分钟以内,一链接一用,使用后立刻失效。重置成功后通过站内信或邮件通知用户,发现问题时可以及时申诉。
5.3 日志与接口返回的脱敏
我见过最滑稽的安全事故是,密码加密做得很好,但登录失败日志里写了一条log.info("user {} login failed, password: {}", username, password)。数据库里看不出密码,日志导出时明文躺了一大片。密码在任何日志、异常信息、接口响应里都不能出现。
接口返回用户信息时,密码字段要彻底排除,不要有“为了省事把实体直接序列化”的习惯,否则某天加了个新字段,密码字段就可能被带出去。建议用户实体内部使用@JsonIgnore或单独建一个VO对象,只暴露需要的字段。即使是管理员后台导出用户表,密码哈希列也应该默认隐藏,需要脱敏处理后或明确授权才能导出。
数据库备份文件、慢日志、监控平台同样要注意,这些往往比代码日志更容易泄露数据。安全不是某一个点做得好,而是所有环节都没有明显短板。
结合我自己的经验,密码加密这件事最怕的其实不是“不知道用什么算法”,而是“以为做了就等于做对了”。选BCrypt、随机盐、恒定时长比较、历史平滑迁移,这套组合拳打完后,密码安全这块稳妥很多。还有一个容易被忽略的小细节:加解密相关的公共代码一定要写单元测试,至少覆盖“空密码”“中文密码”“超长密码”“相同密码两次加密结果不同”这几个用例,改代码或升级依赖时跑一遍,心里踏实。