160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
AdminAuthorizationError,
|
|
requirePermission,
|
|
type AdminPermission,
|
|
type AdminSession,
|
|
} from "./auth";
|
|
import {
|
|
ADMIN_MFA_PROOF_COOKIE,
|
|
HIGH_RISK_ADMIN_PROOF_COOKIE,
|
|
isSameOriginAdminMutation,
|
|
resolveAdminMfaStatus,
|
|
verifyAdminMfaProof,
|
|
verifyHighRiskAdminProof,
|
|
type AdminMfaStatus,
|
|
} from "./auth-policy";
|
|
import { isPostgresError } from "./database";
|
|
|
|
export const listQuerySchema = z.object({
|
|
page: z.coerce.number().int().min(1).default(1),
|
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
|
sort: z.string().trim().max(64).optional(),
|
|
order: z.enum(["asc", "desc"]).default("desc"),
|
|
q: z.string().trim().max(200).optional(),
|
|
status: z.string().trim().max(50).optional(),
|
|
});
|
|
|
|
export type ListQuery = z.infer<typeof listQuerySchema>;
|
|
|
|
export function parseListQuery(request: Request) {
|
|
return listQuerySchema.safeParse(
|
|
Object.fromEntries(new URL(request.url).searchParams.entries()),
|
|
);
|
|
}
|
|
|
|
export function requestId(request: Request): string {
|
|
const supplied = request.headers.get("x-request-id")?.trim();
|
|
return supplied && supplied.length <= 200 ? supplied : crypto.randomUUID();
|
|
}
|
|
|
|
export function adminErrorResponse(error: unknown) {
|
|
if (error instanceof AdminAuthorizationError) {
|
|
return NextResponse.json({ error: error.message }, { status: error.status });
|
|
}
|
|
if (isPostgresError(error)) {
|
|
if (error.code === "42501") return NextResponse.json({ error: "无权执行此操作" }, { status: 403 });
|
|
if (error.code === "40001") return NextResponse.json({ error: "资源已被其他管理员修改,请刷新后重试" }, { status: 409 });
|
|
if (error.code === "22023" || error.code === "23514" || error.code === "23505") {
|
|
return NextResponse.json({ error: "提交内容不符合业务约束" }, { status: 400 });
|
|
}
|
|
}
|
|
return NextResponse.json(
|
|
{ error: "后台服务暂时不可用" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
export async function requireAdminMutation(request: Request, permission: AdminPermission) {
|
|
if (!isSameOriginAdminMutation(request.headers.get("origin"), request.url)) {
|
|
throw new AdminAuthorizationError("请求来源不可信", 403);
|
|
}
|
|
return requirePermission(permission, request.headers);
|
|
}
|
|
|
|
export function requestCookie(request: Request, name: string): string | undefined {
|
|
for (const part of request.headers.get("cookie")?.split(";") ?? []) {
|
|
const separator = part.indexOf("=");
|
|
if (separator > 0 && part.slice(0, separator).trim() === name) {
|
|
return part.slice(separator + 1).trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
export function adminProofSigningSecret(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string {
|
|
const secret = env.BETTER_AUTH_USER_SECRET?.trim();
|
|
if (!secret || secret.length < 32) {
|
|
throw new AdminAuthorizationError("MFA 服务暂时不可用", 503);
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
export function readAdminMfaStatus(
|
|
request: Request,
|
|
session: AdminSession,
|
|
): AdminMfaStatus {
|
|
const enrolled = session.user.twoFactorEnabled === true;
|
|
const verified = enrolled && verifyAdminMfaProof(
|
|
requestCookie(request, ADMIN_MFA_PROOF_COOKIE),
|
|
{
|
|
userId: session.user.id,
|
|
sessionId: session.identitySession.id,
|
|
origin: new URL(request.url).origin,
|
|
},
|
|
adminProofSigningSecret(),
|
|
session.identitySession.token,
|
|
);
|
|
return resolveAdminMfaStatus(session.requiresMfa, enrolled, verified);
|
|
}
|
|
|
|
export function requireAdminMfaIfRequired(
|
|
request: Request,
|
|
session: AdminSession,
|
|
): AdminMfaStatus {
|
|
const status = readAdminMfaStatus(request, session);
|
|
if (!status.required) return status;
|
|
if (!status.enrolled) {
|
|
throw new AdminAuthorizationError("此管理员角色必须先启用 MFA", 403);
|
|
}
|
|
if (!status.verified) {
|
|
throw new AdminAuthorizationError("请先完成当前会话的 MFA 验证", 403);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
export async function requireHighRiskAdminMutation(
|
|
request: Request,
|
|
permission: AdminPermission,
|
|
): Promise<AdminSession> {
|
|
const session = await requireAdminMutation(request, permission);
|
|
requireAdminMfaIfRequired(request, session);
|
|
|
|
const origin = new URL(request.url).origin;
|
|
const valid = verifyHighRiskAdminProof(
|
|
requestCookie(request, HIGH_RISK_ADMIN_PROOF_COOKIE),
|
|
{
|
|
userId: session.user.id,
|
|
sessionId: session.identitySession.id,
|
|
permission,
|
|
origin,
|
|
},
|
|
adminProofSigningSecret(),
|
|
session.identitySession.token,
|
|
);
|
|
if (!valid) {
|
|
throw new AdminAuthorizationError("请先使用邮箱验证码重新认证", 403);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
export function invalidQueryResponse(details?: unknown) {
|
|
return NextResponse.json(
|
|
{ error: "查询参数不正确", ...(details ? { details } : {}) },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
export async function readonlyAdminMutation() {
|
|
try {
|
|
const { requireAdminSession } = await import("./auth");
|
|
await requireAdminSession();
|
|
return NextResponse.json({ error: "此资源只读" }, { status: 405 });
|
|
} catch (error) {
|
|
return adminErrorResponse(error);
|
|
}
|
|
}
|