跳至主要内容

簽章連結(Signed Link)使用指南

Packageio.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
CacheSignedLinkStoreSignedLinkStore 的預設快取實作

快速開始

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 成功後即失效,再次消費丟 VerificationExceptionsession 換發 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;需要攜帶租戶等應用層脈絡時,放進 SignedLinkSpecattributes,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 即可——具體類名 / 路徑屬參考實作,非框架契約。

三層邊界如下:

層級所有權
capabilityjar 的 SignedLinkServiceSignedLinkStore 與 token/spec 原語
FeatureSignedLinkConfig 的 cache/store/service 預設,以及上層只需依賴的 SignedLinkApplication 契約
reference implementationSignedLinkAppService 的帳號查找、通知、session 與 URL 政策;controller/DTO 的 HTTP 契約

參考實作 SignedLinkAppService 實作 SignedLinkApplication,把原語接成事件通知深連結; consume → 產一次性 exchange code → 導向 → exchange 當下換發 session。 下游只有在改變產品工作流時才替換該實作,不需複製或修改 Feature 預設。

事件通知深連結(系統主動)

  1. 系統因事件(如低庫存)發布通知;NotificationDeepLinkProvider 依收件人呼叫 createEventActionLink(email, "/products/123/edit", attrs)
  2. 服務簽一張 REUSABLE 連結(purpose = event-actiontarget 帶資源頁),通知子系統 將連結放入每位收件人的渲染模型後非同步遞送。
  3. 使用者在 TTL 視窗內任意時間點信 → GET /api/v1/auth/link/consume

共用中段:exchange code 模式

consume 不把 session 放進導向 URL,而是:

  1. consume 連結 → 拿 subject 確認帳號仍存在,但不簽發 session
  2. 簽一張秒級 SINGLE_USE exchange code(同一個原語,purpose = token-exchange);code 只攜帶 subject 與必要的 app,不含 access / refresh token。
  3. 303 導向前端 {redirect-base}{target}?code=...
  4. 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/exchangeSPA 以一次性 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 僅適合單節點。

相關文檔