diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index 1ea1737..e1478df 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -24,6 +24,7 @@ import { kbdbProxyRouter } from './routes/kbdb-proxy'; import { consoleRouter } from './routes/console'; import { consoleAuthRouter } from './routes/console-auth'; import { consoleDashboardRouter } from './routes/console-dashboard'; +import { portalRouter } from './routes/portal'; const app = new Hono<{ Bindings: Bindings }>(); @@ -56,6 +57,7 @@ app.route('/', kbdbProxyRouter); // kbdb-base 9.5:KBDB 資料層 proxy(讓 app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼) app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶) app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀) +app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API(P3 UI 另一波) // Worker 導出(fetch + scheduled) // scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick; diff --git a/cypher-executor/src/lib/portal-auth.ts b/cypher-executor/src/lib/portal-auth.ts new file mode 100644 index 0000000..b5c00cb --- /dev/null +++ b/cypher-executor/src/lib/portal-auth.ts @@ -0,0 +1,106 @@ +/** + * Portal 密碼 KDF 模組(portal-auth design §4.1 / D-5、D-6,Gitea #24/#25 P2) + * + * 職責界線(rule 2.1/2.2 對照,design D-5 已釐清): + * 這是「UI session 登入」的密碼雜湊——console-auth.ts 同類先例,**不是** workflow + * credential 原語(那些屬 WASM auth primitive,本檔不碰 crypto.subtle.decrypt / + * RSASSA / template 展開)。只用 WebCrypto 原生 PBKDF2(crypto.subtle.deriveBits)。 + * + * 規格(OWASP 現行建議值): + * - PBKDF2-SHA256、600,000 iterations、salt 16 bytes、輸出 256-bit + * - 儲存格式 `pbkdf2-sha256$$$`(自帶演算法前綴, + * 未來換 KDF 可共存漸進遷移——verify 按前綴解析,不寫死參數) + * - 驗證用常數時間比對(同 mcp/src/oauth/crypto.ts PR#15 慣例) + * - 密碼永不明碼儲存、永不進 log(本模組不 console.log 任何輸入) + */ + +export const PBKDF2_ALGO_PREFIX = 'pbkdf2-sha256'; +export const PBKDF2_ITERATIONS = 600_000; + +function b64encode(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +function b64decode(s: string): Uint8Array | null { + try { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +async function deriveBits(password: string, salt: Uint8Array, iterations: number): Promise { + const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [ + 'deriveBits', + ]); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations }, + key, + 256, + ); + return new Uint8Array(bits); +} + +/** + * 常數時間字串比對(防 timing attack)。與 mcp/src/oauth/crypto.ts 同實作 + * (cypher-executor 與 mcp 是不同 package,無共用 lib 路徑,故各持一份同款)。 + */ +export function constantTimeEqual(a: string, b: string): boolean { + const ab = new TextEncoder().encode(a); + const bb = new TextEncoder().encode(b); + let diff = ab.length ^ bb.length; + const len = Math.max(ab.length, bb.length); + for (let i = 0; i < len; i++) { + diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); + } + return diff === 0; +} + +/** 雜湊一組密碼 → `pbkdf2-sha256$600000$$`。 */ +export async function hashPassword(password: string, iterations: number = PBKDF2_ITERATIONS): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const hash = await deriveBits(password, salt, iterations); + return `${PBKDF2_ALGO_PREFIX}$${iterations}$${b64encode(salt)}$${b64encode(hash)}`; +} + +/** + * 驗證密碼 vs 儲存格式。格式壞掉 / 前綴不認得 → false(誠實拒絕,不拋錯洩漏細節)。 + * iterations 從儲存值解析(漸進遷移:舊 hash 用舊參數驗,新寫入用現行常數)。 + */ +export async function verifyPassword(password: string, stored: string): Promise { + const parts = (stored ?? '').split('$'); + if (parts.length !== 4 || parts[0] !== PBKDF2_ALGO_PREFIX) return false; + const iterations = Number.parseInt(parts[1], 10); + if (!Number.isFinite(iterations) || iterations < 1 || iterations > 10_000_000) return false; + const salt = b64decode(parts[2]); + if (!salt || salt.length === 0) return false; + const derived = await deriveBits(password, salt, iterations); + return constantTimeEqual(b64encode(derived), parts[3]); +} + +/** 密碼學等級隨機 hex token(session token 用;與 console-auth randomHex 同款)。 */ +export 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(''); +} + +/** + * 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。 + * 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。 + */ +export function generatePassword(length = 16): string { + const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; + const arr = new Uint8Array(length); + crypto.getRandomValues(arr); + let out = ''; + for (const b of arr) out += charset[b % charset.length]; + return out; +} diff --git a/cypher-executor/src/lib/portal-seeds.ts b/cypher-executor/src/lib/portal-seeds.ts new file mode 100644 index 0000000..8416b26 --- /dev/null +++ b/cypher-executor/src/lib/portal-seeds.ts @@ -0,0 +1,38 @@ +/** + * Portal KBDB template 種子資料(portal-auth design §2.1/§3.2,Gitea #24/#25 P2) + * + * 種子資料檔慣例(rule 07 §1 / pre-write-guard *-seeds.ts 類別): + * 「裝好後預設有哪些 template」是 API 的能力,資料宣告放 server(本檔), + * 由 /init/seed(與 /portal/admin/bootstrap 的 ensure 路徑)冪等灌入 KBDB。 + * 薄殼(CLI/MCP)不自帶這份清單。 + * + * 零新表鐵律:這些是 KBDB 萬用表的 template(虛擬表定義),不是 D1 真表。 + * 帳號「資料」(records/entries)一律寫 `{CONSOLE_TENANT}::portal` 子 namespace + * (design D-2);template 定義本身是全域 schema(templates 表無 owner 概念)。 + */ + +export interface PortalTemplateSeed { + name: string; + description: string; + slots: string[]; + created_by: 'system'; +} + +export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [ + { + // design §2.1:portal 同仁帳號。password_hash 存 KDF 輸出(pbkdf2-sha256$…,D-6), + // 永不存明碼;libraries 是 JSON array 字串(["general"] / ["*"]=全庫)。 + name: 'portal_user', + description: 'RAG Portal 同仁帳號(portal-auth §2.1;資料寫 {tenant}::portal 子 namespace)', + slots: ['email', 'display_name', 'status', 'role', 'password_hash', 'libraries', 'created_at', 'updated_at'], + created_by: 'system', + }, + { + // design §3.2:庫目錄(admin 頁列庫用)。庫本體=知識條目 metadata_json.$.library 標記, + // 這裡只是「有哪些庫」的登記簿。 + name: 'portal_library', + description: 'RAG Portal 庫目錄登記(portal-auth §3.2;庫=metadata_json.$.library 標記)', + slots: ['name', 'display_name', 'description', 'status'], + created_by: 'system', + }, +]; diff --git a/cypher-executor/src/routes/init-seed.ts b/cypher-executor/src/routes/init-seed.ts index 74c63e5..07c9454 100644 --- a/cypher-executor/src/routes/init-seed.ts +++ b/cypher-executor/src/routes/init-seed.ts @@ -22,6 +22,7 @@ import type { RecipeDefinition, AuthRecipeDefinition } from './recipes'; import { installRecipeRecord, resolveRecipe } from './recipes'; import { API_RECIPE_SEEDS } from '../lib/api-recipe-seeds'; import { AUTH_RECIPE_SEEDS } from '../lib/auth-recipe-seeds'; +import { ensurePortalTemplates } from './portal'; export const initSeedRouter = new Hono<{ Bindings: Bindings }>(); @@ -82,15 +83,20 @@ initSeedRouter.post('/init/seed', async (c) => { } } - const allOk = apiFail === 0 && authFail === 0; + // portal-auth P2(#24/#25):portal_user / portal_library template 也是「裝好後預設就緒」 + // 的種子(KBDB 萬用表 template,零新表),冪等 ensure(已存在跳過)。seed 是 API 行為(rule 07)。 + const portalTemplates = await ensurePortalTemplates(c.env); + + const allOk = apiFail === 0 && authFail === 0 && portalTemplates.errors.length === 0; return c.json( { success: allOk, api_recipes: { seeded: apiOk, failed: apiFail, errors: apiErrors }, auth_recipes: { seeded: authOk, failed: authFail, errors: authErrors }, + portal_templates: portalTemplates, message: allOk - ? `seed 完成:${apiOk} 個 API recipe + ${authOk} 個 auth recipe` - : `seed 部分失敗(誠實回報,未假綠):API ${apiOk}✓/${apiFail}✗,auth ${authOk}✓/${authFail}✗`, + ? `seed 完成:${apiOk} 個 API recipe + ${authOk} 個 auth recipe + portal templates(新建 ${portalTemplates.created.length}/已存在 ${portalTemplates.existing.length})` + : `seed 部分失敗(誠實回報,未假綠):API ${apiOk}✓/${apiFail}✗,auth ${authOk}✓/${authFail}✗,portal templates 錯誤 ${portalTemplates.errors.length}`, }, allOk ? 200 : 207, ); diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts new file mode 100644 index 0000000..a36c3a8 --- /dev/null +++ b/cypher-executor/src/routes/portal.ts @@ -0,0 +1,678 @@ +/** + * RAG Portal 多人授權 — P2:用戶模型+認證 API(portal-auth design §2/§4/§5,Gitea #24/#25) + * + * 架構(design D-1):Portal=cypher-executor 的 /portal 路由(非獨立 worker)。 + * 本檔只做 P2 的 auth/admin API;P3(/portal HTML 殼+ /portal/data/* enforce)另一波。 + * + * 鐵律對照: + * - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential + * 原語。KDF 在 lib/portal-auth.ts(WebCrypto PBKDF2),本檔無解密/簽章/template 展開。 + * - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template; + * 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。 + * - 子 namespace(design D-2):一切帳號資料 owner_id=`{CONSOLE_TENANT}::portal`。 + * 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料 + * (email/password_hash 不會出現在知識搜尋結果)。 + * - session(design §4.3):KV `portal_sess:{token}` 只存 record_id(TTL 暫存=合規), + * **每個請求回讀 user record 當唯一真相源** → 停用/改權限即時生效,不靠 session 反向索引。 + * - 絕不下發租戶字串(design §3.3 關鍵差異 vs console):/portal/session 只回 + * display_name/role/libraries。 + * - 密碼永不明碼儲存、永不進 log(本檔不 log 任何 body)。 + */ +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import type { Bindings } from '../types'; +import { kbdbBase } from './kbdb-proxy'; +import { validateConsoleSession } from './console-auth'; +import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth'; +import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds'; + +export const portalRouter = new Hono<{ Bindings: Bindings }>(); + +const SESSION_PREFIX = 'portal_sess:'; +const LOCKFAIL_PREFIX = 'portal_lockfail:'; +const LOCK_LIMIT = 5; // design §4.3:5 次失敗 +const LOCK_TTL_SECONDS = 15 * 60; // 鎖 15 分鐘(KV TTL 自然過期) +const DEFAULT_SESSION_TTL = 604800; // 7 天(design §4.3,比 console 30 天緊) + +const USER_TEMPLATE = 'portal_user'; +const LIBRARY_TEMPLATE = 'portal_library'; + +// ── 基礎 helpers ──────────────────────────────────────────────────────────── + +/** 帳號子 namespace(design D-2)。tenant 預設沿 console-auth 同款 'leo'。 */ +function portalNamespace(env: Bindings): string { + return `${env.CONSOLE_TENANT || 'leo'}::portal`; +} + +function sessionTtl(env: Bindings): number { + const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? '', 10); + // KV expirationTtl 下限 60 秒;壞值誠實退回預設而非炸掉 + return Number.isFinite(n) && n >= 60 ? n : DEFAULT_SESSION_TTL; +} + +function bearerToken(c: Context<{ Bindings: Bindings }>): string | null { + const auth = c.req.header('authorization') ?? ''; + return auth.match(/^Bearer\s+(\S+)/i)?.[1] ?? null; +} + +/** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報(不假綠、不偽裝成 401)。 */ +class KbdbError extends Error {} + +async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise { + const { base, headers } = kbdbBase(env); + let res: Response; + try { + res = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record | undefined) } }); + } catch (e) { + throw new KbdbError(`fetch ${path} 失敗:${e instanceof Error ? e.message : String(e)}`); + } + return res; +} + +/** route handler 包一層:KbdbError → 502(誠實),其餘照拋。 */ +async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯:${e.message}` }, 502); + throw e; + } +} + +// ── KBDB 資料層 helpers(全走 base HTTP API,零 SQL)──────────────────────────── + +interface PortalRecord { + record_id: string; + template_id: string; + values: Record; +} + +/** 冪等確保 portal templates 存在(seed 是 API 行為,rule 07;/init/seed 與 bootstrap 共用)。 */ +export async function ensurePortalTemplates( + env: Bindings, +): Promise<{ created: string[]; existing: string[]; errors: string[] }> { + const created: string[] = []; + const existing: string[] = []; + const errors: string[] = []; + for (const seed of PORTAL_TEMPLATE_SEEDS) { + try { + const got = await kbdbFetch(env, `/templates/${encodeURIComponent(seed.name)}`); + if (got.ok) { + existing.push(seed.name); + continue; + } + if (got.status !== 404) throw new KbdbError(`GET /templates/${seed.name} → ${got.status}`); + const res = await kbdbFetch(env, '/templates', { + method: 'POST', + body: JSON.stringify({ + name: seed.name, + slots: seed.slots, + description: seed.description, + created_by: seed.created_by, + }), + }); + if (!res.ok) throw new KbdbError(`POST /templates ${seed.name} → ${res.status}`); + created.push(seed.name); + } catch (e) { + errors.push(`${seed.name}: ${e instanceof Error ? e.message : String(e)}`); + } + } + return { created, existing, errors }; +} + +/** email → user record_id(design §2.3 head entry O(1) 查找:page_name=email 走 index)。 */ +async function findUserRecordId(env: Bindings, email: string): Promise { + const ns = portalNamespace(env); + const params = new URLSearchParams({ + page_name: email, + entry_type: USER_TEMPLATE, + owner_id: ns, + limit: '1', + }); + const res = await kbdbFetch(env, `/entries?${params.toString()}`); + if (!res.ok) throw new KbdbError(`head entry 查找 → ${res.status}`); + const body = (await res.json()) as { entries?: { content: string | null }[] }; + const content = body.entries?.[0]?.content; + return content ?? null; +} + +async function getRecordById(env: Bindings, recordId: string): Promise { + const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`); + if (res.status === 404) return null; + if (!res.ok) throw new KbdbError(`GET /records/${recordId} → ${res.status}`); + const body = (await res.json()) as { record?: PortalRecord }; + return body.record ?? null; +} + +async function patchRecordValues(env: Bindings, recordId: string, values: Record): Promise { + const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { + method: 'PATCH', + body: JSON.stringify({ values }), + }); + if (!res.ok) throw new KbdbError(`PATCH /records/${recordId} → ${res.status}`); + const body = (await res.json()) as { record?: PortalRecord }; + if (!body.record) throw new KbdbError(`PATCH /records/${recordId} 回應缺 record`); + return body.record; +} + +async function listRecordsByTemplate(env: Bindings, template: string): Promise { + const ns = portalNamespace(env); + const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`); + if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`); + const body = (await res.json()) as { records?: PortalRecord[] }; + return body.records ?? []; +} + +interface CreateUserInput { + email: string; + display_name: string; + role: 'user' | 'admin'; + libraries: string[]; + password_hash: string; +} + +/** 建 portal_user record(子 namespace)+ email head entry(§2.3)。 */ +async function createPortalUser(env: Bindings, input: CreateUserInput): Promise { + const ns = portalNamespace(env); + const now = new Date().toISOString(); + const res = await kbdbFetch(env, '/records', { + method: 'POST', + body: JSON.stringify({ + template: USER_TEMPLATE, + owner_id: ns, + values: { + email: input.email, + display_name: input.display_name, + status: 'active', + role: input.role, + password_hash: input.password_hash, + libraries: JSON.stringify(input.libraries), + created_at: now, + updated_at: now, + }, + }), + }); + if (!res.ok) throw new KbdbError(`POST /records(portal_user)→ ${res.status}`); + const body = (await res.json()) as { record?: { record_id: string } }; + const recordId = body.record?.record_id; + if (!recordId) throw new KbdbError('POST /records 回應缺 record_id'); + + // head entry:page_name=email(indexed)→ content=record_id,O(1) 登入查找 + const head = await kbdbFetch(env, '/entries', { + method: 'POST', + body: JSON.stringify({ + entry_type: USER_TEMPLATE, + page_name: input.email, + content: recordId, + owner_id: ns, + }), + }); + if (!head.ok) throw new KbdbError(`head entry 建立失敗(record ${recordId} 已建,需人工收拾)→ ${head.status}`); + return recordId; +} + +// ── user 值域 helpers ────────────────────────────────────────────────────── + +function parseLibraries(raw: string | undefined): string[] { + if (!raw) return []; + try { + const arr = JSON.parse(raw); + if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr; + } catch { + /* fallthrough */ + } + return []; +} + +function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254; +} + +/** 庫名進 metadata/query(逗號分隔參數),故禁逗號/空白/怪字元。 */ +function isValidLibraryName(name: string): boolean { + return /^(\*|[A-Za-z0-9_-]{1,64})$/.test(name); +} + +function validLibrariesInput(libs: unknown): libs is string[] { + return Array.isArray(libs) && libs.length > 0 && libs.every((x) => typeof x === 'string' && isValidLibraryName(x)); +} + +/** admin 面向的公開 user 形狀:**絕不含 password_hash**。 */ +function toPublicUser(rec: PortalRecord) { + const v = rec.values; + return { + record_id: rec.record_id, + email: v.email ?? '', + display_name: v.display_name ?? '', + status: v.status ?? '', + role: v.role ?? '', + libraries: parseLibraries(v.libraries), + created_at: v.created_at ?? '', + updated_at: v.updated_at ?? '', + }; +} + +// ── session 閘 ──────────────────────────────────────────────────────────── + +type AuthedUser = { token: string; recordId: string; values: Record }; +type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response }; + +/** + * portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。 + * 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。 + */ +async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise { + const token = bearerToken(c); + if (!token) return { ok: false, res: c.json({ error: '未登入' }, 401) }; + const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`); + if (!sess) return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; + let recordId: string | undefined; + try { + recordId = (JSON.parse(sess) as { record_id?: string }).record_id; + } catch { + /* fallthrough */ + } + if (!recordId) { + await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); + return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; + } + const rec = await getRecordById(c.env, recordId); + if (!rec) { + await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); + return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) }; + } + if ((rec.values.status ?? '') !== 'active') { + await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); + return { ok: false, res: c.json({ error: '帳號已停用' }, 403) }; + } + return { ok: true, user: { token, recordId, values: rec.values } }; +} + +async function requirePortalAdmin(c: Context<{ Bindings: Bindings }>): Promise { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth; + if ((auth.user.values.role ?? '') !== 'admin') { + return { ok: false, res: c.json({ error: '需要 admin 權限' }, 403) }; + } + return auth; +} + +/** + * admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內) + * 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」, + * 防 admin 拿任意 record_id 改到不相干的 KBDB record。 + */ +async function assertPortalUserRecord(env: Bindings, recordId: string): Promise { + const rec = await getRecordById(env, recordId); + if (!rec) return null; + const email = rec.values.email; + if (!email) return null; + const headRecordId = await findUserRecordId(env, email); + if (headRecordId !== recordId) return null; + return rec; +} + +// ── 登入節流(design §4.3:KV 計數,TTL 自然過期)────────────────────────────── + +async function isLocked(env: Bindings, email: string): Promise { + const raw = await env.SESSIONS_KV.get(`${LOCKFAIL_PREFIX}${email}`); + if (!raw) return false; + try { + return ((JSON.parse(raw) as { count?: number }).count ?? 0) >= LOCK_LIMIT; + } catch { + return false; + } +} + +async function recordLoginFail(env: Bindings, email: string): Promise { + const key = `${LOCKFAIL_PREFIX}${email}`; + const raw = await env.SESSIONS_KV.get(key); + let count = 0; + if (raw) { + try { + count = (JSON.parse(raw) as { count?: number }).count ?? 0; + } catch { + count = 0; + } + } + await env.SESSIONS_KV.put(key, JSON.stringify({ count: count + 1 }), { expirationTtl: LOCK_TTL_SECONDS }); +} + +async function clearLoginFail(env: Bindings, email: string): Promise { + await env.SESSIONS_KV.delete(`${LOCKFAIL_PREFIX}${email}`); +} + +// ═══════════════════════════════ 認證端點 ═══════════════════════════════════ + +// POST /portal/login — body {email, password}。成功發 portal session token。 +// 錯誤訊息刻意不分「帳號不存在 vs 密碼錯」(不洩帳號存在性);停用帳號誠實回 403。 +portalRouter.post('/portal/login', (c) => + run(c, async () => { + const body = await c.req.json().catch(() => null); + const email = String(body?.email ?? '').trim().toLowerCase(); + const password = String(body?.password ?? ''); + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + + if (await isLocked(c.env, email)) { + return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429); + } + + const recordId = await findUserRecordId(c.env, email); + if (!recordId) { + await recordLoginFail(c.env, email); + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + const rec = await getRecordById(c.env, recordId); + if (!rec) { + await recordLoginFail(c.env, email); + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + if ((rec.values.status ?? '') !== 'active') { + return c.json({ error: '帳號已停用' }, 403); + } + const ok = await verifyPassword(password, rec.values.password_hash ?? ''); + if (!ok) { + await recordLoginFail(c.env, email); + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + + await clearLoginFail(c.env, email); + const token = randomHex(32); + // session 值只存 record_id(design §4.3)——權限/狀態每請求回讀 record,不快取進 session + await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), { + expirationTtl: sessionTtl(c.env), + }); + return c.json({ + success: true, + session_token: token, + display_name: rec.values.display_name ?? '', + role: rec.values.role ?? 'user', + libraries: parseLibraries(rec.values.libraries), + // 絕不回租戶字串(design §3.3:portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*) + }); + }), +); + +// POST /portal/logout +portalRouter.post('/portal/logout', async (c) => { + const token = bearerToken(c); + if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`); + return c.json({ success: true }); +}); + +// GET /portal/session — 每請求回讀 user record(真相源);回 display_name/role/libraries, +// **絕不回租戶字串**(design §5)。 +portalRouter.get('/portal/session', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const v = auth.user.values; + return c.json({ + valid: true, + display_name: v.display_name ?? '', + role: v.role ?? 'user', + libraries: parseLibraries(v.libraries), + }); + }), +); + +// POST /portal/me/password — body {current, new}。驗舊密改新密。 +portalRouter.post('/portal/me/password', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const body = await c.req.json().catch(() => null); + const current = String(body?.current ?? ''); + const next = String(body?.new ?? ''); + if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400); + if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400); + + const ok = await verifyPassword(current, auth.user.values.password_hash ?? ''); + if (!ok) return c.json({ error: '舊密碼不正確' }, 401); + + const newHash = await hashPassword(next); + await patchRecordValues(c.env, auth.user.recordId, { + password_hash: newHash, + updated_at: new Date().toISOString(), + }); + return c.json({ success: true }); + }), +); + +// ═══════════════════════════════ admin 端點 ══════════════════════════════════ + +// POST /portal/admin/bootstrap — 需 **console owner session**(design D-7:owner secret 是 +// 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user; +// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templates(seed 是 API 行為)。 +portalRouter.post('/portal/admin/bootstrap', (c) => + run(c, async () => { + const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization')); + if (!consoleOk) return c.json({ error: '需要 console owner session(先登入 /console)' }, 401); + + const seeded = await ensurePortalTemplates(c.env); + if (seeded.errors.length > 0) { + return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502); + } + + const users = await listRecordsByTemplate(c.env, USER_TEMPLATE); + if (users.some((u) => (u.values.role ?? '') === 'admin')) { + return c.json({ error: '已有 admin,bootstrap 只能執行一次;後續帳號請用 /portal/admin/users' }, 409); + } + + const body = await c.req.json().catch(() => null); + const email = String(body?.email ?? '').trim().toLowerCase(); + const password = String(body?.password ?? ''); + const displayName = String(body?.display_name ?? '').trim() || email; + if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400); + if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400); + if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409); + + const recordId = await createPortalUser(c.env, { + email, + display_name: displayName, + role: 'admin', + libraries: ['*'], // bootstrap admin 預設全庫(design §3.3:["*"]=不注 library filter) + password_hash: await hashPassword(password), + }); + return c.json({ success: true, record_id: recordId, email, role: 'admin' }); + }), +); + +// GET /portal/admin/users — 同仁列表(role=admin 閘)。**回應剝除 password_hash**。 +portalRouter.get('/portal/admin/users', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const users = await listRecordsByTemplate(c.env, USER_TEMPLATE); + return c.json({ success: true, users: users.map(toPublicUser), count: users.length }); + }), +); + +// POST /portal/admin/users — 新增同仁。body {email, display_name?, role?, libraries?, password?}。 +// 未帶 password → server 產一次性密碼隨回應回傳一次(不落地明碼,design §4.3 簡化版)。 +portalRouter.post('/portal/admin/users', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = await c.req.json().catch(() => null); + const email = String(body?.email ?? '').trim().toLowerCase(); + const displayName = String(body?.display_name ?? '').trim() || email; + const role = body?.role === 'admin' ? 'admin' : 'user'; + const libraries: string[] = validLibrariesInput(body?.libraries) ? (body.libraries as string[]) : ['general']; + if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400); + if (body?.libraries !== undefined && !validLibrariesInput(body?.libraries)) { + return c.json({ error: 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*")' }, 400); + } + if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409); + + let password = body?.password !== undefined ? String(body.password) : ''; + let generated: string | undefined; + if (password) { + if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400); + } else { + generated = generatePassword(); + password = generated; + } + + const recordId = await createPortalUser(c.env, { + email, + display_name: displayName, + role, + libraries, + password_hash: await hashPassword(password), + }); + const rec = await getRecordById(c.env, recordId); + return c.json({ + success: true, + user: rec ? toPublicUser(rec) : { record_id: recordId, email }, + // 一次性回傳(不儲存明碼);admin 口頭轉交同仁後即失效於 server 側 + ...(generated ? { generated_password: generated } : {}), + }); + }), +); + +// PATCH /portal/admin/users/:id — 改 status/role/libraries(design §5)。 +// 停用即時生效機制=每請求回讀 record(§4.3),不依賴刪 session。 +portalRouter.patch('/portal/admin/users/:id', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const recordId = c.req.param('id'); + const rec = await assertPortalUserRecord(c.env, recordId); + if (!rec) return c.json({ error: '用戶不存在' }, 404); + + const body = await c.req.json().catch(() => null); + if (!body) return c.json({ error: 'body 必須是 JSON' }, 400); + const patch: Record = {}; + if (body.status !== undefined) { + if (body.status !== 'active' && body.status !== 'disabled') { + return c.json({ error: 'status 只能是 active / disabled' }, 400); + } + patch.status = body.status; + } + if (body.role !== undefined) { + if (body.role !== 'user' && body.role !== 'admin') return c.json({ error: 'role 只能是 user / admin' }, 400); + patch.role = body.role; + } + if (body.libraries !== undefined) { + if (!validLibrariesInput(body.libraries)) { + return c.json({ error: 'libraries 須為非空字串陣列(庫名限 A-Za-z0-9_- 或 "*")' }, 400); + } + patch.libraries = JSON.stringify(body.libraries); + } + if (Object.keys(patch).length === 0) return c.json({ error: '沒有可更新的欄位(status/role/libraries)' }, 400); + patch.updated_at = new Date().toISOString(); + + const updated = await patchRecordValues(c.env, recordId, patch); + return c.json({ success: true, user: toPublicUser(updated) }); + }), +); + +// POST /portal/admin/users/:id/reset-password — 產一次性新密碼回傳(design §4.3 簡化版: +// admin 口頭轉交;must_change 首登改密列第二波)。 +portalRouter.post('/portal/admin/users/:id/reset-password', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const recordId = c.req.param('id'); + const rec = await assertPortalUserRecord(c.env, recordId); + if (!rec) return c.json({ error: '用戶不存在' }, 404); + + const password = generatePassword(); + await patchRecordValues(c.env, recordId, { + password_hash: await hashPassword(password), + updated_at: new Date().toISOString(), + }); + return c.json({ success: true, password }); // 一次性回傳,server 不留明碼 + }), +); + +// ── 庫目錄(design §3.2 portal_library:登記簿;庫本體=條目上的 metadata 標記)──── + +function toPublicLibrary(rec: PortalRecord) { + const v = rec.values; + return { + record_id: rec.record_id, + name: v.name ?? '', + display_name: v.display_name ?? '', + description: v.description ?? '', + status: v.status ?? '', + }; +} + +// GET /portal/admin/libraries — 庫目錄列表。 +portalRouter.get('/portal/admin/libraries', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + return c.json({ success: true, libraries: libs.map(toPublicLibrary), count: libs.length }); + }), +); + +// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。 +portalRouter.post('/portal/admin/libraries', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = await c.req.json().catch(() => null); + const name = String(body?.name ?? '').trim(); + if (!isValidLibraryName(name) || name === '*') { + return c.json({ error: '庫名限 A-Za-z0-9_-(1-64 字元;"*" 是保留值不可登記)' }, 400); + } + const seeded = await ensurePortalTemplates(c.env); + if (seeded.errors.length > 0) { + return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502); + } + const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + if (existing.some((l) => (l.values.name ?? '') === name)) { + return c.json({ error: `庫 ${name} 已登記` }, 409); + } + const ns = portalNamespace(c.env); + const res = await kbdbFetch(c.env, '/records', { + method: 'POST', + body: JSON.stringify({ + template: LIBRARY_TEMPLATE, + owner_id: ns, + values: { + name, + display_name: String(body?.display_name ?? '').trim() || name, + description: String(body?.description ?? '').trim(), + status: 'active', + }, + }), + }); + if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`); + const created = (await res.json()) as { record?: PortalRecord }; + return c.json({ success: true, library: created.record ? toPublicLibrary(created.record) : { name } }); + }), +); + +// PATCH /portal/admin/libraries/:id — 改 display_name/description/status(停用庫=翻 status slot)。 +portalRouter.patch('/portal/admin/libraries/:id', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const recordId = c.req.param('id'); + // 成員資格:record 必須在本實例的庫目錄列表內(庫數小,list 比對即可) + const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + if (!libs.some((l) => l.record_id === recordId)) return c.json({ error: '庫不存在' }, 404); + + const body = await c.req.json().catch(() => null); + if (!body) return c.json({ error: 'body 必須是 JSON' }, 400); + const patch: Record = {}; + if (body.display_name !== undefined) patch.display_name = String(body.display_name).trim(); + if (body.description !== undefined) patch.description = String(body.description).trim(); + if (body.status !== undefined) { + if (body.status !== 'active' && body.status !== 'disabled') { + return c.json({ error: 'status 只能是 active / disabled' }, 400); + } + patch.status = body.status; + } + if (Object.keys(patch).length === 0) { + return c.json({ error: '沒有可更新的欄位(display_name/description/status)' }, 400); + } + const updated = await patchRecordValues(c.env, recordId, patch); + return c.json({ success: true, library: toPublicLibrary(updated) }); + }), +); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 21e1867..59292c0 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -102,6 +102,10 @@ export type Bindings = { // 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。 // 可調功能(設定頁改→存 KBDB→發 token 時讀)=Arcrun#19,實作前先在部署端 env 調。 MCP_TOKEN_TTL?: string; + // Portal session TTL 秒數(portal-auth P2,#24/#25,非機密)。portal_sess:{token} KV 的 + // expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。 + // 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。 + PORTAL_SESSION_TTL?: string; // kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算 // https://kbdb-graph-plugin..workers.dev(該 repo wrangler.toml name 固定)。 // console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。 diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts new file mode 100644 index 0000000..f6e8716 --- /dev/null +++ b/cypher-executor/tests/portal-auth.test.ts @@ -0,0 +1,399 @@ +/** + * portal-auth P2 測試(design §2/§4/§5,Gitea #24/#25) + * + * 覆蓋(=tasks.md P2 測試項): + * 1. KDF:pbkdf2-sha256$600000$… 格式、驗證對錯、壞格式誠實 false + * 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace; + * 已有 admin → 409 + * 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401 + * 4. 節流:5 次失敗 → 429(KV TTL 計數) + * 5. session:每請求回讀 record;停用即拒(既有 session 立即失效) + * 6. 改密碼:驗舊密;新 hash 以 600k 格式落 slot + * 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash** + * + * KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+ + * disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker + * 端到端 curl 驗證(PR 驗收證據表),這裡驗「寫入時 owner_id=leo::portal」的機械事實。 + */ +import { SELF, env, fetchMock } from 'cloudflare:test'; +import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest'; +import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth'; + +const KBDB = 'https://kbdb.test'; +const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace +const EMAIL = 'user@example.com'; +const PASSWORD = 'correct-horse-9'; + +// 測試用低迭代 hash(verify 從儲存格式解析 iterations → 舊/低參數 hash 也驗得動=漸進遷移特性) +let storedHash: string; + +beforeAll(async () => { + fetchMock.activate(); + fetchMock.disableNetConnect(); + storedHash = await hashPassword(PASSWORD, 10_000); +}); +afterEach(() => fetchMock.assertNoPendingInterceptors()); + +function json(method: string, path: string, body?: unknown, headers: Record = {}) { + return SELF.fetch(`http://localhost${path}`, { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +// ── KBDB mock helpers ────────────────────────────────────────────────────── + +/** head entry 查找(GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1) */ +function mockHeadLookup(email: string, recordId: string | null) { + const needle = new URLSearchParams({ page_name: email }).toString(); + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => + p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)), + method: 'GET', + }) + .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 }); +} + +function mockGetRecord(recordId: string, values: Record) { + fetchMock + .get(KBDB) + .intercept({ path: `/records/${recordId}`, method: 'GET' }) + .reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } }); +} + +function mockListByTemplate(template: string, records: { record_id: string; values: Record }[]) { + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith(`/records/by-template/${template}`), method: 'GET' }) + .reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl' })), count: records.length }); +} + +function mockTemplatesExist() { + for (const name of ['portal_user', 'portal_library']) { + fetchMock + .get(KBDB) + .intercept({ path: `/templates/${name}`, method: 'GET' }) + .reply(200, { success: true, template: { id: `tpl-${name}`, name } }); + } +} + +function activeUserValues(overrides: Record = {}): Record { + return { + email: EMAIL, + display_name: '測試同仁', + status: 'active', + role: 'user', + password_hash: storedHash, + libraries: '["general","finance"]', + created_at: '2026-07-14T00:00:00.000Z', + updated_at: '2026-07-14T00:00:00.000Z', + ...overrides, + }; +} + +async function seedPortalSession(token: string, recordId: string) { + await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); +} + +// ═══════════════ 1. KDF 單元 ═══════════════ + +describe('PBKDF2 模組(lib/portal-auth)', () => { + it('hashPassword 預設格式 = pbkdf2-sha256$600000$salt$hash,且驗證通過', async () => { + const h = await hashPassword('some-password-123'); + const parts = h.split('$'); + expect(parts.length).toBe(4); + expect(parts[0]).toBe('pbkdf2-sha256'); + expect(parts[1]).toBe(String(PBKDF2_ITERATIONS)); + expect(PBKDF2_ITERATIONS).toBe(600_000); + expect(await verifyPassword('some-password-123', h)).toBe(true); + expect(await verifyPassword('wrong-password-x', h)).toBe(false); + }); + + it('壞格式 / 前綴不認得 / 被竄改 → false(誠實拒絕不拋錯)', async () => { + expect(await verifyPassword('x', '')).toBe(false); + expect(await verifyPassword('x', 'bcrypt$10$abc$def')).toBe(false); + expect(await verifyPassword('x', 'pbkdf2-sha256$notanumber$AA$BB')).toBe(false); + const tampered = storedHash.slice(0, -4) + 'AAA='; + expect(await verifyPassword(PASSWORD, tampered)).toBe(false); + }); +}); + +// ═══════════════ 2. bootstrap 閘 ═══════════════ + +describe('POST /portal/admin/bootstrap', () => { + it('無 console owner session → 401,不碰 KBDB', async () => { + const res = await json('POST', '/portal/admin/bootstrap', { email: 'a@b.co', password: 'longenough' }); + expect(res.status).toBe(401); + }); + + it('console session OK → 建第一個 admin:record + head entry 都寫 {tenant}::portal 子 namespace', async () => { + await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() })); + mockTemplatesExist(); + mockListByTemplate('portal_user', []); // 尚無 admin + mockHeadLookup('admin@example.com', null); // email 未占用 + + let recordBody = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records', method: 'POST' }) + .reply(200, (opts) => { + recordBody = String(opts.body); + return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: {} } }; + }); + let headBody = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/entries', method: 'POST' }) + .reply(200, (opts) => { + headBody = String(opts.body); + return { success: true, entry: { id: 'e_head' } }; + }); + + const res = await json( + 'POST', + '/portal/admin/bootstrap', + { email: 'Admin@Example.com', password: 'bootstrap-pw-1', display_name: '管理員' }, + { Authorization: 'Bearer owner-token' }, + ); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.success).toBe(true); + expect(data.record_id).toBe('rec_admin'); + expect(data.email).toBe('admin@example.com'); // 存小寫(design §2.1) + + const rec = JSON.parse(recordBody) as { owner_id: string; values: Record; template: string }; + expect(rec.template).toBe('portal_user'); + expect(rec.owner_id).toBe(NS); // ← D-2 子 namespace 機械斷言 + expect(rec.values.role).toBe('admin'); + expect(rec.values.status).toBe('active'); + expect(rec.values.libraries).toBe('["*"]'); + expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true); + expect(recordBody).not.toContain('bootstrap-pw-1'); // 明碼絕不落 KBDB + + const head = JSON.parse(headBody) as Record; + expect(head.owner_id).toBe(NS); + expect(head.entry_type).toBe('portal_user'); + expect(head.page_name).toBe('admin@example.com'); + expect(head.content).toBe('rec_admin'); + }); + + it('已有 admin → 409 拒絕重複 bootstrap', async () => { + await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() })); + mockTemplatesExist(); + mockListByTemplate('portal_user', [{ record_id: 'rec_a', values: activeUserValues({ role: 'admin' }) }]); + const res = await json( + 'POST', + '/portal/admin/bootstrap', + { email: 'x@y.co', password: 'whatever-123' }, + { Authorization: 'Bearer owner-token' }, + ); + expect(res.status).toBe(409); + }); +}); + +// ═══════════════ 3. 登入對錯 ═══════════════ + +describe('POST /portal/login', () => { + it('成功:發 session token;回 display_name/role/libraries;**無任何租戶字串欄位**', async () => { + mockHeadLookup(EMAIL, 'rec_1'); + mockGetRecord('rec_1', activeUserValues()); + const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD }); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.success).toBe(true); + expect(typeof data.session_token).toBe('string'); + expect(data.display_name).toBe('測試同仁'); + expect(data.role).toBe('user'); + expect(data.libraries).toEqual(['general', 'finance']); + expect('tenant' in data).toBe(false); // design §3.3:Portal 絕不下發租戶字串 + expect(JSON.stringify(data)).not.toContain('leo'); // 連值都不含租戶字串 + + const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`); + expect(sess).toBeTruthy(); + expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id + }); + + it('密碼錯 → 401 通用訊息+lockfail 計數 +1', async () => { + mockHeadLookup(EMAIL, 'rec_1'); + mockGetRecord('rec_1', activeUserValues()); + const res = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-password' }); + expect(res.status).toBe(401); + const raw = await env.SESSIONS_KV.get(`portal_lockfail:${EMAIL}`); + expect(raw).toBeTruthy(); + expect((JSON.parse(raw!) as { count: number }).count).toBe(1); + }); + + it('未知 email → 401 同樣通用訊息(不洩帳號存在性)', async () => { + mockHeadLookup('ghost@example.com', null); + const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' }); + expect(res.status).toBe(401); + const data = (await res.json()) as { error: string }; + expect(data.error).toBe('email 或密碼錯誤'); + }); + + it('停用帳號 → 403(正確密碼也拒)', async () => { + mockHeadLookup(EMAIL, 'rec_1'); + mockGetRecord('rec_1', activeUserValues({ status: 'disabled' })); + const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD }); + expect(res.status).toBe(403); + }); + + it('節流:計數達 5 → 429,不碰 KBDB;正確密碼也擋', async () => { + await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 5 }), { expirationTtl: 900 }); + const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD }); + expect(res.status).toBe(429); + }); + + it('第 5 次失敗後下一次直接 429(KV 計數累加)', async () => { + await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 4 }), { expirationTtl: 900 }); + mockHeadLookup(EMAIL, 'rec_1'); + mockGetRecord('rec_1', activeUserValues()); + const res5 = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-again' }); + expect(res5.status).toBe(401); + const res6 = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD }); + expect(res6.status).toBe(429); // 第 6 次不再打 KBDB(無 pending interceptor 可證) + }); +}); + +// ═══════════════ 4. session 回讀(真相源=record)═══════════════ + +describe('GET /portal/session', () => { + it('有效 session → 回 display_name/role/libraries,無租戶字串', async () => { + await seedPortalSession('tok-1', 'rec_1'); + mockGetRecord('rec_1', activeUserValues()); + const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-1' }); + expect(res.status).toBe(200); + const data = (await res.json()) as Record; + expect(data.valid).toBe(true); + expect(data.libraries).toEqual(['general', 'finance']); + expect('tenant' in data).toBe(false); + }); + + it('帳號被停用 → 既有 session 立即失效(403)且 KV session 被清', async () => { + await seedPortalSession('tok-2', 'rec_1'); + mockGetRecord('rec_1', activeUserValues({ status: 'disabled' })); + const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-2' }); + expect(res.status).toBe(403); + expect(await env.SESSIONS_KV.get('portal_sess:tok-2')).toBeNull(); + }); + + it('無 token / 壞 token → 401', async () => { + expect((await json('GET', '/portal/session')).status).toBe(401); + expect((await json('GET', '/portal/session', undefined, { Authorization: 'Bearer nope' })).status).toBe(401); + }); +}); + +// ═══════════════ 5. 改自己密碼 ═══════════════ + +describe('POST /portal/me/password', () => { + it('舊密碼錯 → 401,不發 PATCH', async () => { + await seedPortalSession('tok-3', 'rec_1'); + mockGetRecord('rec_1', activeUserValues()); + const res = await json( + 'POST', + '/portal/me/password', + { current: 'wrong-old', new: 'new-password-1' }, + { Authorization: 'Bearer tok-3' }, + ); + expect(res.status).toBe(401); + }); + + it('舊密碼對 → PATCH 新 hash(600k 格式、非明碼、與舊 hash 不同)', async () => { + await seedPortalSession('tok-4', 'rec_1'); + mockGetRecord('rec_1', activeUserValues()); + let patched = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_1', method: 'PATCH' }) + .reply(200, (opts) => { + patched = String(opts.body); + return { success: true, record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues() } }; + }); + const res = await json( + 'POST', + '/portal/me/password', + { current: PASSWORD, new: 'brand-new-pw-1' }, + { Authorization: 'Bearer tok-4' }, + ); + expect(res.status).toBe(200); + const sent = JSON.parse(patched) as { values: Record }; + expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true); + expect(sent.values.password_hash).not.toBe(storedHash); + expect(patched).not.toContain('brand-new-pw-1'); // 明碼不落 KBDB + }); +}); + +// ═══════════════ 6. role 閘+admin 列表剝敏 ═══════════════ + +describe('admin 端點 role 閘', () => { + it('一般 user 打 GET /portal/admin/users → 403', async () => { + await seedPortalSession('tok-5', 'rec_1'); + mockGetRecord('rec_1', activeUserValues({ role: 'user' })); + const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-5' }); + expect(res.status).toBe(403); + }); + + it('admin 列表 → 200 且**每筆都不含 password_hash**', async () => { + await seedPortalSession('tok-6', 'rec_admin'); + mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' })); + mockListByTemplate('portal_user', [ + { record_id: 'rec_admin', values: activeUserValues({ role: 'admin', email: 'admin@example.com' }) }, + { record_id: 'rec_1', values: activeUserValues() }, + ]); + const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-6' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { users: Record[] }; + expect(data.users.length).toBe(2); + for (const u of data.users) { + expect('password_hash' in u).toBe(false); + expect(Array.isArray(u.libraries)).toBe(true); + } + expect(JSON.stringify(data)).not.toContain('pbkdf2-sha256'); // 整包回應無雜湊外洩 + }); + + it('admin 停用同仁:PATCH status=disabled → 經 head entry 成員驗證後改 record', async () => { + await seedPortalSession('tok-7', 'rec_admin'); + mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' })); + mockGetRecord('rec_1', activeUserValues()); // assertPortalUserRecord 回讀目標 + mockHeadLookup(EMAIL, 'rec_1'); // head 指回同 record → 成員資格成立 + let patched = ''; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_1', method: 'PATCH' }) + .reply(200, (opts) => { + patched = String(opts.body); + return { + success: true, + record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues({ status: 'disabled' }) }, + }; + }); + const res = await json( + 'PATCH', + '/portal/admin/users/rec_1', + { status: 'disabled' }, + { Authorization: 'Bearer tok-7' }, + ); + expect(res.status).toBe(200); + const sent = JSON.parse(patched) as { values: Record }; + expect(sent.values.status).toBe('disabled'); + const data = (await res.json()) as { user: { status: string } }; + expect(data.user.status).toBe('disabled'); + }); + + it('head entry 指向別的 record(成員資格不符)→ 404 不 PATCH', async () => { + await seedPortalSession('tok-8', 'rec_admin'); + mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' })); + mockGetRecord('rec_evil', activeUserValues({ email: EMAIL })); + mockHeadLookup(EMAIL, 'rec_1'); // head 指 rec_1 ≠ rec_evil + const res = await json( + 'PATCH', + '/portal/admin/users/rec_evil', + { status: 'disabled' }, + { Authorization: 'Bearer tok-8' }, + ); + expect(res.status).toBe(404); + }); +}); diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index 35492bd..bbcff6f 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -138,6 +138,10 @@ KBDB_BASE_URL = "https://arcrun-kbdb.uncle6-me.workers.dev" # (登入系統只擋外人看頁面,不做多租戶)。Self-hosted fork:改成你自己資料實際所在的租戶字串。 CONSOLE_TENANT = "leo" +# Portal session TTL 秒數(portal-auth P2,#24/#25,routes/portal.ts)。預設 7 天(604800)—— +# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。 +PORTAL_SESSION_TTL = "604800" + # 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data,2026-07-07): # InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)——KBDB dash_wait 沒有 # 活的維護管線(07-04 之後沒人寫、銷案不同步),不再當首選資料源。 diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts index bbddcd2..63997d0 100644 --- a/kbdb/src/actions/record-crud.ts +++ b/kbdb/src/actions/record-crud.ts @@ -101,14 +101,23 @@ export async function updateRecord( values: Record, ): Promise { // Existing slot → entry_id + template_id for this record. + // JOIN entries 帶回 owner_id:grow 路徑建新 entry 時要沿用 record 既有 owner_id + //(portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entry(owner_id=NULL), + // owner-scoped 查詢(searchByTemplate / searchEntries)看不到該 slot 值)。 const evRes = await db - .prepare(`SELECT slot_name, entry_id, template_id FROM entry_values WHERE record_id = ?`) + .prepare( + `SELECT ev.slot_name AS slot_name, ev.entry_id AS entry_id, ev.template_id AS template_id, e.owner_id AS owner_id + FROM entry_values ev JOIN entries e ON ev.entry_id = e.id + WHERE ev.record_id = ?`, + ) .bind(recordId) - .all<{ slot_name: string; entry_id: string; template_id: string }>(); + .all<{ slot_name: string; entry_id: string; template_id: string; owner_id: string | null }>(); const evRows = evRes.results ?? []; if (evRows.length === 0) return null; // record does not exist const templateId = evRows[0].template_id; + // record 的歸屬=其既有 slot entries 的 owner_id(createRecord 寫入時同一值)。 + const recordOwnerId = evRows.find((r) => r.owner_id != null)?.owner_id ?? null; const slotToEntry = new Map(evRows.map((r) => [r.slot_name, r.entry_id])); const tpl = await getTemplate(db, templateId); @@ -124,7 +133,8 @@ export async function updateRecord( await db.prepare(`UPDATE entries SET content = ?, updated_at = unixepoch() WHERE id = ?`).bind(content, entryId).run(); } else { // valid template slot not yet on this record → grow it (create entry + link) - const entry = await createEntry(db, { content, entry_type: 'value' }); + // owner_id 帶 record 既有歸屬(design §2.2 附帶修復,防孤兒 entry) + const entry = await createEntry(db, { content, entry_type: 'value', owner_id: recordOwnerId }); await db .prepare(`INSERT INTO entry_values (id, record_id, template_id, slot_name, entry_id) VALUES (?, ?, ?, ?, ?)`) .bind(uid('ev'), recordId, templateId, slot, entry.id) diff --git a/kbdb/tests/record-grow-owner.test.ts b/kbdb/tests/record-grow-owner.test.ts new file mode 100644 index 0000000..cf0cfda --- /dev/null +++ b/kbdb/tests/record-grow-owner.test.ts @@ -0,0 +1,96 @@ +// portal-auth P2 附帶修復回歸測試(design §2.2): +// updateRecord 的「grow 新 slot」路徑建 entry 時,必須帶 record 既有 owner_id。 +// 修前:createEntry(db, { content, entry_type: 'value' }) → owner_id=NULL 孤兒 entry, +// owner-scoped 查詢(searchByTemplate JOIN entries.owner_id / searchEntries owner filter)看不到該 slot 值。 +// 測法沿 library-filter.test.ts 慣例:fake D1 按 SQL 形狀路由回應+捕捉 INSERT 參數 +// (不解讀 SQL 語意——真語意由本機 miniflare 端到端驗證,PR 附證據)。 +import { describe, it, expect } from 'vitest'; +import { updateRecord } from '../src/actions/record-crud'; + +interface Captured { sql: string; params: unknown[] } + +/** + * 可路由 fake D1: + * - entry_values JOIN entries 查詢 → 回既有 slot rows(含 owner_id) + * - templates 查詢 → 回 template(slots_json 含既有 + 可 grow 的 slot) + * - INSERT INTO entries → 捕捉參數(本測試的斷言目標) + * - SELECT * FROM entries WHERE id → 回假 entry(createEntry 的 insert 後回讀) + * - 其餘(UPDATE / INSERT entry_values / getRecord SELECT)→ 空殼 + */ +function makeRoutedDB(recordOwnerId: string | null, captured: Captured[]) { + const prepare = (sql: string) => { + const rec: Captured = { sql, params: [] }; + captured.push(rec); + const stmt = { + bind(...args: unknown[]) { rec.params = args; return stmt; }, + async all() { + if (sql.includes('FROM entry_values ev JOIN entries e')) { + return { + results: [ + { slot_name: 'email', entry_id: 'e_existing', template_id: 'tpl_pu', owner_id: recordOwnerId }, + ] as unknown as T[], + }; + } + if (sql.includes('FROM entry_values ev JOIN entries e ON ev.entry_id = e.id')) { + return { results: [] as T[] }; + } + return { results: [] as T[] }; + }, + async first() { + if (sql.includes('FROM templates')) { + return { + id: 'tpl_pu', name: 'portal_user', description: null, + slots_json: JSON.stringify(['email', 'status']), created_by: 'system', + } as unknown as T; + } + if (sql.startsWith('SELECT * FROM entries WHERE id')) { + return { + id: 'e_new', content: 'active', entry_type: 'value', owner_id: recordOwnerId, + parent_id: null, page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, + content_hash: null, is_embedded: 0, confidence: null, metadata_json: null, + created_at: 1, updated_at: 1, + } as unknown as T; + } + return null as unknown as T; + }, + async run() { return { success: true }; }, + }; + return stmt; + }; + return { prepare } as unknown as D1Database; +} + +describe('updateRecord grow-slot 路徑帶 owner_id(design §2.2 回歸)', () => { + it('record 有 owner_id → grow 出的新 entry INSERT 帶同一 owner_id(修前為 null)', async () => { + const captured: Captured[] = []; + const db = makeRoutedDB('leo::portal', captured); + // 'status' slot 在 template 裡但 record 尚無 → 走 grow 路徑 + await updateRecord(db, 'rec_1', { status: 'active' }); + + const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries')); + expect(insertEntry, 'grow 路徑應 INSERT 新 entry').toBeTruthy(); + // entry-crud.ts createEntry 的 INSERT 欄位序:id, content, entry_type, owner_id, ... + expect(insertEntry!.params[1]).toBe('active'); // content + expect(insertEntry!.params[2]).toBe('value'); // entry_type + expect(insertEntry!.params[3]).toBe('leo::portal'); // owner_id ← 本修復的斷言核心 + }); + + it('record 本就無 owner_id(全域資料)→ grow 出的 entry owner_id 維持 null(行為不變)', async () => { + const captured: Captured[] = []; + const db = makeRoutedDB(null, captured); + await updateRecord(db, 'rec_1', { status: 'active' }); + + const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries')); + expect(insertEntry).toBeTruthy(); + expect(insertEntry!.params[3]).toBeNull(); + }); + + it('既有 slot 更新(非 grow)→ 走 UPDATE entries,不 INSERT 新 entry', async () => { + const captured: Captured[] = []; + const db = makeRoutedDB('leo::portal', captured); + await updateRecord(db, 'rec_1', { email: 'new@example.com' }); + + expect(captured.some((c) => c.sql.includes('UPDATE entries SET content'))).toBe(true); + expect(captured.some((c) => c.sql.includes('INSERT INTO entries'))).toBe(false); + }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index f217b99..0538667 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -41,14 +41,29 @@ ## P2 — portal_user 模型+認證 API(design §2/§4)|觸碰:`cypher-executor/`(新 route 檔) -- [ ] seed template `portal_user`+`portal_library`(init-seed 慣例) -- [ ] PBKDF2-SHA256 雜湊模組(600k iters、常數時間比對、`pbkdf2-sha256$…` 格式) -- [ ] `POST /portal/admin/bootstrap`(console owner session 閘) -- [ ] login/logout/session/改密碼(KV `portal_sess:`、TTL var、每請求回讀 record 驗 status) -- [ ] admin 用戶 CRUD+libraries 授權+庫目錄 CRUD(role=admin 閘) -- [ ] 登入失敗節流(5 次/15 分鐘,KV TTL) -- [ ] 順手修 `record-crud.ts` updateRecord grow 路徑漏 owner_id(design §2.2 附帶) -- [ ] 測試:bootstrap 閘、登入對錯、停用即拒(session 立即失效)、role 閘、雜湊格式、`{tenant}::portal` 子 namespace 隔離(**搜 email 搜不到**) +- [x] seed template `portal_user`+`portal_library`(init-seed 慣例) + - 實作註(2026-07-14):種子資料 `cypher-executor/src/lib/portal-seeds.ts`(*-seeds.ts 慣例類別); + 冪等 ensure 在 `routes/portal.ts` `ensurePortalTemplates()`,掛進 `POST /init/seed`(回應加 + `portal_templates` 區塊)+ bootstrap 前自動 ensure(沒跑過 seed 也能 bootstrap)。 +- [x] PBKDF2-SHA256 雜湊模組(600k iters、常數時間比對、`pbkdf2-sha256$…` 格式) + - `cypher-executor/src/lib/portal-auth.ts`(WebCrypto deriveBits;verify 從儲存格式解析 iterations + → 換 KDF/參數可共存漸進遷移;constantTimeEqual 同 mcp PR#15 實作)。 +- [x] `POST /portal/admin/bootstrap`(console owner session 閘) +- [x] login/logout/session/改密碼(KV `portal_sess:`、TTL var、每請求回讀 record 驗 status) + - `PORTAL_SESSION_TTL` 進 wrangler.toml [vars]+types.ts(預設 604800)。 +- [x] admin 用戶 CRUD+libraries 授權+庫目錄 CRUD(role=admin 閘) + - 安全註:admin 操作目標 record 需過「email head entry 指回同 record_id」成員驗證(防拿任意 + record_id 改到不相干 KBDB record);列表/回應一律剝除 password_hash;未帶密碼建帳號 → + server 產一次性密碼隨回應回傳一次(不落地明碼)。 +- [x] 登入失敗節流(5 次/15 分鐘,KV TTL) +- [x] 順手修 `record-crud.ts` updateRecord grow 路徑漏 owner_id(design §2.2 附帶) + - 修法:首查 JOIN entries 帶回 record 既有 owner_id,grow 的新 entry 沿用。回歸測試 + `kbdb/tests/record-grow-owner.test.ts`(3 項:帶 owner/原無 owner 維持 null/非 grow 不 INSERT)。 +- [x] 測試:bootstrap 閘、登入對錯、停用即拒(session 立即失效)、role 閘、雜湊格式、`{tenant}::portal` 子 namespace 隔離(**搜 email 搜不到**) + - 單元:`cypher-executor/tests/portal-auth.test.ts` 20 項(fetchMock 假 KBDB host,絕不外連); + cypher 全套 133/134(唯一失敗=executor「不存在的零件」pre-existing,stash 複驗);kbdb 20/20; + 兩包 tsc exit 0。端到端:本機雙 worker(隔離 persist)33/33 全綠,含**搜尋隔離實測** + (租戶 ns 搜 email/雜湊 0 筆+對照組 leo::portal 搜得到=非假綠)與明碼落地抽查 0 筆。 - **驗收**(=#25 驗收):新增用戶能登入;停用登入被拒+既有 session 失效;KBDB 無新表;密碼抽查非明碼 - **工程量**:中(約 1 個 CC 工作天)