Files
Jyotisha/frontend/src/components/admin/mfa-security.tsx
T
Jesse_Chen 4f6cf5782d
Independent Staging Quality Gate / validate (push) Successful in 12m37s
Independent Staging Quality Gate / publish (push) Successful in 10m0s
fix(admin): remove operation-level email reauthentication
2026-08-16 14:07:57 +08:00

273 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Alert,
Button,
Card,
Descriptions,
Divider,
Form,
Input,
List,
Radio,
Space,
Spin,
Typography,
} from "antd";
import { useEffect, useState } from "react";
const { Paragraph, Text, Title } = Typography;
type MfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
expiresIn?: number;
};
type Enrollment = {
totpUri: string;
backupCodes: string[];
verificationRequired: true;
};
type Factor = "totp" | "backup";
async function mfaRequest<T>(body?: object): Promise<T> {
const response = await fetch("/api/admin/mfa", {
method: body ? "POST" : "GET",
cache: "no-store",
credentials: "same-origin",
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json().catch(() => null) as { data?: T; error?: unknown } | null;
if (!response.ok || !payload?.data) {
throw new Error(typeof payload?.error === "string" ? payload.error : "MFA 请求失败,请稍后再试");
}
return payload.data;
}
export default function MfaSecurity() {
const [status, setStatus] = useState<MfaStatus>();
const [enrollment, setEnrollment] = useState<Enrollment>();
const [backupCodes, setBackupCodes] = useState<string[]>();
const [factor, setFactor] = useState<Factor>("totp");
const [loading, setLoading] = useState(true);
const [action, setAction] = useState<string>();
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
useEffect(() => {
let cancelled = false;
void mfaRequest<MfaStatus>()
.then((value) => {
if (!cancelled) setStatus(value);
})
.catch((cause: unknown) => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : "无法读取 MFA 状态");
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
async function run<T>(name: string, operation: () => Promise<T>): Promise<T | undefined> {
setAction(name);
setError(undefined);
setNotice(undefined);
try {
return await operation();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "操作失败,请稍后再试");
} finally {
setAction(undefined);
}
}
async function enroll({ password }: { password: string }) {
const data = await run("enroll", () => mfaRequest<Enrollment>({ action: "enroll", password }));
if (!data) return;
setEnrollment(data);
setBackupCodes(data.backupCodes);
setNotice("请先保存恢复码,再使用认证器生成的 6 位验证码完成 MFA 绑定。未验证前 MFA 不会启用。");
}
async function verify({ code }: { code: string }) {
const data = await run("verify", () => mfaRequest<MfaStatus>({ action: "verify", code }));
if (!data) return;
setStatus(data);
setEnrollment(undefined);
setNotice("当前管理员会话已完成 MFA 验证。");
}
async function recover({ code }: { code: string }) {
const data = await run("recover", () => mfaRequest<MfaStatus>({ action: "recover", code }));
if (!data) return;
setStatus(data);
setNotice("恢复码已消费,当前会话已完成 MFA 验证。请在恢复访问后重新生成恢复码。");
}
async function regenerate({ password }: { password: string }) {
const data = await run("regenerate", () => mfaRequest<{ backupCodes: string[] }>({
action: "regenerate",
password,
}));
if (!data) return;
setBackupCodes(data.backupCodes);
setNotice("新的恢复码已生成,旧恢复码已全部失效。请立即离线保存。");
}
async function disable({ password }: { password: string }) {
const data = await run("disable", () => mfaRequest<MfaStatus>({ action: "disable", password }));
if (!data) return;
setStatus(data);
setEnrollment(undefined);
setBackupCodes(undefined);
setNotice(data.required
? "MFA 已禁用。当前角色要求启用 MFA,可随时重新绑定。"
: "MFA 已禁用,当前会话的 MFA 证明已撤销。");
}
if (loading && !status) {
return <Card><Space><Spin /><Text> MFA </Text></Space></Card>;
}
return <Space direction="vertical" size="large" style={{ width: "100%" }}>
<div>
<Title level={2}></Title>
<Paragraph type="secondary">
MFA 使 Better Auth TOTP MFA
</Paragraph>
</div>
{error ? <Alert type="error" showIcon message={error} /> : null}
{notice ? <Alert type="success" showIcon message={notice} /> : null}
{status?.required && !status.enrolled ? <Alert
type="warning"
showIcon
message="当前角色强制要求 MFA"
description="这是账户级安全要求,不会再触发具体管理操作的邮箱验证码复核。"
/> : null}
<Card title="当前状态">
<Descriptions column={{ xs: 1, sm: 2 }}>
<Descriptions.Item label="角色要求">
<Text type={status?.required ? "danger" : "secondary"}>{status?.required ? "必须 MFA" : "可选 MFA"}</Text>
</Descriptions.Item>
<Descriptions.Item label="MFA 绑定">
<Text type={status?.enrolled ? undefined : "warning"}>{status?.enrolled ? "已启用" : "未启用"}</Text>
</Descriptions.Item>
<Descriptions.Item label="当前会话">
<Text type={status?.verified ? undefined : "secondary"}>{status?.verified ? "已验证" : "未验证"}</Text>
</Descriptions.Item>
</Descriptions>
</Card>
{!status?.enrolled && !enrollment ? <Card title="启用 TOTP MFA">
<Paragraph> URI seed </Paragraph>
<Form layout="vertical" onFinish={enroll} style={{ maxWidth: 520 }}>
<Form.Item name="password" label="当前密码" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "enroll"}></Button>
</Form>
</Card> : null}
{enrollment ? <Card title="完成 MFA 绑定">
<Alert
type="info"
showIcon
message="先保存恢复码,再验证 TOTP"
description="刷新页面不会再次显示这些明文恢复码。不要把 URI、seed 或恢复码粘贴到日志、工单或聊天中。"
/>
<Divider orientation="left"> URI</Divider>
<Paragraph copyable={{ text: enrollment.totpUri }} code style={{ overflowWrap: "anywhere" }}>
{enrollment.totpUri}
</Paragraph>
<Divider orientation="left"></Divider>
<BackupCodeList codes={backupCodes ?? enrollment.backupCodes} />
<Divider />
<Form layout="vertical" onFinish={verify} style={{ maxWidth: 520 }}>
<Form.Item name="code" label="认证器验证码" rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}>
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "verify"}> MFA</Button>
</Form>
</Card> : null}
{status?.enrolled && !status.verified ? <Card title="验证当前会话">
<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>
</Radio.Group>
<Form
key={factor}
layout="vertical"
onFinish={factor === "totp" ? verify : recover}
style={{ maxWidth: 520, marginTop: 16 }}
>
<Form.Item
name="code"
label={factor === "totp" ? "6 位验证码" : "一次性恢复码"}
rules={factor === "totp"
? [{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]
: [{ required: true, whitespace: true }]}
>
<Input
inputMode={factor === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
maxLength={factor === "totp" ? 6 : 128}
/>
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "verify" || action === "recover"}>
</Button>
</Form>
</Card> : null}
{status?.enrolled && status.verified ? <Card title="恢复与禁用">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<div>
<Title level={4}></Title>
<Paragraph type="secondary"></Paragraph>
<Form layout="inline" onFinish={regenerate}>
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button htmlType="submit" loading={action === "regenerate"}></Button>
</Form>
</div>
{backupCodes?.length ? <BackupCodeList codes={backupCodes} /> : null}
<Divider />
<div>
<Title level={4}> MFA</Title>
<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} />
</Form.Item>
<Button danger htmlType="submit" loading={action === "disable"}> MFA</Button>
</Form>
</div>
</Space>
</Card> : null}
</Space>;
}
function BackupCodeList({ codes }: { codes: string[] }) {
return <List
bordered
size="small"
dataSource={codes}
grid={{ gutter: 8, xs: 1, sm: 2, md: 3 }}
renderItem={(code) => <List.Item><Text code>{code}</Text></List.Item>}
/>;
}