280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
"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>}
|
||
/>;
|
||
}
|