本文描述了一个匿名化的企业级实施案例。公司名称、内部域名、仓库标识符、工单编号以及专有控制名称均已被有意删除或泛化。

多因素身份验证通常被描述为一个登录问题:输入密码、接收验证码、确认身份。

对于本案例研究中描述的系统,这种模式还不够。

该产品运行在店内环境中,同一台平板电脑可能在一次交易中被多人使用:

  • 一名员工发起流程;
  • 一名经理审批或支持;
  • 一名顾客在自己的设备上查看并签署。

挑战不仅仅是证明用户知道一个六位数代码。我们需要创建一个安全、短期的交接机制,在共享的店内会话与顾客的个人手机之间进行,同时不泄露后续的签名会话,也不允许多个设备认领同一笔交易。

本文将解释该平台背后的架构、安全模型、权衡以及生产实践。

实际问题:安全设备交接

工作流从一台共享平板电脑开始。在某一时刻,顾客需要将部分流程继续到自己的手机上。

因此平台必须回答几个问题:

  1. 手机如何证明它属于站在员工面前的顾客?
  2. 共享平板如何知道正确的手机认领了正确的会话?
  3. 如果二维码被扫描两次会发生什么?
  4. 我们如何防止会话标识符和令牌出现在 URL、浏览器历史、日志或引用头中?
  5. 验证成功后,我们如何立即通知手机?
  6. 我们如何确保一次性签名 URL 在验证完成前不被暴露?

这些约束条件将一个看似简单的 MFA 功能转变为一个分布式系统问题,涉及身份、实时通信、并发、边缘交付、基础设施和运维控制。

高层架构

解决方案使用了两个主要应用组件:

  • 面向顾客手机的 React 和 TypeScript 单页应用;
  • 基于 AWS AppSync、Lambda 和 DynamoDB 构建的无服务器 GraphQL API。

基础设施还包括 CloudFront、S3、API Gateway、Route 53、ACM、WAF、Terraform、集中式日志、APM、合成监控以及自动化部署工作流。

┌─────────────────────────────┐
│ Shared in-store tablet      │
│ Employee / manager workflow │
└──────────────┬──────────────┘
               │ create handoff session
               ▼
┌─────────────────────────────┐
│ GraphQL API                 │
│ AppSync + Lambda            │
└──────────────┬──────────────┘
               │ persist state
               ▼
┌─────────────────────────────┐
│ DynamoDB                    │
│ session + short-link data   │
└─────────────────────────────┘

Shared tablet displays QR
               │
               ▼
┌─────────────────────────────┐
│ Customer phone              │
│ React SPA via CloudFront    │
└──────────────┬──────────────┘
               │ claim + subscribe
               ▼
┌─────────────────────────────┐
│ GraphQL API                 │
│ verification + status push  │
└─────────────────────────────┘

Enter fullscreen mode Exit fullscreen mode

该架构有意采用无服务器设计。工作负载具有突发性,用户旅程较短,大多数操作都是小型状态转换而非长时间运行的计算。

端到端会话交接流程

1. 创建交接会话

店内应用创建了一条短期的交接记录,包含交易上下文、下游签名 URL、过期数据以及 PENDING 状态。

签名 URL 被视为敏感且一次性的。它被内部存储,但在交接会话仍处于待定状态时,从不返回给面向顾客的应用。

2. 生成短 QR 链接

API 创建了一个与交接记录关联的短代码。店内平板将该短链接渲染为二维码。

顾客用手机扫描该二维码。

3. 安全引导手机会话

QR 请求通过 CloudFront 和一个小重定向端点。

重定向没有将会话标识符和令牌放在查询参数中,而是返回配置了以下属性的短期 Cookie:

  • Secure
  • SameSite=Strict
  • 非常短的过期时间。

手机应用读取这些值,配置其 GraphQL 客户端,并继续会话交接流程。

这避免了通过以下方式暴露凭证:

  • 浏览器历史;
  • 访问日志;
  • 复制的 URL;
  • 分析工具;
  • 引用头。

4. 认领交接会话

手机调用 claimSession 变更。

后端生成:

  • 一个六位数安全码;
  • 一个唯一的会话标识符。

