Add user authentication and role management
This commit is contained in:
@@ -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;
|
||||
|
||||
47
app/api/auth/login/route.ts
Normal file
47
app/api/auth/login/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
17
app/api/auth/logout/route.ts
Normal file
17
app/api/auth/logout/route.ts
Normal 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) } },
|
||||
);
|
||||
}
|
||||
@@ -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 : "加载失败" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() || "";
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
138
app/api/users/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user