Files
koc-loop/lib/user-auth.ts

298 lines
8.4 KiB
TypeScript
Raw Permalink Normal View History

import { getRuntimeEnv } from "./runtime-env";
import { ensureSchema, getRawDb } from "./mvp-db";
const env = getRuntimeEnv();
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;
// Keep existing password records compatible while using a production-safe cost.
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 function requestUsesHttps(request: Request) {
const forwardedProtocol = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
return forwardedProtocol === "https" || new URL(request.url).protocol === "https:";
}
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<AuthUser | null> {
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<RequestPrincipal | null> {
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 });
}