该代码显示在顾客手机上,并通过共享平板读回给员工。

平台采用最后扫描者获胜模型。如果另一部手机扫描相同的二维码,新的认领将取代之前的认领。

这防止了两台设备持有同一活跃交接会话。

5. 验证代码

员工在店内应用中输入六位数代码。

后端将验证评估为状态转换:

  • 代码正确:PENDING → VERIFIED
  • 代码错误且仍有尝试次数:减少尝试次数;
  • 最后一次错误尝试:PENDING → CANCELED
  • 超时:PENDING → EXPIRED

这些转换使用 DynamoDB 条件写入而非读-修改-写序列。数据库成为并发原语。

这一点很重要,因为两个验证请求或两个竞争认领不能同时成功。

6. 实时推送结果

手机使用 GraphQL 订阅订阅交接状态更新。

当验证成功时,AppSync 立即将新状态推送到手机。

没有轮询循环。

手机随后执行最后一次经过身份验证的读取。仅在此时,API 才暴露一次性签名 URL。

应用将顾客重定向到签名体验并使交接记录过期。

为什么使用 GraphQL 订阅而不是轮询?

轮询更容易解释,但也会引入不必要的流量、更慢的反馈以及更多的生命周期边缘情况。

产品需求本质上是事件驱动的:

当店内会话验证了这个确切的交接会话时,通知这部手机。

GraphQL 订阅提供了:

  • 低延迟的状态变更;
  • 按交接上下文内置过滤;
  • 通过客户端库管理的 WebSocket 重连;
  • 无需自定义轮询间隔或重试调度器。

前端仍需处理一种移动设备特有的故障模式:当用户锁屏或切换应用时,操作系统经常会挂起浏览器标签页。

当标签页再次可见时,应用会重新获取交接状态。这覆盖了 WebSocket 被暂停且错过最终事件的情况。

六位数代码的两个用途

最重要的设计决策之一是将六位数值同时用作:

  1. 人类可读的 MFA 挑战;
  2. 访问交接状态所需的服务器端能力。

该代码在读取和订阅过滤时都需要。它不会返回给已被取代的设备,且在状态达到 VERIFIED 之前,下游签名 URL 始终保持隐藏。

这消除了对第二个不透明会话令牌的需求,同时保留了字段级授权。

它还减少了需要生成、存储、同步和失效的凭证数量。

分层安全模型

平台采用纵深防御,而不是将 MFA 代码视为唯一的控制手段。

边缘保护

CloudFront 和区域端点受 AWS WAF 保护。托管规则组分阶段引入,高风险规则优先执行,而易产生误报的规则组最初保持在监控模式。

传输与身份

所有流量均使用 HTTPS。GraphQL API 使用自定义 Lambda 授权器,验证 OAuth/JWT 令牌、签发者、签名、过期时间、操作级作用域和授权权重。

未知操作将失败关闭。

契约强化

GraphQL 接口在已部署环境中禁用了内省,并限制了查询深度和解析器数量。

字段级授权

解析器在返回交接状态前检查安全码。

仅当交接会话已验证时才包含签名 URL。

对于已被取代的订阅,解析器不返回数据,而不是揭示会话变更的原因。

凭证卫生

敏感的交接凭证不通过 URL 传递。日志排除个人身份信息和身份验证密钥。保留关联标识符以便调试,但不泄露会话数据。

保持前端有意精简

面向顾客的应用是一个单一用途流程,因此前端避免了不必要的框架复杂度。

它使用了:

  • React;
  • 严格模式的 TypeScript;
  • Vite;
  • React Context 和本地钩子;
  • 托管的 GraphQL 客户端;
  • 共享的企业设计系统。

没有路由器,也没有全局状态库。

应用只有几个状态:

  • 加载或认领中;
  • 显示 MFA 代码;
  • 显示错误或终端状态;
  • 验证后重定向。

这种简洁性减少了包体积、攻击面、升级负担和认知开销。

从第一天起即采用基础设施即代码

所有基础设施均用 Terraform 定义。

