/** * 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-14,leo 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 ";有效回 true。 */ export async function validateConsoleSession(env: Bindings, authHeader: string | undefined): Promise { 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; // hex,sha256(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 { 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 { 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_KV(binding)優先**;沒有才回退讀舊家 * (D61 的認證儲存,CF Workers Secrets——讀取零成本、零外部憑證需求), * 且一旦從舊家讀到就順手搬回 SESSIONS_KV(best-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 { 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_KV(binding),不再需要安裝精靈遞任何臨時 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 token(localStorage 存這個,不存密碼)。 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 還有效 // + 拿回固定租戶字串(不必再手貼 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 。 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 }); });