其实这个技术可以靠ai编程实现,但是我想水一篇文章,所以决定写一下本站是如何实现的,大家可以直接拿本文章给ai进行参考集成到你的网站,下面是没有依赖 `otplib` 等第三方库,服务端 + 前端完整落地记录。(其实此文也是ai拟写~)
为什么做这件事
最近顺手自己做了一个博客系统,同步做了一大堆安全防护,一般大家的网站后台管理着文章、评论、等设置,一旦账号密码泄露就是"裸奔"。虽然各位在部署网站时常常会在后台登陆加入各种防线,但大多数网站只有”密码“这一道认证,验证码只是作用于人机检测和防撞库频率,意味着:密码一旦被撞库或钓鱼,攻击者就能长驱直入。
两步验证(2FA)的思路很简单——认证的密码只有你自己可以在手机验证器app中可以看到,并且会自动刷新。
效果如图
后台设置页点"开启 2FA" → 认证器扫码/手动录入 → 输入 6 位码确认 → 以后每次登录输完密码还要多输一次手机上的动态码。整个过程零第三方依赖,核心算法加起来不到一百行,安全感拉满。



方案上我选了最通用的 TOTP(基于时间的一次性密码),也就是 Google Authenticator / Authy 那套。它不依赖短信、不需要额外发邮件,服务器和认证器各凭一个共享密钥 + 当前时间就能算出同一串 6 位数字。
为什么要自己造轮子
之所以手写而不是用 `otplib`,TOTP 核心算法本身只有几十行,用内置 `crypto` 完全可以覆盖,还能顺手把一些细节(恒定时间比较、容错时间窗)按自己的要求实现。
一、TOTP 原理
服务器生成一个随机的共享密钥 `secret`(Base32 编码,方便人肉录入);
当前 Unix 时间戳除以 `period`(30 秒)得到计数器 `counter`;
用 HMAC-SHA1 对 `counter` 做签名:`HMAC(secret, counter)`;
取 HMAC 结果的动态截断(Dynamic Truncation),得到 6 位数字;
服务器和认证器在相同时间、相同密钥下会算出**完全相同**的 6 位数字。
因为算法是公开的,用户只需要在认证器 App 里录入密钥(扫码或手动输入),之后 App 每 30 秒生成一次验证码。

