Add user authentication and role management
This commit is contained in:
285
lib/user-auth.ts
Normal file
285
lib/user-auth.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user