286 lines
8.0 KiB
TypeScript
286 lines
8.0 KiB
TypeScript
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;
|
|
const PASSWORD_ITERATIONS = 120_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<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 });
|
|
}
|