密码重置流程:令牌、限流与旧会话失效

后台密码这件事,是我见过最容易「写了一版就再也不敢动」的功能。原因很实际:它涉及邮件、令牌、过期、登录态、限流五件事,改错一次就是「用户找不回账号」,客服会立刻炸。所以大多数个人站的做法是:注册时用框架自带的找回,之后就再也没人看过那段代码。

这篇文章把这条链路拆开讲,从申请令牌到重新登录,把每一步的实现、失效边界和常见漏洞都说清楚。它不是一个可以直接复制的类库,而是一份「你自己实现时该长什么样」的规格说明。

一、先定三件事,再写代码

动手之前必须先回答三个问题,因为它们决定了后面所有的设计:

  • 令牌放在哪? 放数据库(一次性、可吊销、能审计)、放 Redis(快、但要设过期)、还是无状态签名令牌(JWT 那种,不用存储但无法吊销)。个人站我建议放数据库,行数少、能查到「谁在什么时候申请过」,而且吊销就是一句 UPDATE。
  • 有效期多长? 15 到 30 分钟。太短用户来不及点,太长就是一张长期有效的后门。有效期必须由服务端判定,不能只靠 URL 上的时间戳。
  • 能不能重复用? 不能。这是整条链路的安全底线。令牌在被使用的那一刻就作废,哪怕这次请求因为别的原因失败了,也不能让它再活过来。

这三个问题回答完,剩下的就是工程细节。而我见过的事故,九成出在「令牌可以重复用」和「令牌没有绑用户」这两点上。

二、申请阶段:先做到「不泄露账号是否存在」

用户输入邮箱,点「发送重置链接」。这一步有一个广泛存在的错误做法:如果邮箱不存在,页面提示「该邮箱未注册」。听起来很友好,实际上它把整站的用户库变成了一个可以批量探测的接口——攻击者拿一份常见邮箱列表跑一遍,就知道哪些人在你这注册过。

正确做法是无论邮箱存不存在,都返回同一句话:「如果该邮箱已注册,我们已发送重置邮件」。内部再分两条路:存在就真的发信,不存在就什么也不做(但同样要耗时几百毫秒,别让它因为「没有发信」而特别快,这是很微弱的时序侧信道,但既然实现起来不难,就顺手抹平)。

申请的另一个重点是限流。这里要限三层,缺一层都能被拿来打邮件轰炸或者爆破令牌:

# Nginx 层:按 IP 限制申请频率
limit_req_zone $binary_remote_addr zone=reset_ip:10m rate=3r/m;
limit_req_zone $http_x_real_ip zone=reset_ip2:10m rate=3r/m;

location = /account/reset-request {
    limit_req zone=reset_ip burst=2 nodelay;
    limit_req_status 429;
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}

Nginx 层只能防同一 IP 刷。真正的防护要落在应用层:同一邮箱 10 分钟内只能申请一次,每天最多 5 次。这条规则必须在数据库里判,因为攻击者可以换 IP。实现就是把上一条的 created_at 查出来比较,别用「在黑名单里就不要发」这种含糊逻辑。

还有一个细节:申请成功后的响应页面不能带任何用户数据。不要显示「重置邮件已发送至 z***@qq.com 」这种掩码形式,攻击者只要试几个常见前缀就能反推完整邮箱。要么显示完整邮箱(因为对方本来就知道自己输的是什么),要么什么都不显示,别做部分掩码。

三、令牌本身:随机性、存储方式、绑定关系

令牌生成只有一条正确的写法:用密码学安全的随机源。PHP 里是 random_bytes(),不要用 rand()、mt_rand()、uniqid(),更不要用 md5(time())。后者的空间只有几万种可能,暴力枚举几分钟就能撞上。

$raw    = bin2hex(random_bytes(32));   // 给用户的令牌,64 个十六进制字符
$hash   = hash('sha256', $raw);        // 入库的是哈希,不是原文
$expire = date('Y-m-d H:i:s', time() + 1800);
$ip     = $_SERVER['REMOTE_ADDR'] ?? '';

