邮箱验证码登录的工程实现:有效期、限次校验与发信失败处理
本文目录
邮箱验证码登录看起来只是“发一封邮件,再比对六位数字”,但上线后要同时处理猜码、重复发送、邮件延迟、服务异常和账号状态。验证码本身只是一次短时凭据,整个链路都要围绕它的生命周期设计。
生成与存储
验证码应该由密码学安全随机源生成,不要用 Math.random()。服务端把验证码按规范化后的邮箱地址作为键存进 Redis,设置较短过期时间;不要把验证码写入数据库、普通日志或 API 响应。验证成功后立即删除,避免重复使用。
限制发送和尝试
- 同一邮箱设置发送冷却时间,降低误触和邮件轰炸风险。
- 验证码设置总有效期,过期后必须重新申请。
- 每个验证码只允许少量错误尝试,超过后作废并要求重新发送。
- 生产环境还应结合来源 IP、边缘网关或验证码挑战做频率控制。
邮件发送失败
先发信再提交验证码,可以避免“用户没收到邮件,但验证码已经开始计时”的状态。冷却锁使用随机请求令牌;发信完成后,只有仍持有该令牌的请求才能把验证码写入 Redis。这样即使 SMTP 请求超时、冷却锁过期后另一个请求成功,较晚返回的旧请求也不能覆盖新验证码。发送失败时只释放自己持有的锁,不删除其他请求已有的有效验证码。SMTP 错误详情应进入受控服务日志,不能把账号、密码或完整连接配置返回给浏览器。
账号绑定规则
邮箱验证码证明当前请求者能收取该邮箱的邮件。首次通过验证码登录时可以创建普通读者账号;已有邮箱则进入原账号。角色始终由服务端数据库决定,客户端不能通过注册请求选择管理员或编辑角色。
上线检查
确认 Redis 不可用时系统会失败关闭;检查错误码是否真实传递到浏览器;验证验证码过期、重试上限、发送冷却、封禁账号和 SMTP 故障;检查日志中没有验证码和邮件凭据。邮件服务未配置时应清楚提示配置状态,不能假报发送成功。
把链路画清楚
一个清晰的邮箱验证码登录流程可以分成申请、投递、验证、账号解析和签发会话五步。申请阶段先规范化邮箱并执行 IP 与邮箱限流;生成后只把短时凭据放入有 TTL 的受控存储;投递成功后返回统一提示;验证阶段限制尝试次数并一次性消费验证码;最后由服务端查找或创建普通读者账号,再按数据库里的角色签发会话。
本博客当前实现使用 Node crypto.randomInt 生成六位数字,Redis 中验证码有效期为 10 分钟;同一邮箱发送冷却为 60 秒,每个来源 IP 每小时最多请求 10 次,每个验证码最多允许 5 次校验请求,第 6 次会作废。邮箱统一 trim 并转小写;首次验证成功时创建 USER 角色,角色不从客户端接收。验证比较使用 timingSafeEqual,验证码成功后通过 Redis Lua 比较并删除,防止同一验证码被并发兑换成多个会话。这里的次数是当前实现参数,不是适用于所有产品的通用安全阈值。
当前实现将验证码短时保存在 Redis 中,成功校验后立即删除。Redis 应只监听受控网络、启用访问凭证、避免对外开放,并确保备份与运维日志不会泄露验证码。更高威胁模型下,可以只保存带服务端密钥的 HMAC 摘要,让单独泄露 Redis 数据库不足以离线枚举六位码:
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
const code = randomInt(100_000, 1_000_000).toString();
const digest = createHmac("sha256", otpPepper)
.update(normalizedEmail + ":" + code)
.digest();
const candidate = createHmac("sha256", otpPepper)
.update(normalizedEmail + ":" + submittedCode)
.digest();
const valid = digest.length === candidate.length &&
timingSafeEqual(digest, candidate);
HMAC 密钥要与数据库和 Redis 分开管理;轮换时要考虑仍有效的验证码。摘要比较仍需固定长度并使用 timingSafeEqual。验证码空间很小,不能只做普通无密钥哈希后就认为安全:拿到哈希的人可以枚举所有六位组合。
让限流可并发且可运维
限流至少分邮箱和来源 IP 两个桶:只按 IP 会被分布式来源绕过,只按邮箱则容易被攻击者针对单个地址轰炸。多实例部署时,计数必须使用共享 Redis,而不是进程内 Map。本博客当前实现用 Lua 把 INCR 与首次 EXPIRE 合成一个原子操作,避免进程在两条命令之间退出而遗留永久计数键:
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return count
固定窗口实现简单,但窗口边界可能出现突发流量。更高流量时可评估滑动窗口或令牌桶;限额应按业务容量、邮件服务商配额和滥用成本调整,并监控被限流比例。客户端显示剩余等待时间即可,不需要暴露内部桶的精确状态和攻击判定规则。
避免账号枚举与重复消费
若邮箱验证码同时用于登录和注册,服务端可以统一处理“已有邮箱”和“新邮箱”,不在公开响应里区分账号是否存在。若只允许已有账号登录,申请接口也应返回一致的提示,并尽量避免明显的响应时长差异。邮件发出成功只代表 SMTP 接受了本次投递,不代表最终进入收件箱。
验证阶段先用 Lua 原子读取验证码并增加尝试次数,再在应用层做固定时间比较;超过五次时 Lua 会同时删除验证码和尝试计数。比较成功后,另一个 compare-and-delete Lua 脚本只有在 Redis 中的值仍与本次读取值一致时才删除验证码并返回成功。两次请求即使同时读到同一码,也只有一个能完成消费,另一个不会查用户或签发 JWT。错误、过期、被消费、账号封禁等情况应有稳定错误码,但不应泄露不必要的账号信息。
错误尝试的计数要在比较之前增加,防止并发猜码绕过;但产品文案和测试要说清楚限制的是“总校验请求次数”还是“错误次数”。阈值、TTL 与代码逻辑必须一致,避免用户认为还有一次机会时服务端已经锁定。
SMTP 的 TLS 与投递诊断
Nodemailer 常见配置是端口 465 使用隐式 TLS(secure=true),端口 587 使用 STARTTLS(secure=false,并要求服务端支持 TLS 升级)。生产不能为绕过证书错误关闭证书校验。当前实现将连接、问候和 socket 超时分别限制在 10 秒、10 秒和 15 秒;不要让邮件服务故障无限占住 HTTP 请求。当前按请求创建 transporter,流量增长后应复用 transporter 或受控使用连接池,避免重复创建 TLS 连接。
域名发信还要配置并验证 SPF、DKIM 和 DMARC,检查发件人地址与 SMTP 账户策略匹配。排查“接口成功但收不到邮件”时,按 SMTP 接受结果、服务商退信、垃圾邮件、收件地址拼写和域名认证逐层定位。完整 SMTP 错误只写入脱敏的受控日志,用户侧只显示可恢复的通用提示。
测试与生产门槛
- 同一邮箱重复申请、同一 IP 更换邮箱、不同实例并发申请都触发正确限流。
- 过期码、错误码、已使用码和并发重复提交不能签发多个会话。
- SMTP 连接失败、超时、认证错误或 Redis 写入失败时,不返回“已发送”;旧请求不能覆盖新验证码,也不能误删另一笔并发请求的状态。
- 验证码只能创建普通读者账号;被封禁账号不能登录;角色始终从服务端读取。
- 检查注册、密码登录、邮箱登录对不存在账号的错误文案、状态码和耗时,避免无意泄露账号存在性。
- 日志、异常跟踪、代理请求体和邮件服务调试模式中都不出现验证码、密码、JWT 或 SMTP 凭据。
- 检查 Redis 的 TTL、限流键增长、SMTP 发送延迟、退信率和验证码登录成功率,并设置告警与数据保留周期。
本轮为 API 网关、核心服务和评论服务发布了同一份认证修复,线上重启后首页、管理端、图书分类接口均正常,未登录 profile 仍正确返回 401。生产环境目前尚未配置 SMTP,因此账号密码登录可用,但邮箱验证码发信链路会明确返回“邮件服务尚未配置”;真实邮件收发、SPF/DKIM/DMARC 和端到端投递仍未验证。需要由站点所有者选择邮件服务并在服务器环境中填写凭据后,才能完成最终上线验证;不要把 SMTP 密码放进前端或代码仓库。
延伸阅读
- Node.js crypto.randomInt:安全随机整数与范围语义。
- Nodemailer SMTP transport:TLS、端口、超时和连接选项。
- OWASP Email Validation and Verification:邮箱验证、防枚举与限流建议。
- OWASP Authentication Cheat Sheet:通用认证错误和账号枚举风险。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。