Files
Arcrun/cypher-executor/src/routes/console-auth.ts
T
uncle6me-web 4b6cc159b8 feat(auth): 認證儲存搬回 D1/KV——落實方案C(leo confirm「走C」,arcrun-rag#99)
實作 pending-changes.md「認證儲存要不要搬回 D1/KV」提案(commit 8286c8a):
D61 的病根「重裝時 binding 被安裝器照名字重新指到新建空資源」已被更通用的
shared/resource-rule(Arcrun#97,2026-08-13)解掉,故不再需要繞開 binding
去躲這個病——而繞開的代價正是這次要收的債:Workers Secrets 寫入需要外部
CF_SECRETS_API_TOKEN,這把 token 從安裝那天起就沒被種過,止血版只解掉
「建第一個帳號」這一格,之後的每一次寫入(換密碼/加帳號/改權限)仍卡死。

改動:
- console 管理員帳密:家改回 SESSIONS_KV(binding,console-auth.ts)
- portal 多人帳號:家改回 KBDB(binding,走 base HTTP API,D38 零 SQL,portal.ts)
- D61 認證儲存(CF Workers Secrets)留為舊實例的唯讀回退路徑:讀取零成本、
  零外部憑證需求(只有寫入才要 token);登入成功即 best-effort 自動搬進新家,
  且**這次登入發出的 session 就直接指向新 record_id**(不必等下一次登入)
- D61 的三項「明顯失敗」語意全部保留:auth_store_empty(讀不到不算密碼錯、
  不計入鎖定)、/console/setup 遇既有帳號說清楚密碼沒被採用、/health 與
  /console/auth-status 吐儲存狀態
- 移除止血版的 x-arcrun-install-token 表頭傳遞機制(installToken 參數)——
  帳號寫入從此不需要任何外部 CF token,這個結構性缺口已從根拔除

測試:cypher-executor 全套 vitest 439/453(14 個既存失敗與本改動無關,已用
git stash 對照 clean checkout 逐一比對檔名確認完全相同);tsc --noEmit
無新增錯誤(3 個既存錯誤同上核實無關)。已跑 build-worker-artifacts.mjs
重打 tier2 bundle,grep 複驗 createKbdbUserRecord/promoteToKbdb 進了成品、
promoteLegacyUser/x-arcrun-install-token 完全從成品消失。

未覆蓋:POST /credentials(一般 workflow API 金鑰儲存)仍依賴
CF_SECRETS_API_TOKEN——這是 01-tech-stack.md 既有的、獨立於 D61 之外的
credential 儲存架構(D19「擁有目錄不擁有內容物」),本提案範圍只涵蓋「認證」
(登入帳密),不涵蓋一般 credential 儲存;07-29 已知缺口仍待另案處理。

不准 merge 進 main(SDD 鐵律③,等總管審過再併);不准部署(D20 出貨閘)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:52:07 +08:00

272 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* arcrun console 登入(Arcrun#3 發現②,2026-07-03
*
* 背景:console v0 那格叫「API Key」但 self-hosted 單租戶下其實只是 namespace 明碼字串,
* 不是註冊制 key(leo 原話:理論上根本沒有 API Key 這件事)。leo 拍板:
* 換成簡單 email+password 登入頁(自己設一組帳密即可,不用第三方 OAuth 或高級機制),
* 登入成功後端發 session token 存 localStorage**後端 API 呼叫仍是用固定租戶字串打
* KBDB**(登入系統只是擋外人看到頁面,不是要做多租戶)。
*
* 與 routes/auth.ts 的差異:auth.ts 是官方 SaaS 的 Google/GitHub OAuth 多租戶註冊(每個
* 使用者各自一把 ak_... api_key,各自一個租戶)。這裡是 self-hosted console 的「單一管理員
* 帳密」— 全站只有一組帳密,只為擋外人看頁面,不產生新租戶、不核發 API key。
*
* 帳密怎麼設(不是雲端工人幫 leo 決定密碼):首次造訪 /console 時若尚未設定過,前端會走
* 「首次設定」流程(POST /console/setup)——leo 自己在瀏覽器輸入 email/password,一次性寫入
* SESSIONS_KV `console:credentials`(已存在就 409,不能覆蓋,換帳密走 /console/setup/reset
* 需帶舊密碼)。之後才是一般登入(POST /console/login)。
*
* 固定租戶字串:CONSOLE_TENANT[vars],非機密——self-hosted 架構本就是明碼 namespace)。
* 預設 "leo"Arcrun#3 發現①已核實:owner_id='leo' 是 D1 中 458,357 筆資料實際使用的租戶字串,
* ak_... 只有 2 筆孤兒資料,故統一收斂到 'leo',不製造第三個租戶)。
*/
import { Hono } from 'hono';
import type { Bindings } from '../types';
// D61 補充(2026-08-14leo confirm「走C」,pending-changes.md「認證儲存要不要搬回 D1/KV」):
// D61 把這組管理員帳密搬去認證儲存(CF Workers Secrets)是為了躲開「重裝時 binding 被安裝器
// 照名字重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`,而這把 token 從安裝那天起就沒被種過,於是每一台全新實例永遠建不出
// 第一個帳號(arcrun-rag#99)。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`Arcrun#97)解掉——
// 現在每次安裝/更新都會沿用既有 binding,不會再把 SESSIONS_KV 重指到空資源。既然病根已解,
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債:**帳密改回住 SESSIONS_KV**
// `console:credentials`binding,永不需要外部 CF token)。
// 認證儲存(Workers Secrets)留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求(只有寫入才要 token)——查到就順手搬回 SESSIONS_KV(見 loadCredentials)。
import { readAuthStore } from '../lib/portal-auth-store';
// Arcrun#108:租戶字串唯一產地。
import { knowledgeOwner } from '../lib/tenant';
export const consoleAuthRouter = new Hono<{ Bindings: Bindings }>();
const CREDS_KEY = 'console:credentials';
const SESSION_PREFIX = 'console_sess:';
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天
/**
* 驗證 console session(給其他 route 共用,如 /console/triage-data)。
* authHeader 形如 "Bearer <token>";有效回 true。
*/
export async function validateConsoleSession(env: Bindings, authHeader: string | undefined): Promise<boolean> {
const token = (authHeader ?? '').match(/^Bearer\s+(\S+)/i)?.[1];
if (!token) return false;
const sess = await env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
return !!sess;
}
interface StoredCredentials {
email: string;
salt: string; // hex
hash: string; // hexsha256(salt + password) 迭代 3 次
created_at: string;
}
function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
}
async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** 簡易迭代雜湊(非 bcrypt/argon2,但比單輪 SHA-256 好一點;self-hosted 單管理員帳密,威脅模型輕)。 */
async function hashPassword(password: string, salt: string): Promise<string> {
let h = `${salt}:${password}`;
for (let i = 0; i < 3; i++) h = await sha256Hex(h);
return h;
}
/**
* console 登入後下發給前端當 api_key 用的租戶字串(舊 console 的設計,與 portal 不同:
* portal 絕不下發,console 會)。
*
* Arcrun#108:這是**知識資料面**的 owner_id(前端拿它直打 `/kbdb/*`),所以必須與寫入端
* (CLI/小幫手/MCP 用的實例 namespace)同源。以前直接讀 `env.CONSOLE_TENANT || 'leo'`
* ⇒ 與 portal 同一個病:資料在 `bfezv28v`、過濾拿 `leo`console 首頁的藏書地圖同樣是空的。
* 現在走唯一產地 `lib/tenant.ts`。
*/
function tenantOf(c: { env: Bindings }): string {
return knowledgeOwner(c.env);
}
// ── 帳密的家(2026-08-14 起:SESSIONS_KV 為主,認證儲存為舊實例回退讀路徑)────────────
/**
* 讀出 console 管理員帳密。**SESSIONS_KVbinding)優先**;沒有才回退讀舊家
* D61 的認證儲存,CF Workers Secrets——讀取零成本、零外部憑證需求),
* 且一旦從舊家讀到就順手搬回 SESSIONS_KVbest-effort,搬不動不影響本次登入)。
*/
async function loadCredentials(env: Bindings): Promise<{ creds: StoredCredentials | null; source: 'kv' | 'legacy-secrets' | 'none' }> {
const raw = await env.SESSIONS_KV.get(CREDS_KEY);
if (raw) {
try {
return { creds: JSON.parse(raw) as StoredCredentials, source: 'kv' };
} catch {
/* KV 這份壞了,當作沒有,往下查舊家 */
}
}
// 舊家(D61 的認證儲存):純讀 env 字串,零網路呼叫、不需要任何外部 CF 憑證。
const legacy = readAuthStore(env).console;
if (!legacy) return { creds: null, source: 'none' };
try {
// best-effort 搬回 SESSIONS_KV——這是 binding put,本來就不需要外部 token,
// 幾乎不會失敗;失敗也不影響本次用這份舊資料繼續(狀態看 /console/auth-status)。
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(legacy));
} catch {
/* 照舊用這份,下次再試著搬一次 */
}
return { creds: legacy as StoredCredentials, source: 'legacy-secrets' };
}
/**
* 寫入 console 管理員帳密——**只寫 SESSIONS_KV**binding,永不需要外部 CF token)。
* 不再寫回認證儲存(Workers Secrets):那是要被淘汰的舊家,寫回去等於把債種回土裡。
*/
async function saveCredentials(env: Bindings, record: StoredCredentials): Promise<void> {
await env.SESSIONS_KV.put(CREDS_KEY, JSON.stringify(record));
}
/** `/console/auth-status`、`/health` 共用的儲存狀態區塊(不洩漏 email/雜湊,只回統計)。 */
function consoleAuthStoreStatus(env: Bindings): { home: 'sessions-kv'; writable: true; legacy_secrets_present: boolean } {
return {
home: 'sessions-kv',
writable: true, // binding-based,只要 wrangler.toml 有這個 binding 就一定寫得進去
legacy_secrets_present: readAuthStore(env).console !== null,
};
}
// GET /console/auth-status — 前端用來決定顯示「首次設定」還是「登入」表單。不洩漏 email。
consoleAuthRouter.get('/console/auth-status', async (c) => {
const { creds, source } = await loadCredentials(c.env);
// 多回一個 auth_store 區塊——「認證住在哪、寫不寫得進去」要在實例自己這一側看得出來,
// 不是等用戶登不進去才發現(#10「寧可明顯失敗,不要靜默錯置」)。
return c.json({ configured: !!creds, credentials_source: source, auth_store: consoleAuthStoreStatus(c.env) });
});
// POST /console/setup — 首次設定帳密(body: {email, password})。已設定過 → 409(不可覆蓋,防外人搶注)。
consoleAuthRouter.post('/console/setup', async (c) => {
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();
const password = body?.password ?? '';
if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);
if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
const salt = randomHex(16);
const hash = await hashPassword(password, salt);
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: new Date().toISOString() };
try {
// 2026-08-14 起:寫 SESSIONS_KVbinding),不再需要安裝精靈遞任何臨時 CF token
// arcrun-rag#99 那個結構性缺口——見檔頭說明——已經隨儲存層搬回 binding 一併解掉)。
await saveCredentials(c.env, record);
} catch (e) {
// 寫不進去就誠實回報(不假綠:binding put 幾乎不會失敗,於是沒人處理過這條路)
return c.json({ error: `帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, 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() }), {
expirationTtl: SESSION_TTL_SECONDS,
});
return c.json({ success: true, session_token: token, tenant: tenantOf(c) });
});
// POST /console/setup/reset — 換帳密(body: {current_password, email, password})。需驗舊密碼,防外人重設。
consoleAuthRouter.post('/console/setup/reset', async (c) => {
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 ?? '';
const email = (body?.email ?? '').trim();
const password = body?.password ?? '';
if (!currentPassword || !email || !password) return c.json({ error: 'current_password、email、password 必填' }, 400);
if (password.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);
const currentHash = await hashPassword(currentPassword, existing.salt);
if (currentHash !== existing.hash) return c.json({ error: '舊密碼不正確' }, 401);
const salt = randomHex(16);
const hash = await hashPassword(password, salt);
const record: StoredCredentials = { email: email.toLowerCase(), salt, hash, created_at: existing.created_at };
try {
await saveCredentials(c.env, record);
} catch (e) {
return c.json({ error: `新帳密沒有存起來:${e instanceof Error ? e.message : String(e)}`, code: 'auth_store_not_writable' }, 502);
}
return c.json({ success: true });
});
// POST /console/login — body: {email, password}。成功 → session tokenlocalStorage 存這個,不存密碼)。
consoleAuthRouter.post('/console/login', async (c) => {
const { creds: existing } = await loadCredentials(c.env);
if (!existing) {
// 明顯失敗(#10「寧可明顯失敗,不要靜默錯置」):這是「這台實例讀不到認證資料」,
// 不是「你帳密打錯」——兩句話混成一句正是 2026-08-09 leo 被誤鎖 15 分鐘的根因。
return c.json(
{
error: '這台實例還沒有管理員帳密(或讀不到)——不是密碼錯。請先完成首次設定。',
code: 'auth_store_empty',
auth_store: consoleAuthStoreStatus(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) {
return c.json({ error: 'email 或密碼錯誤' }, 401);
}
const token = randomHex(32);
await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ created_at: Date.now() }), {
expirationTtl: SESSION_TTL_SECONDS,
});
return c.json({ success: true, session_token: token, tenant: tenantOf(c) });
});
// GET /console/session — Authorization: Bearer <session_token>。前端載入頁面時用來確認 session 還有效
// + 拿回固定租戶字串(不必再手貼 API Key)。
consoleAuthRouter.get('/console/session', async (c) => {
const auth = c.req.header('authorization') ?? '';
const token = auth.match(/^Bearer\s+(\S+)/i)?.[1];
if (!token) return c.json({ valid: false }, 401);
const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
if (!sess) return c.json({ valid: false }, 401);
return c.json({ valid: true, tenant: tenantOf(c) });
});
// POST /console/logout — Authorization: Bearer <session_token>。
consoleAuthRouter.post('/console/logout', async (c) => {
const auth = c.req.header('authorization') ?? '';
const token = auth.match(/^Bearer\s+(\S+)/i)?.[1];
if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
return c.json({ success: true });
});