入库存哈希,不存原文。 这一点和存密码是同一个道理:如果数据库被读了(SQL 注入、备份泄露、旧服务器卖掉),原文令牌等于一串可以直接登录的钥匙,而哈希值什么也做不了。校验时用同样的方式哈希用户提交的令牌再比对。

令牌必须和用户绑定,但不要和 IP 强绑定。这是很多人设计过严的地方:申请时记录了 IP,点击时必须同 IP,否则报错。听上去更安全,实际上会大量误伤——用户在公司邮箱收到链接,回家在手机上点开;或者从 Wi-Fi 切到 4G。我的做法是记录 IP 用于审计和风控(同一 IP 短时间内申请多个账号的重置就标记可疑),但校验时只校验令牌哈希和过期时间,IP 不同就写日志、不阻断。

如果你确实需要更强的绑定,绑 UA 的前若干字符比绑 IP 靠谱得多,因为 UA 在同一台设备上是稳定的。但这依然会误伤「在邮件客户端里打开」这种场景(内置浏览器 UA 和系统浏览器不同),所以绑 UA 也只能作为风控信号,不能作为硬性条件。

四、点击阶段:这里的顺序错一步,整条防线就没了

用户点开链接,服务端要做五件事,而且顺序很关键:

  1. 取出令牌行。 用哈希值查,查不到就直接返回「链接无效」。
  2. 判断是否已用、是否过期。 过期就删掉这行,返回「链接已过期,请重新申请」。
  3. 原子地把令牌作废。 这一步必须和「校验」在同一个事务里,否则并发下两个请求可以同时通过校验。
  4. 生成一个短期的「重置会话」,把它放在服务端 session 里,重定向到设置新密码的页面。
  5. 更新密码后,把该用户所有旧的登录态全部踢掉。

第三步是最容易写错的地方。常见的错误写法是:先 SELECT 查,判断 valid=1,然后在另一个请求里 UPDATE valid=0。这样两个并发请求都能通过 SELECT,最后一条令牌被用了两次。正确写法是用带条件的 UPDATE,靠影响行数判断成败:

UPDATE password_reset
   SET used_at = NOW()
 WHERE token_hash = ?
   AND used_at IS NULL
   AND expires_at > NOW();

rowCount() 等于 1 才算抢到,等于 0 说明已经被别人用掉或已过期,立刻返回失败。这一句是整条链路里唯一「原子性」保证,比任何校验代码都重要。

为什么要有第 4 步的「重置会话」,而不是让用户直接在同一个 URL 里填新密码?因为链接里带着令牌,如果页面表单直接 POST 到 /reset?token=xxx,这个令牌会出现在:浏览器历史、Referer 头(页面里有第三方资源时)、服务器访问日志里。而短会话说到底只是 $_SESSION 里的一个标志位,它不会出现在 URL 上。这一小步能消掉一批「令牌通过日志泄露」的风险。

五、新密码这一端:三条硬规矩

规矩一:改完密码必须让所有旧会话失效。 这是很多人漏掉的一步,也是「账号被盗」故事里最常见的结局:攻击者已经在里面了,用户改密码,攻击者照用不误。PHP 文件 session 的实现方式是改库里的 auth_token 或者删除 usr/sessions/ 下该用户的文件;如果是自建会话表,就是一句 UPDATE ... SET session_version = session_version + 1,然后校验时比对版本号。后者更干净,因为不用去遍历文件。

规矩二:新密码不能和历史密码相同。 至少要检查最近 3 次。实现上不需要单独的表,把 password_hash 历史的几个哈希存在用户表的一个 JSON 字段里就够(数量固定,几 KB)。这里要注意:比对时用 password_verify 逐个验,不能直接比哈希值——同一个密码每次用 password_hash 生成的哈希都不同(因为 salt 随机),比哈希永远不相等。

规矩三:改密码成功后要发一封通知邮件。 内容只说「你的密码在 X 时间被修改,如果不是你本人操作,请立刻联系我们」。这封信是整套流程里唯一的「事后告警」,很多人不做,但那恰恰是受害者发现问题的唯一机会。这封信里不能包含重置链接,也不要说「点击这里撤销」——撤销操作应该走「再申请一次重置」,不要在通知邮件里塞可执行的动作。