API 仓库不仅配置了后端,还配置了前端所需的共享基础设施:

  • AppSync;
  • Lambda 函数;
  • DynamoDB;
  • S3;
  • CloudFront;
  • API Gateway;
  • DNS 和证书;
  • WAF;
  • 监控资源。

前端仓库生成不可变的构建产物,但不拥有单独的基础设施栈。

这为平台拓扑创建了单一事实来源,同时允许应用和 API 独立演进。

一次构建,多处部署

前端没有在构建时嵌入环境特定配置。

相反,它从部署的主机名派生环境。因此同一构建产物可以原样提升通过开发、QA、预生产和生产环境。

该模式提供了两个好处:

  1. 生产环境收到之前已测试过的完全相同的产物;
  2. 配置仍是部署关注点而非构建关注点。

交付管道包括:

  • 代码检查和自动化测试;
  • 静态分析;
  • 基础设施验证;
  • 安全扫描;
  • 语义版本控制;
  • 不可变产物发布;
  • 环境审批;
  • 生产变更管理集成。

可观测性是产品的一部分

运维可见性并非在上线后才添加。

平台包含:

  • 结构化 JSON 日志;
  • 关联标识符;
  • 命名业务事件;
  • 集中式日志转发;
  • APM 插桩;
  • 针对 Lambda、GraphQL 和交易健康的仪表盘;
  • 延迟、错误率和限流告警;
  • 合成健康检查;
  • 环境特定的升级规则。

监控配置位于独立 Terraform 状态中,因此可以在不修改主应用基础设施的情况下更改。

这种分离很有用,因为告警通常以不同于应用资源的节奏演进。

真实权衡:合规优先于更便宜的 API 选项

涉及 API Gateway 的决策更具启发性。

第一个实现使用了较新的 HTTP API,因为它更简单且更便宜。在合规工作中,团队确认该 API 类型不支持所需的 WAF 关联。

可用选项包括:

  • 保留较新的 API 并申请例外;
  • 重新设计边缘流程;
  • 迁移到支持所需 WAF 附件的 REST API v1。

平台迁移到了 REST API v1。

这增加了每次请求的成本,但观察到的流量足够低,每月影响可以忽略不计。安全和合规收益比微小的基础设施节省更重要。

教训并非 REST API v1 总是更好,而是要根据完整的生产约束集(而非仅开发便利性或标价)评估云服务选择。

按垂直切片交付

产品通过增量阶段从空仓库走向生产:

  1. 基础 API、数据库和 Terraform 模块;
  2. 边缘交付、DNS、证书和首个移动 UI;
  3. 自定义授权和作用域访问;
  4. 过期、短链接、QR 重定向和安全会话引导;
  5. 独占认领和实时订阅;
  6. 结构化日志、仪表盘、告警和合成监控;
  7. WAF 强制执行和合规 rollout;
  8. 生产自动化和生命周期维护。

每个阶段交付一个完整的架构能力,而不是一组不连贯的文件。

这使集成风险更早可见,并允许设计随着平台约束的发现而演进。

值得复用的模式

对小型分布式状态机使用条件写入

对于具有简单转换的工作流,DynamoDB 条件表达式可以取代应用锁和先读后写逻辑。

将被拒绝的设计视为有价值的文档

若干实现路径因平台约束而失败。记录失败原因可防止未来工程师重复相同的实验。

尽可能将授权推近数据

解析器中的字段级检查确保查询和订阅遵循相同规则。

逐步发布安全控制

WAF 配置以监控模式引入、分析,然后通过回滚标志和生产金丝雀选择性强制执行。

保持单一用途前端的单一用途

缺少路由器或复杂状态库是经过深思熟虑的架构选择,而非缺少复杂性。

在初始架构中构建生产就绪性

身份验证、CI/CD、可观测性、安全扫描、健康检查和回滚机制不应推迟到功能完成后。

最终要点

该平台最难的部分并非生成六位数代码。

真正的工程挑战是在处理并发、实时状态、短期凭证、一次性下游会话、生产安全和企业交付控制的同时,在共享设备与顾客自有设备之间建立可信的交接。

当自定义 MFA 流程被视为一个完整的分布式系统而非小型身份验证组件时,它才具有价值。