password_reset 表必须包含 token_hash(VARCHAR(255),非空,存 Argon2 哈希值)、user_id(外键)、used_at(TIMESTAMP NULL)、expires_at(NOT NULL),并建立 (token_hash, used_at) 联合唯一索引。

生成并发送重置令牌时,为什么不能直接用 md5($email . time())
因为时间戳可预测、无熵,且 md5 是快速哈希,容易被暴力碰撞。更严重的是,若攻击者控制邮箱(如通过开放重定向或 SSRF),可能批量生成有效链接。
正确做法是使用密码学安全随机数 + 单向哈希:
- 用
random_bytes(32)生成令牌,再经bin2hex()转为字符串 - 存储前必须用
password_hash($token, PASSWORD_ARGON2ID)加密(不是哈希邮箱或时间) - 令牌本身绝不落库明文,也不参与任何可逆运算
- 链接有效期严格设为 15–30 分钟,数据库字段加
expires_at并带索引
password_reset 表结构设计的关键约束有哪些
常见错误是只存 token 和 user_id,漏掉关键防护字段。
-
token_hash:VARCHAR(255),存password_hash()结果,不可为 NULL -
user_id:外键关联用户表,级联删除或手动清理旧记录 -
used_at:TIMESTAMP NULL,首次验证成功后立刻写入,后续请求直接拒绝 -
expires_at:TIMESTAMP NOT NULL,生成时设为date('Y-m-d H:i:s', time() + 1800) - 联合唯一索引:
(token_hash, used_at)防止重复使用,同时加速查询
验证令牌时,password_verify() 调用失败的常见原因
不是密码不匹配,而是流程逻辑出错。典型现象:前端点了重置链接,后端返回“令牌无效”,但数据库里明明有记录。
- 查库时用了
SELECT * FROM password_reset WHERE token_hash = ?,但没加AND used_at IS NULL AND expires_at > NOW() - 调用
password_verify($_GET['t'], $row['token_hash'])前,没先检查$row是否存在 - 令牌从 URL 获取后未过滤空格或换行(尤其邮件客户端自动折行),应强制
trim($_GET['t']) - 数据库字符集为
utf8mb4,但token_hash字段定义为VARCHAR(60)——PASSWORD_ARGON2ID输出长度约 97 字符,会截断
更新密码后,必须立即失效所有其他活跃会话
用户重置密码 ≠ 当前登录态自动过期。如果只改了 users.password,攻击者仍可用旧 session 继续操作。
- 在事务中完成:更新密码 + 清空该用户的
sessions表记录(或设invalidated_at时间戳) - 若用 PHP 内置 session,需调用
session_destroy()并删 cookie,但注意这仅影响当前请求的 session_id - 更可靠的是在用户表加
password_changed_at字段,中间件每次请求校验session_created_at - 别忘了清 Redis 或数据库里的 remember_token(如有“记住我”功能)
令牌不是一次性的意思,是它根本不能被重复解析;session 失效不是靠删 cookie,而是靠服务端主动拒绝旧凭证。这些细节不写进代码,光靠文档没人记得住。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











