You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

忘记密码令牌存储:用户实体加字段还是独立关联实体更优?

问题解答

一、最初方案的实现方式(不推荐)

如果一定要在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 19:06:34