feat(auth): 認證與資料分離——搬動知識資料時登入不再跟著壞掉(D61 / arcrun-rag#55)
leo 2026-08-10 下令:「登入認證資料要分離⋯⋯就算只有我一個人存在單獨的 json 檔也好,
它不能被改資料庫的連結導致無法登入。」「昨天不能登入 portal,今天不能登入 mcp,
這根本就是一個問題。」
病:portal 帳號住 KBDB(owner_id = {CONSOLE_TENANT}::portal),console 管理員帳密住
SESSIONS_KV。兩者都靠 binding 指過去,重裝/遷移一定會被重新指一次 ⇒ 保險箱的鑰匙
放在保險箱裡。2026-08-09 leo 資料一個位元組都沒動,卻被鎖在門外。
修:認證搬到 CF Workers per-script Secrets(掛在 script 上,與 bindings 兩套資源,
重部不會洗掉;journeys/gemini-key-lost-on-reinstall.md 與 installer worker.js:1148 皆有實證)。
- 新增 lib/portal-auth-store.ts:自足的 JSON,讀取零網路呼叫,>4.6KB 自動溢位分片
- portal.ts 的帳號讀寫全部改走它;KBDB 只留為舊實例的回退讀路徑,登入成功順手搬過去
- console-auth.ts 的第二份認證資料同樣搬離 KV
- 不牴觸 D38:KBDB 三張核心表不增不減,本案是把東西搬出去
- 沿用 credentials.ts 既有的 putWorkerSecret/deleteWorkerSecret,不另造第二套寫入路徑(D36)
明顯失敗(把 #10「寧可明顯失敗,不要靜默錯置」套到門鎖上):
- 「這台實例讀不到任何登入資料」回 503 + code=auth_store_empty,且**不計入 5 次鎖定**
(08-09 leo 就是被系統自己的誤判鎖了 15 分鐘)
- /console/setup 遇既有帳號改說「你剛才輸入的密碼沒有被採用」,不再只說「已設定過」
- /health 與 /console/auth-status 吐 auth_store 狀態(住哪、寫不寫得進去)
stage 實測撞到並修掉的坑:改 secret 會產生 worker 新版本,既有 isolate 讀到的還是舊 env
⇒ 「建好帳號立刻登入」有 15 秒以上 401,還被算進鎖定。加一層短 TTL 的 KV 加速器
(非真相源,只在 secret 查不到/密碼對不上時問一次),換 KV 不影響不變量。
驗收:stage(youlin)把知識資料庫換成另一顆空的 + 換租戶代號 + SESSIONS_KV 換成空的,
三樣一起換之後 portal / MCP /authorize / console 三條登入路徑仍全綠(複跑 3 次)。
對照組(舊版程式碼同樣換庫):登入回「email 或密碼錯誤」,5 次後鎖 15 分鐘。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,19 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
// D61(ADR D61 / Leo/arcrun-rag#55):這組管理員帳密原本住 SESSIONS_KV(`console:credentials`,
|
||||
// 而且沒有 TTL)——KV 是靠 binding 指過去的,重裝會被指到**新建的空 KV** ⇒ 帳密憑空消失。
|
||||
// 這是「KV=暫存、非長期真相源」第三次被違反,而這一次違反的是大門的鎖。
|
||||
// 現改存進認證儲存(Workers Secrets,不靠 binding);舊 KV 只保留為回退讀路徑,
|
||||
// 讀到就順手搬過去(見 loadCredentials)。
|
||||
import {
|
||||
AuthStoreWriteError,
|
||||
authStoreStatus,
|
||||
hydrateFromAccelerator,
|
||||
mutateAuthStore,
|
||||
readAuthStore,
|
||||
type AuthConsoleRecord,
|
||||
} from '../lib/portal-auth-store';
|
||||
|
||||
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -70,16 +83,72 @@ function tenantOf(c: { env: Bindings }): string {
|
||||
return c.env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
// ── D61:帳密的家 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 讀出 console 管理員帳密。**新家(Workers Secrets)優先**;沒有才回退舊家(KV),
|
||||
* 且一旦從舊家讀到就順手搬過去(best-effort,搬不動不影響本次登入)。
|
||||
*/
|
||||
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'secrets' | 'legacy-kv' | 'none' }> {
|
||||
let fromStore = readAuthStore(env).console;
|
||||
if (!fromStore && (await hydrateFromAccelerator(env))) {
|
||||
// 剛設定完帳密、secret 的新版本還沒鋪到這顆 isolate(實測有 15 秒以上的窗口)
|
||||
// → 先問一次加速器,免得「剛設好就說你沒設過」。細節見 lib 的 ACCEL_KEY 註解。
|
||||
fromStore = readAuthStore(env).console;
|
||||
}
|
||||
if (fromStore) return { creds: fromStore, source: 'secrets' };
|
||||
|
||||
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return { creds: null, source: 'none' };
|
||||
let legacy: StoredCredentials | null = null;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return { creds: null, source: 'none' };
|
||||
}
|
||||
try {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
if (!data.console) data.console = legacy as AuthConsoleRecord;
|
||||
});
|
||||
} catch {
|
||||
/* 搬不動就照舊用 KV 這份(狀態看 /health 的 auth_store) */
|
||||
}
|
||||
return { creds: legacy, source: 'legacy-kv' };
|
||||
}
|
||||
|
||||
/** 寫入 console 管理員帳密——**只寫新家**,不再寫 KV(寫回去等於把病種回土裡)。 */
|
||||
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
|
||||
await mutateAuthStore(env, (data) => {
|
||||
data.console = record;
|
||||
});
|
||||
}
|
||||
|
||||
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
|
||||
consoleAuthRouter.get('/console/auth-status', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
return c.json({ configured: !!existing });
|
||||
const { creds, source } = await loadCredentials(c.env);
|
||||
// D61:多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
|
||||
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
|
||||
return c.json({ configured: !!creds, credentials_source: source, auth_store: authStoreStatus(c.env) });
|
||||
});
|
||||
|
||||
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
|
||||
consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const existing = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (existing) return c.json({ error: '已設定過帳密,請改用登入;要換帳密請用 /console/setup/reset(需舊密碼)' }, 409);
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (existing) {
|
||||
// D61 明顯失敗:舊版只說「已設定過」,**沒說剛才填的那組密碼被整個丟掉了**——
|
||||
// 用戶(含安裝精靈裡的 leo)以為自己剛設好了新密碼,其實從頭到尾沒有被採用過。
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'這台實例已經有管理員帳密了,**你剛才輸入的密碼沒有被採用**,目前的密碼仍是當初設定的那一組。' +
|
||||
'要用舊密碼登入,或用 /console/setup/reset(需要舊密碼)換一組。',
|
||||
code: 'already_configured',
|
||||
password_applied: false,
|
||||
reset_path: '/console/setup/reset',
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim();
|
||||
@@ -90,7 +159,13 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
// 寫不進去就誠實回報(不假綠:舊版寫 KV 幾乎不會失敗,於是沒人處理過這條路)
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
|
||||
const token = randomHex(32);
|
||||
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
|
||||
@@ -101,9 +176,8 @@ consoleAuthRouter.post('/console/setup', async (c) => {
|
||||
|
||||
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
|
||||
consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) return c.json({ error: '尚未設定過,請用 /console/setup' }, 400);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const currentPassword = body?.current_password ?? '';
|
||||
@@ -118,23 +192,48 @@ consoleAuthRouter.post('/console/setup/reset', async (c) => {
|
||||
const salt = randomHex(16);
|
||||
const hash = await hashPassword(password, salt);
|
||||
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
|
||||
await c.env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
|
||||
try {
|
||||
await saveCredentials(c.env, record);
|
||||
} catch (e) {
|
||||
const msg = e instanceof AuthStoreWriteError ? e.message : String(e);
|
||||
return c.json({ error: `新帳密沒有存起來:${msg}`, code: 'auth_store_not_writable' }, 502);
|
||||
}
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /console/login — body: {email, password}。成功 → session token(localStorage 存這個,不存密碼)。
|
||||
consoleAuthRouter.post('/console/login', async (c) => {
|
||||
const raw = await c.env.SESSIONS_KV.get(CREDS_KEY);
|
||||
if (!raw) return c.json({ error: '尚未設定帳密,請先完成首次設定' }, 400);
|
||||
const existing = JSON.parse(raw) as StoredCredentials;
|
||||
const { creds: existing } = await loadCredentials(c.env);
|
||||
if (!existing) {
|
||||
// D61 明顯失敗:這是「這台實例讀不到認證資料」,不是「你帳密打錯」
|
||||
return c.json(
|
||||
{
|
||||
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
|
||||
code: 'auth_store_empty',
|
||||
auth_store: authStoreStatus(c.env),
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const email = (body?.email ?? '').trim().toLowerCase();
|
||||
const password = body?.password ?? '';
|
||||
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
|
||||
|
||||
const hash = await hashPassword(password, existing.salt);
|
||||
if (email !== existing.email || hash !== existing.hash) {
|
||||
let creds = existing;
|
||||
let hash = await hashPassword(password, creds.salt);
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
// D61:剛改完帳密、secret 新版本還沒鋪開的窗口 → 問一次加速器再判失敗
|
||||
if (await hydrateFromAccelerator(c.env)) {
|
||||
const again = (await loadCredentials(c.env)).creds;
|
||||
if (again) {
|
||||
creds = again;
|
||||
hash = await hashPassword(password, creds.salt);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (email !== creds.email || hash !== creds.hash) {
|
||||
return c.json({ error: 'email 或密碼錯誤' }, 401);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user