六、六个真实见过的问题

问题一:令牌出现在 Referer 里。 设置密码的页面如果加载了第三方统计脚本、字体、图片,浏览器会把完整 URL(含 query 里的令牌)作为 Referer 发出去,对方服务器的日志里就有你的令牌。修法:一是用第四节的「短会话 + 重定向」,二是页面加 <meta name="referrer" content="no-referrer">,三是 Nginx 上加 add_header Referrer-Policy "no-referrer" always;。三件事一起做,成本极低。

问题二:重置链接被邮件安全网关「预取」。 企业邮箱的链接扫描器会主动访问邮件里的所有链接,用来判断是不是钓鱼。如果「点击即作废」,扫描器一点,用户的链接就废了。这是一个真实存在且很难解释的客服问题。修法是:把「打开链接」和「提交新密码」分成两步——GET 打开页面只校验令牌、不作废;只有 POST 提交新密码时才作废。这样扫描器 GET 一下没有影响。第三节说的「原子 UPDATE」就放在 POST 处理里。

问题三:令牌表无限增长。 每次申请插一行,两年下来几十万行,还带着用户 id。加上清理:每天跑一次 DELETE FROM password_reset WHERE expires_at < NOW() - INTERVAL 7 DAY。这里保留 7 天而不是立刻删,是为了留着做「某用户今天申请了 12 次」这类风控查询。

问题四:用户重复点击导致状态混乱。 用户在浏览器里点两次、或者在两个标签页里都打开了同一封邮件。因为第二步的原子 UPDATE,第二次会拿到「链接已使用」——这是正确行为,但提示语要写清楚:「该链接已使用过,如果你刚才设置成功,请直接登录;否则请重新申请」。不要只说「无效」,那会让用户以为系统坏了。

问题五:改完密码却登录不了。 最常见的原因是密码规则不一致:前端限制了 8-20 位,后端正则允许 6-64 位,而重置接口用的是第三套规则。用户在重置页设了一个 25 位的密码(前端没拦住,因为重置页用的是旧前端),后端存进去了,但登录页的正则只允许 20 位——于是他登录不了,而且看到的提示是「密码错误」。修法是把规则抽成一份配置,注册、登录、重置、改密码四处引用同一份。

问题六:日志里留下了明文令牌。 排查问题时我在访问日志里看到 GET /reset?token=9f3a...,而那条日志被 rsyslog 转到了另一台机器,还被备份到了对象存储。修法:Nginx 的 log_format 里把 query 里的 token 参数抹掉,或者干脆把重置接口从访问日志里排除:

map $uri $loggable {
    ~^/reset     0;
    default      1;
}
access_log /var/log/nginx/access.log main if=$loggable;

七、从零到上线的顺序

如果你现在就要动手,按这个顺序做,每一步都能独立验证:

  1. 建表:password_reset(id, uid, token_hash, expires_at, used_at, ip, ua, created_at),token_hash 上加唯一索引——唯一索引本身就是一层并发保护。
  2. 写申请接口:只做「存在则插行并发信」,先不管限流,手工测通。
  3. 写点击接口:GET 只校验并展示表单,POST 做原子 UPDATE 并更新密码。
  4. 加登录态失效:确认改完密码后旧 session 真的用不了(开两个浏览器实测,不要只信代码)。
  5. 加三层限流:Nginx 按 IP、应用按邮箱、全局按小时总量。
  6. 加通知邮件和清理 cron。
  7. 最后再做「不泄露账号是否存在」这句提示语的统一。这条放最后是因为它最容易被漏,但改起来只要一行。

八、小结

密码重置这条链路,真正决定安全性的不是加密算法,而是三件很朴素的事:令牌只能用一次、令牌存的是哈希不是原文、改完密码要让旧会话失效。这三条守住了,即使实现得很粗糙,也不会出大事故;这三条任意一条破了,剩下的设计再精致也没用。至于限流、不打码的提示语、Referer 保护,都是「让攻击变贵一点」的增量,按顺序慢慢加就好。

Last modification:November 6th, 2026 at 10:29 pm

Leave a Comment