Add user authentication and role management

This commit is contained in:
巫凤萍
2026-08-07 11:06:20 +08:00
parent 1f910c975d
commit c926a6a874
25 changed files with 2370 additions and 132 deletions

View File

@@ -1,35 +1,4 @@
import { env } from "cloudflare:workers";
export function isAdminEmail(input: string | null | undefined) {
const allowedEmail = String(
(env as unknown as { ADMIN_ALLOWED_EMAIL?: string }).ADMIN_ALLOWED_EMAIL ??
"",
)
.trim()
.toLowerCase();
const requestEmail = String(input ?? "").trim().toLowerCase();
return Boolean(allowedEmail && requestEmail && requestEmail === allowedEmail);
}
export function isAdminRequest(request: Request) {
if (isAdminEmail(request.headers.get("oai-authenticated-user-email"))) {
return true;
}
const expectedToken = String(
(env as unknown as { ADMIN_INTERNAL_TOKEN?: string }).ADMIN_INTERNAL_TOKEN ??
"",
).trim();
const requestToken = String(
request.headers.get("x-koc-admin-token") ?? "",
).trim();
return Boolean(
expectedToken &&
requestToken &&
expectedToken.length === requestToken.length &&
expectedToken === requestToken,
);
}
export function adminForbidden() {
return Response.json({ error: "无后台操作权限" }, { status: 403 });
}
export {
authForbidden as adminForbidden,
isAppRequest as isAdminRequest,
} from "./user-auth";

View File

@@ -149,6 +149,22 @@ export async function ensureSchema(database?: D1Database) {
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
password_iterations INTEGER NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
];
for (const statement of statements) {
@@ -305,6 +321,25 @@ export async function ensureSchema(database?: D1Database) {
ON collection_runs(task_id, scheduled_date)`,
)
.run();
await db
.prepare("CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON users(username)")
.run();
await db
.prepare(
`CREATE UNIQUE INDEX IF NOT EXISTS users_single_super_admin_idx
ON users(role) WHERE role = 'super_admin'`,
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)",
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS auth_sessions_expires_at_idx ON auth_sessions(expires_at)",
)
.run();
await db
.prepare(
`UPDATE distributions

285
lib/user-auth.ts Normal file
View File

@@ -0,0 +1,285 @@
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 });
}