portal-auth P2(#24 #25):portal_user 模型+認證 API(不 merge,待總管審) (#51)

This commit was merged in pull request #51.
This commit is contained in:
Leo
2026-07-14 04:19:54 +00:00
parent 7f409646e5
commit 1260d8cffb
11 changed files with 1372 additions and 14 deletions
+2
View File
@@ -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.5KBDB 資料層 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
+106
View File
@@ -0,0 +1,106 @@
/**
* Portal 密碼 KDF 模組(portal-auth design §4.1 / D-5、D-6Gitea #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 原生 PBKDF2crypto.subtle.deriveBits)。
*
* 規格(OWASP 現行建議值):
* - PBKDF2-SHA256、600,000 iterations、salt 16 bytes、輸出 256-bit
* - 儲存格式 `pbkdf2-sha256$<iterations>$<salt_b64>$<hash_b64>`(自帶演算法前綴,
* 未來換 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<Uint8Array> {
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$<salt_b64>$<hash_b64>`。 */
export async function hashPassword(password: string, iterations: number = PBKDF2_ITERATIONS): Promise<string> {
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<boolean> {
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 tokensession 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;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Portal KBDB template 種子資料(portal-auth design §2.1/§3.2Gitea #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 定義本身是全域 schematemplates 表無 owner 概念)。
*/
export interface PortalTemplateSeed {
name: string;
description: string;
slots: string[];
created_by: 'system';
}
export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
{
// design §2.1portal 同仁帳號。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',
},
];
+9 -3
View File
@@ -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,
);
+678
View File
@@ -0,0 +1,678 @@
/**
* RAG Portal 多人授權 — P2:用戶模型+認證 APIportal-auth design §2/§4/§5Gitea #24/#25
*
* 架構(design D-1):Portalcypher-executor 的 /portal 路由(非獨立 worker)。
* 本檔只做 P2 的 auth/admin APIP3/portal HTML 殼+ /portal/data/* enforce)另一波。
*
* 鐵律對照:
* - rule 2.1/2.2:這是 UI session 登入(console-auth 同類先例),非 workflow credential
* 原語。KDF 在 lib/portal-auth.tsWebCrypto PBKDF2),本檔無解密/簽章/template 展開。
* - 零新表(design §2):portal_user / portal_library 都是 KBDB 萬用表 template
* 資料經 KBDB base HTTP API 寫入(kbdbBase 慣例,不直連 D1、不寫 SQL)。
* - 子 namespacedesign D-2):一切帳號資料 owner_id`{CONSOLE_TENANT}::portal`。
* 既有租戶查詢面(/kbdb/*、MCP)都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料
* email/password_hash 不會出現在知識搜尋結果)。
* - sessiondesign §4.3):KV `portal_sess:{token}` 只存 record_idTTL 暫存=合規),
* **每個請求回讀 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.35 次失敗
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 ────────────────────────────────────────────────────────────
/** 帳號子 namespacedesign 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<Response> {
const { base, headers } = kbdbBase(env);
let res: Response;
try {
res = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record<string, string> | 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<Response>): Promise<Response> {
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<string, string>;
}
/** 冪等確保 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_iddesign §2.3 head entry O(1) 查找:page_name=email 走 index)。 */
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
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<PortalRecord | null> {
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<string, string>): Promise<PortalRecord> {
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<PortalRecord[]> {
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<string> {
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 /recordsportal_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 entrypage_name=emailindexed)→ content=record_idO(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<string, string> };
type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response };
/**
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KVbest-effort,正確性不依賴它)。
*/
async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
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<AuthResult> {
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<PortalRecord | null> {
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<boolean> {
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<void> {
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<void> {
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_iddesign §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.3portal_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-7owner secret 是
// 安裝期人閘,不引入新 secret、不開放無閘註冊)。建第一個 role=admin 的 portal_user
// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templatesseed 是 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: '已有 adminbootstrap 只能執行一次;後續帳號請用 /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/librariesdesign §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<string, string> = {};
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 /recordsportal_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<string, string> = {};
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) });
}),
);
+4
View File
@@ -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。未設 → 6048007 天,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.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
+399
View File
@@ -0,0 +1,399 @@
/**
* portal-auth P2 測試(design §2/§4/§5Gitea #24/#25
*
* 覆蓋(=tasks.md P2 測試項):
* 1. KDFpbkdf2-sha256$600000$… 格式、驗證對錯、壞格式誠實 false
* 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace
* 已有 admin → 409
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
* 4. 節流:5 次失敗 → 429KV TTL 計數)
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
* 6. 改密碼:驗舊密;新 hash 以 600k 格式落 slot
* 7. role 閘:非 admin 打 admin 端點 → 403admin 列表**剝除 password_hash**
*
* KBDB 打 fetchMock 假 hostwrangler.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';
// 測試用低迭代 hashverify 從儲存格式解析 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<string, string> = {}) {
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<string, string>) {
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<string, string> }[]) {
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<string, string> = {}): Record<string, string> {
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 → 建第一個 adminrecord + 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<string, unknown>;
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<string, string>; 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<string, string>;
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<string, unknown>;
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.3Portal 絕不下發租戶字串
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<string, unknown>;
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 新 hash600k 格式、非明碼、與舊 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<string, string> };
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<string, unknown>[] };
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<string, string> };
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);
});
});
+4
View File
@@ -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/#25routes/portal.ts)。預設 7 天(604800)——
# issue 要求比 console 30 天短效。停用帳號的即時性不靠這個 TTL(每請求回讀 user record)。
PORTAL_SESSION_TTL = "604800"
# 駕駛艙「等你的事」活資料源(fix/console-dashboard-live-data2026-07-07):
# InkStoneCo sprint 檔「## 等 leo 清單」(progress-guard 每日維護)——KBDB dash_wait 沒有
# 活的維護管線(07-04 之後沒人寫、銷案不同步),不再當首選資料源。
+13 -3
View File
@@ -101,14 +101,23 @@ export async function updateRecord(
values: Record<string, string>,
): Promise<RecordResult | null> {
// Existing slot → entry_id + template_id for this record.
// JOIN entries 帶回 owner_idgrow 路徑建新 entry 時要沿用 record 既有 owner_id
//portal-auth design §2.2 附帶修復——原本漏帶 → 孤兒 entryowner_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_idcreateRecord 寫入時同一值)。
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)
+96
View File
@@ -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 templateslots_json + grow slot
* - INSERT INTO entries
* - SELECT * FROM entries WHERE id entrycreateEntry 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<T>() {
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<T>() {
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_iddesign §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);
});
});
+23 -8
View File
@@ -41,14 +41,29 @@
## P2 — portal_user 模型+認證 APIdesign §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 用戶 CRUDlibraries 授權+庫目錄 CRUDrole=admin 閘
- [ ] 登入失敗節流(5 次/15 分鐘,KV TTL)
- [ ] 順手修 `record-crud.ts` updateRecord grow 路徑漏 owner_iddesign §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 deriveBitsverify 從儲存格式解析 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 用戶 CRUDlibraries 授權+庫目錄 CRUDrole=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_iddesign §2.2 附帶)
- 修法:首查 JOIN entries 帶回 record 既有 owner_idgrow 的新 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-existingstash 複驗);kbdb 20/20
兩包 tsc exit 0。端到端:本機雙 worker(隔離 persist33/33 全綠,含**搜尋隔離實測**
(租戶 ns 搜 email/雜湊 0 筆+對照組 leo::portal 搜得到=非假綠)與明碼落地抽查 0 筆。
- **驗收**=#25 驗收):新增用戶能登入;停用登入被拒+既有 session 失效;KBDB 無新表;密碼抽查非明碼
- **工程量**:中(約 1 個 CC 工作天)