Files
Jyotisha/frontend/src/components/admin/mfa-security.tsx
T
2026-08-06 20:15:08 +08:00

280 lines
11 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,
Tag,
Typography,
} from "antd";
import { useEffect, useState } from "react";
const { Paragraph, Text, Title } = Typography;
type MfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
highRiskWritesEnabled: 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 位验证码完成 enrollment。未验证前 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("当前管理员 session 已完成真实第二因素验证。高风险操作仍需随后完成权限范围内的邮箱验证码。");
}
async function recover({ code }: { code: string }) {
const data = await run("recover", () => mfaRequest<MfaStatus>({ action: "recover", code }));
if (!data) return;
setStatus(data);
setNotice("恢复码已消费,当前 session 已完成 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,因此高风险写入现已关闭,重新 enrollment 后才能恢复。"
: "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 server session session
</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="在 enrollment 完成前,所有高风险写入都会 fail closed。"
/> : null}
<Card title="当前状态">
<Descriptions column={{ xs: 1, sm: 2 }}>
<Descriptions.Item label="角色要求">
<Tag color={status?.required ? "red" : "default"}>{status?.required ? "必须 MFA" : "可选 MFA"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Enrollment">
<Tag color={status?.enrolled ? "green" : "orange"}>{status?.enrolled ? "已启用" : "未启用"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="当前 session">
<Tag color={status?.verified ? "green" : "default"}>{status?.verified ? "已验证" : "未验证"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="高风险写入前置能力">
<Tag color={status?.highRiskWritesEnabled ? "green" : "red"}>
{status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"}
</Tag>
</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"}> enrollment</Button>
</Form>
</Card> : null}
{enrollment ? <Card title="完成 enrollment">
<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="验证当前 session">
<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"}>
session
</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 session 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>}
/>;
}