portal-auth P2(#24 #25):portal_user 模型+認證 API(不 merge,待總管審) (#51)
This commit was merged in pull request #51.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Portal 密碼 KDF 模組(portal-auth design §4.1 / D-5、D-6,Gitea #24/#25 P2)
|
||||
*
|
||||
* 職責界線(rule 2.1/2.2 對照,design D-5 已釐清):
|
||||
* 這是「UI session 登入」的密碼雜湊——console-auth.ts 同類先例,**不是** workflow
|
||||
* credential 原語(那些屬 WASM auth primitive,本檔不碰 crypto.subtle.decrypt /
|
||||
* RSASSA / template 展開)。只用 WebCrypto 原生 PBKDF2(crypto.subtle.deriveBits)。
|
||||
*
|
||||
* 規格(OWASP 現行建議值):
|
||||
* - PBKDF2-SHA256、600,000 iterations、salt 16 bytes、輸出 256-bit
|
||||
* - 儲存格式 `pbkdf2-sha256$<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 token(session token 用;與 console-auth randomHex 同款)。 */
|
||||
export function randomHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。
|
||||
* 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。
|
||||
*/
|
||||
export function generatePassword(length = 16): string {
|
||||
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
const arr = new Uint8Array(length);
|
||||
crypto.getRandomValues(arr);
|
||||
let out = '';
|
||||
for (const b of arr) out += charset[b % charset.length];
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Portal KBDB template 種子資料(portal-auth design §2.1/§3.2,Gitea #24/#25 P2)
|
||||
*
|
||||
* 種子資料檔慣例(rule 07 §1 / pre-write-guard *-seeds.ts 類別):
|
||||
* 「裝好後預設有哪些 template」是 API 的能力,資料宣告放 server(本檔),
|
||||
* 由 /init/seed(與 /portal/admin/bootstrap 的 ensure 路徑)冪等灌入 KBDB。
|
||||
* 薄殼(CLI/MCP)不自帶這份清單。
|
||||
*
|
||||
* 零新表鐵律:這些是 KBDB 萬用表的 template(虛擬表定義),不是 D1 真表。
|
||||
* 帳號「資料」(records/entries)一律寫 `{CONSOLE_TENANT}::portal` 子 namespace
|
||||
* (design D-2);template 定義本身是全域 schema(templates 表無 owner 概念)。
|
||||
*/
|
||||
|
||||
export interface PortalTemplateSeed {
|
||||
name: string;
|
||||
description: string;
|
||||
slots: string[];
|
||||
created_by: 'system';
|
||||
}
|
||||
|
||||
export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
|
||||
{
|
||||
// design §2.1:portal 同仁帳號。password_hash 存 KDF 輸出(pbkdf2-sha256$…,D-6),
|
||||
// 永不存明碼;libraries 是 JSON array 字串(["general"] / ["*"]=全庫)。
|
||||
name: 'portal_user',
|
||||
description: 'RAG Portal 同仁帳號(portal-auth §2.1;資料寫 {tenant}::portal 子 namespace)',
|
||||
slots: ['email', 'display_name', 'status', 'role', 'password_hash', 'libraries', 'created_at', 'updated_at'],
|
||||
created_by: 'system',
|
||||
},
|
||||
{
|
||||
// design §3.2:庫目錄(admin 頁列庫用)。庫本體=知識條目 metadata_json.$.library 標記,
|
||||
// 這裡只是「有哪些庫」的登記簿。
|
||||
name: 'portal_library',
|
||||
description: 'RAG Portal 庫目錄登記(portal-auth §3.2;庫=metadata_json.$.library 標記)',
|
||||
slots: ['name', 'display_name', 'description', 'status'],
|
||||
created_by: 'system',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user