跳至主要内容

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

Packageio.leandev.appfuse.security.link.* 狀態:穩定

簽章連結是一個租戶中性的原語,把「簽一張帶用途與導向目標的連結 token」與「驗章 + 依政策贖回」做成可復用的工具,讓下游用它實作「能收到該帳號 email 即可進入目標頁」的功能——免密碼登入、事件通知深連結都是它的特例。

概述

許多功能的共同形狀是:「我們相信能收到 alice@example.com 信件的人就是 Alice,所以寄一條連結給她,她點了就能直接進入某個頁面」。本原語把這個形狀抽出來:

  • SignedLinkService.issue(spec) — 鑄出一張帶 purpose(用途)、target(導向目標)、attributes(泛用屬性袋)的簽章 JWT,回傳 token 字串。
  • SignedLinkService.consume(rawToken) — 驗章 + 依政策贖回,回傳解析後的 SignedLinkToken

原語只負責簽章與驗章,刻意不碰三件事,全交給消費端決定:

不負責由誰決定
連結 URL 怎麼拼消費端(拿 token 拼進自己的 endpoint)
信件主旨 / 內文 / 何時寄消費端(用任何郵件機制,如 Mailer
身分如何載入、session 如何換發消費端(consume 後拿 subject 自行查帳號、發 token)

登入只是特例:免密碼登入 = 「target 指向首頁」的簽章連結;事件深連結 = 「target 指向某資源頁」的簽章連結。兩者共用同一條 issue / consume 管線,差別只在 target 與贖回政策。

核心型別

型別角色
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("alice@example.com") // 身分(不透明字串,如 email / username)
.purpose("passwordless-login") // 用途(消費端自定義,用來隔離不同種連結)
.ttl(Duration.ofMinutes(15)) // 有效期(必填,須為正)
.redemption(LinkRedemption.SINGLE_USE)
.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);
String email = link.subject(); // 拿身分自行查帳號、換發 session
String target = link.target(); // 導向目標(可能為 null = 預設首頁)
// ... 消費端決定:載入帳號、發 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) 原子地移除(移除成功 = 首次使用,失敗 = 已用過或已逾期)。REUSABLE 連結純靠 JWT 簽章與 exp經過 store。

預設實作 CacheSignedLinkStore

以 AppFuse Cache 儲存 jti,jti 自然過期後由快取自動清除。鏡像 TokenBlacklistStore / CacheTokenBlacklistStore 模式。

:::warning 快取 TTL 須涵蓋連結壽命 CacheSignedLinkStore 不設 per-entry TTL,改依快取整體 TTL 清除。因此注入的快取 TTL 必須不小於最長的 SINGLE_USE 連結有效期,否則 jti 會在 token 仍有效時被逐出,導致合法連結被誤判為「已使用」。 :::

何時改用資料庫實作

CacheSignedLinkStoreconsume 以 get-then-remove 實作,屬 best-effort——極高併發下理論上存在「同一 jti 被兩個請求同時判定為存在」的極小重入窗。需嚴格單次保證或多節點佈署時,請自行實作 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 怎麼拼由消費端負責;原語可用於任何傳遞媒介
purpose 隔離用途簽出的是獨立用途 token,與一般 session token 互不相通;消費端應以 purpose 隔離不同用途的連結,consume 後務必檢查 purpose 是否為預期值
金鑰可共用簽章重用 RSA JWT,可與 JwtTokenProvider 共用同一組金鑰,無需另管金鑰
執行緒安全SignedLinkService 無可變狀態;單次性由 SignedLinkStore 承擔

參考實作走查(app-server)

以下為參考實作(app-server 花店示範)的示意,展示如何用同一條管線接出兩個情境。下游照抄後改成自己的帳號模型、郵件範本、i18n 即可——具體類名 / 路徑屬參考實作,非框架契約。

參考實作 SignedLinkAppService 把原語接成兩個情境,consume → 換發 session → 產一次性 exchange code → 導向的中段兩情境完全共用:

情境 A:免密碼登入(使用者主動)

  1. 使用者送出 email → POST /api/v1/auth/passwordless(回應一律 200,不洩漏帳號是否存在)。
  2. 服務簽一張 SINGLE_USE 連結(purpose = passwordless-logintarget 空 → 導向首頁),組信寄出。
  3. 使用者點信 → GET /api/v1/auth/link/consume?token=...

情境 B:事件通知深連結(系統主動)

  1. 系統因事件(如低庫存)主動呼叫 sendEventActionLink(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 查帳號、換發 access / refresh token。
  2. 簽一張秒級 SINGLE_USE exchange code(同一個原語,purpose = token-exchange),把剛換的 token 夾在 attributes
  3. 303 導向前端 {redirect-base}{target}?code=...
  4. SPA 落地後以 POST /api/v1/auth/exchange 用 code 換回正式 token——URL 只短暫出現秒級單次 code,session 不入 URL。
使用者點信
→ GET /link/consume?token=...
→ consume(token) 驗章 + 贖回
→ 換發 session 拿 subject 查帳號、發 token
→ issue(exchange code) 秒級 SINGLE_USE,夾帶 token
→ 303 {redirect-base}{target}?code=...
→ SPA 落地,POST /exchange { code }
→ consume(code) 換回正式 access / refresh token
端點用途
POST /api/v1/auth/passwordless使用者要求免密碼登入連結(情境 A 發出端)
GET /api/v1/auth/link/consume?token=...點信進入:換發 session 並 303 導向前端(兩情境共用)
POST /api/v1/auth/exchangeSPA 以一次性 code 換回正式 token(兩情境共用)

事件深連結(情境 B)由系統因事件主動呼叫,刻意開對外端點,避免成為開放轉發(open redirect)。

安全考量

  • 連結即憑證:能收到信的人即可進入目標頁。ttl 應盡量短(登入級分鐘、事件級數小時~數天視情境),並以 SINGLE_USE 防重放。
  • 隱私保護:要求登入連結時,無論帳號是否存在都回相同回應(如 200),不洩漏帳號是否註冊。
  • 連結是機密:簽出的連結含可換 session 的能力,不應寫入 log(參考實作以 dev-log-link flag 控管,預設關閉、僅 dev 開)。
  • 務必檢查 purpose:consume 後確認 purpose 為本流程預期值,避免某用途的連結被拿去走另一條流程。
  • 嚴格單次需求:多節點或高併發下要嚴格單次保證時,改用資料庫 SignedLinkStore(見上)。

相關文檔