iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
佛心分享-SideProject30

為你自己蓋一座會複利的知識庫——WikiBrain系列 第 19 篇

Day 19 - 接上 better-auth,用 AES-256-GCM 存使用者的 API key

  • 分享至 

  • xImage
  •  

前言

這座知識庫有兩種存取方式:人用瀏覽器登入,agent 拿 token 打 MCP。它們的驗證機制完全不同,一邊是 session cookie,一邊是 Authorization: Bearer,但背後必須是同一個身分,否則 agent 寫進去的頁面,使用者在網頁上會看不到。

這一篇講人這一側:帳號從註冊到刪除,以及他交出來的第二樣東西,那把會花他錢的模型 API key。

不要自己寫認證

我用 better-auth 這個套件,整組設定三十五行。自己寫要處理雜湊參數、session 輪替、CSRF、驗證信的 token 過期、重設密碼的一次性連結,每一項單獨都不難,但每一項寫錯都是帳號被接管。

這是我在整個專案裡少數「直接用別人寫好的」決定之一。判準是:協定裡有標準答案的部分,用別人的;產品邏輯才自己寫。

export const authOptions = {
  database: pool,
  baseURL: config.appUrl,
  secret: config.authSecret,
  trustedOrigins: [config.appUrl, 'http://localhost:5173', ...config.trustedOrigins],
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    minPasswordLength: 8,
    sendResetPassword: async ({ user, url }) => { await sendMail({ to: user.email, … }); },
  },
  emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, … },
  socialProviders: config.google ? { google: config.google } : {},
  user: { deleteUser: { enabled: true } },
  databaseHooks: { … },
} satisfies BetterAuthOptions;

出處:src/auth-web.ts:10-44

database: pool 那行直接用應用程式現有的連線池,帳號表就長在同一個資料庫裡。不是另外接一個認證服務。多一個外部相依,就多一個會在半夜掛掉而你無法修的東西。

沒設 Google 憑證就不要有那顆按鈕

socialProviders: config.google ? { google: config.google } : {},

出處:src/auth-web.ts:30

這一行決定了自架的人會不會看到一顆按下去必定失敗的「用 Google 登入」。沒填憑證就整個關掉,前端也不會渲染那顆按鈕。

這個開關是隱式的,副作用是除錯時它會誤導你:功能沒出現的時候,「沒設定」跟「有 bug」長得一模一樣,而你會先去查程式。

隱式開關的副作用就是這個。要嘛接受它,要嘛在介面上把偵測結果顯示出來;目前這個產品是前者,登入頁沒有那顆按鈕本身就是唯一的訊號。

擋機器人:只擋註冊,而且擋不住就放行

註冊端點是唯一一個不用登入就能建立帳號的地方,所以它是唯一掛了機器人檢查的地方。用的是 Cloudflare Turnstile,而且兩把金鑰都有設才會啟用,自架的人不必為了跑起來去辦一個 Cloudflare 帳號。

/** Mount on the sign-up route, ahead of better-auth. A missing or bad token answers 403 without creating anything. */
export async function requireTurnstile(req: Request, res: Response, next: NextFunction): Promise<void> {
  if (!turnstileOn()) return next();
  const token = String(req.headers['x-turnstile-token'] ?? '').slice(0, 4096);
  …
  if (!token) return fail('no token');
  const { ok, detail } = await verify(token, req.ip);
  if (!ok) return fail(detail);
  next();
}

出處:src/turnstile.ts:47-59

它掛在 better-auth 前面,所以直接對端點送 POST 的腳本連帳號都建不出來。

登入沒有擋。 密碼本身已經是一個祕密,而在登入放一道挑戰,等於每一次都讓真正的使用者跟自己的資料之間多一道關卡。

最值得講的是驗不過的時候怎麼辦。這裡選的是失敗放行:

/* Failing open is deliberate. A token we cannot check is a maybe-bot; a verifier we cannot reach turns every
   real person away. The first costs a junk account, the second costs every signup for the duration. */

出處:src/turnstile.ts:40-41

連不到 Cloudflare 就放行,連「我們自己的金鑰被 Cloudflare 拒絕」也放行。那是我設定錯了,不是訪客的錯,而且擋下來的話註冊會整個關閉,看起來還跟一波機器人攻擊一模一樣。金鑰設錯本來就保護不了任何人,唯一的問題只剩「大門要不要跟著壞掉」。

