fix(admin): remove operation-level email reauthentication
Independent Staging Quality Gate / validate (push) Successful in 12m37s
Independent Staging Quality Gate / publish (push) Successful in 10m0s

This commit is contained in:
Jesse_Chen
2026-08-16 14:07:57 +08:00
parent 9d8570640d
commit 4f6cf5782d
30 changed files with 230 additions and 582 deletions
@@ -8,7 +8,7 @@ import {
invalidQueryResponse,
parseListQuery,
readAdminMfaStatus,
requireHighRiskAdminMutation,
requireAdminMutation,
requestId,
} from "@/lib/admin/http";
@@ -68,7 +68,6 @@ export async function GET(request: Request) {
roles: row.roles,
})),
total: Number(rows[0]?.total_count ?? 0),
highRiskWritesEnabled: mfa.highRiskWritesEnabled,
mfa,
});
} catch (error) {
@@ -78,7 +77,7 @@ export async function GET(request: Request) {
async function mutate(request: Request, assign: boolean) {
try {
const session = await requireHighRiskAdminMutation(request, "admin.users.manage_roles");
const session = await requireAdminMutation(request, "admin.users.manage_roles");
const parsed = mutationSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const targetRows = parsed.data.userId
@@ -5,7 +5,7 @@ import { runCodeRpc } from "@/lib/admin/codes";
import {
adminErrorResponse,
invalidQueryResponse,
requireHighRiskAdminMutation,
requireAdminMutation,
requestId,
} from "@/lib/admin/http";
@@ -30,7 +30,7 @@ export async function PATCH(
context: { params: Promise<{ id: string }> },
) {
try {
const session = await requireHighRiskAdminMutation(
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
);
@@ -66,7 +66,7 @@ export async function DELETE(
context: { params: Promise<{ id: string }> },
) {
try {
const session = await requireHighRiskAdminMutation(
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
);
+2 -2
View File
@@ -12,7 +12,7 @@ import {
adminErrorResponse,
invalidQueryResponse,
parseListQuery,
requireHighRiskAdminMutation,
requireAdminMutation,
requestId,
} from "@/lib/admin/http";
import {
@@ -123,7 +123,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const session = await requireHighRiskAdminMutation(
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
);
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/auth";
import { isPostgresError, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, requireHighRiskAdminMutation } from "@/lib/admin/http";
import { adminErrorResponse, requireAdminMutation } from "@/lib/admin/http";
import { suggestedEpayUrls } from "@/lib/epay/config";
import { encryptEpayKey } from "@/lib/epay/encryption";
import { assertConfiguredEpayUrl, assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
@@ -99,7 +99,7 @@ export async function GET() {
export async function PUT(request: Request) {
try {
const session = await requireHighRiskAdminMutation(request, "billing.adjustments.write");
const session = await requireAdminMutation(request, "billing.adjustments.write");
const parsed = settingsSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "易支付配置参数不正确" }, { status: 400 });
try {
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation, requireHighRiskAdminMutation } from "@/lib/admin/http";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
export const runtime="nodejs";
const schema=z.discriminatedUnion("action",[
z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional(),reason:z.string().trim().min(1).max(500)}).strict(),
@@ -10,4 +10,4 @@ const schema=z.discriminatedUnion("action",[
]);
type Row={id:string;flag_key:string;version:number;enabled:boolean;rollout_percentage:number;config:Record<string,unknown>;status:string;created_at:Date;published_at:Date|null;total_count:string};
export async function GET(request:Request){try{await requirePermission("admin.access");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select f.*,count(*) over()::text total_count from public.feature_flags f where ($1::text is null or f.flag_key ilike $1) and ($2::text is null or f.status=$2) order by f.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,flagKey:r.flag_key,version:r.version,enabled:r.enabled,rolloutPercentage:r.rollout_percentage,config:r.config,status:r.status,createdAt:r.created_at.toISOString(),publishedAt:r.published_at?.toISOString()??null})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=b.data.action==="publish"?await requireHighRiskAdminMutation(request,"ops.flags.write"):await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}}
+7 -17
View File
@@ -10,8 +10,6 @@ import { AdminAuthorizationError, requirePermission, type AdminSession } from "@
import {
ADMIN_MFA_PROOF_COOKIE,
ADMIN_MFA_PROOF_TTL_MS,
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
HIGH_RISK_ADMIN_PROOF_COOKIE,
issueAdminMfaProof,
} from "@/lib/admin/auth-policy";
import {
@@ -114,7 +112,6 @@ function mfaProofResponse(
required: session.requiresMfa,
enrolled: true,
verified: true,
highRiskWritesEnabled: true,
expiresIn: ADMIN_MFA_PROOF_TTL_MS / 1_000,
},
});
@@ -282,23 +279,16 @@ export async function POST(request: Request) {
required: session.requiresMfa,
enrolled: false,
verified: false,
highRiskWritesEnabled: !session.requiresMfa,
},
});
copyNativeCookies(nativeResponse, response);
for (const name of [
ADMIN_MFA_PROOF_COOKIE,
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
HIGH_RISK_ADMIN_PROOF_COOKIE,
]) {
response.cookies.set(name, "", {
httpOnly: true,
sameSite: "strict",
secure: true,
path: "/api/admin",
maxAge: 0,
});
}
response.cookies.set(ADMIN_MFA_PROOF_COOKIE, "", {
httpOnly: true,
sameSite: "strict",
secure: true,
path: "/api/admin",
maxAge: 0,
});
return response;
} catch (error) {
return nativeErrorResponse(error) ?? adminErrorResponse(error);
+2 -2
View File
@@ -8,7 +8,7 @@ import {
invalidQueryResponse,
parseListQuery,
requestId,
requireHighRiskAdminMutation,
requireAdminMutation,
} from "@/lib/admin/http";
export const runtime = "nodejs";
@@ -108,7 +108,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
const session = await requireHighRiskAdminMutation(
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
);
+2 -2
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireHighRiskAdminMutation } from "@/lib/admin/http";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
export const runtime = "nodejs";
@@ -88,7 +88,7 @@ export async function POST(request: Request) {
const body = mutationSchema.safeParse(await request.json().catch(() => null));
if (!body.success) return invalidQueryResponse(body.error.flatten());
const permission = body.data.action === "publish" ? "billing.products.publish" : "billing.products.write";
const session = await requireHighRiskAdminMutation(request, permission);
const session = await requireAdminMutation(request, permission);
const rid = requestId(request);
if (body.data.action === "publish") {
const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, body.data.reason, rid]);
-123
View File
@@ -1,123 +0,0 @@
import { APIError } from "better-auth";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getIdentityEmailOtpApi } from "@/modules/identity/auth";
import { AdminAuthorizationError } from "@/lib/admin/auth";
import {
adminPermissions,
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
HIGH_RISK_ADMIN_PROOF_COOKIE,
HIGH_RISK_ADMIN_PROOF_TTL_MS,
issueHighRiskAdminChallenge,
issueHighRiskAdminProof,
verifyHighRiskAdminChallenge,
} from "@/lib/admin/auth-policy";
import {
adminProofSigningSecret,
adminErrorResponse,
invalidQueryResponse,
requestCookie,
requireAdminMutation,
} from "@/lib/admin/http";
export const runtime = "nodejs";
const reauthSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("request"),
permission: z.enum(adminPermissions),
}),
z.object({
action: z.literal("verify"),
permission: z.enum(adminPermissions),
otp: z.string().regex(/^\d{6}$/),
}),
]);
export async function POST(request: Request) {
try {
const parsed = reauthSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const session = await requireAdminMutation(request, parsed.data.permission);
const auth = getIdentityEmailOtpApi();
const proofContext = {
userId: session.user.id,
sessionId: session.identitySession.id,
permission: parsed.data.permission,
origin: new URL(request.url).origin,
};
const proofSecret = adminProofSigningSecret();
if (parsed.data.action === "request") {
await auth.sendVerificationOTP({
body: { email: session.user.email, type: "email-verification" },
});
const response = NextResponse.json({ data: { sent: true, expiresIn: 300 } });
response.cookies.set(
HIGH_RISK_ADMIN_CHALLENGE_COOKIE,
issueHighRiskAdminChallenge(
proofContext,
proofSecret,
session.identitySession.token,
),
{
httpOnly: true,
sameSite: "strict",
secure: true,
path: "/api/admin",
maxAge: HIGH_RISK_ADMIN_CHALLENGE_TTL_MS / 1_000,
},
);
return response;
}
if (!verifyHighRiskAdminChallenge(
requestCookie(request, HIGH_RISK_ADMIN_CHALLENGE_COOKIE),
proofContext,
proofSecret,
session.identitySession.token,
)) {
throw new AdminAuthorizationError("请先请求当前权限的邮箱验证码", 403);
}
try {
await auth.verifyEmailOTP({
body: { email: session.user.email, otp: parsed.data.otp },
});
} catch (error) {
if (error instanceof APIError && error.statusCode >= 400 && error.statusCode < 500) {
return NextResponse.json({ error: "验证码错误或已过期" }, { status: 400 });
}
throw error;
}
const response = NextResponse.json({ data: { verified: true } });
response.cookies.set(
HIGH_RISK_ADMIN_PROOF_COOKIE,
issueHighRiskAdminProof(
proofContext,
proofSecret,
session.identitySession.token,
),
{
httpOnly: true,
sameSite: "strict",
secure: true,
path: "/api/admin",
maxAge: HIGH_RISK_ADMIN_PROOF_TTL_MS / 1_000,
},
);
response.cookies.set(HIGH_RISK_ADMIN_CHALLENGE_COOKIE, "", {
httpOnly: true,
sameSite: "strict",
secure: true,
path: "/api/admin",
maxAge: 0,
});
return response;
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireHighRiskAdminMutation } from "@/lib/admin/http";
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation } from "@/lib/admin/http";
export const runtime = "nodejs";
const mutationSchema = z.object({
@@ -28,7 +28,7 @@ export async function GET(request: Request) {
export async function POST(request: Request){
try{
const body=mutationSchema.safeParse(await request.json().catch(()=>null)); if(!body.success)return invalidQueryResponse(body.error.flatten());
const session=await requireHighRiskAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request);
const session=await requireAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request);
const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,body.data.reason,rid]);
return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}});
}catch(error){return adminErrorResponse(error);}
@@ -100,7 +100,7 @@ export default function AdministratorsResource() {
extra={canManage ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openAssignment()}></Button> : <Typography.Text type="secondary"></Typography.Text>}
>
<Typography.Paragraph type="secondary">
Owner
Owner
</Typography.Paragraph>
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="q" label="搜索">
@@ -154,10 +154,9 @@ export default function AdministratorsResource() {
<ReasonActionModal
open={Boolean(pendingAction)}
title={pendingAction?.action === "revoke" ? `撤销角色:${pendingAction.label}` : `分配角色:${pendingAction?.label ?? ""}`}
okText={pendingAction?.action === "revoke" ? "验证并撤销" : "验证并分配"}
okText={pendingAction?.action === "revoke" ? "确认撤销" : "确认分配"}
danger={pendingAction?.action === "revoke"}
confirmLoading={saving}
reauthPermission="admin.users.manage_roles"
onCancel={() => setPendingAction(null)}
onSubmit={submitRoleAction}
/>
@@ -206,9 +206,8 @@ export function SubscriptionsResource() {
<ReasonActionModal
open={Boolean(selected && extendDays !== null)}
title="延长订阅权益"
okText="验证并延长"
okText="确认延长"
confirmLoading={saving}
reauthPermission="billing.adjustments.write"
onCancel={() => setExtendDays(null)}
onSubmit={extend}
/>
@@ -218,7 +217,6 @@ export function SubscriptionsResource() {
okText="确认撤销"
danger
confirmLoading={Boolean(revokingId)}
reauthPermission="billing.adjustments.write"
onCancel={() => setRevokeTarget(null)}
onSubmit={(reason) => revoke(revokeTarget!, reason)}
/>
@@ -430,7 +428,6 @@ export function OrdersResource() {
okText="确认执行"
danger={target?.action === "record_refund"}
confirmLoading={saving}
reauthPermission="billing.adjustments.write"
onCancel={() => setTarget(null)}
onSubmit={adjust}
/>
@@ -319,18 +319,16 @@ export default function CodesPage() {
<ReasonActionModal
open={Boolean(pendingCreate)}
title="生成兑换码"
okText="验证并生成"
okText="确认生成"
confirmLoading={createMutation.isPending}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingCreate(null)}
onSubmit={submitCreate}
/>
<ReasonActionModal
open={Boolean(pendingEdit)}
title="编辑未兑换码"
okText="验证并保存"
okText="确认保存"
confirmLoading={updateMutation.isPending}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingEdit(null)}
onSubmit={submitEdit}
/>
@@ -340,7 +338,6 @@ export default function CodesPage() {
okText="确认撤销"
danger
confirmLoading={revoking}
reauthPermission="billing.adjustments.write"
onCancel={() => setRevokeRecord(null)}
onSubmit={(reason) => revoke(revokeRecord!, reason)}
/>
@@ -162,7 +162,6 @@ export default function FeatureFlagsManagement() {
title={`发布功能开关${publishTarget ? `${publishTarget.flagKey}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
reauthPermission="ops.flags.write"
onCancel={() => setPublishTarget(null)}
onSubmit={(reason) => publish(publishTarget!, reason)}
/>
+6 -12
View File
@@ -22,7 +22,6 @@ type MfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
highRiskWritesEnabled: boolean;
expiresIn?: number;
};
@@ -104,7 +103,7 @@ export default function MfaSecurity() {
if (!data) return;
setStatus(data);
setEnrollment(undefined);
setNotice("当前管理员会话已完成真实第二因素验证。高风险操作仍需随后完成权限范围内的邮箱验证。");
setNotice("当前管理员会话已完成 MFA 验证。");
}
async function recover({ code }: { code: string }) {
@@ -131,8 +130,8 @@ export default function MfaSecurity() {
setEnrollment(undefined);
setBackupCodes(undefined);
setNotice(data.required
? "MFA 已禁用。角色要求 MFA因此高风险写入现已关闭,重新绑定后才能恢复。"
: "MFA 已禁用,当前 MFA 与邮箱重认证证明已撤销。");
? "MFA 已禁用。当前角色要求启用 MFA可随时重新绑定。"
: "MFA 已禁用,当前会话的 MFA 证明已撤销。");
}
if (loading && !status) {
@@ -153,7 +152,7 @@ export default function MfaSecurity() {
type="warning"
showIcon
message="当前角色强制要求 MFA"
description="在 MFA 绑定完成前,所有高风险写入均默认拒绝。"
description="这是账户级安全要求,不会再触发具体管理操作的邮箱验证码复核。"
/> : null}
<Card title="当前状态">
@@ -167,11 +166,6 @@ export default function MfaSecurity() {
<Descriptions.Item label="当前会话">
<Text type={status?.verified ? undefined : "secondary"}>{status?.verified ? "已验证" : "未验证"}</Text>
</Descriptions.Item>
<Descriptions.Item label="高风险写入前置能力">
<Text type={status?.highRiskWritesEnabled ? undefined : "danger"}>
{status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"}
</Text>
</Descriptions.Item>
</Descriptions>
</Card>
@@ -208,7 +202,7 @@ export default function MfaSecurity() {
</Card> : null}
{status?.enrolled && !status.verified ? <Card title="验证当前会话">
<Paragraph></Paragraph>
<Paragraph>使</Paragraph>
<Radio.Group value={factor} onChange={(event) => setFactor(event.target.value as Factor)}>
<Radio.Button value="totp"></Radio.Button>
<Radio.Button value="backup"></Radio.Button>
@@ -254,7 +248,7 @@ export default function MfaSecurity() {
<Divider />
<div>
<Title level={4}> MFA</Title>
<Paragraph type="secondary"> Better Auth MFA </Paragraph>
<Paragraph type="secondary"> Better Auth MFA </Paragraph>
<Form layout="inline" onFinish={disable}>
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
@@ -280,9 +280,8 @@ export default function PaymentManagement() {
<ReasonActionModal
open={Boolean(pendingEpaySettings)}
title="保存易支付设置"
okText="验证并保存"
okText="确认保存"
confirmLoading={epaySaving}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingEpaySettings(null)}
onSubmit={saveEpaySettings}
/>
@@ -280,9 +280,8 @@ export default function ProductManagement() {
<ReasonActionModal
open={Boolean(pendingSave)}
title="保存商品草稿"
okText="验证并保存"
okText="确认保存"
confirmLoading={saving}
reauthPermission="billing.products.write"
onCancel={() => setPendingSave(null)}
onSubmit={save}
/>
@@ -291,7 +290,6 @@ export default function ProductManagement() {
title={`发布商品${publishTarget ? `${publishTarget.name}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
reauthPermission="billing.products.publish"
onCancel={() => setPublishTarget(null)}
onSubmit={(reason) => publish(publishTarget!, reason)}
/>
@@ -1,136 +1,76 @@
"use client";
import { Alert, Button, Form, Input, Modal, Space } from "antd";
import { Alert, Form, Input, Modal } from "antd";
import { useState } from "react";
import type { AdminPermission } from "@/lib/admin/auth-policy";
type ReasonActionModalProps = {
open: boolean;
title: string;
okText: string;
confirmLoading?: boolean;
danger?: boolean;
reauthPermission?: AdminPermission;
confirmLoading?: boolean;
onCancel: () => void;
onSubmit: (reason: string) => void | Promise<void>;
onSubmit: (reason: string) => Promise<void> | void;
};
type FormValues = { reason: string; otp?: string };
async function adminSecurityRequest<T>(url: string, body: object): Promise<T> {
const response = await fetch(url, {
method: "POST",
cache: "no-store",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const value = await response.json().catch(() => null) as {
data?: T;
error?: unknown;
} | null;
if (response.ok && value?.data) return value.data;
throw new Error(
typeof value?.error === "string" ? value.error : "安全验证失败,请稍后再试",
);
}
type ReasonFormValues = {
reason: string;
};
export function ReasonActionModal({
open,
title,
okText,
confirmLoading = false,
danger = false,
reauthPermission,
confirmLoading = false,
onCancel,
onSubmit,
}: ReasonActionModalProps) {
const [form] = Form.useForm<FormValues>();
const [otpSent, setOtpSent] = useState(false);
const [reauthLoading, setReauthLoading] = useState(false);
const [reauthError, setReauthError] = useState<string>();
const [form] = Form.useForm<ReasonFormValues>();
const [actionLoading, setActionLoading] = useState(false);
const [actionError, setActionError] = useState<string>();
async function requestOtp() {
if (!reauthPermission) return;
setReauthLoading(true);
setReauthError(undefined);
async function submit(values: ReasonFormValues) {
setActionError(undefined);
setActionLoading(true);
try {
await adminSecurityRequest("/api/admin/reauth", {
action: "request",
permission: reauthPermission,
});
setOtpSent(true);
await onSubmit(values.reason.trim());
} catch (error) {
setReauthError(error instanceof Error ? error.message : "验证码发送失败");
setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试");
} finally {
setReauthLoading(false);
setActionLoading(false);
}
}
async function submit({ reason, otp }: FormValues) {
setReauthError(undefined);
setReauthLoading(true);
try {
if (reauthPermission) {
await adminSecurityRequest("/api/admin/reauth", {
action: "verify",
permission: reauthPermission,
otp,
});
}
await onSubmit(reason.trim());
} catch (error) {
setReauthError(error instanceof Error ? error.message : "操作失败,请稍后再试");
} finally {
setReauthLoading(false);
}
}
return <Modal
title={title}
open={open}
okText={okText}
cancelText="取消"
confirmLoading={confirmLoading || reauthLoading}
okButtonProps={{
danger,
disabled: Boolean(reauthPermission && !otpSent),
}}
onOk={() => form.submit()}
onCancel={onCancel}
afterOpenChange={(visible) => {
if (visible) form.resetFields();
setOtpSent(false);
setReauthError(undefined);
}}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={submit}>
<Form.Item name="reason" label="操作原因" rules={[{ required: true, whitespace: true }, { max: 500 }]}>
<Input.TextArea rows={3} showCount maxLength={500} autoFocus />
</Form.Item>
{reauthPermission ? <Form.Item
label="邮箱验证码"
extra="验证码发送到当前登录管理员邮箱,5 分钟内有效,且只授权本次操作权限。"
required
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="otp"
noStyle
rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}
>
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="请输入 6 位验证码" />
</Form.Item>
<Button type="default" loading={reauthLoading} onClick={() => void requestOtp()}>
{otpSent ? "重新发送" : "发送验证码"}
</Button>
</Space.Compact>
</Form.Item> : null}
{reauthError ? <Alert type="error" showIcon message={reauthError} /> : null}
</Form>
</Modal>;
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText="取消"
okButtonProps={{ danger }}
confirmLoading={confirmLoading || actionLoading}
onCancel={onCancel}
onOk={() => form.submit()}
afterOpenChange={(visible) => {
if (visible) form.resetFields();
setActionError(undefined);
}}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false} onFinish={submit}>
<Form.Item
label="操作原因"
name="reason"
rules={[
{ required: true, whitespace: true, message: "请输入操作原因" },
{ max: 500, message: "操作原因最多 500 字" },
]}
>
<Input.TextArea rows={3} maxLength={500} showCount autoFocus />
</Form.Item>
{actionError ? <Alert type="error" showIcon message={actionError} /> : null}
</Form>
</Modal>
);
}
+1 -95
View File
@@ -39,10 +39,6 @@ export type AdminAccessResult =
export const ADMIN_MFA_PROOF_COOKIE = "jyotisha-admin.mfa";
export const ADMIN_MFA_PROOF_TTL_MS = 10 * 60 * 1_000;
export const HIGH_RISK_ADMIN_CHALLENGE_COOKIE = "jyotisha-admin.reauth-challenge";
export const HIGH_RISK_ADMIN_CHALLENGE_TTL_MS = 5 * 60 * 1_000;
export const HIGH_RISK_ADMIN_PROOF_COOKIE = "jyotisha-admin.reauth";
export const HIGH_RISK_ADMIN_PROOF_TTL_MS = 5 * 60 * 1_000;
type AdminMfaProofContext = {
userId: string;
@@ -56,21 +52,10 @@ type AdminMfaProofClaims = AdminMfaProofContext & {
expiresAt: number;
};
type HighRiskAdminProofContext = AdminMfaProofContext & {
permission: AdminPermission;
};
type HighRiskAdminProofClaims = HighRiskAdminProofContext & {
version: 1;
issuedAt: number;
expiresAt: number;
};
export type AdminMfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
highRiskWritesEnabled: boolean;
};
export function authorizeAdminAccess(
@@ -167,7 +152,6 @@ export function resolveAdminMfaStatus(
required,
enrolled,
verified: currentSessionVerified,
highRiskWritesEnabled: !required || currentSessionVerified,
};
}
@@ -188,7 +172,7 @@ function signProof(
function encodeProof(
purpose: string,
claims: AdminMfaProofClaims | HighRiskAdminProofClaims,
claims: AdminMfaProofClaims,
proofSecret: string,
sessionToken: string,
): string {
@@ -255,81 +239,3 @@ export function verifyAdminMfaProof(
&& claims.issuedAt! <= now
&& now < claims.expiresAt!;
}
export function issueHighRiskAdminChallenge(
context: HighRiskAdminProofContext,
proofSecret: string,
sessionToken: string,
now = Date.now(),
): string {
return encodeProof("jyotisha-admin-reauth-challenge-v1", {
version: 1,
...context,
issuedAt: now,
expiresAt: now + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
} satisfies HighRiskAdminProofClaims, proofSecret, sessionToken);
}
export function verifyHighRiskAdminChallenge(
proof: string | undefined,
context: HighRiskAdminProofContext,
proofSecret: string,
sessionToken: string,
now = Date.now(),
): boolean {
const claims = decodeProof<HighRiskAdminProofClaims>(
proof,
"jyotisha-admin-reauth-challenge-v1",
proofSecret,
sessionToken,
);
return claims?.version === 1
&& claims.userId === context.userId
&& claims.sessionId === context.sessionId
&& claims.permission === context.permission
&& claims.origin === context.origin
&& Number.isSafeInteger(claims.issuedAt)
&& Number.isSafeInteger(claims.expiresAt)
&& claims.expiresAt! - claims.issuedAt! === HIGH_RISK_ADMIN_CHALLENGE_TTL_MS
&& claims.issuedAt! <= now
&& now < claims.expiresAt!;
}
export function issueHighRiskAdminProof(
context: HighRiskAdminProofContext,
proofSecret: string,
sessionToken: string,
now = Date.now(),
): string {
return encodeProof("jyotisha-admin-reauth-v1", {
version: 1,
...context,
issuedAt: now,
expiresAt: now + HIGH_RISK_ADMIN_PROOF_TTL_MS,
} satisfies HighRiskAdminProofClaims, proofSecret, sessionToken);
}
export function verifyHighRiskAdminProof(
proof: string | undefined,
context: HighRiskAdminProofContext,
proofSecret: string,
sessionToken: string,
now = Date.now(),
): boolean {
const claims = decodeProof<HighRiskAdminProofClaims>(
proof,
"jyotisha-admin-reauth-v1",
proofSecret,
sessionToken,
);
return claims?.version === 1
&& claims.userId === context.userId
&& claims.sessionId === context.sessionId
&& claims.permission === context.permission
&& claims.origin === context.origin
&& Number.isSafeInteger(claims.issuedAt)
&& Number.isSafeInteger(claims.expiresAt)
&& claims.expiresAt! - claims.issuedAt! === HIGH_RISK_ADMIN_PROOF_TTL_MS
&& claims.issuedAt! <= now
&& now < claims.expiresAt!;
}
-26
View File
@@ -9,11 +9,9 @@ import {
} from "./auth";
import {
ADMIN_MFA_PROOF_COOKIE,
HIGH_RISK_ADMIN_PROOF_COOKIE,
isTrustedAdminMutationRequest,
resolveAdminMfaStatus,
verifyAdminMfaProof,
verifyHighRiskAdminProof,
type AdminMfaStatus,
} from "./auth-policy";
import { adminErrorResponse } from "./admin-error-response";
@@ -86,30 +84,6 @@ export function readAdminMfaStatus(
return resolveAdminMfaStatus(session.requiresMfa, enrolled, verified);
}
export async function requireHighRiskAdminMutation(
request: Request,
permission: AdminPermission,
): Promise<AdminSession> {
const session = await requireAdminMutation(request, permission);
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 } : {}) },
@@ -16,7 +16,8 @@ test("admin account reset stays owner-only and explicit without email reauth", (
assert.match(users, /permissions\.includes\("admin\.users\.manage_roles"\)/);
assert.doesNotMatch(users, /reauthPermission=/);
assert.match(users, /登录身份、管理员角色、积分及账务审计记录会保留/);
assert.match(administratorsRoute, /requireHighRiskAdminMutation\(\s*request,\s*"admin\.users\.manage_roles",?\s*\)/);
assert.match(administratorsRoute, /requireAdminMutation\(\s*request,\s*"admin\.users\.manage_roles",?\s*\)/);
assert.doesNotMatch(administratorsRoute, /requireHighRiskAdminMutation/);
});
test("database reset mirrors the existing staging reset boundary and audits it", () => {
+13 -18
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import { GET as getAdminUnavailable } from "../src/app/admin-unavailable/route.ts";
@@ -23,7 +23,6 @@ const ownerRecoveryMigration = readFileSync(new URL("../db/migrations/2026080701
const bootstrapRoles = readFileSync(new URL("../../deploy/postgres/001-bootstrap-roles.sh", import.meta.url), "utf8");
const compatibilityRoles = readFileSync(new URL("../../deploy/postgres/002-ensure-business-compatibility-roles.sql", import.meta.url), "utf8");
const administratorsRoute = readFileSync(new URL("../src/app/api/admin/administrators/route.ts", import.meta.url), "utf8");
const reauthRoute = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8");
const mfaRoute = readFileSync(new URL("../src/app/api/admin/mfa/route.ts", import.meta.url), "utf8");
const mfaSecurity = readFileSync(new URL("../src/components/admin/mfa-security.tsx", import.meta.url), "utf8");
const reasonActionModal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
@@ -51,8 +50,10 @@ test("admin APIs use persisted Better Auth roles with admin-only boundaries", ()
assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/);
assert.match(authBoundary, /readAuthProvider\(\)\?\.trim\(\) !== "self-hosted"/);
assert.match(authBoundary, /后台服务暂时不可用", 503/);
assert.match(codesRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.match(codeRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/g);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
assert.doesNotMatch(codesRoute, /requireHighRiskAdminMutation/);
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
});
test("self-hosted account entry uses the database permission graph", () => {
@@ -230,20 +231,15 @@ test("Refine dependencies and same-origin admin data provider are present", () =
assert.doesNotMatch(providers, /https?:\/\//);
});
test("administrator writes require scoped email OTP reauthentication without mandatory MFA", () => {
test("administrator writes keep permission and origin checks without operation-level email OTP", () => {
assert.match(adminHttp, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
assert.match(administratorsRoute, /requireHighRiskAdminMutation\(request, "admin\.users\.manage_roles"\)/);
assert.match(adminHttp, /requireAdminMutation\(request, permission\)[\s\S]*verifyHighRiskAdminProof/);
assert.doesNotMatch(adminHttp, /requireAdminMfaIfRequired/);
assert.match(reauthRoute, /requireAdminMutation\(request, parsed\.data\.permission\)[\s\S]*sendVerificationOTP/);
assert.doesNotMatch(reauthRoute, /requireAdminMfaIfRequired/);
assert.match(reauthRoute, /sendVerificationOTP/);
assert.match(reauthRoute, /verifyEmailOTP/);
assert.match(reauthRoute, /httpOnly: true/);
assert.match(reasonActionModal, /action: "request"/);
assert.match(reasonActionModal, /action: "verify"/);
assert.match(reasonActionModal, /邮箱验证码/);
assert.doesNotMatch(reasonActionModal, /\/api\/admin\/mfa|\/admin\/security|MfaStatus|mfaFactor|mfaReady/);
assert.match(administratorsRoute, /requireAdminMutation\(request, "admin\.users\.manage_roles"\)/);
assert.doesNotMatch(adminHttp, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
assert.doesNotMatch(administratorsRoute, /requireHighRiskAdminMutation/);
assert.equal(existsSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url)), false);
assert.match(reasonActionModal, /label="操作原因"/);
assert.match(reasonActionModal, /await onSubmit\(values\.reason\.trim\(\)\)/);
assert.doesNotMatch(reasonActionModal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
assert.match(authFactory, /twoFactor\(/);
assert.match(authFactory, /schema: identityModelMapping\.twoFactor/);
@@ -255,7 +251,6 @@ test("administrator writes require scoped email OTP reauthentication without man
assert.match(mfaRoute, /disableTwoFactor/);
assert.match(mfaSecurity, /\/api\/admin\/mfa/);
assert.doesNotMatch(administratorsRoute, /ADMIN_MFA_CAPABLE|process\.env/);
assert.doesNotMatch(reauthRoute, /ADMIN_MFA_CAPABLE|process\.env/);
});
test("customer birth data uses a narrow permission, is masked by default, and audits sensitive reads", () => {
+18 -16
View File
@@ -29,20 +29,26 @@ test("MFA proof is server-signed, short-lived, and bound to the current user, se
assert.equal(verifyAdminMfaProof(proof, context, proofSecret, sessionToken, now + ADMIN_MFA_PROOF_TTL_MS), false);
});
test("highRiskWritesEnabled means the current session has the MFA prerequisite, not email proof", () => {
test("MFA status only reports account-level enrollment and current-session verification", () => {
assert.deepEqual(resolveAdminMfaStatus(false, false, false), {
required: false,
enrolled: false,
verified: false,
highRiskWritesEnabled: true,
});
assert.equal(resolveAdminMfaStatus(true, false, false).highRiskWritesEnabled, false);
assert.equal(resolveAdminMfaStatus(true, true, false).highRiskWritesEnabled, false);
assert.deepEqual(resolveAdminMfaStatus(true, false, false), {
required: true,
enrolled: false,
verified: false,
});
assert.deepEqual(resolveAdminMfaStatus(true, true, false), {
required: true,
enrolled: true,
verified: false,
});
assert.deepEqual(resolveAdminMfaStatus(true, true, true), {
required: true,
enrolled: true,
verified: true,
highRiskWritesEnabled: true,
});
});
@@ -62,24 +68,20 @@ test("MFA API keeps native Better Auth enrollment, recovery, rotation, and proof
assert.match(route, /copyNativeCookies\(nativeResponse, response\)/);
assert.match(route, /requireVerifiedMfa\(request, session\)/);
assert.match(route, /ADMIN_MFA_PROOF_COOKIE/);
assert.match(route, /HIGH_RISK_ADMIN_PROOF_COOKIE/);
assert.doesNotMatch(route, /HIGH_RISK_ADMIN_PROOF_COOKIE/);
assert.match(route, /adminProofSigningSecret\(\)/);
assert.doesNotMatch(route, /console\.|logger|log.*(?:seed|secret|backup|totp)|(?:seed|secret|backup|totp).*log/i);
assert.match(migration, /revoke all on table identity\.two_factors[\s\S]*admin_runtime/);
assert.match(migration, /grant select, insert, update, delete on table identity\.two_factors[\s\S]*identity_runtime/);
});
test("high-risk UI uses permission-scoped email OTP without requiring MFA", () => {
test("business confirmation UI requires only an audit reason while account MFA remains separate", () => {
const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
const reauth = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8");
const http = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8");
assert.match(modal, /action: "request"/);
assert.match(modal, /action: "verify"/);
assert.match(modal, /邮箱验证码/);
assert.doesNotMatch(modal, /\/api\/admin\/mfa|\/admin\/security|MfaStatus|mfaFactor|mfaReady/);
assert.match(reauth, /requireAdminMutation\(request, parsed\.data\.permission\)[\s\S]*getIdentityEmailOtpApi/);
assert.doesNotMatch(reauth, /requireAdminMfaIfRequired/);
assert.match(http, /requireAdminMutation\(request, permission\)[\s\S]*verifyHighRiskAdminProof/);
assert.doesNotMatch(http, /requireAdminMfaIfRequired/);
assert.match(modal, /label="操作原因"/);
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
assert.doesNotMatch(modal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
assert.match(http, /requireAdminMutation/);
assert.doesNotMatch(http, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
});
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { extname, join } from "node:path";
import test from "node:test";
const root = new URL("../", import.meta.url);
function source(path: string) {
return readFileSync(new URL(path, root), "utf8");
}
function sourceFiles(path: string): string[] {
const directory = new URL(path, root);
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const child = join(directory.pathname, entry.name);
if (entry.isDirectory()) {
return sourceFiles(`${path}/${entry.name}`);
}
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
});
}
test("operation-level admin email reauthentication is removed from routes and UI", () => {
assert.equal(existsSync(new URL("src/app/api/admin/reauth/route.ts", root)), false);
const adminSources = [
...sourceFiles("src/app/api/admin"),
...sourceFiles("src/components/admin"),
...sourceFiles("src/lib/admin"),
];
for (const file of adminSources) {
const contents = readFileSync(file, "utf8");
assert.doesNotMatch(
contents,
/requireHighRiskAdminMutation|reauthPermission|\/api\/admin\/reauth|HIGH_RISK_ADMIN|issueHighRisk|verifyHighRisk/,
file,
);
}
});
test("admin mutations retain session, permission, trusted-origin, reason, request-id, and audit boundaries", () => {
const http = source("src/lib/admin/http.ts");
const codesRoute = source("src/app/api/admin/codes/route.ts");
const codesUi = source("src/components/admin/codes-resource.tsx");
const modal = source("src/components/admin/reason-action-modal.tsx");
assert.match(http, /requirePermission\(permission, request\.headers\)/);
assert.match(http, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
assert.match(codesRoute, /requireAdminMutation\([\s\S]*"billing\.adjustments\.write"/);
assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
assert.match(codesRoute, /requestId\(request\)/);
assert.match(codesRoute, /admin_create_redemption_codes/);
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
assert.match(codesUi, /okText="确认生成"/);
assert.match(modal, /label="操作原因"/);
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
});
test("account-level TOTP MFA and customer login email OTP remain available", () => {
const mfaRoute = source("src/app/api/admin/mfa/route.ts");
const mfaUi = source("src/components/admin/mfa-security.tsx");
const loginUi = source("src/components/email-otp-login.tsx");
assert.match(mfaRoute, /verifyTOTP|verifyBackupCode/);
assert.match(mfaUi, /认证器验证码/);
assert.match(loginUi, /邮箱验证码登录/);
assert.match(loginUi, /验证并登录/);
});
-110
View File
@@ -1,110 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
authorizeAdminAccess,
HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
issueHighRiskAdminChallenge,
issueHighRiskAdminProof,
isSameOriginAdminMutation,
verifyHighRiskAdminChallenge,
verifyHighRiskAdminProof,
} from "../src/lib/admin/auth-policy.ts";
import type { IdentityUser } from "../src/modules/identity/contracts.ts";
const now = 1_786_000_000_000;
const context = {
userId: "11111111-1111-4111-8111-111111111111",
sessionId: "22222222-2222-4222-8222-222222222222",
permission: "admin.users.manage_roles" as const,
origin: "https://admin.staging.jyotisha.chat",
};
const sessionToken = "better-auth-session-token-held-server-side";
const proofSecret = "admin-proof-secret-held-only-by-the-server";
function user(): IdentityUser {
return {
id: context.userId,
email: "admin@example.com",
emailVerified: true,
name: "Admin",
image: null,
role: ["user"],
twoFactorEnabled: false,
};
}
test("email OTP challenge is short-lived and bound to the requested permission and session", () => {
const challenge = issueHighRiskAdminChallenge(context, proofSecret, sessionToken, now);
assert.equal(
verifyHighRiskAdminChallenge(challenge, context, proofSecret, sessionToken, now + 1_000),
true,
);
assert.equal(verifyHighRiskAdminChallenge(
challenge,
{ ...context, permission: "billing.products.publish" },
proofSecret,
sessionToken,
now + 1_000,
), false);
assert.equal(verifyHighRiskAdminChallenge(
challenge,
context,
proofSecret,
"rotated-session-token",
now + 1_000,
), false);
assert.equal(verifyHighRiskAdminChallenge(
challenge,
context,
proofSecret,
sessionToken,
now + HIGH_RISK_ADMIN_CHALLENGE_TTL_MS,
), false);
});
test("high-risk proof succeeds only for its live Better Auth session", () => {
const proof = issueHighRiskAdminProof(context, proofSecret, sessionToken, now);
assert.equal(verifyHighRiskAdminProof(proof, context, proofSecret, sessionToken, now + 1_000), true);
assert.equal(verifyHighRiskAdminProof(proof, { ...context, sessionId: "wrong-session" }, proofSecret, sessionToken, now + 1_000), false);
assert.equal(verifyHighRiskAdminProof(
proof,
{ ...context, permission: "billing.products.publish" },
proofSecret,
sessionToken,
now + 1_000,
), false);
assert.equal(verifyHighRiskAdminProof(proof, context, "wrong-server-secret-that-is-long-enough", sessionToken, now + 1_000), false);
assert.equal(verifyHighRiskAdminProof(proof, context, proofSecret, sessionToken, now + 300_000), false);
});
test("high-risk authorization rejects missing permission and cross-origin requests", () => {
assert.deepEqual(authorizeAdminAccess(user(), [], context.permission), { allowed: false, status: 403 });
assert.equal(isSameOriginAdminMutation(context.origin, `${context.origin}/api/admin/reauth`), true);
assert.equal(isSameOriginAdminMutation("https://evil.example", `${context.origin}/api/admin/reauth`), false);
assert.equal(isSameOriginAdminMutation(null, `${context.origin}/api/admin/reauth`), false);
});
test("reauth route consumes Better Auth email OTP and sets a scoped HttpOnly proof", () => {
const route = readFileSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url), "utf8");
const modal = readFileSync(new URL("../src/components/admin/reason-action-modal.tsx", import.meta.url), "utf8");
assert.match(route, /sendVerificationOTP/);
assert.match(route, /verifyEmailOTP/);
assert.match(route, /type: "email-verification"/);
assert.match(route, /issueHighRiskAdminChallenge/);
assert.match(route, /verifyHighRiskAdminChallenge/);
assert.match(route, /HIGH_RISK_ADMIN_CHALLENGE_COOKIE/);
assert.match(route, /verifyHighRiskAdminChallenge[\s\S]*verifyEmailOTP/);
assert.match(route, /httpOnly: true/);
assert.match(route, /sameSite: "strict"/);
assert.match(route, /secure: true/);
assert.match(route, /path: "\/api\/admin"/);
assert.match(route, /adminProofSigningSecret\(\)/);
assert.doesNotMatch(route, /console\.|otp.*log|log.*otp/i);
assert.match(modal, /reauthPermission/);
assert.match(modal, /action: "request"/);
assert.match(modal, /action: "verify"/);
assert.match(modal, /邮箱验证码/);
});
@@ -4,7 +4,7 @@ import test from "node:test";
const source = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
const highRiskMappings = [
const mutationMappings = [
{
name: "商品保存",
ui: "src/components/admin/product-management.tsx",
@@ -61,15 +61,16 @@ const highRiskMappings = [
},
] as const;
test("high-risk UI reauth permissions match their API guards", async (t) => {
for (const mapping of highRiskMappings) {
test("admin mutation UI permissions match ordinary API guards without operation reauth", async (t) => {
for (const mapping of mutationMappings) {
await t.test(mapping.name, () => {
const ui = source(mapping.ui);
const api = source(mapping.api);
assert.match(ui, /ReasonActionModal/);
assert.match(ui, new RegExp(`reauthPermission=[\\s\\S]{0,80}["']${mapping.permission.replaceAll(".", "\\.")}["']|reauthPermission="${mapping.permission.replaceAll(".", "\\.")}"`));
assert.match(api, new RegExp(`requireHighRiskAdminMutation\\(\\s*request,\\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\\s*\\)`));
assert.match(ui, new RegExp(`permissions\\.includes\\(["']${mapping.permission.replaceAll(".", "\\.")}["']\\)`));
assert.doesNotMatch(ui, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
assert.match(api, new RegExp(String.raw`requireAdminMutation\(\s*request,\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\s*\)`));
assert.doesNotMatch(api, /requireHighRiskAdminMutation/);
assert.match(ui, new RegExp(String.raw`permissions\.includes\(["']${mapping.permission.replaceAll(".", "\\.")}["']\)`));
});
}
});
@@ -122,9 +123,10 @@ test("admin request failures are real Error instances and reason modal keeps fai
/Object\.assign\(new Error\(message\),\s*\{\s*statusCode: response\.status,/,
);
assert.doesNotMatch(providers, /throw \{ message, statusCode/);
assert.match(modal, /catch \(error\)[\s\S]*setReauthError\(error instanceof Error \? error\.message/);
assert.match(modal, /catch \(error\)[\s\S]*setActionError\(error instanceof Error \? error\.message/);
assert.match(modal, /name="reason"[\s\S]*required: true, whitespace: true/);
assert.match(modal, /await onSubmit\(reason\.trim\(\)\)/);
assert.match(modal, /await onSubmit\(values\.reason\.trim\(\)\)/);
assert.doesNotMatch(modal, /reauth|邮箱验证码|发送验证码/);
});
test("customer list is always masked and a single explicit reveal is audited each time", () => {
+2 -1
View File
@@ -22,7 +22,8 @@ test("admin surfaces await database-backed administrator checks", () => {
assert.match(adminSource, /export async function isAdminUser/);
assert.match(adminSource, /admin_has_permission\(\$1, 'admin\.access'\)/);
assert.match(sessionSource, /await requirePermission\("admin\.access"\)/);
assert.match(codesSource, /await requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.doesNotMatch(codesSource, /requireHighRiskAdminMutation/);
assert.match(accountSource, /const isAdmin = await isAdminUser\(user\)/);
assert.match(accountSource, /isAdmin,/);
});
+2 -1
View File
@@ -124,7 +124,8 @@ test("公网网关解析拒绝混入私网地址且每次校验只解析一次",
test("管理员 API 不回显任何密钥并强制首次显式录入", () => {
assert.match(route, /requirePermission\("billing\.orders\.read"\)/);
assert.match(route, /requireHighRiskAdminMutation\(request, "billing\.adjustments\.write"\)/);
assert.match(route, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
assert.match(route, /\.strict\(\)/);
assert.match(route, /crypto\.randomUUID\(\)/);
assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/);
@@ -38,15 +38,18 @@ test("existing payment orders remain queryable and settleable when subscriptions
assert.match(notifyRoute, /createEpayNotifyHandler[\s\S]*settle_order/);
});
test("billing and operations high-risk writes use the shared mutation guard", () => {
assert.match(productsRoute, /requireHighRiskAdminMutation\(request, permission\)/);
test("billing and operations writes use the shared ordinary mutation guard", () => {
assert.match(productsRoute, /requireAdminMutation\(request, permission\)/);
assert.match(productsRoute, /body\.data\.reason[\s\S]*requestId/);
assert.match(epaySettingsRoute, /requireHighRiskAdminMutation\(request, "billing\.adjustments\.write"\)/);
assert.match(epaySettingsRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/);
assert.match(epaySettingsRoute, /admin_save_epay_settings/);
assert.match(subscriptionsRoute, /requireHighRiskAdminMutation\(request,"billing\.adjustments\.write"\)/);
assert.match(subscriptionsRoute, /requireAdminMutation\(request,"billing\.adjustments\.write"\)/);
assert.match(subscriptionsRoute, /body\.data\.reason,rid/);
assert.match(featureFlagsRoute, /action==="publish"\?await requireHighRiskAdminMutation\(request,"ops\.flags\.write"\):await requireAdminMutation/);
assert.match(featureFlagsRoute, /requireAdminMutation\(request,"ops\.flags\.write"\)/);
assert.match(featureFlagsRoute, /admin_publish_feature_flag[\s\S]*b\.data\.reason,rid/);
for (const route of [productsRoute, epaySettingsRoute, subscriptionsRoute, featureFlagsRoute]) {
assert.doesNotMatch(route, /requireHighRiskAdminMutation/);
}
});
test("model provider changes and release mutations use the ordinary admin guard", () => {
@@ -71,10 +74,10 @@ test("self-hosted payment catalog uses simple queries and immutable product snap
assert.match(createRoute, /product_snapshot:\s*productSnapshot/);
});
test("order adjustments require reauth, reason, version, idempotency request id, and the domain RPC", () => {
test("order adjustments require permission, reason, version, idempotency request id, and the domain RPC", () => {
assert.match(
ordersRoute,
/requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/,
/requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/,
);
assert.match(ordersRoute, /expectedVersion:\s*z\.number\(\)\.int\(\)\.min\(0\)/);
assert.match(ordersRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
@@ -85,13 +88,14 @@ test("order adjustments require reauth, reason, version, idempotency request id,
assert.match(billingOperationsUi, /retry_grant/);
assert.match(billingOperationsUi, /compensate/);
assert.match(billingOperationsUi, /record_refund/);
assert.match(billingOperationsUi, /reauthPermission="billing\.adjustments\.write"/);
assert.doesNotMatch(billingOperationsUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
assert.match(billingOperationsUi, /不调用支付网关|仅记录账务/);
});
test("every redemption-code write requires a reason and forwards it to the audited RPC", () => {
assert.match(codesRoute, /requireHighRiskAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.equal((codeRoute.match(/requireHighRiskAdminMutation\(/g) ?? []).length, 2);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
assert.match(codesRoute, /reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
assert.match(codesRoute, /p_reason:\s*parsed\.data\.reason/);
assert.match(codeRoute, /const revokeCodeSchema[\s\S]*reason:\s*z\.string\(\)\.trim\(\)\.min\(1\)\.max\(500\)/);
@@ -104,7 +108,7 @@ test("every redemption-code write requires a reason and forwards it to the audit
assert.match(codesHelper, /public\.admin_revoke_redemption_code\(/);
assert.doesNotMatch(codesHelper, /createAdminSupabaseClient|\.rpc\(|\$\{functionName\}/);
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
assert.equal((codesUi.match(/reauthPermission="billing\.adjustments\.write"/g) ?? []).length, 3);
assert.doesNotMatch(codesUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
assert.match(codesUi, /open=\{Boolean\(pendingCreate\)\}[\s\S]*onSubmit=\{submitCreate\}/);
assert.match(codesUi, /open=\{Boolean\(pendingEdit\)\}[\s\S]*onSubmit=\{submitEdit\}/);
assert.match(codesUi, /<ReasonActionModal[\s\S]*onSubmit=\{\(reason\) => revoke\(revokeRecord!, reason\)\}/);