簽章連結(Signed Link)使用指南
Package:
io.leandev.appfuse.security.link.*狀態:穩定
簽章連結是一個租戶中性的原語,把「簽一張帶用途與導向目標的連結 token」與「驗章 + 依政策贖回」做成可復用的工具。參考實作以它提供事件通知深連結;Email 免密碼登入統一由 Email OTP 負責。
概述
許多功能的共同形狀是:「我們相信能收到 alice@example.com 信件的人就是 Alice,所以寄一條連結給她,她點了就能直接進入某個頁面」。本原語把這個形狀抽出來:
SignedLinkService.issue(spec)— 鑄出一張帶purpose(用途)、target(導向目標)、attributes(泛用屬性袋)的簽章 JWT,回傳 token 字串。SignedLinkService.consume(rawToken, expectedPurpose)— 驗章、確認 token family / purpose,再依政策贖回,回傳解析後的SignedLinkToken。
原語只負責簽章與驗章,刻意不碰三件事,全交給消費端決定:
| 不負責 | 由誰決定 |
|---|---|
| 連結 URL 怎麼拼 | 消費端(拿 token 拼進自己的 endpoint) |
| 信件主旨 / 內文 / 何時寄 | 消費端(用任何郵件機制,如 Mailer) |
| 身分如何載入、session 如何換發 | 消費端(consume 後拿 subject 自行查帳號、發 token) |
Signed-link 原語仍可承載不同用途,但參考實作只提供
event-action,不再暴露公開的 passwordless-link 發送端點。
核心型別
| 型別 | 角色 |
|---|---|
SignedLinkService | 原語本體:issue / consume |
SignedLinkSpec(record + builder) | issue 入參:subject / purpose(必填)、target / attributes(選填)、ttl(必填正值)、redemption(缺省 SINGLE_USE) |
SignedLinkToken(record) | consume 後的不可變結果:subject / purpose / target / attributes / jti / issuedAt / expiresAt / redemption |
LinkRedemption(enum) | 贖回政策:SINGLE_USE / REUSABLE |
SignedLinkStore(介面) | 僅 SINGLE_USE 連結使用的 jti 單次性 store |
CacheSignedLinkStore | SignedLinkStore 的預設快取實作 |
快速開始
1. 建立 SignedLinkService
簽章重用 JWT(RSA),金鑰由建構子注入——可與 JwtTokenProvider 共用同一組金鑰。SINGLE_USE 連結的單次性由 SignedLinkStore 承擔(見下)。
SignedLinkService signedLinkService = new SignedLinkService(
privateKey, // 簽章用私鑰(可與 JwtTokenProvider 共用)
publicKey, // 驗章用公鑰
new CacheSignedLinkStore(cache)); // SINGLE_USE 的 jti store
2. 簽發連結
String token = signedLinkService.issue(SignedLinkSpec.builder()
.subject("account-123") // 穩定身分鍵(不透明字串)
.purpose("event-action") // 用途(消費端自定義,用來隔離不同種連結)
.target("/orders/123") // 事件對應的站內頁面
.ttl(Duration.ofHours(48)) // 有效期(必填,須為正)
.redemption(LinkRedemption.REUSABLE)
.build());
// 拿 token 拼進自己的 endpoint,再經郵件寄出(皆由消費端負責)
String url = "https://app.example.com/api/v1/auth/link/consume?token="
+ URLEncoder.encode(token, StandardCharsets.UTF_8);
3. 驗章並贖回
try {
SignedLinkToken link = signedLinkService.consume(rawToken, "event-action");
String accountId = link.subject(); // 拿身分自行查帳號、換發 session
String target = link.target(); // 導向目標
// ... 消費端決定:載入帳號、發 token、導向 target
} catch (VerificationException e) {
// 簽章無效 / 已過期 / SINGLE_USE 已被使用過
}
贖回政策(LinkRedemption)
一張連結在有效期內可被消費幾次、消費時是否查 store,由 redemption 決定:
| 政策 | 行為 | 是否查 store | 適用情境 |
|---|---|---|---|
SINGLE_USE | 簽章 + 未過期 + jti 原子消費:第一次 consume 成功後即失效,再次消費丟 VerificationException | 是 | session 換發 code 等「用過即廢」 |
REUSABLE | 簽章 + 未過期即有效:TTL 視窗內可多次 consume,純 stateless JWT | 否 | 事件通知深連結等「使用者可能先點開瞭解、稍後再點處理」 |
issue未指定redemption時預設SINGLE_USE(fail-safe);consume遇缺值的舊 token 也採最嚴格的SINGLE_USE。
REUSABLE不代表「動作可重複執行」:連結只保證「可重複進入目標頁」。「同一動作不可重複執行」(如同一筆訂單不可重複確認)應由目標資源自身的狀態把關,不是連結的責任。
SignedLinkStore:單次性的接縫
只有 SINGLE_USE 連結會用到 SignedLinkStore——簽發時 register(jti, ttl)、消費時 consume(jti) 原子地移除(移除成功 = 首次使用,失敗 = 已用過或已逾期)。purpose 會在移除前驗證,因此錯誤流程不會燒掉合法連結。REUSABLE 連結純靠 JWT 簽章與 exp,不經過 store。
預設實作 CacheSignedLinkStore
以 AppFuse Cache 儲存 jti,jti 自然過期後由快取自動清除。consume 使用 cache 的條件式 remove,對同一個 backing store 為原子操作;併發測試保證同一 jti 只有一個消費者成功。
:::warning 快取 TTL 須涵蓋連結壽命
CacheSignedLinkStore 不設 per-entry TTL,改依快取整體 TTL 清除。因此注入的快取 TTL 必須不小於最長的 SINGLE_USE 連結有效期,否則 jti 會在 token 仍有效時被逐出,導致合法連結被誤判為「已使用」。
:::
多節點部署與替換 store
預設 Ehcache 是單節點記憶體 store。多節點部署時,各節點必須使用同一個具原子 delete / compare-and-remove 的共享 store(例如資料庫或分散式 cache),否則簽發與消費落到不同節點時無法維持可用性與單次性。可自行實作 SignedLinkStore:
// 以 DELETE 的回傳列數判定原子單次性
public boolean consume(String jti) {
return jdbcTemplate.update("DELETE FROM signed_link_jti WHERE jti = ?", jti) > 0;
}
介面就是這個替換的接縫——換 store 不動 SignedLinkService。
設計原則
| 原則 | 說明 |
|---|---|
| 租戶中性 | 原語不認得 Account / tenant / email;需要攜帶租戶等應用層脈絡時,放進 SignedLinkSpec 的 attributes,consume 後原樣取回。對單租戶 / 多租戶皆成立 |
| 不碰寄信 | 原語只回 token 字串;要不要寄信、主旨內文,全由消費端決定 |
| 不認得 HTTP | 連結 URL 怎麼拼由消費端負責;原語可用於任何傳遞媒介 |
以 token family / purpose 雙重隔離 | access、refresh、signed-link 都帶固定 tokenType;Resource Server 只接受 access,SignedLinkService 只接受 signed-link。消費端再以 expected purpose overload 限定用途,且 purpose 驗證發生在單次贖回之前 |
| 金鑰可共用 | 簽章重用 RSA JWT,可與 JwtTokenProvider 共用同一組金鑰,無需另管金鑰 |
| 執行緒安全 | SignedLinkService 無可變狀態;單次性由 SignedLinkStore 承擔 |
參考實作走查(app-tenant-server)
以下為參考實作(
app-tenant-server花店示範)的示意,展示事件通知深連結。下游照抄後改成自己的帳號模型、郵件範本、i18n 即可——具體類名 / 路徑屬參考實作,非框架契約。
三層邊界如下:
| 層級 | 所有權 |
|---|---|
| capability | jar 的 SignedLinkService、SignedLinkStore 與 token/spec 原語 |
| Feature | SignedLinkConfig 的 cache/store/service 預設,以及上層只需依賴的 SignedLinkApplication 契約 |
| reference implementation | SignedLinkAppService 的帳號查找、通知、session 與 URL 政策;controller/DTO 的 HTTP 契約 |
參考實作 SignedLinkAppService 實作 SignedLinkApplication,把原語接成事件通知深連結;
consume → 產一次性 exchange code → 導向 → exchange 當下換發 session。
下游只有在改變產品工作流時才替換該實作,不需複製或修改 Feature 預設。
事件通知深連結(系統主動)
- 系統因事件(如低庫存)發布通知;
NotificationDeepLinkProvider依收件人呼叫createEventActionLink(email, "/products/123/edit", attrs)。 - 服務簽一張
REUSABLE連結(purpose = event-action、target帶資源頁),通知子系統 將連結放入每位收件人的渲染模型後非同步遞送。 - 使用者在 TTL 視窗內任意時間點信 →
GET /api/v1/auth/link/consume。
共用中段:exchange code 模式
consume 不把 session 放進導向 URL,而是:
- consume 連結 → 拿
subject確認帳號仍存在,但不簽發 session。 - 簽一張秒級
SINGLE_USEexchange code(同一個原語,purpose = token-exchange);code 只攜帶 subject 與必要的app,不含 access / refresh token。 303導向前端{redirect-base}{target}?code=...。- SPA 落地後以
POST /api/v1/auth/exchange贖回 code;後端重新載入 principal,走LoginService的 enabled / locked / expired / interactive-login / app policy 檢查,通過後才簽發正式 token。
使用者點信
→ GET /link/consume?token=...
→ consume(token) 驗章 + 贖回
→ issue(exchange code) 秒級 SINGLE_USE,不夾帶 session
→ 303 {redirect-base}{target}?code=...
→ SPA 落地,POST /exchange { code }
→ consume(code) 重新查帳號與登入政策
→ issue session 回傳正式 access / refresh token
| 端點 | 用途 |
|---|---|
GET /api/v1/auth/link/consume?token=... | 點信進入:建立 exchange code 並 303 導向前端,尚未簽發 session |
POST /api/v1/auth/exchange | SPA 以一次性 code 換回正式 token |
事件深連結由系統因事件主動呼叫,刻意不開發送端點,避免成為開放轉發(open redirect)。
安全考量
- 連結即憑證:能收到信的人即可進入目標頁。
ttl應依事件敏感度設為數小時至數天;真正業務動作仍由目標資源授權與狀態機把關。 - 連結是機密:簽出的連結含可換 session 的能力,不應寫入 log。
- Outbox 會保存 plaintext link:reference implementation 不加密 notification business
data。連結必須採短 TTL/
SINGLE_USE,限制 Outbox 與備份的讀取權限,並依 retention policy 清除歷史列;若產品的威脅模型要求 at-rest protection,再由應用選配 purpose-scoped cipher。 - 務必在贖回時限定
purpose:使用consume(rawToken, expectedPurpose)或集合 overload,避免某用途的連結進入另一條流程,也避免錯誤用途先燒掉單次 token。 - Bearer 僅接受 access token:refresh、signed-link 與 exchange code 即使共用 RSA 金鑰,也會因
tokenType不符而被 Resource Server 拒絕。 - 多節點單次性:使用具原子操作的共享
SignedLinkStore(見上);預設本機 Ehcache 僅適合單節點。
相關文檔
- 安全性模組使用指南 — Token 黑名單、登入鎖定
- Mailer 郵件模組 — 寄送簽章連結信件的常見搭配
- 持久化資料加密 — 應用選配 business-data protection 時的 primitive 與 key rotation
- Cache 快取模組 —
CacheSignedLinkStore的底層 - CHANGELOG —
appfuse-server的security/link/條目(API 簽章權威來源)