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)。