那 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 分鐘簡報,我們會在第一天就為你的帳號設定好電子郵件按鈕核准。