273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
"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>}
|
|
/>;
|
|
}
|