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