簽章連結(Signed Link)使用指南
Package:
io.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 |
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("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 仍有效時被逐出,導致合法連結被誤判為「已使用」。
:::
何時改用資料庫實作
CacheSignedLinkStore 的 consume 以 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;需要攜帶租戶等應用層脈絡時,放進 SignedLinkSpec 的 attributes,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:免密碼登入(使用者主動)
- 使用者送出 email →
POST /api/v1/auth/passwordless(回應一律 200,不洩漏帳號是否存在)。 - 服務簽一張
SINGLE_USE連結(purpose = passwordless-login、target空 → 導向首頁),組信寄出。 - 使用者點信 →
GET /api/v1/auth/link/consume?token=...。
情境 B:事件通知深連結(系統主動)
- 系統因事件(如低庫存)主動呼叫
sendEventActionLink(email, "/products/123/edit", attrs)。 - 服務簽一張
REUSABLE連結(purpose = event-action、target帶資源頁),組信寄出。 - 使用者在 TTL 視窗內任意時間點信 → 同樣走
GET /api/v1/auth/link/consume。
共用中段:exchange code 模式
兩情境的 consume 都不把 session 放進導向 URL,而是:
- consume 連結 → 拿
subject查帳號、換發 access / refresh token。 - 簽一張秒級
SINGLE_USEexchange code(同一個原語,purpose = token-exchange),把剛換的 token 夾在attributes。 303導向前端{redirect-base}{target}?code=...。- 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/exchange | SPA 以一次性 code 換回正式 token(兩情境共用) |
事件深連結(情境 B)由系統因事件主動呼叫,刻意不開對外端點,避免成為開放轉發(open redirect)。
安全考量
- 連結即憑證:能收到信的人即可進入目標頁。
ttl應盡量短(登入級分鐘、事件級數小時~數天視情境),並以SINGLE_USE防重放。 - 隱私保護:要求登入連結時,無論帳號是否存在都回相同回應(如 200),不洩漏帳號是否註冊。
- 連結是機密:簽出的連結含可換 session 的能力,不應寫入 log(參考實作以
dev-log-linkflag 控管,預設關閉、僅 dev 開)。 - 務必檢查
purpose:consume 後確認purpose為本流程預期值,避免某用途的連結被拿去走另一條流程。 - 嚴格單次需求:多節點或高併發下要嚴格單次保證時,改用資料庫
SignedLinkStore(見上)。
相關文檔
- 安全性模組使用指南 — Token 黑名單、登入鎖定
- Mailer 郵件模組 — 寄送簽章連結信件的常見搭配
- Cache 快取模組 —
CacheSignedLinkStore的底層 - CHANGELOG —
appfuse-server的security/link/條目(API 簽章權威來源)