Kent Shen
← 所有文章

設計一個給所有小專案共用的登入中樞

  • auth-hub
  • 登入
  • 架構

當你一個人養著好幾個小工具,其中幾個需要登入、需要判斷「這個人有沒有付這個功能的錢」,最誘人的做法是每個專案各寫一套。我試過一次就決定不要再做第二次,改成做一個獨立的登入與會員權限中樞,叫 auth-hub。Undercurrent 與 Veil 都接在它上面,台灣百岳地圖也已在它的服務清單裡登記。

資料模型:用欄位組合區隔,不是用多套系統

auth-hub 的核心只有四個概念:

  • users:誰
  • apps:哪個服務(例如 Undercurrent)
  • features:那個服務裡的哪個付費功能
  • entitlements:誰對哪個功能有權限,以及到期日

新增一個服務時,不用開新的資料庫或新的帳號表,只要在 apps 與 features 登記,再替使用者開通對應的權限。我把這些登記寫成 migration,而不是臨時打 API,這樣每次登記都有紀錄、可以回溯。

登入只支援 Google

一開始我也做了帳號密碼註冊,後來拿掉了。理由很實際:帳密登入需要 email 驗證、忘記密碼、防暴力破解,每一項都要另外接寄信服務並維護。我的使用者主要用 Google 登入,維護帳密只是增加攻擊面,所以一併移除。如果日後真的要開放,那幾項是必須先補齊的。

流程是這樣:服務把使用者導去 auth-hub 的 Google 登入,完成後 auth-hub 把 refresh token 種在共用網域的 cookie 上,再導回原本的服務。服務再用這個 cookie 換一個短效期的 access token。access token 只存在記憶體變數,不寫進 localStorage。

各站「不自己驗 JWT」

這是我最想分享的設計決定。很多人的直覺是:既然 auth-hub 簽發 JWT,各個服務拿到公鑰(或共用金鑰)自己驗就好,省一次請求。

但如果使用對稱金鑰,等於把「能簽發所有服務通行證」的鑰匙複製到每個站。只要其中一個站被攻破,攻擊者就能偽造任何人的身分,打進所有服務。

所以我的做法是:服務收到使用者的 token 後,轉發給 auth-hub 驗證,由 auth-hub 回答「這個人有沒有這個功能的權限」。多的只是一次子請求,而像「生成商業模式報告」這類功能,本來就要等 LLM 跑幾十秒,多這一次幾乎感覺不到。

像 Veil 這種不想讓瀏覽器直連的服務,還會透過 Cloudflare 的 service binding 在 Worker 之間呼叫,瀏覽器完全不接觸 auth-hub,所以前端的內容安全政策仍然可以維持最嚴格的 connect-src 'self'。

查不到,就當作沒有權限

auth-hub 連不上時,各服務一律當作沒有權限。寧可暫時擋掉合法的使用者,也不要在驗證系統出問題時,把付費功能免費放送。這個預設值要在寫第一行程式前就決定好,因為出事當下的你不會做出好的決定。

還有一個關於前端的原則:前端的旗標只決定要不要畫出按鈕,不是安全邊界。真正的關卡永遠在伺服器端,沒付費就回 402。

也要記得快取

權限查詢的回應一律帶 Cache-Control: no-store。這不是理論上的顧慮:少了它,剛開通的權限會被瀏覽器裡舊的「沒有權限」答案蓋住,使用者明明付了錢卻看不到功能,你還查不出原因。

這套架構的資安面向,我另外寫了一篇整理成檢查表。