這是安全機制少見的一種取捨:這道防線壞掉的時候,正確的行為是讓它失效,而不是讓它擋住所有人。判準是兩邊的代價不對稱:放行賠上幾個垃圾帳號,擋住賠上這段期間每一個真實使用者。

註冊那一刻要做的事

新帳號建立之後要有一個工作區,否則使用者登入進來會看到空白。這件事掛在 better-auth 的資料庫 hook 上:

databaseHooks: {
  user: { create: { after: async (user, ctx) => {
    const al = ctx?.headers?.get?.('accept-language') ?? '';
    const lang = !al ? 'zh-TW' : /^\s*zh/i.test(al) ? 'zh-TW' : 'en';
    const ws = await ensureWorkspaceFor(user.id, lang);
    track('signup', { userId: user.id, workspaceId: ws.id });
  } } },
}

出處:src/auth-web.ts:32-43

注意 lang 是從註冊當下的 accept-language 決定的。之前我們已經講過,這個產品只有三個地方需要語言,其中一個是「模版預設內容的語言」,就是這裡。使用者用中文瀏覽器註冊,他的第一份規則頁就是中文的。

這個判斷刻意寫得很鈍:不是中文開頭就給英文,而且空值也當成中文(因為這是一個台灣人做的產品,預設值應該偏向多數)。空值那一條不是多寫的,Node 的 fetch 預設不送 accept-language,所以用程式打自己的註冊端點時,這個欄位是空的。少了那一條,所有從腳本建立的帳號都會拿到英文模版,而你在瀏覽器上測不出來。

兩套認證怎麼共用身分

網頁這一側是 better-auth 的 session,MCP 那一側是自己寫的 Bearer token 驗證。它們的交會點是資料表:user 底下同時掛著 better-auth 管的 session(瀏覽器用)與自己管的 mcp_tokens(agent 用),而最後都落在同一個 workspace。

好處是任何一側寫進去的東西,另一側立刻看得到。Cursor 在背景編纂完,你重新整理網頁就看到新頁面;你在網頁上改了規則,下一次 agent 呼叫 get_instructions 拿到的就是新的。

壞處是有兩套要維護,而且撤銷的語意不對稱:token 在設定頁列得出來、可以一把一把撤銷,撤掉之後那個 agent 就永遠進不來;session 沒有列表,只有一顆登出按鈕,結束的是你當下這一個。使用者對這兩種東西的心智模型本來就不一樣:token 是「我發給某個程式的鑰匙」,session 是「我現在坐在這裡」。

刪帳號:Google 登入的人目前刪不掉

user: { deleteUser: { enabled: true } }, // POST /api/auth/delete-user { password }; FKs cascade to workspace, notes, tokens, jobs

出處:src/auth-web.ts:31

打開之後會多一個端點,要求使用者輸入密碼才能執行。這一行只是開關,做事的是資料庫的外鍵:工作區、筆記、token、工作紀錄都設成 cascade,帳號一刪就跟著走。

這裡先記一個現在還沒補的洞:只用 Google 登入的人沒有密碼,而刪帳號的端點固定要一組密碼。那些人目前刪不掉自己的帳號。正確的做法是對社群登入的使用者改成重新驗證一次身分,這件事還在待辦上。

帳號之後,第二樣他交出來的東西

帳號建好、工作區開出來之後,使用者在設定頁交出來的第二樣東西是他自己的模型 API key。那把 key 能花他的錢,所以保管的規格比帳號本身還嚴。

加密,而且金鑰要跟別的分開

明文存進資料庫顯然不行。用 AES-256-GCM 加密,金鑰用 HKDF 從一個環境變數派生出來。

重點在那個環境變數不能跟簽章 session 的那把共用。

我一開始是共用的:一把 BETTER_AUTH_SECRET 同時負責簽 session 跟派生加密金鑰。少一個變數要管,看起來很合理。

改掉是因為看到別家平台的一則事故:叢集層級被入侵,環境變數整包外洩。我把那個情境套到自己身上想了一遍。如果我的環境變數外洩,攻擊者拿到那一把,就同時能偽造任何人的登入,還能解開所有使用者的 API key。一次外洩,全盤皆輸。

現在是兩把不同的 secret,而且加密的那把支援用檔案掛載:

KEY_ENCRYPTION_SECRET_FILE=/run/secrets/wikibrain_key

Docker secret、Kubernetes 的 secret volume、或從雲端 KMS 拉下來寫成檔案都可以。這樣環境變數整包外洩,密文還是解不開,因為金鑰根本不在環境變數裡。

前兩道寫在派生金鑰的地方,設錯了就不讓你跑:

