import { env } from "cloudflare:workers"; import { ensureSchema, getRawDb } from "./mvp-db"; export type UserRole = "super_admin" | "admin" | "user"; export type AuthUser = { id: string; username: string; role: UserRole; }; export type RequestPrincipal = | { kind: "internal" } | { kind: "user"; user: AuthUser }; const SESSION_COOKIE = "koc_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // Cloudflare Workers caps PBKDF2 at 100,000 iterations. const PASSWORD_ITERATIONS = 100_000; function bytesToHex(bytes: Uint8Array) { return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } function hexToBytes(value: string) { if (!/^(?:[0-9a-f]{2})+$/i.test(value)) return new Uint8Array(); return new Uint8Array(value.match(/.{2}/g)?.map((part) => Number.parseInt(part, 16)) ?? []); } function utf8(value: string) { return new TextEncoder().encode(value); } async function sha256(value: string) { return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(value)))); } async function derivePasswordHash( password: string, saltHex: string, iterations: number, ) { const key = await crypto.subtle.importKey( "raw", utf8(password), "PBKDF2", false, ["deriveBits"], ); const bits = await crypto.subtle.deriveBits( { name: "PBKDF2", hash: "SHA-256", salt: hexToBytes(saltHex), iterations, }, key, 256, ); return bytesToHex(new Uint8Array(bits)); } function safeEqual(left: string, right: string) { if (left.length !== right.length) return false; let mismatch = 0; for (let index = 0; index < left.length; index += 1) { mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index); } return mismatch === 0; } export function normalizeUsername(value: unknown) { return String(value ?? "").normalize("NFKC").trim().toLowerCase(); } export function validateUsername(value: string) { return /^[\p{L}\p{N}_.@+-]{2,32}$/u.test(value); } export function validatePassword(value: string) { return value.length >= 8 && value.length <= 72; } export async function createPasswordRecord(password: string) { const salt = new Uint8Array(16); crypto.getRandomValues(salt); const passwordSalt = bytesToHex(salt); return { passwordHash: await derivePasswordHash( password, passwordSalt, PASSWORD_ITERATIONS, ), passwordSalt, passwordIterations: PASSWORD_ITERATIONS, }; } export async function verifyPassword( password: string, record: { password_hash: string; password_salt: string; password_iterations: number; }, ) { const candidate = await derivePasswordHash( password, record.password_salt, record.password_iterations, ); return safeEqual(candidate, record.password_hash); } function getAuthEnv() { return env as unknown as { SUPER_ADMIN_USERNAME?: string; SUPER_ADMIN_PASSWORD?: string; ADMIN_INTERNAL_TOKEN?: string; }; } export async function ensureInitialSuperAdmin() { await ensureSchema(); const db = getRawDb(); const existing = await db .prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1") .first<{ id: string }>(); if (existing) return; const username = normalizeUsername(getAuthEnv().SUPER_ADMIN_USERNAME); const password = String(getAuthEnv().SUPER_ADMIN_PASSWORD ?? ""); if (!validateUsername(username) || !validatePassword(password)) { throw new Error("超级管理员账号尚未配置,请设置 SUPER_ADMIN_USERNAME 和 SUPER_ADMIN_PASSWORD"); } const conflict = await db .prepare("SELECT id FROM users WHERE username = ?") .bind(username) .first<{ id: string }>(); if (conflict) { throw new Error("超级管理员用户名已被占用,请更换 SUPER_ADMIN_USERNAME"); } const passwordRecord = await createPasswordRecord(password); try { await db .prepare( `INSERT INTO users (id, username, password_hash, password_salt, password_iterations, role) VALUES (?, ?, ?, ?, ?, 'super_admin')`, ) .bind( crypto.randomUUID(), username, passwordRecord.passwordHash, passwordRecord.passwordSalt, passwordRecord.passwordIterations, ) .run(); } catch { const concurrent = await db .prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1") .first<{ id: string }>(); if (!concurrent) throw new Error("超级管理员初始化失败"); } } export function sessionCookieFromHeader(cookieHeader: string | null) { const match = String(cookieHeader ?? "") .split(";") .map((part) => part.trim()) .find((part) => part.startsWith(`${SESSION_COOKIE}=`)); return match ? decodeURIComponent(match.slice(SESSION_COOKIE.length + 1)) : ""; } export function createSessionCookie(token: string, secure = true) { return [ `${SESSION_COOKIE}=${encodeURIComponent(token)}`, "Path=/", "HttpOnly", "SameSite=Lax", secure ? "Secure" : "", `Max-Age=${SESSION_MAX_AGE_SECONDS}`, ] .filter(Boolean) .join("; "); } export function clearSessionCookie(secure = true) { return [ `${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Lax", secure ? "Secure" : "", "Max-Age=0", ] .filter(Boolean) .join("; "); } export async function createSession(userId: string) { const tokenBytes = new Uint8Array(32); crypto.getRandomValues(tokenBytes); const token = bytesToHex(tokenBytes); const tokenHash = await sha256(token); const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000).toISOString(); const db = getRawDb(); await db.batch([ db .prepare("DELETE FROM auth_sessions WHERE expires_at <= ?") .bind(new Date().toISOString()), db .prepare( `INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)`, ) .bind(tokenHash, userId, expiresAt), ]); return token; } export async function deleteSession(token: string) { if (!token) return; await ensureSchema(); await getRawDb() .prepare("DELETE FROM auth_sessions WHERE token_hash = ?") .bind(await sha256(token)) .run(); } export async function getUserFromSessionToken(token: string): Promise { if (!token) return null; await ensureSchema(); const row = await getRawDb() .prepare( `SELECT u.id, u.username, u.role FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ? LIMIT 1`, ) .bind(await sha256(token), new Date().toISOString()) .first<{ id: string; username: string; role: string }>(); if (!row || !["super_admin", "admin", "user"].includes(row.role)) return null; return { id: row.id, username: row.username, role: row.role as UserRole }; } export async function getUserFromCookieHeader(cookieHeader: string | null) { return getUserFromSessionToken(sessionCookieFromHeader(cookieHeader)); } function hasInternalToken(request: Request) { const expected = String(getAuthEnv().ADMIN_INTERNAL_TOKEN ?? "").trim(); const received = String(request.headers.get("x-koc-admin-token") ?? "").trim(); return Boolean(expected && received && safeEqual(expected, received)); } export async function getRequestPrincipal( request: Request, ): Promise { if (hasInternalToken(request)) return { kind: "internal" }; const user = await getUserFromCookieHeader(request.headers.get("cookie")); return user ? { kind: "user", user } : null; } export async function isAppRequest(request: Request) { return Boolean(await getRequestPrincipal(request)); } export async function isManagerRequest(request: Request) { const principal = await getRequestPrincipal(request); return Boolean( principal && (principal.kind === "internal" || principal.user.role === "super_admin" || principal.user.role === "admin"), ); } export function authForbidden() { return Response.json({ error: "请先登录后再操作" }, { status: 401 }); } export function managerForbidden() { return Response.json({ error: "当前账号没有管理权限" }, { status: 403 }); }