忘记密码令牌存储:用户实体加字段还是独立关联实体更优?
问题解答
一、最初方案的实现方式(不推荐)
如果一定要在User实体上加布尔字段标记、存储令牌,TypeORM设置布尔字段默认值为false非常简单,直接在@Column装饰器中传入default配置即可。注意光靠布尔值完全无法支撑完整逻辑,你还需要额外存储令牌内容、过期时间,参考字段定义:
// User实体中新增以下字段 @Column({ default: false }) isForgotPasswordToken: boolean; @Column({ nullable: true }) forgotPasswordToken: string; @Column({ type: 'timestamp', nullable: true }) forgotPasswordTokenExpiresAt: Date;
对应逻辑流程:
- 用户申请重置密码时,先查询用户:如果
isForgotPasswordToken为true且当前时间早于forgotPasswordTokenExpiresAt,直接提示用户已有有效重置链接,无需重复申请 - 如果令牌已过期,生成新令牌、更新过期时间为当前时间+2小时,将
isForgotPasswordToken设为true后保存到用户记录 - 用户完成密码重置后,清空令牌和过期时间字段,将
isForgotPasswordToken设回false
二、为什么上述方案不符合最佳实践
这个方案属于典型的把附属业务字段堆在核心用户表的实现,存在几个硬伤:
- 扩展性极差:后续如果要加邮箱验证令牌、手机验证码令牌、一次性登录令牌之类的逻辑,会不断给User表加冗余字段,最后User表会变得非常臃肿难以维护
- 安全能力缺失:单用户只能存储一枚令牌,无法记录令牌申请历史,也无法实现令牌泄露后主动作废所有有效令牌、审计重置申请记录之类的安全需求
- 逻辑灵活性差:如果后续要支持「同一用户多次申请重置时自动作废之前的旧令牌」「限制单位时间内申请重置的次数」之类的需求,在User表上改造的成本非常高
三、推荐实现:独立密码重置令牌实体
单独建PasswordResetToken实体和User表做关联是行业通用的最佳实现,具体实现步骤如下:
1. 新建令牌实体
// src/entities/PasswordResetToken.ts import { Column, Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, CreateDateColumn } from "typeorm"; import { User } from "./User"; import bcrypt from 'bcryptjs'; @Entity('password_reset_tokens') export class PasswordResetToken { @PrimaryGeneratedColumn('uuid') id: string; // 存储令牌的哈希值,*禁止存明文令牌*,避免数据库拖库后令牌直接泄露 @Column() tokenHash: string; // 令牌过期时间 @Column() expiresAt: Date; // 标记令牌是否已使用 @Column({ default: false }) isUsed: boolean; // 关联对应用户,用户删除时级联删除对应令牌记录 @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'userId' }) user: User; @Column() userId: string; @CreateDateColumn() createdAt: Date; // 令牌校验工具方法 async compareToken(plainToken: string): Promise<boolean> { return bcrypt.compare(plainToken, this.tokenHash); } }
2. 调整忘记密码控制器逻辑
先修正你现有代码的几个问题:
- 不要复用登录用的JWT密钥和过期时间配置,重置令牌属于一次性安全令牌,建议用高随机度的字符串生成,不要用和登录态相同的JWT
- 无论用户邮箱是否存在,返回提示尽量统一,避免被攻击者枚举已注册邮箱
- 生成新令牌前先检查是否存在有效令牌,避免重复发送
参考修改后的控制器逻辑:
import { Request, Response } from "express"; import { MoreThan } from "typeorm"; import { User } from "../entities/User"; import { PasswordResetToken } from "../entities/PasswordResetToken"; import bcrypt from 'bcryptjs'; import { AppDataSource } from "../database/Config"; import crypto from 'crypto'; class ForgotPasswordController { // 申请重置密码接口 async requestReset(req: Request, res: Response) { const { email } = req.body; const tokenRepository = AppDataSource.getRepository(PasswordResetToken); const userRepository = AppDataSource.getRepository(User); try { const user = await userRepository.findOneBy({ email }); // 统一返回提示,避免枚举邮箱 if (!user) { return res.status(200).json('如果该邮箱已注册,我们将发送有效期2小时的重置链接至您的邮箱'); } // 检查是否已有未过期、未使用的有效令牌 const existingValidToken = await tokenRepository.findOne({ where: { userId: user.id, isUsed: false, expiresAt: MoreThan(new Date()) } }); if (existingValidToken) { return res.status(200).json('如果该邮箱已注册,我们将发送有效期2小时的重置链接至您的邮箱'); } // 生成高随机度明文令牌 const plainToken = crypto.randomBytes(32).toString('hex'); // 哈希后存库 const tokenHash = bcrypt.hashSync(plainToken, 8); // 过期时间设为2小时后 const expiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000); // 保存新令牌 const newToken = tokenRepository.create({ userId: user.id, tokenHash, expiresAt }); await tokenRepository.save(newToken); // 这里写发邮件的逻辑,把plainToken拼到重置链接里发给用户,比如 https://你的域名/reset-password?token=xxx // await sendResetEmail(user.email, plainToken) return res.status(200).json('如果该邮箱已注册,我们将发送有效期2小时的重置链接至您的邮箱'); } catch (err) { return res.status(500).json(err); } } // 重置密码提交接口 async resetPassword(req: Request, res: Response) { const { token, newPassword } = req.body; const tokenRepository = AppDataSource.getRepository(PasswordResetToken); const userRepository = AppDataSource.getRepository(User); try { // 查出所有未过期未使用的令牌 const validTokens = await tokenRepository.find({ where: { isUsed: false, expiresAt: MoreThan(new Date()) }, relations: ['user'] }); // 比对找到匹配的令牌 let matchedToken: PasswordResetToken | null = null; for (const t of validTokens) { if (await t.compareToken(token)) { matchedToken = t; break; } } if (!matchedToken) { return res.status(400).json('无效或已过期的重置链接'); } // 更新用户密码 const user = matchedToken.user; user.password = newPassword; await userRepository.save(user); // 作废当前令牌,同时把该用户所有其他未使用的令牌也全部作废 await tokenRepository.update( { userId: user.id, isUsed: false }, { isUsed: true } ); return res.status(200).json('密码重置成功,请使用新密码登录'); } catch (err) { return res.status(500).json(err); } } } export default new ForgotPasswordController();
3. 方案优势
- 符合单一职责原则:核心User表不需要存储临时业务字段,结构更干净
- 安全度更高:令牌哈希存储、可主动作废、可追溯所有重置申请记录
- 扩展性强:后续要加令牌申请次数限制、多端令牌隔离、过期时间自定义之类的需求,只需要修改令牌实体即可,不需要动核心User表逻辑
额外注意点
- 不要用无状态JWT做重置令牌:JWT签发后无法主动作废,一旦泄露只能等过期,风险极高,一次性安全令牌必须做持久化存储
- 重置接口要加频率限制,避免被恶意刷邮件
- 重置密码成功后最好给用户发一封提醒邮件,告知密码已被修改,如果不是本人操作请及时联系客服
内容的提问来源于stack exchange,提问作者Routfin
相关产品推荐
相关产品推荐