if (src.secret && src.secret.length < 32) throw new Error('KEY_ENCRYPTION_SECRET 至少 32 字元(建議 openssl rand -hex 32)');
if (src.secret && src.secret === config.authSecret) throw new Error('KEY_ENCRYPTION_SECRET 不可與 BETTER_AUTH_SECRET 相同');

出處:src/crypto.ts:26-27

第三道是啟動時的守門,而且正式環境跟開發環境待遇不同:

export function assertEncryptionReady(): void {
  const v = encryptionVersion();
  if (v === 'v1') {
    const msg = 'KEY_ENCRYPTION_SECRET 未設定:用戶 API key 目前只靠 BETTER_AUTH_SECRET 保護。…';
    if (process.env.NODE_ENV === 'production') throw new Error(`拒絕啟動:${msg}`);
    console.warn(`⚠ ${msg}`);
  }
}

出處:src/crypto.ts:34-42

開發環境只警告不擋,否則每個想跑起來看看的人都要先生一把金鑰。正式環境直接拒絕啟動。這種事不能只是警告,因為警告會被忽略,而被忽略的後果是別人的 API key。

換金鑰要能換得動

加密做完會遇到下一個問題:金鑰換掉之後,舊的密文就解不開了。

處理方式是密文帶版本前綴。加密時只認新的那把,解密時兩把都認:

export function encrypt(plain: string): string {
  const k = keys(); const v = k.v2 ? 'v2' : 'v1'; const key = k.v2 ?? k.v1;
  …
}
export function decrypt(token: string): string {
  …
  const key = v === 'v2' ? k.v2 : k.v1;

出處:src/crypto.ts:44-58

v1. 是舊的(從 session secret 派生),只能解不能加密;新寫入一律 v2。然後有一支 npm run keys:rotate,把資料庫裡所有 v1 的密文解開、用 v2 重新加密。可以重複執行,跑到一半中斷也沒關係。

/healthz 會回報目前用的是哪個版本,這樣部署完看一眼就知道有沒有設對。

只回末四碼

使用者存了 key 之後,設定頁只顯示末四碼,永遠不回傳完整內容。要換就重填一次。

這是慣例做法,但值得說一下為什麼:沒有任何正當理由需要把 key 讀回前端。使用者已經有那把 key 了,他不需要從我這裡拿回去。少一個回傳路徑,就少一個被 XSS 或錯誤的權限檢查偷走的機會。

方案閘門

用量限制跟金鑰是同一批工作,因為 agent 是唯一會花錢的操作。

  • 免費方案每月二十次 agent 工作(編纂、對話、健檢合計)
  • 筆記數、儲存空間、token 數量也各有上限
  • 超過上限之後是唯讀:讀得到、刪得掉、縮減得了,就是不能再長大

最後那條不是靠「擋住寫入」做的,而是算差額:

let isNew = true, delta = newBytes;
if (path) {
  const { rows } = await pool.query(`SELECT octet_length(content_md) AS len FROM notes WHERE …`, [ws, path]);
  if (rows[0]) { isNew = false; delta = newBytes - Number(rows[0].len); }
}
if (isNew && s.notes_used >= s.notes_limit) throw new NoteError('FORBIDDEN', { … });

出處:src/plans.ts:75-86

只有新增會被筆記數上限擋下來;改既有的頁比的是位元組差額,所以你把一頁刪成三行永遠會過。

這條是刻意的。超過額度就把人鎖在門外,是一種糟糕的體驗,而且他的資料還在你手上。讓他繼續讀、可以匯出、可以刪東西騰出空間,這樣他至少不會覺得被綁架。

小結

這一篇把使用者交出來的兩樣東西都安頓好了。身分那邊:註冊登入交給 better-auth,註冊那一刻開好工作區、按瀏覽器語言給模版,機器人檢查只擋註冊、驗證服務掛了放行,瀏覽器的 session 與 agent 的 token 落在同一個工作區,刪帳號靠外鍵 cascade 一次清完。key 那邊:AES-256-GCM 加密,金鑰從獨立的 secret 派生、可以用檔案掛載,密文帶版本前綴所以金鑰換得動,設定頁只回末四碼;超過免費額度的工作區是唯讀,不是鎖死。


上一篇
Day 18 - 用雜湊比對分出規則頁改過沒有
下一篇
Day 20 - 讓 Claude.ai 走 OAuth 2.1 連進來
系列文
為你自己蓋一座會複利的知識庫——WikiBrain 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言