這座知識庫有兩種存取方式:人用瀏覽器登入,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;
database: pool 那行直接用應用程式現有的連線池,帳號表就長在同一個資料庫裡。不是另外接一個認證服務。多一個外部相依,就多一個會在半夜掛掉而你無法修的東西。
socialProviders: config.google ? { google: config.google } : {},
這一行決定了自架的人會不會看到一顆按下去必定失敗的「用 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();
}
它掛在 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. */
連不到 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 });
} } },
}
注意 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 是「我現在坐在這裡」。
user: { deleteUser: { enabled: true } }, // POST /api/auth/delete-user { password }; FKs cascade to workspace, notes, tokens, jobs
打開之後會多一個端點,要求使用者輸入密碼才能執行。這一行只是開關,做事的是資料庫的外鍵:工作區、筆記、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 相同');
第三道是啟動時的守門,而且正式環境跟開發環境待遇不同:
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}`);
}
}
開發環境只警告不擋,否則每個想跑起來看看的人都要先生一把金鑰。正式環境直接拒絕啟動。這種事不能只是警告,因為警告會被忽略,而被忽略的後果是別人的 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;
v1. 是舊的(從 session secret 派生),只能解不能加密;新寫入一律 v2。然後有一支 npm run keys:rotate,把資料庫裡所有 v1 的密文解開、用 v2 重新加密。可以重複執行,跑到一半中斷也沒關係。
/healthz 會回報目前用的是哪個版本,這樣部署完看一眼就知道有沒有設對。
使用者存了 key 之後,設定頁只顯示末四碼,永遠不回傳完整內容。要換就重填一次。
這是慣例做法,但值得說一下為什麼:沒有任何正當理由需要把 key 讀回前端。使用者已經有那把 key 了,他不需要從我這裡拿回去。少一個回傳路徑,就少一個被 XSS 或錯誤的權限檢查偷走的機會。
用量限制跟金鑰是同一批工作,因為 agent 是唯一會花錢的操作。
最後那條不是靠「擋住寫入」做的,而是算差額:
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', { … });
只有新增會被筆記數上限擋下來;改既有的頁比的是位元組差額,所以你把一頁刪成三行永遠會過。
這條是刻意的。超過額度就把人鎖在門外,是一種糟糕的體驗,而且他的資料還在你手上。讓他繼續讀、可以匯出、可以刪東西騰出空間,這樣他至少不會覺得被綁架。
這一篇把使用者交出來的兩樣東西都安頓好了。身分那邊:註冊登入交給 better-auth,註冊那一刻開好工作區、按瀏覽器語言給模版,機器人檢查只擋註冊、驗證服務掛了放行,瀏覽器的 session 與 agent 的 token 落在同一個工作區,刪帳號靠外鍵 cascade 一次清完。key 那邊:AES-256-GCM 加密,金鑰從獨立的 secret 派生、可以用檔案掛載,密文帶版本前綴所以金鑰換得動,設定頁只回末四碼;超過免費額度的工作區是唯讀,不是鎖死。