做Java后端的朋友,迟早会在某个版本里遇到用户密码这一关。我两年前接手过一个运营了几年的老系统,用户表里的password字段存的就是明文,运维只要拿到数据库备份,就能看到所有人的密码。更麻烦的是,代码里还自己封装了一套“加密工具”,用的是AES加固定密钥,密钥就写在配置类里。像这样的历史包袱,我猜不少项目里都有。
用户密码管理在Java生态里不算新话题,但能说清楚的人真不多。很多人知道不能用MD5,也知道要加盐,但一到了算法选型、字段设计、迁移方案、Spring Security集成这些具体环节,就开始凭感觉来。今天就把我做密码管理的经验整体整理一遍,重点聊清楚四件事:密码哈希算法怎么选、注册登录怎么落地、旧系统怎么平滑升级、生产环境有哪些坑。适合正在做用户体系的Java后端开发者,也适合准备给老项目做密码安全整改的架构师。
1. 为什么MD5和SHA-256不能用来存密码
1.1 快速哈希的致命短板:算得太快
先明确一点:存储密码用的不是加密,而是哈希。加密可逆,哈希不可逆。所以正确的做法是对用户输入的密码做单向哈希后入库。但“单向”只是第一层要求,真正筛选算法的是另一条指标:计算速度。
MD5在诞生时追求的是吞吐量,SHA-256也一样,它们是给文件校验、数据去重用的,速度越快越好。可密码校验恰恰需要反过来——慢。一个普通的GPU集群跑MD5,每秒可以做几十亿次计算,哪怕是加了随机盐,只要口令本身不够复杂,暴力穷举也只是时间问题。把密码交给MD5,等于给保险柜换了一把普通木门,锁芯再豪华也没用。
正规的密码哈希算法都是刻意设计成“慢”的,业内叫慢哈希或者密钥派生函数。它们通过大量迭代、占用内存等方式提高单次计算的成本。对正常用户来说,登录时多花几十毫秒几乎无感,但对攻击者来说,一次破解尝试的成本从纳秒级变成毫秒级甚至百毫秒级,量级完全不同。
1.2 加盐不是万能药:很多人都理解错了
加盐的目的是解决两个问题:一是相同密码会产生相同哈希,攻击者能通过频率分析发现规律;二是预计算的彩虹表可以直接命中普通哈希。盐是一段随机数据,在哈希前拼进密码里,让相同密码在不同用户身上得到完全不同的哈希值。
但很多人对盐的理解只停留在“加了就行”,于是用了固定盐,甚至用用户名当盐。固定盐等于没加,只要被泄露,整张表的哈希都可以被离线碰撞。用用户名当盐的问题更隐蔽:用户名变更后,密码哈希就得重新生成;用户名被回收重用时,老用户的哈希甚至可以被新用户输入撞出来。正确的做法是每个用户独立生成随机盐,长度至少128位,也就是16字节。盐不需要保密,通常和哈希一起放进同一个字符串里存储。
不过盐解决不了弱密码。密码如果是123456,加完盐依然是字典里排在前面的候选。所以后面聊密码策略时我会强调:算法和盐只是最后防线,弱口令拦截必须放在注册阶段。
1.3 Java生态里可以选的四款密码哈希算法
目前主流方案基本就四个:PBKDF2、bcrypt、scrypt、Argon2id。我先把差异放在一张表里:
| 算法 | 核心思路 | 内存占用 | Java接入难度 | 我的优先级 |
|---|---|---|---|---|
| PBKDF2 | 反复执行伪随机函数 | 低 | JDK自带 | 可用但不突出 |
| bcrypt | 基于Blowfish的慢哈希 | 低 | Spring Security自带 | 推荐 |
| scrypt | 抗ASIC,依赖大内存 | 高 | 需要额外依赖 | 看场景 |
| Argon2id | 内存加CPU综合防护 | 高 | 需要额外依赖 | 当前首选 |
PBKDF2是JDK原生支持的,用SecretKeyFactory就能实现,不引第三方包也能跑。缺点是它对GPU和ASIC的抵抗相对弱,但只要迭代次数够大,作为生产方案依然达标。
bcrypt是Java后端用得最广的方案,每个用Spring Security的项目,最后基本都会落到BCryptPasswordEncoder上。它内置盐,输出结果自带版本和成本因子,字段设计非常省心。
scrypt和Argon2id更强调内存占用,攻击者要用大规模并行硬件破解,成本会高很多。Argon2id在业界评价很高,适合对安全要求更高的系统。
选型建议很直接:已经在用Spring Security,直接用bcrypt,资料多、坑少;没有框架依赖,用JDK自带的PBKDF2也能接受;想一步到位,Argon2id值得投资,前提是你愿意维护额外依赖。
2. 用户注册与登录的完整实践:从BCrypt到字段设计
2.1 注册时生成密码哈希的正确流程
先看一个最简单的注册流程:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; public class RegistrationService { private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(10); public void register(String username, String rawPassword) { if (isWeakPassword(rawPassword)) { throw new IllegalArgumentException("弱密码"); } String encoded = passwordEncoder.encode(rawPassword); userRepository.insert(username, encoded); } }这段代码里值得注意的细节有三个。
第一,不要把原始密码做trim、大小写转换、去空格等预处理。密码是敏感业务数据,用户输入什么就应该取什么。交互上要防的只是用户在输入框多按了空格,那是前端校验和输入提示的事。后端一旦改了原始输入,哈希结果就彻底变了,用户自己都说不清原密码是什么。
第二,new BCryptPasswordEncoder(10)里的10被称为cost,意思是内部迭代次数是2的10次方,也就是1024轮。cost越大,单次验证越慢,也就越抗暴力破解。具体到底设多少,要看服务器性能和应用场景,我在后面踩坑部分会专门讲。
第三,encode方法每次调用都会生成不同的盐,所以同一个原始密码两次调用,返回的哈希完全不一样。注册时感知不强,但写测试用例时,千万不要断言两次encode结果相等。
我习惯在注册时同时记录password_changed_at字段,后面做定期审计和强制改密时非常有用。
2.2 登录校验时matches方法的底层逻辑
登录校验在代码上确实只有一行:
public boolean login(String username, String rawPassword) { User user = userRepository.findByUsername(username); if (user != null && passwordEncoder.matches(rawPassword, user.getPasswordHash())) { return true; } return false; }但这一行背后的逻辑值得拆开看。BCrypt存储的哈希字符串长这样:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy这段字符串本身是自描述的:$2a$表示版本,10表示cost,后面22个字符是自动生成的盐,再后面31个字符是真正的哈希值。matches方法会先从存库的哈希字符串里把盐和cost解析出来,然后用它们对用户本次输入的原始密码做一次哈希,最后用恒定时间比较的方式对比结果,避免用equals比较时产生时间差泄漏信息。
这也是为什么很多方案都不再单独存盐字段。盐就在哈希字符串里,读取和验证都从同一个字段取,数据库和代码都省事。
恒定时间比较还提醒了一件事:不要在matches之前手动equals一次自己算出的哈希,更不要自己写循环比对逻辑,直接用框架实现就好。
2.3 数据库字段设计:别在密码列上省空间
很多表设计把密码字段定成varchar(20)或varchar(50),这明显是按明文密码和弱哈希来留空间的。换到bcrypt之后,单哈希就要60个字符。如果再算上Spring Security的前缀方案,实际存储长度要到68甚至更长。
我现在的建表习惯是这样的:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, password_changed_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );password_hash直接给255,看着浪费,但给未来换算法留了余量。数据库里这种字段省几个字符不会有任何性能收益,省得太狠反而埋雷,后面我会讲一个因为字段长度不够导致的真实故障。
另外务必给password_hash加NOT NULL约束,并且不要让它在普通业务查询中默认返回。密码哈希对普通开发人员都不应随意可见,能脱敏就脱敏,不能脱敏就把敏感表和普通表做隔离。
2.4 老系统明文密码的平滑迁移方案
老系统如果是明文密码,绝对不能靠一条SQL把所有密码刷成哈希,因为系统根本不知道用户真实的原始密码是什么。最稳妥的方式是“登录时升级”。
用户输入密码登录后,系统先拿原始密码做明文比对;比对成功,立刻用bcrypt生成新哈希,写回password_hash。这样用户全程无感知,密码自动从明文升级成了bcrypt。
如果老系统已经是MD5加盐,逻辑也一样,只是在“校验通过”后多做一步rehash:
if (legacyPasswordMatches(rawPassword, user.getPasswordHash())) { String newHash = passwordEncoder.encode(rawPassword); user.setPasswordHash(newHash); userMapper.updatePassword(user.getId(), newHash); }这里要提醒一个容易忽略的点:校验通过并rehash之后,一定要保证更新操作和登录状态创建在同一个事务里。不然用户这次登录成功,但哈希没写库,下次登录又走一次老逻辑,一次登录产生两次不必要的计算开销。
对于长期不登录的老用户,“登录时升级”会拖很久,可以在后台做一个“超过N天未升级的用户强制走一次找回密码流程”的机制作为兜底。
3. Spring Security中PasswordEncoder的整合与兼容技巧
3.1 DelegatingPasswordEncoder的设计逻辑
Spring Security 5引入的DelegatingPasswordEncoder,解决的就是多算法共存问题。它的存储格式很简单,在哈希字符串前面加一个前缀:
{bcrypt}$2a$10$... {pbkdf2}... {noop}明文框架看到前缀后,会自动路由到对应的PasswordEncoder实现。配置也非常简单:
@Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); }这样做的价值在迁移场景里体现得非常明显。老项目用了旧算法,新项目想换成bcrypt,不必要求所有用户下次登录都强制改密码。老用户的哈希前缀还是{旧算法},框架依然能正确验证;新用户统一走新算法,等所有活跃用户都完成升级后,再把旧前缀的支持关掉就行。
这里要专门提一句:{noop}是明文前缀,只适合本地跑通Demo。生产环境如果出现{noop}开头的记录,基本等于把密码挂在墙上。我在代码评审时只要见到它,一定会要求修复。
3.2 自研PasswordEncoder最容易踩的坑
密码管理这块,我最常说的劝告就是:不要自己造轮子,尤其不要自研PasswordEncoder。
自研实现最常见的问题有三个。第一个是用普通哈希再自己加盐,比如SHA-256拼接用户名当盐,还专门建一列salt字段,查询时先取盐再拼接。这类组合没有经过时间检验,破绽往往比想象中多。第二个是用了对称加密,把密码AES加密后入库,密钥又不小心写死在配置里,一旦密钥泄露,所有密码全部可还原,完全违背了“不可逆存储”的底线。第三个是自研比较逻辑,用equals比较两个哈希字符串,攻击者可以通过时间差猜出匹配前缀,原理上很简单,但很多人根本没意识到。
如果非要说有什么值得自己封装,无非是加一个是否过期、是否需要迁移的标记。密码算法本身永远选择现成实现。
3.3 旧系统MD5加盐哈希的临时兼容实现
假设老库里存的是MD5加盐,格式是“salt:md5(salt+password)”,想平滑迁到Spring Security体系,可以临时写一个只用来校验的PasswordEncoder:
public class LegacyMd5PasswordEncoder implements PasswordEncoder { @Override public String encode(CharSequence rawPassword) { throw new UnsupportedOperationException("旧算法不支持新密码编码"); } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { if (encodedPassword == null || !encodedPassword.contains(":")) { return false; } String[] parts = encodedPassword.split(":", 2); return DigestUtils.md5DigestAsHex( (parts[1] + rawPassword.toString()).getBytes(StandardCharsets.UTF_8) ).equals(parts[0]); } }然后在配置里把这个实现挂到DelegatingPasswordEncoder上,前缀可以叫{legacy-md5}。新注册用户一律用{bcrypt},老用户保留{legacy-md5}。每次老用户登录成功,就走前面说的rehash逻辑,把库里的前缀更新成{bcrypt}。
这个类必须写成“只能matches、不能encode”,防止以后有人误用旧算法给用户生成密码。等存量用户清零,这个类就可以整体删掉了。整个过程不打扰用户,所有换算法动作都在登录成功那一瞬间完成。
4. 密码策略、防暴力破解与找回流程的工程化设计
4.1 密码强度规则:与其限制复杂字符,不如检查泄露密码库
很多团队的密码规则仍然是老一套正则:必须包含大小写字母、数字和特殊字符,最短8位。效果怎么样?看看那些被公开的密码集合就能发现,大量“合规”密码都长成P@ssw0rd1、Admin123!这种形式。用户为了满足规则,会自觉地把字典单词套上同样几种变形,攻击者只要把字典也做同一种变形,规则形同虚设。
我现在更倾向于三个方向。第一,把最小长度提到12位以上,长度对密钥空间的影响比字符集种类更直接。第二,维护一份高频弱密码黑名单,把已知泄露库里出现频率最高的几万个密码拿来做本地过滤,注册和改密时直接拒绝。第三,借鉴强度评估算法的思路,对键盘序列、重复字符、用户名与密码相同这类模式做额外拦截。
关于定期改密,现在很多安全团队已经不再强制要求用户每90天改一次密码,因为频繁变更多半让用户把新密码写在便利贴上。真正确认密码已经泄露时,再做定向强制重置才有意义。
4.2 登录接口限流与临时锁定:业务侧该有的防御
密码哈希做得再好,也只是把单次破解成本拉高。攻击者如果控制了大量IP和账号库,还是可以慢慢磨。所以业务侧一定要加防暴力破解的机制。
最基本的做法是按“账号+IP”维度统计失败次数。比如连续失败5次后,该账号临时锁定15分钟;同一IP短时间内大量尝试不同账号,则在IP维度上执行验证码挑战或者更低频限制。实现上可以借助带过期时间的本地缓存,骨架大概长这样:
// 用Guava Cache做计数器,15分钟过期 LoadingCache<String, AtomicInteger> failures = CacheBuilder.newBuilder() .expireAfterWrite(15, TimeUnit.MINUTES) .build(CacheLoader.from(() -> new AtomicInteger(0))); String key = username + ":" + clientIp; int current = failures.get(key).incrementAndGet(); if (current >= 5) { throw new TooManyAttemptsException(); }这段代码只能算骨架,生产上更建议把计数器放Redis并控制并发,但思路是一致的:失败计数要有时效,不能因为一个月前的错误把用户今天锁死;锁定时间到了要自动恢复;要给管理员解锁入口。
完整锁定账号也有被滥用的风险。攻击者可以用故意输错密码来锁别人的号,造成拒绝服务。所以很多系统会退一步,不做硬锁定,而是要求解锁后必须通过验证码。两种策略我都实践过,如果业务对用户体验敏感,优先选择“失败多次后增加验证码”,而不是直接封禁。
4.3 找回密码Token的设计要点
找回密码功能是密码管理里最容易出漏洞的角落。我见过太多找回链接里的token是时间戳加用户id拼出来的,或者干脆把用户id直接放在URL里,这种设计等于告诉攻击者可以重置任何人的密码。
正确做法并不是很复杂。用SecureRandom生成至少32字节的随机数作为token,这个值交给用户,但库里只存它的哈希。就像密码一样,token本身也不应该可逆存储。用户点击链接后,系统比较token哈希,匹配成功且未过期,才允许进入设置新密码的页面。
有效期建议15到30分钟,必须一次性,使用后立刻从数据库删除。链接里别带密码参数,页面提交新密码时也不能在URL里传递。日志、邮件记录、服务器访问日志里都要对token做脱敏,或者干脆避免完整token落盘。
很多团队喜欢用现成的JWT当找回密码token,我不建议这么做。JWT签发之后在服务端没有状态,很难主动作废;找回密码场景恰恰要求短生命周期和强作废能力。一个随机的、服务端可控的字符串比JWT更合适。
5. 生产环境踩坑实录:密码模块的几个经典翻车现场
5.1 数据库字段长度不够,导致哈希被截断
我排查过一次登录故障,现象很诡异:一部分新用户注册成功,但登录时一直提示密码错误,旧用户反而正常。查数据库才发现,新用户的password字段是varchar(45),而bcrypt哈希是60个字符。MySQL在非严格模式下插入过长字符串不会报错,而是悄悄截断。于是入库的哈希只有前44个字符,matches自然永远失败。
问题找到后,修复并不难:把字段改成255,清掉那批坏数据或者走一遍重置密码。但这个坑本来可以在建表阶段就避免,varchar(45)这个值一看就是用明文时代的标准在估算长度。密码哈希字段的设计不是给当前哈希留空间,而是要给未来升级留空间,所以我现在的表都直接上255。
排查这类问题时,最快的定位方式就是看数据库里存的哈希长度是否正常。如果看到长度和标准算法输出不一致,基本上就是截断或乱码。
5.2 不同语言生成的bcrypt哈希,兼容性没那么简单
做跨语言项目时,很多人以为bcrypt是全球通用标准,哪个语言生成的哈希拿到Java里都能匹配。现实没那么理想。
bcrypt常见的前缀有$2a$、$2b$、$2y$这三种。有些语言的默认实现生成$2y$,部分Java库收到$2y$会直接当成非法输入,有些能解析但比较结果不对。反过来,老版本Java实现生成$2a$,拿到其他环境里也可能遇到问题。我遇到过Java端用某个老版本BCrypt实现校验另一个环境生成的哈希一直失败,最后干脆把对账逻辑统一放到单一口径里,才从折腾里解脱出来。
如果现在要跨语言共用密码库,建议尽量采用统一的密码管理端来生成和校验哈希,或者至少做一次小规模的交叉测试,把不同语言各生成几个样本互相验证。不要上了生产才发现两边的哈希不互通。
5.3 成本因子拍脑袋调高,登录接口直接超时
成本因子这件事,调高一时爽,代价很容易被忽略。我有一次调整bcrypt的cost,从10直接提到13,结果单次密码验证从大约80毫秒涨到接近600毫秒。某个内部系统的登录接口平时并发不算高,这个单次时间叠加起来,高峰时段接口RT冲到好几秒,数据库连接也跟着吃紧。
后来我把cost调回12,才达到可接受的平衡。这个教训让我养成了一个习惯:涉及成本因子的调整,先压测,再灰度。压测目标很简单,预估生产峰值登录并发每秒有多少,然后保证在峰值并发下,单次验证耗时不会让CPU长期运行在超阈值状态。
安全社区对bcrypt参数的建议一般在10到13之间浮动,具体多少没有绝对答案。我的判断是:如果服务器性能一般或登录并发高,cost取10到12;如果面向高安全场景且机器有余量,cost可以取12以上,但一定要用真实流量峰值做过验证再上。
5.4 日志与异常信息里千万别带密码原文
最后这个坑看起来最“低级”,但实际出现频率极高。很多团队接了统一日志平台之后,会把所有请求参数打成一个JSON,登录接口自然就把password明文写进去了。我曾在日志检索里搜一个用户id,结果旁边就是用户输入的原始密码,那一瞬间的冷汗直到现在都记得。
处理方式我从三个层面一起做。第一,在接收请求的DTO里,给密码字段加脱敏注解或自定义序列化器,确保日志序列化时不输出明文。第二,全局请求日志过滤器里把敏感字段名加入exclude列表,碰到password、oldPassword、confirmPassword直接替换成***。第三,异常信息里不要携带原始密码变量,catch块中避免把request对象整个打印出来。
另外在登录提示上也要统一口径。不要用户不存在时返回“用户不存在”,密码错误时返回“密码错误”,这等于把账号是否存在的情报送给了攻击者。无论哪种情况,统一返回“用户名或密码错误”就好。
最后按自己的习惯收个尾。做密码管理,技术门槛其实不高,真正拉开差距的是细节:字段长度、成本因子、日志脱敏、迁移节奏。每次把这些点写进代码评审清单之后,坏味道就少了很多。如果你手头正有一个老系统要做密码安全升级,我的建议是从一张表开始试点,先把注册和登录两条链路跑通,再逐步覆盖找回和强制改密,别一上来就搞大而全的重构。老老实实选一个标准算法,给字段留足长度,把验证和升级逻辑放进同一个事务里,这套底子就足够稳。