Files
Jyotisha/frontend/src/lib/admin/http.ts
T
2026-08-06 20:15:08 +08:00

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);
}
}