diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6d1725af..f1a6aed4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3465,3 +3465,18 @@ - 防复发:首页业务入口不得把 API 失败只写入未渲染状态;本地资料完整与服务端资料不一致时必须提供可见错误和恢复动作,不能静默返回。 - 相关记录:BUG-177、BUG-198、BUG-199 - 修复版本:本地未提交候选 + +## BUG-207 | 管理端业务操作重复要求邮箱验证码复核 + +- 状态:resolved(本地修复,未提交、未发布) +- 首次发现:2026-08-16 +- 最近更新:2026-08-16 +- 影响面:管理端兑换码生成/编辑/撤销、管理员角色变更、账务与订阅调整、商品保存/发布、功能开关发布、易支付设置等写操作。 +- 用户现象:管理员已经登录后台并具备对应权限,执行批量生成兑换码等日常营销操作时仍需发送并等待邮箱验证码,造成重复验证与操作中断。 +- 根因:多个管理写入被统一接入 operation-level `requireHighRiskAdminMutation`,公共 `ReasonActionModal` 又内置权限级邮箱 OTP challenge/proof,导致账户登录安全与每次业务操作二次验证被错误叠加到所有管理业务。 +- 修复:所有管理业务写入统一改用 `requireAdminMutation`,继续强制管理员会话、细粒度权限与可信 Origin;公共确认弹窗只保留必填操作原因与确认状态;删除 `/api/admin/reauth`、high-risk challenge/proof cookie 及相关 UI 参数。兑换码批量生成改为“确认生成”。 +- 安全边界:保留 request ID、数据库权限检查、领域 RPC、append-only 审计、最后一位 Owner 保护;保留账户级 Better Auth TOTP MFA 和一次性恢复码;普通用户登录、注册与找回密码的邮箱 OTP 不受影响。 +- 验证:新增全局合同扫描,锁定管理路由、组件和公共库中不得恢复 operation-level reauth;兑换码、角色、账务、商品、功能开关、易支付、MFA 与普通登录 OTP 聚焦测试 75/75 通过,`tsc --noEmit`、目标 ESLint 与 `git diff --check` 通过。 +- 防复发:新增管理业务时只能在登录、权限、Origin、原因和审计边界内扩展;不得把邮箱 OTP 放回公共业务确认弹窗或新建操作级 reauth API。账户级 MFA 与普通用户身份验证必须保持独立。 +- 相关记录:BUG-155、BUG-156 +- 修复版本:本地未提交候选 diff --git a/frontend/src/app/api/admin/administrators/route.ts b/frontend/src/app/api/admin/administrators/route.ts index 7314acd9..94d8167b 100644 --- a/frontend/src/app/api/admin/administrators/route.ts +++ b/frontend/src/app/api/admin/administrators/route.ts @@ -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 diff --git a/frontend/src/app/api/admin/codes/[id]/route.ts b/frontend/src/app/api/admin/codes/[id]/route.ts index 7d18f679..12d896b2 100644 --- a/frontend/src/app/api/admin/codes/[id]/route.ts +++ b/frontend/src/app/api/admin/codes/[id]/route.ts @@ -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", ); diff --git a/frontend/src/app/api/admin/codes/route.ts b/frontend/src/app/api/admin/codes/route.ts index 5e7c3515..071dca80 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -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", ); diff --git a/frontend/src/app/api/admin/epay-settings/route.ts b/frontend/src/app/api/admin/epay-settings/route.ts index 10e731ef..e7de29c0 100644 --- a/frontend/src/app/api/admin/epay-settings/route.ts +++ b/frontend/src/app/api/admin/epay-settings/route.ts @@ -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 { diff --git a/frontend/src/app/api/admin/feature-flags/route.ts b/frontend/src/app/api/admin/feature-flags/route.ts index d5b709df..57e38ae4 100644 --- a/frontend/src/app/api/admin/feature-flags/route.ts +++ b/frontend/src/app/api/admin/feature-flags/route.ts @@ -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;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(`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)}} diff --git a/frontend/src/app/api/admin/mfa/route.ts b/frontend/src/app/api/admin/mfa/route.ts index 58471699..8abf7f7f 100644 --- a/frontend/src/app/api/admin/mfa/route.ts +++ b/frontend/src/app/api/admin/mfa/route.ts @@ -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); diff --git a/frontend/src/app/api/admin/orders/route.ts b/frontend/src/app/api/admin/orders/route.ts index 3d36471b..ee8c0a2c 100644 --- a/frontend/src/app/api/admin/orders/route.ts +++ b/frontend/src/app/api/admin/orders/route.ts @@ -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", ); diff --git a/frontend/src/app/api/admin/products/route.ts b/frontend/src/app/api/admin/products/route.ts index 5c909f63..4e2748f7 100644 --- a/frontend/src/app/api/admin/products/route.ts +++ b/frontend/src/app/api/admin/products/route.ts @@ -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]); diff --git a/frontend/src/app/api/admin/reauth/route.ts b/frontend/src/app/api/admin/reauth/route.ts deleted file mode 100644 index 8697b640..00000000 --- a/frontend/src/app/api/admin/reauth/route.ts +++ /dev/null @@ -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); - } -} diff --git a/frontend/src/app/api/admin/subscriptions/route.ts b/frontend/src/app/api/admin/subscriptions/route.ts index 77fa6e78..b9d755ad 100644 --- a/frontend/src/app/api/admin/subscriptions/route.ts +++ b/frontend/src/app/api/admin/subscriptions/route.ts @@ -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);} diff --git a/frontend/src/components/admin/administrators-resource.tsx b/frontend/src/components/admin/administrators-resource.tsx index 9b0012f4..8ecb330e 100644 --- a/frontend/src/components/admin/administrators-resource.tsx +++ b/frontend/src/components/admin/administrators-resource.tsx @@ -100,7 +100,7 @@ export default function AdministratorsResource() { extra={canManage ? : 只读权限} > - 六类系统角色可在此分配和撤销。每次变更都需要邮箱验证码与操作原因;最后一位 Owner 受服务端保护,不能被撤销。 + 六类系统角色可在此分配和撤销。每次变更都需要填写操作原因并写入审计日志;最后一位 Owner 受服务端保护,不能被撤销。
@@ -154,10 +154,9 @@ export default function AdministratorsResource() { setPendingAction(null)} onSubmit={submitRoleAction} /> diff --git a/frontend/src/components/admin/billing-operations-resources.tsx b/frontend/src/components/admin/billing-operations-resources.tsx index 04c3016d..6370dd8c 100644 --- a/frontend/src/components/admin/billing-operations-resources.tsx +++ b/frontend/src/components/admin/billing-operations-resources.tsx @@ -206,9 +206,8 @@ export function SubscriptionsResource() { 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} /> diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx index e34a9f43..62d90de4 100644 --- a/frontend/src/components/admin/codes-resource.tsx +++ b/frontend/src/components/admin/codes-resource.tsx @@ -319,18 +319,16 @@ export default function CodesPage() { setPendingCreate(null)} onSubmit={submitCreate} /> 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)} /> diff --git a/frontend/src/components/admin/feature-flags-management.tsx b/frontend/src/components/admin/feature-flags-management.tsx index ac889177..37139034 100644 --- a/frontend/src/components/admin/feature-flags-management.tsx +++ b/frontend/src/components/admin/feature-flags-management.tsx @@ -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)} /> diff --git a/frontend/src/components/admin/mfa-security.tsx b/frontend/src/components/admin/mfa-security.tsx index cd3ce357..2935182a 100644 --- a/frontend/src/components/admin/mfa-security.tsx +++ b/frontend/src/components/admin/mfa-security.tsx @@ -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} @@ -167,11 +166,6 @@ export default function MfaSecurity() { {status?.verified ? "已验证" : "未验证"} - - - {status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"} - - @@ -208,7 +202,7 @@ export default function MfaSecurity() { : null} {status?.enrolled && !status.verified ? - 高权限操作前,先用认证器验证码或一次性恢复码完成真实第二因素。 + 可使用认证器验证码或一次性恢复码验证当前管理员会话。 setFactor(event.target.value as Factor)}> 认证器验证码 恢复码 @@ -254,7 +248,7 @@ export default function MfaSecurity() {
禁用 MFA - 禁用会轮换 Better Auth 会话,并撤销当前 MFA 与邮箱重认证证明。 + 禁用会轮换 Better Auth 会话,并撤销当前会话的 MFA 证明。 diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx index 35f329d6..b801c0c9 100644 --- a/frontend/src/components/admin/payment-management.tsx +++ b/frontend/src/components/admin/payment-management.tsx @@ -280,9 +280,8 @@ export default function PaymentManagement() { setPendingEpaySettings(null)} onSubmit={saveEpaySettings} /> diff --git a/frontend/src/components/admin/product-management.tsx b/frontend/src/components/admin/product-management.tsx index 4c1ad5f0..9b8d6366 100644 --- a/frontend/src/components/admin/product-management.tsx +++ b/frontend/src/components/admin/product-management.tsx @@ -280,9 +280,8 @@ export default function ProductManagement() { 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)} /> diff --git a/frontend/src/components/admin/reason-action-modal.tsx b/frontend/src/components/admin/reason-action-modal.tsx index e1173035..b71f29d9 100644 --- a/frontend/src/components/admin/reason-action-modal.tsx +++ b/frontend/src/components/admin/reason-action-modal.tsx @@ -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; + onSubmit: (reason: string) => Promise | void; }; -type FormValues = { reason: string; otp?: string }; - -async function adminSecurityRequest(url: string, body: object): Promise { - 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(); - const [otpSent, setOtpSent] = useState(false); - const [reauthLoading, setReauthLoading] = useState(false); - const [reauthError, setReauthError] = useState(); + const [form] = Form.useForm(); + const [actionLoading, setActionLoading] = useState(false); + const [actionError, setActionError] = useState(); - 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 form.submit()} - onCancel={onCancel} - afterOpenChange={(visible) => { - if (visible) form.resetFields(); - setOtpSent(false); - setReauthError(undefined); - }} - destroyOnHidden - > - - - - - - {reauthPermission ? - - - - - - - : null} - - {reauthError ? : null} - - ; + return ( + form.submit()} + afterOpenChange={(visible) => { + if (visible) form.resetFields(); + setActionError(undefined); + }} + destroyOnHidden + > +
+ + + + {actionError ? : null} + +
+ ); } diff --git a/frontend/src/lib/admin/auth-policy.ts b/frontend/src/lib/admin/auth-policy.ts index 32cf0710..bcb86ca6 100644 --- a/frontend/src/lib/admin/auth-policy.ts +++ b/frontend/src/lib/admin/auth-policy.ts @@ -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( - 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( - 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!; -} diff --git a/frontend/src/lib/admin/http.ts b/frontend/src/lib/admin/http.ts index a2c06b33..2c9ed490 100644 --- a/frontend/src/lib/admin/http.ts +++ b/frontend/src/lib/admin/http.ts @@ -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 { - 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 } : {}) }, diff --git a/frontend/tests/admin-account-reset-contract.test.ts b/frontend/tests/admin-account-reset-contract.test.ts index 03436afe..affcaf99 100644 --- a/frontend/tests/admin-account-reset-contract.test.ts +++ b/frontend/tests/admin-account-reset-contract.test.ts @@ -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", () => { diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 0a392bce..8bef5f57 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -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", () => { diff --git a/frontend/tests/admin-mfa.test.ts b/frontend/tests/admin-mfa.test.ts index 5dcfaa46..e0e73842 100644 --- a/frontend/tests/admin-mfa.test.ts +++ b/frontend/tests/admin-mfa.test.ts @@ -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/); }); diff --git a/frontend/tests/admin-operation-reauth-removal.test.ts b/frontend/tests/admin-operation-reauth-removal.test.ts new file mode 100644 index 00000000..3d7ff304 --- /dev/null +++ b/frontend/tests/admin-operation-reauth-removal.test.ts @@ -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, /验证并登录/); +}); diff --git a/frontend/tests/admin-reauth.test.ts b/frontend/tests/admin-reauth.test.ts deleted file mode 100644 index b9e00ab0..00000000 --- a/frontend/tests/admin-reauth.test.ts +++ /dev/null @@ -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, /邮箱验证码/); -}); diff --git a/frontend/tests/admin-ui-permission-contract.test.ts b/frontend/tests/admin-ui-permission-contract.test.ts index 62e9eae9..659e9c5a 100644 --- a/frontend/tests/admin-ui-permission-contract.test.ts +++ b/frontend/tests/admin-ui-permission-contract.test.ts @@ -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", () => { diff --git a/frontend/tests/admin-users-contract.test.ts b/frontend/tests/admin-users-contract.test.ts index a637710f..360eedcd 100644 --- a/frontend/tests/admin-users-contract.test.ts +++ b/frontend/tests/admin-users-contract.test.ts @@ -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,/); }); diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts index 5df397fb..a07830d7 100644 --- a/frontend/tests/epay-settings.test.ts +++ b/frontend/tests/epay-settings.test.ts @@ -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, /首次保存数据库配置时必须输入新的商户密钥/); diff --git a/frontend/tests/high-risk-billing-routes-contract.test.ts b/frontend/tests/high-risk-billing-routes-contract.test.ts index b64ef36c..b6b640d2 100644 --- a/frontend/tests/high-risk-billing-routes-contract.test.ts +++ b/frontend/tests/high-risk-billing-routes-contract.test.ts @@ -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, / revoke\(revokeRecord!, reason\)\}/);