二、数据库:两个新字段
`AdminUserEntity` 增加两个字段,`totp_secret` 允许为空,`totp_enabled` 默认关闭:
@Column('varchar', { name: 'totp_secret', length: 255, nullable: true })
totpSecret: string | null;
@Column('tinyint', { name: 'totp_enabled', default: 0 })
totpEnabled: boolean;注意一个细节:`secret` 和 `enabled` 是分开存储的,这正好支撑了"先扫码、后启用"的流程——密钥可以先存着,但开关始终是关闭的,直到用户用验证码确认过才置为 `true`。
三、服务端:手写 TOTP 工具类
const STEP_SECONDS = 30;
const DIGITS = 6;
const WINDOW = 1; // 容错 ±1 个时间窗
/** 生成 32 位大写 Base32 密钥 */
export function generateTotpSecret(): string {
return base32Encode(crypto.randomBytes(20));
}
/** 生成某个时刻的 TOTP(动态截断) */
function generateTotp(secret: string, offset = 0): string {
const counter = Math.floor(Date.now() / 1000 / STEP_SECONDS) + offset;
const buffer = Buffer.alloc(8);
buffer.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
buffer.writeUInt32BE(counter >>> 0, 4);
const hmac = crypto.createHmac('sha1', base32Decode(secret)).update(buffer).digest();
const offsetByte = hmac[hmac.length - 1] & 0x0f;
const binary =
((hmac[offsetByte] & 0x7f) << 24) |
((hmac[offsetByte + 1] & 0xff) << 16) |
((hmac[offsetByte + 2] & 0xff) << 8) |
(hmac[offsetByte + 3] & 0xff);
return (binary % 10 ** DIGITS).toString().padStart(DIGITS, '0');
}几个值得说的点:
Base32 编码/解码也是自己实现的(`base32Decode` / `base32Encode`),Google Authenticator 的密钥就是 Base32 字符集 `A-Z2-7`;
计数器对齐:时间戳先用 `Math.floor(Date.now() / 1000 / 30)` 对齐到 30 秒窗口,再拆成 8 字节大端序——注意 `writeUInt32BE` 每次只能写 4 字节,所以拆成高 32 位和低 32 位两次写入,避免 JS 数字精度问题;
动态截断:取 HMAC 结果最后一字节的低 4 位作为偏移量,从这个偏移位置取 4 字节,高位清零后对 `10^6` 取模,不足 6 位补零。
验证时,为了让网络延迟或手机时间轻微偏差不至于导致验证失败,我会在 `±WINDOW`(±1 个时间窗)内各算一次来比对:
export function verifyTotp(secret: string, code: string): boolean {
const cleaned = (code || '').replace(/\s+/g, '');
if (!/^\d{6}$/.test(cleaned)) return false;
for (let offset = -WINDOW; offset <= WINDOW; offset++) {
if (constantTimeEqual(generateTotp(secret, offset), cleaned)) return true;
}
return false;
}验证码比对用了恒定时间比较,防止通过响应耗时来推断正确码位。另外还生成 `otpauth://` 链接,方便认证器扫码直接添加:
export function buildOtpauthUrl(secret: string, account: string, issuer = 'nmsl'): string {
const params = new URLSearchParams({
secret, issuer,
algorithm: 'SHA1', digits: '6', period: '30',
});
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?${params.toString()}`;
}四、服务端:登录校验接入
在 `AuthService.login` 里,密码校验通过后,如果用户已启用 2FA,就强制校验动态码:
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('用户名或密码错误');
}
// 2FA 校验:已启用时强制要求动态口令
if (user.totpEnabled && user.totpSecret) {
if (!dto.totpCode) {
throw new UnauthorizedException('请输入 2FA 动态验证码');
}
if (!verifyTotp(user.totpSecret, dto.totpCode)) {
throw new UnauthorizedException('2FA 验证码错误');
}
}这里有个刻意的顺序:先校验密码,再校验 2FA。这样即使 2FA 报错,也不会把"密码对不对"泄露给攻击者;前端则可以凭借错误消息里是否含 `2FA` 来决定要不要弹出验证码输入框。
`LoginDto` 里 `totpCode` 是可选的,但要允许空串通过校验(因为前端在用户没启用 2FA 时不会传这个字段):
@ValidateIf((_o, value) => value !== undefined && value !== null && value !== '')
@IsString()
@MinLength(6)
@MaxLength(6)
totpCode?: string;五、服务端:开启 / 关闭接口
三个接口都挂在 `/auth` 下,全部需要 JWT 登录态,并且加了接口级限流:
`POST /auth/2fa/setup` | 生成密钥并返回 `secret` + `otpauthUrl` | 10 次/分钟
`POST /auth/2fa/verify` | 校验验证码并正式启用 | 5 次/分钟
`POST /auth/2fa/disable` | 校验验证码后关闭 | 5 次/分钟
setup:只生成,不启用
async setupTwoFactor(userId: number, account: string) {
const user = await this.adminUserRepository.findOne({ where: { id: userId } });
if (user.totpEnabled) {
throw new BadRequestException('2FA 已启用,如需重置请先关闭');
}
const secret = generateTotpSecret();
await this.adminUserRepository.update(userId, {
totpSecret: secret,
totpEnabled: false, // 关键:先不启用
});
return {
code: 0, message: 'ok',
data: { secret, otpauthUrl: buildOtpauthUrl(secret, account || user.username, 'nmsl') },
};
}`secret` 只在这个响应里出现一次,之后服务器不再回传明文(这也是为什么要让用户立即去录入)。
verify:确认后真正开启
async verifyTwoFactor(userId: number, code: string) {
const user = await this.adminUserRepository.findOne({ where: { id: userId } });
if (!user || !user.totpSecret) throw new BadRequestException('请先生成 2FA 密钥');
if (user.totpEnabled) throw new BadRequestException('2FA 已启用');
if (!verifyTotp(user.totpSecret, code)) throw new BadRequestException('验证码错误');
await this.adminUserRepository.update(userId, { totpEnabled: true });
return { code: 0, message: 'ok', data: { totpEnabled: true } };
}这个"先生成密钥 → 验证成功才启用"的两段式设计很有用:如果用户在启用前把密钥弄丢了,`totpEnabled` 仍为 `false`,登录不会被锁死,重新生成密钥即可。
disable:关 2FA 也要验证码
async disableTwoFactor(userId: number, code: string) {
const user = await this.adminUserRepository.findOne({ where: { id: userId } });
if (!user || !user.totpSecret) throw new BadRequestException('2FA 未启用');
if (!verifyTotp(user.totpSecret, code)) throw new BadRequestException('验证码错误');
await this.adminUserRepository.update(userId, {
totpSecret: null,
totpEnabled: false,
});
return { code: 0, message: 'ok', data: { totpEnabled: false } };
}关闭 2FA 同样要验证码——否则攻击者只要拿到登录态,就能一键把用户的安全防线关掉。
限流
登录和 2FA 相关接口都用 `@Throttle` 做了限流,防止验证码被暴力穷举(6 位数字只有 100 万种组合,不限制后果严重):
@Post('2fa/verify')
@UseGuards(JwtAuthGuard)
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: '验证并启用 2FA' })
async verifyTwoFactor(@Request() req: any, @Body() dto: VerifyCodeDto) {
return this.authService.verifyTwoFactor(req.user.sub, dto.code);
}六、前端:登录页
登录页的关键交互是:用户没有启用 2FA 时,页面只有用户名 + 密码两个输入框;一旦服务端返回"请输入 2FA"之类的错误,才动态弹出验证码输入框。这样大多数登录流程保持简洁,也避免"密码错误"和"验证码错误"被混为一谈。
七、前端:后台设置面板
`TwoFactorPanel` 组件放在"账户安全(2FA)"设置区块里,一个组件管三种状态:
1. 未启用 → 一个"开启 2FA"按钮,点击后调 `/auth/2fa/setup`:
2. 已生成密钥待确认 → 展示 `secret`(等宽字体、`userSelect: 'all'` 方便全选复制)和 `otpauthUrl` 链接,用户用认证器录入后,输入 6 位验证码点击"确认启用",调 `/auth/2fa/verify`:
3. 已启用 → 显示"已启用"状态,底部留一个验证码输入框 + "关闭 2FA"按钮,关闭前 `window.confirm` 二次确认。
const handleVerify = async () => {
if (!/^\d{6}$/.test(code.trim())) {
setMessage({ type: 'error', text: '请输入 6 位动态验证码' });
return;
}
setBusy(true);
setMessage(null);
try {
await api.post('/auth/2fa/verify', { code: code.trim() });
setEnabled(true);
setSetup(null);
setCode('');
setMessage({ type: 'success', text: '2FA 已启用' });
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : '验证失败' });
} finally {
setBusy(false);
}
};面板挂载时通过 `GET /auth/me` 读取 `totpEnabled` 来初始化状态,加载态用 `loading` 兜底,所有失败路径都打了日志(`logDataError`)。
踩坑与思考
1. 为什么 secret 在启用前先存库?为了两段式启用。如果只在 verify 时生成并返回,用户扫码的时机和启用是耦合的;分开存可以随时重新生成,不怕半路丢密钥。
2. 时间同步问题:TOTP 强依赖设备时钟,认证器手机时间偏差超过一个时间窗就会失败。我只开了 `±1` 容错,够用但不算宽。真要更稳可以按偏移量留存验证记录、动态放宽窗口(但这会引入新的复杂度,个人博客没必要)。
3. 没有做恢复码(backup code):一旦手机丢失且密钥未备份,2FA 会把自己锁在门外。个人博客场景下,我倾向于留一条人工降级通道(比如直接改库),所以没有专门做恢复码机制。如果面向多用户,恢复码几乎是必须的。
4. 限流是 2FA 的"另一条腿":6 位数字空间只有 100 万,没有限流等于把门虚掩着。登录、验证、关闭三个入口我都压到了 5–20 次/分钟。
5. `VerifyCodeDto` 用正则 `^\d{6}$` 强校验,非法输入在 DTO 层就被挡掉,`verifyTotp` 内部也做了二次防御,双保险。

全部评论