传统的用户名加密码模式在安全层面已经越来越吃力,弱密码、撞库攻击、钓鱼页面层出不穷。多因子认证(MFA)通过要求用户提供两种以上不同类别的凭证,把安全等级提升了一个台阶。目前在Web应用中落地最广的两种方案,一种是基于时间的一次性密码TOTP,比如Google Authenticator生成的六位数字;另一种是W3C标准的WebAuthn协议,也就是常说的无密码登录,通过指纹、面容或硬件密钥完成认证。本文将在Node.js环境下分别实现这两种方案,并分析它们的原理与适用场景。

一、TOTP的工作原理与Node.js实现
1. TOTP的核心机制
TOTP的全称是Time-based One-Time Password,即基于时间的一次性密码。它的核心思想是:服务端在用户开启两步验证时生成一个随机共享密钥,这个密钥以Base32编码后通过二维码的形式交给用户,由验证器App(如Google Authenticator、Microsoft Authenticator)保存在手机本地。之后双方各自用同一个HMAC算法,以密钥和当前时间片作为输入,计算出相同的六位数字。服务端校验时只需要比对自己算出的结果和用户提交的数字是否一致。
时间片通常以30秒为一个单位。客户端和服务端各自取当前Unix时间戳除以30得到计数器,因此双方不需要任何网络通信就能保持同步。这也解释了为什么验证码每隔30秒会变化一次,以及为什么手机时间不准时验证码会校验失败。为了容忍用户输入的时间差,服务端一般会允许前后一个时间窗口,也就是最多接受三个时间片内的验证码。
2. 生成密钥与二维码绑定
在Node.js中实现TOTP最常用的库是otplib,它同时支持TOTP和HOTP两种模式。下面的代码演示了如何生成密钥并构造用于二维码绑定 otpauth:// 协议的字符串:
const { authenticator } = require('otplib');
// 为用户生成一个Base32编码的随机密钥
const secret = authenticator.generateSecret();
// 构造otpauth协议字符串,验证器App扫码后会按此信息配置
const serviceName = encodeURIComponent('我的应用');
const userName = encodeURIComponent('zhangsan@ippipp.com');
const otpauthUrl = authenticator.keyuri(userName, serviceName, secret);
console.log('密钥(需入库保存):', secret);
console.log('otpauth URL(生成二维码用):', otpauthUrl);拿到otpauth URL之后,可以借助qrcode这个库把它渲染成PNG图片返回给前端,用户扫码后App里就会每30秒刷新一次验证码。需要特别注意的是,密钥必须妥善保存在服务端数据库中,建议加密存储,绝不能明文写入日志。另外,绑定流程应该设计一个确认步骤:用户先输入一次当前验证码验证成功后,才正式启用两步验证,避免用户扫完码但App没配置好导致账号被锁死。
3. 登录时的验证逻辑
验证环节的代码非常简单,调用authenticator.verify即可:
const { authenticator } = require('otplib');
// 允许前后各一个时间窗口,容忍手机时间略有偏差
authenticator.options = { window: 1 };
function verifyTotp(userInput, secretFromDb) {
const ok = authenticator.verify({
token: userInput, // 用户输入的六位数字
secret: secretFromDb // 数据库中该用户的共享密钥
});
return ok;
}完整的登录流程应该是两段式的:第一阶段校验用户名密码,成功后不直接建立会话,而是返回一个临时票据;第二阶段要求用户提交TOTP验证码,校验通过后才签发正式的session或JWT。如果验证码连续错误多次,应该触发锁定或增加图形验证码,防止攻击者暴力枚举六位数字。
二、WebAuthn无密码认证的实现
1. WebAuthn的交互模型
WebAuthn是FIDO2标准的组成部分,与TOTP有着本质区别:TOTP的密钥存在用户手机里,而WebAuthn的私钥存在认证器的安全芯片中,可能是笔记本的指纹模块、手机的Face ID,也可能是YubiKey这类硬件密钥。认证过程中私钥永远不会离开认证器,服务端只保存公钥。登录时服务端下发一个随机挑战值,认证器用私钥对其签名,服务端用之前注册的公钥验签。由于私钥不出设备,钓鱼网站即使骗到了用户操作,也无法拿到私钥本身,因此WebAuthn天然抗钓鱼,安全强度远高于TOTP。
整个流程分为注册和认证两个阶段。前端通过navigator.credentials.create发起注册,通过navigator.credentials.get发起登录,服务端负责生成挑战值并校验签名。Node.js侧可以使用fido2-lib这个库来封装协议细节。
2. 服务端注册与认证接口
下面的代码展示了基于Express和fido2-lib的核心实现:
const { Fido2Lib } = require('fido2-lib');
const f2l = new Fido2Lib({
timeout: 60000,
rpId: 'ippipp.com', // 依赖方ID,必须是当前域名
rpName: '我的应用',
challengeSize: 64,
authenticatorUserVerification: 'required'
});
// 第一步:下发注册选项
app.get('/webauthn/register/options', async (req, res) => {
const user = getLoginUser(req);
const options = await f2l.attestationOptions();
// 挑战值必须存入会话,且只能使用一次
req.session.challenge = options.challenge;
options.user = { id: user.id, name: user.email, displayName: user.name };
res.json(options);
});
// 第二步:校验注册结果并保存公钥
app.post('/webauthn/register/verify', async (req, res) => {
const attestation = req.body;
const regResult = await f2l.attestationResult(attestation, {
challenge: req.session.challenge,
origin: 'https://ippipp.com',
factor: 'either'
});
// 保存凭证ID与公钥,绑定到当前用户
saveCredential(req.session.userId, regResult.authnrData);
res.json({ success: true });
});登录阶段的逻辑与之对称,先调用assertionOptions生成挑战值,再调用assertionResult验签。校验通过后即可建立会话。有两个容易踩坑的地方需要强调:一是rpId必须与实际访问域名一致,否则浏览器会直接拒绝;二是挑战值务必一次性使用,用完立即从会话中删除,否则会引入重放攻击风险。
三、方案对比与选型建议
TOTP和WebAuthn各有优劣,选型时要结合用户群体和业务场景。下表是主要维度的对比:
| 对比维度 | TOTP | WebAuthn |
|---|---|---|
| 用户体验 | 需手动打开App抄数字 | 指纹或面容一触即完成 |
| 安全强度 | 中等,可能被钓鱼 | 极高,私钥不出设备,抗钓鱼 |
| 实现成本 | 低,几十行代码 | 较高,需处理较多协议细节 |
| 浏览器兼容 | 无要求,任何设备可用 | 需要较新浏览器与系统支持 |
| 额外设备 | 需要手机装验证器App | 笔记本指纹或硬件密钥 |
从表中可以看出,TOTP胜在通用性和实现简单,几乎所有用户都能用;WebAuthn胜在安全性和体验,但对设备有一定要求。比较务实的做法是分层策略:普通账户默认提供TOTP作为可选增强,管理员、财务等高权限账户强制要求WebAuthn,或者允许用户把两种方式都绑定后自由选择。此外,无论采用哪种方案,都要记得提供恢复手段,比如生成一次性恢复码,否则用户手机丢失或认证器损坏时会陷入无法登录的困境。
最后补充一点工程细节:多因子认证的密钥和凭证数据属于高敏感信息,数据库中应加密存储,接口层面要做速率限制防止暴力尝试,日志中绝不能打印密钥或验证码。把这些细节做到位,多因子认证才能真正发挥应有的防护价值,而不是形同虚设。