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

@@ -109,7 +109,7 @@ async function createTaskFromSource(
}
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const body = (await request.json()) as ActionBody;

View File

@@ -0,0 +1,47 @@
import {
createSession,
createSessionCookie,
ensureInitialSuperAdmin,
normalizeUsername,
verifyPassword,
} from "../../../../lib/user-auth";
import { getRawDb } from "../../../../lib/mvp-db";
export const runtime = "edge";
export async function POST(request: Request) {
try {
await ensureInitialSuperAdmin();
const body = (await request.json()) as { username?: string; password?: string };
const username = normalizeUsername(body.username);
const password = String(body.password ?? "");
const user = await getRawDb()
.prepare(
`SELECT id, username, role, password_hash, password_salt, password_iterations
FROM users WHERE username = ? LIMIT 1`,
)
.bind(username)
.first<{
id: string;
username: string;
role: string;
password_hash: string;
password_salt: string;
password_iterations: number;
}>();
if (!user || !(await verifyPassword(password, user))) {
return Response.json({ error: "账号或密码错误" }, { status: 401 });
}
const token = await createSession(user.id);
const secure = new URL(request.url).protocol === "https:";
return Response.json(
{ user: { id: user.id, username: user.username, role: user.role } },
{ headers: { "Set-Cookie": createSessionCookie(token, secure) } },
);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "登录失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,17 @@
import {
clearSessionCookie,
deleteSession,
sessionCookieFromHeader,
} from "../../../../lib/user-auth";
export const runtime = "edge";
export async function POST(request: Request) {
const token = sessionCookieFromHeader(request.headers.get("cookie"));
await deleteSession(token);
const secure = new URL(request.url).protocol === "https:";
return Response.json(
{ loggedOut: true },
{ headers: { "Set-Cookie": clearSessionCookie(secure) } },
);
}

View File

@@ -12,12 +12,13 @@ import {
getRawDb,
seedIfEmpty,
} from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
export const runtime = "edge";
export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
const principal = await getRequestPrincipal(request);
if (!principal) return authForbidden();
try {
await ensureSchema();
await seedIfEmpty();
@@ -60,7 +61,12 @@ export async function GET(request: Request) {
} else {
await catchup;
}
return Response.json(await getDashboardData());
const dashboard = await getDashboardData();
return Response.json(
principal.kind === "user" && principal.user.role === "user"
? { ...dashboard, accounts: [] }
: dashboard,
);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "加载失败" },

View File

@@ -5,7 +5,7 @@ import { ensureSchema, getUploadBucket } from "../../../lib/mvp-db";
export const runtime = "edge";
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const form = await request.formData();

View File

@@ -8,7 +8,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const distributionId = new URL(request.url).searchParams

View File

@@ -218,7 +218,7 @@ function exactArrayBuffer(bytes: Uint8Array) {
}
export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const taskId = new URL(request.url).searchParams.get("task")?.trim() || "";

View File

@@ -1,4 +1,4 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { parseStoredDate } from "../../../lib/date-utils";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
@@ -48,7 +48,7 @@ function exactArrayBuffer(bytes: Uint8Array) {
}
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isManagerRequest(request))) return managerForbidden();
try {
const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) {

View File

@@ -10,7 +10,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
const form = await request.formData();

138
app/api/users/route.ts Normal file
View File

@@ -0,0 +1,138 @@
import {
createPasswordRecord,
getRequestPrincipal,
managerForbidden,
normalizeUsername,
type UserRole,
validatePassword,
validateUsername,
} from "../../../lib/user-auth";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
export const runtime = "edge";
type UserRow = {
id: string;
username: string;
role: UserRole;
created_at: string;
updated_at: string;
};
async function manager(request: Request) {
const principal = await getRequestPrincipal(request);
if (!principal || principal.kind !== "user") return null;
return ["super_admin", "admin"].includes(principal.user.role)
? principal.user
: null;
}
async function listUsers() {
const result = await getRawDb()
.prepare(
`SELECT id, username, role, created_at, updated_at
FROM users
ORDER BY CASE role WHEN 'super_admin' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END,
created_at ASC`,
)
.all<UserRow>();
return result.results;
}
export async function GET(request: Request) {
await ensureSchema();
if (!(await manager(request))) return managerForbidden();
return Response.json({ users: await listUsers() });
}
export async function POST(request: Request) {
await ensureSchema();
const currentUser = await manager(request);
if (!currentUser) return managerForbidden();
try {
const body = (await request.json()) as {
action?: string;
username?: string;
password?: string;
role?: UserRole;
userId?: string;
};
const password = String(body.password ?? "");
if (!validatePassword(password)) {
return Response.json({ error: "密码需为8—72位" }, { status: 400 });
}
const db = getRawDb();
const passwordRecord = await createPasswordRecord(password);
if (body.action === "create") {
const username = normalizeUsername(body.username);
const role = body.role === "admin" ? "admin" : "user";
if (!validateUsername(username)) {
return Response.json(
{ error: "账号需为2—32位中文、字母、数字或 _ . @ + -" },
{ status: 400 },
);
}
if (currentUser.role !== "super_admin" && role !== "user") {
return managerForbidden();
}
await db
.prepare(
`INSERT INTO users
(id, username, password_hash, password_salt, password_iterations, role)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
crypto.randomUUID(),
username,
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
role,
)
.run();
} else if (body.action === "reset_password") {
const target = await db
.prepare("SELECT id, role FROM users WHERE id = ?")
.bind(String(body.userId ?? ""))
.first<{ id: string; role: UserRole }>();
if (!target) {
return Response.json({ error: "没有找到这个账号" }, { status: 404 });
}
if (
target.role === "super_admin" &&
!(currentUser.role === "super_admin" && target.id === currentUser.id)
) {
return Response.json({ error: "不能修改其他超级管理员账号" }, { status: 403 });
}
if (currentUser.role !== "super_admin" && target.role !== "user") {
return managerForbidden();
}
await db.batch([
db
.prepare(
`UPDATE users SET
password_hash = ?, password_salt = ?, password_iterations = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
target.id,
),
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(target.id),
]);
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}
return Response.json({ users: await listUsers() });
} catch (error) {
const message = error instanceof Error ? error.message : "操作失败";
return Response.json(
{ error: message.includes("UNIQUE") ? "这个登录账号已存在" : message },
{ status: message.includes("UNIQUE") ? 409 : 500 },
);
}
}