那 4 分钟的核准
在上一篇文章中我们说过:「密码学做了 SSO + 2FA 过去做的工作。」几位工程师写信来问这在代码里实际上是什么意思。这篇文章是答案。
起点是 MSP 客户工作流中可量测的摩擦。AI 提案一笔可计费的 ConnectWise 时间记录。主管必须在它入账前签核。旧路径是:
- Slack 通知:「需要核准」
- 点链接 → SSO 重定向 → 密码 → 2FA → 核准页面
- 读条目 → 点核准
首次动作的中位时间:4 分钟。一天 30 笔 × 一个典型的 10 人 MSP 4 个技师,那是一天 8 小时主管注意力,做的是功能上一堆「赞」。
那 4 分钟不是因为任何人慢。是因为核准的路径要求一个完整的身份仪式,而它从信息上看只是单一一个比特(核准/拒绝)加上选填的原因文字。
我们把它换成了这个:主管的电子邮件包含核准和拒绝按钮。一键。动作执行、审计日志记录、时间记录送到 ConnectWise。没登录。没 2FA。
它通过 SOC 2 问卷。下面是怎么通过的。
让它跟审计员证据等价的那四个属性
每个按钮里的链接不是带 ?approval_id=42 的 URL。它是用我们服务器私钥签章的 JWT(JSON Web Token),具有四个属性:
- 已签章。 该 token 的真实性可以用密码学验证。我们不信任 URL 列;我们信任签章。
- 有时效。 每个 token 会过期(
expclaim),通常是发行后 72 小时。之后点击不会做任何事。 - 单次使用。 一个 token 只能赎回一次。同一个核准按钮的第二次点击会打到幂等性缓存,返回「已被你在 [时间戳] 核准」。
- 幂等。 不管网路怎么样——重复点击、重试、浏览器预取——底层动作最多执行一次。每次相同效果。
第一个属性让我们可以略过 SSO。其他三个让我们可以略过审计团队的「但如果……」问题。
线格式
token payload(在 jwt.decode 之后但在签章验证之前)长这样:
{
"iss": "jiegou.ai",
"aud": "approval.action",
"iat": 1714502400,
"exp": 1714761600,
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"action": "approve",
"account_id": "acc_4nM8tYz2",
"scope": "time_entry"
}
关于这个 payload 三件事:
- token 把动作绑到特定核准者。即使电子邮件被转发,收件人也无法以自己的身份赎回它——审计日志仍然会记录
alex@example-msp.com为核准者,而不是转发者。(转发邮件威胁在下面进一步讨论。) jti是一个 ULID——每个 token 唯一,在我们赎回缓存里当幂等性键用。scope字段告诉服务器派发到哪个赎回处理器。time_entry走跟content_publish或outbound_message不同的代码路径。一样的 JWT 模式,不同的 scope 处理器。
验证顺序很重要
点击到服务器时,我们以这个确切顺序验证。任何一步失败,就停下:
- 验证签章。 这是第一件事——在 parse 之前、在信任任何字段之前。如果签章无效,请求收到一个通用的
400 Bad Request,没有细节。我们不泄漏哪个字段失败。 - 检查
iss和aud。 错的发行者或受众 → 拒绝。这抓住了由其他服务铸造或在系统间 replay 的 token。 - 检查
exp。 过期 → 渲染「这个核准链接已过期,请申请一个新的」页面。 - 对赎回缓存检查
jti。 已被赎回 → 渲染「已被 [核准者] 在 [时间] 核准」幂等性响应。 - 查找核准记录。 如果
approval_id不存在或已经透过其他路径解决(例如在控制台核准),渲染「这个核准已经被解决」。 - 比对
approver_email与该核准的允许核准者列表。 如果alex@...不在这个核准的政策核准者列表上,用403拒绝。 - 执行动作。 原子性:写赎回记录(
jti→ 已赎回时间)、发出approval_event审计条目、派发核准后工作流(例如把时间记录送到 ConnectWise)。 - 渲染成功。 显示确认页面;不重新发行任何 token;不在 URL 列里储存任何可能被重新分享的东西。
关键的不那么明显的选择是步骤 1。先验证再 parse。 有些实作先做 jwt.decode 来抽出 kid(key ID)以便验证签章,但那意味着你已经信任了未签章的资料。用一个能在一个调用中验证并在暴露 claim 之前拒绝格式错误 token 的 JWT 函式库。
威胁模型——四件事,每件对攻击者的成本
我们坐下来思考四个最可能的攻击,问:我们的设计怎么处理每一个?
1. 电子邮件被转发到公司外的人
会发生什么: 不是 alex@... 的某人点了按钮。
对攻击者的成本: 零——他们有明文 token。
缓解: token 把动作绑到 alex@... 在审计日志中的身份。转发者无法以自己身份赎回它。动作会执行,但是以 Alex 身份执行。下一次治理检视会浮现「Alex 从 IP 203.0.113.7 核准了这个(那不是 Alex 的办公室)」——而 Alex 在下次登录时马上会发现。
这是我们无法完全用密码学防止的。我们从营运上防止:核准者 IP/UA 的异常侦测、每周治理摘要、以及一个 3 秒的确认页面说「你即将以 alex@example-msp.com 身份核准——继续吗?」电子邮件链接的转发诈骗是可侦测的,不是可预防的。我们在客户 SOC 2 问卷中清楚地写明这件事。
2. token 在某个服务器日志中外泄
会发生什么: 一个 token 出现在我们的 nginx 存取日志、或第三方 webhook 日志、或一个失误的 console.log。
对攻击者的成本: 取决于新鲜度。
缓解: 三层。
- 短
exp。 72 小时过期意味着外泄的 token 在 3 天内就死了。我们不发行 30 天 token。 - 单次使用赎回。 如果合法用户先点,token 就死了。赎回缓存查找在任何副作用之前发生。
- 日志卫生。 动作链接 URL 在我们存取日志和 SIEM 摄取中被 pattern-match 并编辑。token 永远不会在 JWT 本身之外进入持久储存。
3. Replay——同个 token 被点两次
会发生什么: 网路重试、双击、浏览器预取、或恶意 replay。
对攻击者的成本: 零(合法 replay 很常见)。
缓解: 验证步骤 4 中的赎回缓存检查是承重的。缓存是 Redis、以 jti 为键、TTL 比 exp 长。第一次赎回原子性地设置键(SETNX);每个后续点击读取现有记录,并返回幂等的「已核准」响应,包括谁核准和何时。
幂等性不只是 UX 上的好处——它是让系统在重试下安全且不泄漏状态的属性。
4. 我们的签章密钥被入侵
会发生什么: HMAC secret 或 RSA 私钥外泄。
对攻击者的成本: 他们可以为任何核准铸造任意 token。
缓解: 四层。
- 密钥隔离。 签章密钥住在我们的 KMS,不在应用程序记忆体。应用程序服务器调用 KMS 来签章和验证;它们从不拥有原始密钥素材。
- 密钥轮换。 密钥按公布的时程轮换;旧密钥仅在等于最长已发行
exp的宽限期内被接受用于验证。 - 每个核准者范围的 token。 即使密钥被入侵,铸造一个给
alex@...的 token 并点击它的攻击者会产生 Alex 身份下的审计条目,那(依威胁 1)是可侦测的。 - 入侵响应。 如果我们侦测到密钥入侵,我们撤销密钥并使所有未完成 token 失效。客户看到「这个核准链接因安全原因已失效;请使用控制台解决」直到重发。
这是最难完全缓解的威胁,这也是为什么系统有后备:控制台核准队列永远可用。电子邮件按钮核准是一种增添,不是替代。
与 SSO + 2FA 的诚实取舍
电子邮件按钮核准严格来说不等于 SSO + 2FA。取舍:
| 属性 | SSO + 2FA | 签章动作链接 |
|---|---|---|
| 认证身份 | 是(因素 + 因素) | 否——认证一个单一决定绑到一个身份 |
| 抵抗电子邮件转发 | 是(转发用户无法通过 2FA) | 可侦测但不可预防 |
| 抵抗密钥入侵 | 独立于我们基础架构(联邦 IdP) | 取决于我们的 KMS 卫生 |
| 每次核准的摩擦 | 4 分钟 | 5 秒以下 |
| 审计轨迹等价 | 是 | 是(一样 approval_event 形状) |
我们对客户清楚说明他们在选哪个取舍。建议的设定是:
- 低风险、高量核准(时间记录、内容草稿、行事历暂停):默认电子邮件按钮
- 高风险核准(超过阈值的金融交易、对外可见的承诺、影响合规的动作):要求控制台 + SSO;电子邮件按钮变成「提醒」
- 每个账号的覆写:每个客户都可以为哪些动作得到电子邮件按钮 vs. 仅控制台设置阈值
阈值是一个 config 值。密码学一样。
审计日志等价
每次签章动作链接赎回都会发出跟控制台核准一样的 approval_event 形状。entry-point 字段写 email_button;其余相同:
{
"event_type": "approval.resolved",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"decision": "approve",
"decision_at": "2026-04-29T14:32:18Z",
"entry_point": "email_button",
"client_ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 …",
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R"
}
entry_point 是审计员需要知道它从电子邮件来的唯一东西。其他一切——谁、何时、什么、IP——都跟控制台核准证据相符。我们的 SOC 2 证据汇出格式一视同仁。审计员读一个 schema。
五个适合这个模式的其他工作流
一旦 JWT-赎回-缓存模式存在,我们发现它适合很多单一决定的工作流:
- 行事历暂停确认——AI 提案「周三下午 2 点封锁 30 分钟准备 Q3 检视」;操作员确认或移动它
- 内容发布暂停-而后-释放——通过自动检查但被标记为发送者确认的草稿
- 外送讯息暂停——AI 草拟一个客户回复;操作员从暂停队列释放它
- 敏感整合解锁——AI 请求使用一个位于每次使用核准闸门后的特定工具的权限
- 单次同意流程——影响客户资料的动作的对客户同意
每一个过去都需要造访控制台。每一个现在都有电子邮件按钮等价。密码学就是凭证。 操作员的一天因此缩短。
我们会跟另一个团队建造这个的人说什么
如果你在建造类似的东西,三件事倾向去做、三件事避免:
倾向:
- 在任何字段 parse 之前验证 JWT 签章。 用一个在一个调用中做这件事的函式库。
- 以
jti为键的赎回缓存幂等性。 这是让 retry-safe 的。 - 不论进入点为何都有同样的审计事件形状。 审计员不应该关心点击从哪个路径来。
避免:
- 为了「对用户好」而长
exp。 30 天核准链接是 30 天攻击面。72 小时对人类核准者来说绰绰有余。 - 把 token 存在 URL fragment 里希望浏览器不会记录。 它们会。把 URL 当成是「最终会被记录的东西」对待。
- 静默失败模式。 验证失败时,渲染一个清楚的错误页面。合法核准者可能因为忽略电子邮件一周而拿着旧 token。
上一篇文章的口号是:信任没有变弱,它换到了摩擦更低的层。 签章动作链接是这句话的字面范例。信任从 SSO + 2FA + 密码管理员 + 浏览器 session 换到 管理良好的签章密钥 + 短时效 JWT + 幂等赎回缓存。摩擦从 4 分钟变到 5 秒以下。审计轨迹得到更多字段,不是更少。
免费开始 → — 或如果你经营 MSP 而上面的 4 分钟核准问题听起来很熟悉,预约 30 分钟简报,我们会在第一天就为你的账号设置好电子邮件按钮核准。