跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

ColdFusion 学习笔记⑤

二要素认证实现

BY 稻, 草人

这一篇在账号密码之后加入 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,还是逐步拆到其他服务,都更容易测试、维护和替换。