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
+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',
},
];