NestJS 邮箱验证码登录:一次性凭证、Redis 限流与邮件投递边界
本文目录
邮箱验证码看起来只是“生成六位数字,然后发邮件”,真正困难的部分却在并发和失败边界:同一个验证码能否被两个请求同时使用?登录码能否拿去重置密码?SMTP 超时后服务端是否知道邮件已投递?Redis 限流能否跨多个 Node 进程保持一致?
本文按一个 NestJS API 的职责拆分来设计邮箱验证码登录、注册和密码重置。示例是可审阅的架构片段,不是可以直接复制上线的完整认证库。邮箱验证码只证明用户此刻能访问某个邮箱,不等于抗钓鱼的多因素认证,也不能替代密码保护、账号风控或 WebAuthn 等更强认证方式。
先把凭证模型说清楚
一组验证码至少要绑定四类上下文:
| 字段 | 作用 | 常见错误 |
|---|---|---|
| 规范化后的邮箱 | 确定接收人和账号 | 注册时小写化、登录时却用原始大小写查找 |
用途 purpose | 区分登录、注册、重置密码 | 一封登录邮件中的码可用于改密码 |
| 过期时间 | 限制被窃取后的有效窗口 | 只限制发送频率,却让旧码长期有效 |
| 尝试次数与使用状态 | 限制在线猜测并保证一次性 | 在 Node 进程内计数,多实例之间互不知情 |
purpose 应是服务端固定枚举,不接受任意字符串。每个用途使用不同的 Redis 命名空间和摘要上下文;用户请求新验证码时,应明确旧码是继续有效还是立即失效,不能让“最新邮件”语义在不同服务实例间漂移。
生成不可预测的短码
六位十进制验证码只有一百万种可能,必须依赖短有效期、少量失败次数和分层限流防止在线穷举。不能用 Math.random()、当前时间或用户 ID 生成。Node.js crypto.randomInt() 会生成密码学安全的范围内随机整数并避免取模偏差:
const { createHmac, randomInt } = require('node:crypto');
function createEmailCode({ secret, purpose, normalizedEmail }) {
const code = randomInt(1_000_000).toString().padStart(6, '0');
const digest = createHmac('sha256', secret)
.update('email-code:v1\0' + purpose + '\0' + normalizedEmail + '\0' + code, 'utf8')
.digest('hex');
return { code, digest };
}
Redis 只保存带密钥的摘要,不保存明文验证码。这样即使 Redis 快照或诊断输出泄漏,攻击者也不能直接读取可用代码;但摘要不是万能保护:验证码熵低,若 HMAC 密钥也泄漏,攻击者仍可枚举一百万个候选值。密钥必须从受控运行环境注入,不能放进前端、README、日志或 Git;更换密钥会让尚未过期的验证码失效,应将它作为一次认证密钥轮换处理。
邮箱键也不要直接把邮箱地址拼进 Redis key 或日志。可以用独立服务端密钥对规范化地址做 HMAC,作为稳定但不可直接读出的标识;这不应与验证码摘要共用不同用途的原始拼接格式。Redis key 应加入版本和用途,例如 auth:otp:v1:<purpose>:<email-hmac>,并给验证码记录设置明确 TTL。
发送流程:先配置检查,再原子限流
建议的服务端顺序如下:
- 验证请求形状、邮箱长度、purpose 枚举和客户端网络来源。
- 检查 SMTP 主机、端口、TLS、认证信息和已验证的发件人是否齐全;配置不全时,在扣除发送额度前返回服务不可用。
- 使用 Redis Lua 或等价的原子操作,同时检查并占用“地址冷却时间、地址/用途小时额度、客户端 IP 小时额度”。
- 生成验证码和摘要,调用邮件传输器发送;把最少必要的凭证状态以 TTL 写入 Redis,并定义明确的“新码替换旧码”语义。
- 成功时只返回通用提示,不回显验证码、SMTP 错误、密钥、收件账号是否存在等内部信息。
将多个 quota 拆成 GET 后再 INCR 的普通请求序列会产生竞态:两个请求可能都读到“尚未超限”,然后一起发送。Redis Lua 脚本在 Redis 端原子执行,可以把读取、判定和计数放在一个不可插入其他命令的操作内;脚本要短小,key 名称应由客户端显式传入,避免在集群环境中隐式访问其他 slot。
-- 伪代码:真实实现需要定义窗口、错误码、键 TTL 和集群 hash-tag。
local emailCount = tonumber(redis.call('GET', KEYS[1]) or '0')
local ipCount = tonumber(redis.call('GET', KEYS[2]) or '0')
if emailCount >= tonumber(ARGV[1]) or ipCount >= tonumber(ARGV[2]) then
return 0
end
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
redis.call('INCR', KEYS[2])
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
return 1
上面只用于说明原子判定的形状,不能当作完整限流器:固定窗口在窗口边界可能形成突发;生产脚本还需避免重复请求时重置 TTL、为不同配额使用正确的窗口,并在任何 quota 失败时做到“全部不占用”或明确采用保守扣额策略。高风险登录可以组合地址、用途和来源 IP 三个维度;只使用 IP + 邮箱 一个联合键,会允许攻击者轮换 IP 或邮箱绕过另一维度限制。
X-Forwarded-For 只有在请求确实来自受信任反向代理时才可信。若 NestJS 直接信任客户端传入的首个地址,攻击者可以自行伪造 IP 并绕过按 IP 限流。代理层应限定信任的 hop/网段,应用从可信 socket 链路解析地址。
校验与一次性消费
校验时重新规范化邮箱、验证用途和六位格式,按同一上下文计算摘要,再通过原子操作比较、递增错误次数或删除已使用记录。不能采用“先 GET 检查,随后 DEL”的两个独立命令:并发请求可能都读到同一个有效码并同时通过。
推荐的原子状态转换只有三种:
- 摘要不匹配且仍有尝试额度:增加失败计数,保留原 TTL。
- 摘要匹配:立刻删除或标记为已消费,并让并发的后续请求失败。
- 到期、格式错误、用途不一致或超过尝试上限:拒绝;不把内部原因细节暴露给匿名客户端。
如果比较发生在 Lua 中,脚本可以保证“比较并消费”原子化,但 Lua 字符串比较不应被描述成常量时间密码学比较。短码场景的主要在线保护仍是随机生成、严格尝试上限、短 TTL、速率限制和不泄漏是否匹配;若协议要求常量时间比较,需要单独设计不会重新引入消费竞态的校验与原子确认边界。
Redis 和 PostgreSQL 不是同一个事务。验证码已消费但数据库更新失败时,用户可能需要重新申请;数据库先改成功、验证码消费失败又可能允许重放。应先决定可恢复性和风险优先级,再设计“校验-消费-状态变更”的补偿或幂等状态机,不要声称用 Prisma $transaction 能同时原子提交 Redis 和 SQL。
登录、注册和重置密码必须各走各的权限路径
邮箱登录:验证码通过后,服务端再次确认账号存在、未禁用、未删除,再签发会话。请求体里的 userId、role 或用户名都不是身份事实。角色敏感操作仍要根据当前服务端账号状态授权;如果系统只在 JWT 签发时写入角色,变更角色后的旧令牌会继续带着旧声明,直到过期或被撤销。
注册:验证码只能证明邮箱可收取邮件,不能自动保证用户名、昵称或其他字段合法。账号唯一约束应落在数据库;并发注册遇到唯一键冲突要转换成稳定的业务响应。为降低账号枚举,发送接口应对已注册地址与新地址使用一致的公开文案和相近的响应流程;不能因为“没有发邮件”就返回明显不同的提示。
密码重置:必须使用独立的 password-reset 用途,不能拿普通登录码直接改密。新密码通过现有强度策略与慢哈希校验后才可写入;成功后应按系统会话模型撤销旧会话或更新会话版本,并通知用户密码已更改。响应不应泄漏邮箱是否注册。账号锁定策略也不能让攻击者只靠请求重置邮件就能把受害者永久锁在门外。
邮件投递失败不是简单的 try/catch
SMTP 成功返回一般只表示提供方接受了消息,不保证进入收件箱。DNS、SPF、DKIM、DMARC、发件域验证、垃圾邮件策略和收件方退信都会影响实际可达性。配置模板需要明确发件地址;某些 SMTP 提供方的认证用户名并不是邮箱,不能据此自动推断 From。
还要处理“服务端超时,但上游可能已经接受邮件”的不确定结果。如果此时立刻释放所有限流额度并发送一封不同验证码,用户可能先后收到两封码,且较晚到达的旧邮件反而更难判断。需要为每次发送建立版本或请求 ID,只允许当前代次写回 Redis;失败后仅撤销本次尝试占用的状态,不得覆盖并发中更新的验证码。对于结果未知的发送,应提示用户稍后申请新码,并保持合理冷却,不能让客户端无限重试轰炸邮箱。
生产运维至少要有:受限访问的 SMTP 密钥、已验证 sender、DNS 记录核对、投递/退信观察、发送速率告警、密钥轮换步骤,以及在邮件服务不可用时保留账号密码登录和恢复路径。日志可记请求 ID、目的、耗时、状态类别和 provider 的脱敏错误码;不得记录验证码、密码、完整授权头或 SMTP 凭据。
NestJS 分层与测试矩阵
Controller 负责 DTO、HTTP 状态和可信客户端 IP;Service 编排验证码用途、用户状态与会话;Redis helper 承载原子 quota/consume 脚本;邮件适配器只负责传输和错误归类。不要在 Controller 里拼 Redis key、签 JWT、调用 Nodemailer 并直接改数据库,这会让安全边界无法单独测试。
至少测试这些场景:
| 场景 | 预期结果 |
|---|---|
| 不同邮箱大小写/空白 | 统一到同一规范地址,不生成重复账号 |
| 登录码提交到注册或重置接口 | 用途不匹配,不能授权 |
| 过期、已使用、格式错误的验证码 | 拒绝且不给出敏感内部状态 |
| 两个并发请求提交同一码 | 至多一个成功 |
| 多个 API 进程并发申请 | Redis 额度仍一致,不超发 |
| 伪造转发 IP 请求 | 不能绕过可信代理配置 |
| 邮件发送失败或超时 | 旧码/新码代次不被并发旧请求覆盖,提示可恢复 |
| Redis 或数据库故障 | 失败关闭,不静默降级到进程内无限额度 |
| SMTP/发件人缺失 | 发送前返回可诊断的配置错误,不暴露验证码 |
| 禁用账号使用已收取的登录码 | 验码成功也不能签发登录会话 |
| 角色变更或账号封禁后的旧 JWT | 按系统撤销/权威状态策略失效 |
密码学随机数的范围与行为见 Node.js Crypto 文档。重置码的随机性、单次使用、过期和防穷举应结合 OWASP Forgot Password Cheat Sheet 审阅。Redis 的限流方案和脚本原子语义参考 Redis Rate Limiter 与 Redis Lua Scripting。
本文不包含真实邮箱、SMTP 密钥、生产验证码或具体反向代理地址,也没有把“邮件发送 API 可调用”当成“邮箱注册/登录端到端已验收”。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。