这一篇在账号密码之后加入 TOTP 二要素认证。TOTP 是基于共享密钥和时间窗口生成的一次性验证码。真正的实现应使用经过审查的 RFC 6238 库,而不是自己编写加密算法。登录只有在密码和验证码都通过后才算完成。
这一篇会把概念、代码、执行流程和安全边界放在一起说明。
目次
1. 这一篇要解决什么?
最终目标不是只记住一个标签或函数,而是完成下面这条链路:
密码验证成功后建立 pendingLogin
↓
用户输入认证器中的六位验证码
↓
服务器用受保护的 TOTP 密钥验证时间窗口
↓
记录已使用的时间步防止重放
↓
轮换 Session 并建立正式登录状态
2. 先理解核心边界
这个功能至少有三个层次:
页面 / HTTP
↓
业务规则与权限
↓
数据库、文件或外部服务
页面负责收集输入和显示结果,CFC 负责验证与业务处理,底层资源只通过受控代码访问。不要让前端参数直接决定 SQL、文件路径、权限或外部资源名称。
3. 完整流程
- 密码验证成功后建立 pendingLogin
- 用户输入认证器中的六位验证码
- 服务器用受保护的 TOTP 密钥验证时间窗口
- 记录已使用的时间步防止重放
- 轮换 Session 并建立正式登录状态
任何一步失败,都应该停止后续处理,返回稳定的错误结构,并在服务器端记录足够排查但不包含秘密的信息。
4. 两阶段 Session 状态
function beginSecondFactor(required numeric userId) {
session.authenticated = false;
session.pendingLogin = {
userId = arguments.userId,
expiresAt = dateAdd("n", 5, now()),
attempts = 0
};
}
remote struct function verifyTotp(required string code)
returnformat="json" output="false" {
if (!structKeyExists(session, "pendingLogin"))
return {success = false, message = "认证流程已失效。"};
if (now() > session.pendingLogin.expiresAt)
return {success = false, message = "验证码已过期。"};
if (!reFind("^[0-9]{6}$", arguments.code))
return {success = false, message = "验证码格式不正确。"};
session.pendingLogin.attempts++;
if (session.pendingLogin.attempts > 5)
return {success = false, message = "尝试次数过多。"};
local.secret = application.mfaSecretStore.get(session.pendingLogin.userId);
local.check = application.totpService.verify(
secret = local.secret,
code = arguments.code,
allowedWindow = 1
);
if (!local.check.valid || application.mfaReplayStore.wasUsed(session.pendingLogin.userId, local.check.timeStep))
return {success = false, message = "验证码不正确。"};
application.mfaReplayStore.markUsed(session.pendingLogin.userId, local.check.timeStep);
local.userId = session.pendingLogin.userId;
sessionRotate();
session.authenticated = true;
session.user = {id = local.userId};
structDelete(session, "pendingLogin");
return {success = true};
}
示例刻意把参数类型、返回结构和错误边界写清楚。实际项目中,数据源、密钥、目录和第三方客户端应由配置或 Application Scope 提供,不能散落在页面代码中。
5. 启用与恢复设计
启用 MFA
→ 再次验证当前密码
→ 服务器生成随机密钥
→ 显示 otpauth URI / QR Code
→ 用户输入第一个 TOTP
→ 验证成功后才保存 enabled = true
丢失设备
→ 使用一次性恢复码
→ 恢复码验证成功后立即作废
→ 通知用户并记录安全事件
这部分和前面的服务端实现属于同一条请求链路。排查问题时,不要只看页面结果,还要同时检查浏览器 Network、ColdFusion 日志和下游服务状态。
6. 安全与可靠性重点
- TOTP 密钥需要加密保存,密钥加密材料不能和数据库放在同一位置。
- 恢复码只保存强哈希,页面只展示一次。
- 必须限制尝试次数,并对连续失败记录安全日志。
- 服务器时间必须可靠同步;允许窗口不应无限扩大。
安全检查必须由服务器执行。JavaScript 验证、隐藏按钮或改变请求方法,只能改善用户体验,不能阻止攻击者自己构造请求。
7. 常见错误
- 密码验证成功就提前设置 authenticated=true
- 自己实现 Base32、HMAC 和时间窗口却没有测试向量
- 同一验证码可在时间窗口内无限重放
- 管理员可以直接看到用户的 TOTP 明文密钥
这些问题往往在开发环境里不明显,但到了并发、异常数据、权限差异或外部服务故障时就会暴露。
8. 建议的排查顺序
① 确认请求 URL、HTTP 方法和 Content-Type
② 确认 Session、身份与资源权限
③ 确认参数经过验证和规范化
④ 确认数据库、文件或外部服务的真实响应
⑤ 确认 HTTP 状态码与 JSON / HTML 响应一致
⑥ 使用请求标识关联浏览器、应用与数据库日志
不要只根据页面上的一句“失败”判断原因。先找到失败发生在哪一层,再决定修复位置。
9. 测试清单
- 正常输入能够完成整个流程
- 缺少必填参数时返回明确的 400 类错误
- 未登录与无权限用户不能执行操作
- 边界长度、空值、特殊字符和重复请求得到预期结果
- 数据库或外部服务失败时不会留下半完成状态
- 日志中没有密码、令牌、密钥或敏感原文
10. 最后的理解
这一篇最重要的不是某一段语法,而是把功能理解成:
输入
↓
认证与授权
↓
验证与业务规则
↓
受控访问资源
↓
稳定响应与可追踪日志
只要这条边界清楚,代码以后无论继续留在 ColdFusion,还是逐步拆到其他服务,都更容易测试、维护和替换。

