feat: add complete MFA support

Add complete MFA support with TOTP, recovery codes, WebAuthn, trusted-device cookie flow, and email/SMS OTP delivery via notification channels. Security follow-up: trusted device tokens are stored in HttpOnly cookies, and SMS OTP reuses the existing Webhook notifier to avoid introducing a new dynamic URL sink.
This commit is contained in:
Wu Qing
2026-04-25 22:14:50 +08:00
committed by GitHub
parent 67a42b09ba
commit 63fde903d2
47 changed files with 5718 additions and 378 deletions

View File

@@ -5,6 +5,7 @@ describe('notification field config', () => {
it('returns readable type labels', () => {
expect(getNotificationTypeLabel('email')).toBe('Email')
expect(getNotificationTypeLabel('telegram')).toBe('Telegram')
expect(getNotificationTypeLabel('webhook')).toBe('Webhook')
})
it('returns required fields for each notification type', () => {

View File

@@ -1,4 +1,4 @@
import { Avatar, Button, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Typography } from '@arco-design/web-react'
import { Alert, Avatar, Button, Divider, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Tag, Typography } from '@arco-design/web-react'
import {
IconDashboard,
IconStorage,
@@ -23,7 +23,27 @@ import {
} from '@arco-design/web-react/icon'
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { changePassword, type ChangePasswordPayload } from '../services/auth'
import {
changePassword,
beginWebAuthnRegistration,
clearTrustedDeviceToken,
configureOtp,
deleteWebAuthnCredential,
disableTwoFactor,
enableTwoFactor,
finishWebAuthnRegistration,
listTrustedDevices,
listWebAuthnCredentials,
prepareTwoFactor,
regenerateRecoveryCodes,
revokeTrustedDevice,
type ChangePasswordPayload,
type TrustedDevice,
type UserInfo,
type WebAuthnCredential,
type TwoFactorSetupResult,
} from '../services/auth'
import { createWebAuthnCredential } from '../utils/webauthn'
import { useAuthStore } from '../stores/auth'
import { resolveErrorMessage } from '../utils/error'
import { isAdmin, roleLabel } from '../utils/permissions'
@@ -105,11 +125,27 @@ export function AppLayout() {
const [collapsed, setCollapsed] = useState(false)
const [pwdVisible, setPwdVisible] = useState(false)
const [pwdLoading, setPwdLoading] = useState(false)
const [twoFactorVisible, setTwoFactorVisible] = useState(false)
const [twoFactorLoading, setTwoFactorLoading] = useState(false)
const [twoFactorSetup, setTwoFactorSetup] = useState<TwoFactorSetupResult | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [webAuthnCredentials, setWebAuthnCredentials] = useState<WebAuthnCredential[]>([])
const [trustedDevices, setTrustedDevices] = useState<TrustedDevice[]>([])
const [securityDetailsLoading, setSecurityDetailsLoading] = useState(false)
const [pwdForm] = Form.useForm<ChangePasswordPayload & { confirmPassword: string }>()
const [twoFactorForm] = Form.useForm<{ currentPassword: string; code: string; email: string; phone: string }>()
const location = useLocation()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const logout = useAuthStore((state) => state.logout)
const setUser = useAuthStore((state) => state.setUser)
function applySecurityUserUpdate(updated: UserInfo) {
setUser(updated)
if (!updated.mfaEnabled) {
clearTrustedDeviceToken(updated.username)
}
}
async function handleChangePassword() {
try {
@@ -120,6 +156,7 @@ export function AppLayout() {
}
setPwdLoading(true)
await changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword })
clearTrustedDeviceToken(user?.username)
Message.success('密码修改成功')
setPwdVisible(false)
pwdForm.resetFields()
@@ -132,15 +169,227 @@ export function AppLayout() {
}
}
function closeTwoFactorModal() {
setTwoFactorVisible(false)
setTwoFactorSetup(null)
setRecoveryCodes([])
setWebAuthnCredentials([])
setTrustedDevices([])
twoFactorForm.resetFields()
}
async function openSecurityModal() {
setTwoFactorVisible(true)
twoFactorForm.setFieldValue('email', user?.email ?? '')
twoFactorForm.setFieldValue('phone', user?.phone ?? '')
await loadSecurityDetails()
}
async function loadSecurityDetails() {
setSecurityDetailsLoading(true)
try {
const [credentials, devices] = await Promise.all([listWebAuthnCredentials(), listTrustedDevices()])
setWebAuthnCredentials(credentials)
setTrustedDevices(devices)
} catch (err) {
Message.error(resolveErrorMessage(err, '加载安全配置失败'))
} finally {
setSecurityDetailsLoading(false)
}
}
async function copyRecoveryCodes() {
if (recoveryCodes.length === 0) return
try {
await navigator.clipboard.writeText(recoveryCodes.join('\n'))
Message.success('已复制到剪贴板')
} catch {
Message.info('请手动选择文本复制')
}
}
async function handleTwoFactorSetupAction() {
try {
const values = await twoFactorForm.validate()
setTwoFactorLoading(true)
if (!twoFactorSetup) {
const setup = await prepareTwoFactor({ currentPassword: values.currentPassword })
setTwoFactorSetup(setup)
Message.success('TOTP 密钥已生成')
return
}
const result = await enableTwoFactor({ code: values.code })
setUser(result.user)
setRecoveryCodes(result.recoveryCodes)
Message.success('TOTP 已启用')
} catch (err) {
if (err) {
Message.error(resolveErrorMessage(err, 'TOTP 操作失败'))
}
} finally {
setTwoFactorLoading(false)
}
}
async function handleRegenerateRecoveryCodes() {
try {
const values = await twoFactorForm.validate()
setTwoFactorLoading(true)
const result = await regenerateRecoveryCodes({
currentPassword: values.currentPassword,
code: values.code,
})
setUser(result.user)
setRecoveryCodes(result.recoveryCodes)
twoFactorForm.resetFields()
Message.success('恢复码已重新生成')
} catch (err) {
if (err) {
Message.error(resolveErrorMessage(err, '恢复码生成失败'))
}
} finally {
setTwoFactorLoading(false)
}
}
async function handleDisableTwoFactor() {
try {
const values = await twoFactorForm.validate()
setTwoFactorLoading(true)
const updated = await disableTwoFactor({
currentPassword: values.currentPassword,
code: values.code,
})
applySecurityUserUpdate(updated)
Message.success('TOTP 已关闭')
closeTwoFactorModal()
} catch (err) {
if (err) {
Message.error(resolveErrorMessage(err, '关闭 TOTP 失败'))
}
} finally {
setTwoFactorLoading(false)
}
}
function readCurrentPassword() {
const currentPassword = String(twoFactorForm.getFieldValue('currentPassword') ?? '')
if (currentPassword.trim().length < 8) {
Message.error('请输入当前密码')
return ''
}
return currentPassword
}
async function handleRegisterWebAuthn() {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
try {
setTwoFactorLoading(true)
const options = await beginWebAuthnRegistration({ currentPassword })
const credential = await createWebAuthnCredential(options)
const updated = await finishWebAuthnRegistration({ name: navigator.userAgent.slice(0, 120), credential })
applySecurityUserUpdate(updated)
await loadSecurityDetails()
Message.success('通行密钥已注册')
} catch (err) {
Message.error(resolveErrorMessage(err, '通行密钥注册失败'))
} finally {
setTwoFactorLoading(false)
}
}
async function handleDeleteWebAuthnCredential(id: string) {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
try {
setTwoFactorLoading(true)
const updated = await deleteWebAuthnCredential(id, { currentPassword })
applySecurityUserUpdate(updated)
await loadSecurityDetails()
Message.success('通行密钥已删除')
} catch (err) {
Message.error(resolveErrorMessage(err, '删除通行密钥失败'))
} finally {
setTwoFactorLoading(false)
}
}
async function handleConfigureOtp(channel: 'email' | 'sms', enabled: boolean) {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
const email = String(twoFactorForm.getFieldValue('email') ?? '')
const phone = String(twoFactorForm.getFieldValue('phone') ?? '')
try {
setTwoFactorLoading(true)
const updated = await configureOtp({ currentPassword, channel, enabled, email, phone })
applySecurityUserUpdate(updated)
twoFactorForm.setFieldValue('email', updated.email ?? '')
twoFactorForm.setFieldValue('phone', updated.phone ?? '')
Message.success(enabled ? 'OTP 已启用' : 'OTP 已关闭')
} catch (err) {
Message.error(resolveErrorMessage(err, 'OTP 配置失败'))
} finally {
setTwoFactorLoading(false)
}
}
async function handleRevokeTrustedDevice(id: string) {
const currentPassword = readCurrentPassword()
if (!currentPassword) return
try {
setTwoFactorLoading(true)
await revokeTrustedDevice(id, { currentPassword })
clearTrustedDeviceToken(user?.username)
await loadSecurityDetails()
Message.success('可信设备已移除')
} catch (err) {
Message.error(resolveErrorMessage(err, '移除可信设备失败'))
} finally {
setTwoFactorLoading(false)
}
}
function renderTwoFactorFooter() {
if (recoveryCodes.length > 0) {
return (
<Space>
<Button onClick={() => void copyRecoveryCodes()}></Button>
<Button type="primary" onClick={closeTwoFactorModal}></Button>
</Space>
)
}
if (user?.twoFactorEnabled) {
return (
<Space>
<Button onClick={closeTwoFactorModal}></Button>
<Button loading={twoFactorLoading} onClick={() => void handleRegenerateRecoveryCodes()}></Button>
<Button status="danger" loading={twoFactorLoading} onClick={() => void handleDisableTwoFactor()}> TOTP</Button>
</Space>
)
}
return (
<Space>
<Button onClick={closeTwoFactorModal}></Button>
<Button type="primary" loading={twoFactorLoading} onClick={() => void handleTwoFactorSetupAction()}>
{twoFactorSetup ? '启用 TOTP' : '生成 TOTP 二维码'}
</Button>
</Space>
)
}
const userDroplist = (
<Menu onClickMenuItem={(key) => {
if (key === 'password') {
setPwdVisible(true)
} else if (key === 'two-factor') {
void openSecurityModal()
} else if (key === 'logout') {
logout()
}
}}>
<Menu.Item key="password"><IconLock style={{ marginRight: 8 }} /></Menu.Item>
<Menu.Item key="two-factor"><IconSafe style={{ marginRight: 8 }} /></Menu.Item>
<Menu.Item key="logout"><IconPoweroff style={{ marginRight: 8 }} />退</Menu.Item>
</Menu>
)
@@ -217,6 +466,138 @@ export function AppLayout() {
</Form.Item>
</Form>
</Modal>
<Modal
title="多因素认证"
visible={twoFactorVisible}
onCancel={closeTwoFactorModal}
footer={renderTwoFactorFooter()}
unmountOnExit
>
{recoveryCodes.length > 0 ? (
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert type="warning" content="恢复码只会显示一次。请立即保存;每个恢复码只能使用一次。" />
<Input.TextArea value={recoveryCodes.join('\n')} autoSize readOnly />
</Space>
) : (
<Form form={twoFactorForm} layout="vertical">
{user?.twoFactorEnabled ? (
<>
<Alert
type="success"
content={`当前账号已启用 TOTP恢复码剩余 ${user.twoFactorRecoveryCodesRemaining ?? 0} 个。`}
style={{ marginBottom: 16 }}
/>
<Form.Item field="currentPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item field="code" label="TOTP 验证码" rules={[{ required: true, minLength: 6, maxLength: 10 }]}>
<Input placeholder="请输入 6 位验证码" maxLength={10} />
</Form.Item>
</>
) : (
<>
{!twoFactorSetup ? (
<>
<Alert type="info" content="启用前需要验证当前密码。" style={{ marginBottom: 16 }} />
<Form.Item field="currentPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
</>
) : (
<>
<Alert type="warning" content="密钥仅在本次启用流程中显示。启用后会生成一次性恢复码。" style={{ marginBottom: 16 }} />
<div style={{ display: 'flex', gap: 20, alignItems: 'center', marginBottom: 16 }}>
<img
src={twoFactorSetup.qrCodeDataUrl}
alt="TOTP 二维码"
style={{ width: 160, height: 160, border: '1px solid var(--color-border)', borderRadius: 8 }}
/>
<Space direction="vertical" size={8} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text type="secondary"></Typography.Text>
<Input value={twoFactorSetup.secret} readOnly />
</Space>
</div>
<Form.Item field="code" label="TOTP 验证码" rules={[{ required: true, minLength: 6, maxLength: 10 }]}>
<Input placeholder="请输入 6 位验证码" maxLength={10} />
</Form.Item>
</>
)}
</>
)}
<Divider />
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Typography.Title heading={6} style={{ margin: 0 }}></Typography.Title>
<Tag color={webAuthnCredentials.length > 0 ? 'green' : 'gray'} bordered>
{webAuthnCredentials.length > 0 ? `${webAuthnCredentials.length}` : '未注册'}
</Tag>
</Space>
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
Passkey
</Typography.Paragraph>
<Button loading={twoFactorLoading} onClick={() => void handleRegisterWebAuthn()}></Button>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{securityDetailsLoading ? <Typography.Text type="secondary">...</Typography.Text> : null}
{webAuthnCredentials.map((item) => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', padding: '8px 0', borderTop: '1px solid var(--color-border)' }}>
<Space direction="vertical" size={2}>
<Typography.Text>{item.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{item.lastUsedAt ? `最近使用 ${item.lastUsedAt}` : `创建于 ${item.createdAt}`}</Typography.Text>
</Space>
<Button size="small" status="danger" onClick={() => void handleDeleteWebAuthnCredential(item.id)}></Button>
</div>
))}
</Space>
</Space>
<Divider />
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Typography.Title heading={6} style={{ margin: 0 }}> / OTP</Typography.Title>
<Alert type="info" content="邮件 OTP 使用已启用的 Email 通知配置发送;短信 OTP 使用 Webhook 通知配置发送payload 会包含 phone/code/purpose 字段。" />
<Space wrap>
<Tag color={user?.emailOtpEnabled ? 'green' : 'gray'} bordered> OTP {user?.emailOtpEnabled ? '已启用' : '未启用'}</Tag>
<Tag color={user?.smsOtpEnabled ? 'green' : 'gray'} bordered> OTP {user?.smsOtpEnabled ? '已启用' : '未启用'}</Tag>
</Space>
<Form.Item field="email" label="邮箱">
<Input placeholder="启用邮件 OTP 时填写" />
</Form.Item>
<Form.Item field="phone" label="手机号">
<Input placeholder="启用短信 OTP 时填写" />
</Form.Item>
<Space wrap>
<Button loading={twoFactorLoading} onClick={() => void handleConfigureOtp('email', !user?.emailOtpEnabled)}>
{user?.emailOtpEnabled ? '关闭邮件 OTP' : '启用邮件 OTP'}
</Button>
<Button loading={twoFactorLoading} onClick={() => void handleConfigureOtp('sms', !user?.smsOtpEnabled)}>
{user?.smsOtpEnabled ? '关闭短信 OTP' : '启用短信 OTP'}
</Button>
</Space>
</Space>
<Divider />
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Typography.Title heading={6} style={{ margin: 0 }}></Typography.Title>
<Tag color={trustedDevices.length > 0 ? 'green' : 'gray'} bordered>{trustedDevices.length} </Tag>
</Space>
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
30
</Typography.Paragraph>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{trustedDevices.map((item) => (
<div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', padding: '8px 0', borderTop: '1px solid var(--color-border)' }}>
<Space direction="vertical" size={2}>
<Typography.Text>{item.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>使 {item.lastUsedAt || '-'} {item.expiresAt}</Typography.Text>
</Space>
<Button size="small" status="danger" onClick={() => void handleRevokeTrustedDevice(item.id)}></Button>
</div>
))}
{!securityDetailsLoading && trustedDevices.length === 0 ? <Typography.Text type="secondary"></Typography.Text> : null}
</Space>
</Space>
</Form>
)}
</Modal>
</Layout>
)
}

View File

@@ -1,6 +1,7 @@
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createUser, deleteUser, listUsers, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
import { createUser, deleteUser, listUsers, resetUserTwoFactor, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
import { clearTrustedDeviceToken } from '../../services/auth'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { isAdmin, roleLabel } from '../../utils/permissions'
@@ -12,12 +13,13 @@ const roleOptions = [
]
function createEmpty(): UserUpsertPayload {
return { username: '', password: '', displayName: '', email: '', role: 'operator', disabled: false }
return { username: '', password: '', displayName: '', email: '', phone: '', role: 'operator', disabled: false }
}
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
export function UsersPage() {
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.setUser)
const [items, setItems] = useState<UserSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -55,6 +57,7 @@ export function UsersPage() {
password: '',
displayName: item.displayName,
email: item.email,
phone: item.phone,
role: item.role,
disabled: item.disabled,
})
@@ -73,7 +76,13 @@ export function UsersPage() {
setSubmitting(true)
try {
if (editing) {
await updateUser(editing.id, draft)
const updated = await updateUser(editing.id, draft)
if (updated.id === user?.id) {
if (draft.password?.trim()) {
clearTrustedDeviceToken(updated.username)
}
setUser(updated)
}
Message.success('用户已更新')
} else {
await createUser(draft)
@@ -99,6 +108,21 @@ export function UsersPage() {
}
}
async function handleResetTwoFactor(item: UserSummary) {
if (!window.confirm(`确定重置用户「${item.username}」的全部 MFA 配置吗?该用户之后可仅凭密码登录。`)) return
try {
const updated = await resetUserTwoFactor(item.id)
if (updated.id === user?.id) {
clearTrustedDeviceToken(updated.username)
setUser(updated)
}
Message.success('MFA 已重置')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '重置 MFA 失败'))
}
}
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin" />
}
@@ -132,12 +156,27 @@ export function UsersPage() {
</Space>
) },
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
{ title: '邮箱', dataIndex: 'email', render: (v: string) => v || '-' },
{ title: '邮箱 / 手机', dataIndex: 'email', render: (_: string, row: UserSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{row.email || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.phone || '-'}</Typography.Text>
</Space>
) },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: 'MFA', dataIndex: 'mfaEnabled', render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
<Space wrap size={4}>
{row.twoFactorEnabled ? <Tag color="green" bordered>TOTP</Tag> : null}
{row.webAuthnEnabled ? <Tag color="arcoblue" bordered>Passkey {row.webAuthnCredentialCount}</Tag> : null}
{row.emailOtpEnabled ? <Tag color="purple" bordered></Tag> : null}
{row.smsOtpEnabled ? <Tag color="orange" bordered></Tag> : null}
{row.twoFactorEnabled ? <Typography.Text type="secondary" style={{ fontSize: 12 }}> {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
</Space>
) : <Tag bordered></Tag> },
{ title: '创建时间', dataIndex: 'createdAt' },
{ title: '操作', width: 180, render: (_: unknown, row: UserSummary) => (
{ title: '操作', width: 260, render: (_: unknown, row: UserSummary) => (
<Space>
<Button size="small" type="text" onClick={() => openEdit(row)}></Button>
{row.mfaEnabled && <Button size="small" type="text" onClick={() => void handleResetTwoFactor(row)}> MFA</Button>}
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}></Button>
</Space>
) },
@@ -163,6 +202,9 @@ export function UsersPage() {
<Form.Item label="邮箱">
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
</Form.Item>
<Form.Item label="手机号">
<Input value={draft.phone} onChange={(v) => setDraft({ ...draft, phone: v })} />
</Form.Item>
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
</Form.Item>

View File

@@ -26,6 +26,23 @@ const categoryLabels: Record<string, string> = {
const actionLabels: Record<string, string> = {
login_success: '登录成功',
login_failed: '登录失败',
two_factor_required: '需要 MFA',
two_factor_setup: '生成 TOTP',
two_factor_enable: '启用 TOTP',
two_factor_disable: '关闭 TOTP',
two_factor_recovery_code_used: '使用恢复码',
two_factor_recovery_codes_regenerate: '重建恢复码',
webauthn_register: '注册通行密钥',
webauthn_used: '使用通行密钥',
webauthn_delete: '删除通行密钥',
trusted_device_create: '信任设备',
trusted_device_used: '使用可信设备',
trusted_device_revoke: '移除可信设备',
otp_enable: '启用 OTP',
otp_disable: '关闭 OTP',
otp_send: '发送 OTP',
otp_used: '使用 OTP',
reset_two_factor: '重置 MFA',
setup: '系统初始化',
change_password: '修改密码',
create: '创建',

View File

@@ -1,10 +1,11 @@
import { Alert, Button, Card, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
import { IconCloud, IconLock, IconUser } from '@arco-design/web-react/icon'
import { Button, Checkbox, Form, Input, Space, Typography, Message } from '@arco-design/web-react'
import { IconCloud, IconLock, IconSafe, IconUser } from '@arco-design/web-react/icon'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { fetchSetupStatus } from '../../services/auth'
import { beginWebAuthnLogin, fetchSetupStatus, sendLoginOtp } from '../../services/auth'
import { useAuthStore } from '../../stores/auth'
import { getWebAuthnAssertion } from '../../utils/webauthn'
interface SetupFormValues {
username: string
@@ -15,12 +16,17 @@ interface SetupFormValues {
interface LoginFormValues {
username: string
password: string
twoFactorCode?: string
rememberDevice?: boolean
}
function resolveErrorMessage(error: unknown) {
if (axios.isAxiosError(error)) {
return error.response?.data?.message ?? '请求失败,请稍后重试'
}
if (error instanceof Error) {
return error.message
}
return '请求失败,请稍后重试'
}
@@ -29,8 +35,20 @@ export function LoginPage() {
const authStatus = useAuthStore((state) => state.status)
const doLogin = useAuthStore((state) => state.login)
const doSetup = useAuthStore((state) => state.setup)
const [loginForm] = Form.useForm<LoginFormValues>()
const [initialized, setInitialized] = useState<boolean | null>(null)
const [loading, setLoading] = useState(false)
const [mfaActionLoading, setMfaActionLoading] = useState('')
const [twoFactorRequired, setTwoFactorRequired] = useState(false)
function resetTwoFactorPrompt() {
if (!twoFactorRequired) {
return
}
setTwoFactorRequired(false)
loginForm.setFieldValue('twoFactorCode', undefined)
loginForm.setFieldValue('rememberDevice', false)
}
useEffect(() => {
if (authStatus === 'authenticated') {
@@ -73,13 +91,77 @@ export function LoginPage() {
const handleLogin = async (values: LoginFormValues) => {
setLoading(true)
try {
await doLogin(values)
await doLogin({
...values,
trustedDeviceName: values.rememberDevice ? navigator.userAgent.slice(0, 120) : undefined,
})
setTwoFactorRequired(false)
Message.success('登录成功')
navigate('/dashboard', { replace: true })
} catch (error) {
if (axios.isAxiosError(error)) {
const code = error.response?.data?.code
if (code === 'AUTH_2FA_REQUIRED' || code === 'AUTH_2FA_INVALID') {
setTwoFactorRequired(true)
Message.error(resolveErrorMessage(error))
return
}
}
Message.error(resolveErrorMessage(error))
} finally {
setLoading(false)
}
}
function readLoginCredentials(): (LoginFormValues & { username: string; password: string }) | null {
const values = loginForm.getFieldsValue()
if (!values.username?.trim() || !values.password?.trim()) {
Message.error('请先输入用户名和密码')
return null
}
return {
...values,
username: values.username,
password: values.password,
}
}
async function handleSendOTP(channel: 'email' | 'sms') {
const values = readLoginCredentials()
if (!values) return
setMfaActionLoading(channel)
try {
await sendLoginOtp({ username: values.username, password: values.password, channel })
Message.success(channel === 'email' ? '邮件验证码已发送' : '短信验证码已发送')
} catch (error) {
Message.error(resolveErrorMessage(error))
} finally {
setMfaActionLoading('')
}
}
async function handleWebAuthnLogin() {
const values = readLoginCredentials()
if (!values) return
setMfaActionLoading('webauthn')
try {
const options = await beginWebAuthnLogin({ username: values.username, password: values.password })
const assertion = await getWebAuthnAssertion(options)
await doLogin({
username: values.username,
password: values.password,
webAuthnAssertion: assertion,
trustedDeviceToken: '',
rememberDevice: values.rememberDevice,
trustedDeviceName: navigator.userAgent.slice(0, 120),
})
setTwoFactorRequired(false)
Message.success('登录成功')
navigate('/dashboard', { replace: true })
} catch (error) {
Message.error(resolveErrorMessage(error))
} finally {
setLoading(false)
setMfaActionLoading('')
}
}
@@ -181,15 +263,30 @@ export function LoginPage() {
</Button>
</Form>
) : (
<Form<LoginFormValues> layout="vertical" onSubmit={handleLogin}>
<Form<LoginFormValues> form={loginForm} layout="vertical" onSubmit={handleLogin}>
<Form.Item field="username" label="用户名" rules={[{ required: true, minLength: 3 }]}>
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" />
<Input placeholder="请输入用户名" prefix={<IconUser />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
<Form.Item field="password" label="密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" />
<Input.Password placeholder="请输入密码" prefix={<IconLock />} size="large" onChange={resetTwoFactorPrompt} />
</Form.Item>
{twoFactorRequired && (
<>
<Form.Item field="twoFactorCode" label="验证码或恢复码" rules={[{ required: true, minLength: 6, maxLength: 32 }]}>
<Input placeholder="请输入 TOTP、恢复码、邮件或短信验证码" prefix={<IconSafe />} size="large" maxLength={32} />
</Form.Item>
<Space wrap style={{ marginTop: -8, marginBottom: 8 }}>
<Button loading={mfaActionLoading === 'email'} onClick={() => void handleSendOTP('email')}></Button>
<Button loading={mfaActionLoading === 'sms'} onClick={() => void handleSendOTP('sms')}></Button>
<Button loading={mfaActionLoading === 'webauthn'} onClick={() => void handleWebAuthnLogin()}>使</Button>
</Space>
<Form.Item field="rememberDevice" triggerPropName="checked">
<Checkbox> 30 </Checkbox>
</Form.Item>
</>
)}
<Button long type="primary" htmlType="submit" loading={loading} size="large" style={{ borderRadius: 8, height: 44, marginTop: 16 }}>
{twoFactorRequired ? '验证并登录' : '登录'}
</Button>
</Form>
)}

View File

@@ -9,18 +9,39 @@ export interface SetupPayload {
export interface LoginPayload {
username: string
password: string
twoFactorCode?: string
webAuthnAssertion?: WebAuthnAssertion
trustedDeviceToken?: string
rememberDevice?: boolean
trustedDeviceName?: string
}
export interface UserInfo {
id: number
username: string
displayName: string
email?: string
phone?: string
role: string
mfaEnabled?: boolean
twoFactorEnabled?: boolean
twoFactorRecoveryCodesRemaining?: number
webAuthnEnabled?: boolean
webAuthnCredentialCount?: number
trustedDeviceCount?: number
emailOtpEnabled?: boolean
smsOtpEnabled?: boolean
}
export interface AuthResult {
token: string
user: UserInfo
trustedDeviceToken?: string
trustedDevice?: TrustedDevice
}
export function clearTrustedDeviceToken(_username?: string) {
// 可信设备 token 由后端写入 HttpOnly cookie前端不能也不应该读取。
}
export async function fetchSetupStatus() {
@@ -53,6 +74,177 @@ export async function changePassword(payload: ChangePasswordPayload) {
return response.data.data
}
export interface TwoFactorSetupPayload {
currentPassword: string
}
export interface TwoFactorSetupResult {
secret: string
otpAuthUrl: string
qrCodeDataUrl: string
twoFactorEnabled: boolean
twoFactorConfirmed: boolean
}
export interface TwoFactorCodesResult {
user: UserInfo
recoveryCodes: string[]
}
export interface EnableTwoFactorPayload {
code: string
}
export interface DisableTwoFactorPayload {
currentPassword: string
code: string
}
export type RegenerateRecoveryCodesPayload = DisableTwoFactorPayload
export type OTPChannel = 'email' | 'sms'
export interface OTPConfigPayload {
currentPassword: string
channel: OTPChannel
enabled: boolean
email?: string
phone?: string
}
export interface SendLoginOTPPayload {
username: string
password: string
channel: OTPChannel
}
export interface WebAuthnCredentialDescriptor {
type: 'public-key'
id: string
}
export interface WebAuthnRegistrationOptions {
challenge: string
rp: { name: string; id: string }
user: { id: string; name: string; displayName: string }
pubKeyCredParams: Array<{ type: 'public-key'; alg: number }>
timeout: number
attestation: 'none'
authenticatorSelection: { userVerification: UserVerificationRequirement }
excludeCredentials: WebAuthnCredentialDescriptor[]
}
export interface WebAuthnLoginOptions {
challenge: string
rpId: string
timeout: number
userVerification: UserVerificationRequirement
allowCredentials: WebAuthnCredentialDescriptor[]
}
export interface WebAuthnAttestation {
id: string
rawId: string
type: 'public-key'
response: {
clientDataJSON: string
attestationObject: string
}
}
export interface WebAuthnAssertion {
id: string
rawId: string
type: 'public-key'
response: {
clientDataJSON: string
authenticatorData: string
signature: string
userHandle?: string
}
}
export interface WebAuthnCredential {
id: string
name: string
createdAt: string
lastUsedAt?: string
}
export interface TrustedDevice {
id: string
name: string
createdAt: string
lastUsedAt: string
expiresAt: string
lastIp: string
}
export async function prepareTwoFactor(payload: TwoFactorSetupPayload) {
const response = await http.post<{ code: string; message: string; data: TwoFactorSetupResult }>('/auth/2fa/setup', payload)
return response.data.data
}
export async function enableTwoFactor(payload: EnableTwoFactorPayload) {
const response = await http.post<{ code: string; message: string; data: TwoFactorCodesResult }>('/auth/2fa/enable', payload)
return response.data.data
}
export async function regenerateRecoveryCodes(payload: RegenerateRecoveryCodesPayload) {
const response = await http.post<{ code: string; message: string; data: TwoFactorCodesResult }>('/auth/2fa/recovery-codes', payload)
return response.data.data
}
export async function disableTwoFactor(payload: DisableTwoFactorPayload) {
const response = await http.delete<{ code: string; message: string; data: UserInfo }>('/auth/2fa', { data: payload })
return response.data.data
}
export async function configureOtp(payload: OTPConfigPayload) {
const response = await http.put<{ code: string; message: string; data: UserInfo }>('/auth/otp/config', payload)
return response.data.data
}
export async function sendLoginOtp(payload: SendLoginOTPPayload) {
const response = await http.post<{ code: string; message: string; data: { sent: boolean } }>('/auth/otp/send', payload)
return response.data.data
}
export async function beginWebAuthnRegistration(payload: { currentPassword: string }) {
const response = await http.post<{ code: string; message: string; data: WebAuthnRegistrationOptions }>('/auth/webauthn/register/options', payload)
return response.data.data
}
export async function finishWebAuthnRegistration(payload: { name?: string; credential: WebAuthnAttestation }) {
const response = await http.post<{ code: string; message: string; data: UserInfo }>('/auth/webauthn/register/finish', payload)
return response.data.data
}
export async function beginWebAuthnLogin(payload: { username: string; password: string }) {
const response = await http.post<{ code: string; message: string; data: WebAuthnLoginOptions }>('/auth/webauthn/login/options', payload)
return response.data.data
}
export async function listWebAuthnCredentials() {
const response = await http.get<{ code: string; message: string; data: WebAuthnCredential[] }>('/auth/webauthn/credentials')
return response.data.data
}
export async function deleteWebAuthnCredential(id: string, payload: { currentPassword: string }) {
const response = await http.delete<{ code: string; message: string; data: UserInfo }>(`/auth/webauthn/credentials/${id}`, { data: payload })
return response.data.data
}
export async function listTrustedDevices() {
const response = await http.get<{ code: string; message: string; data: TrustedDevice[] }>('/auth/trusted-devices')
return response.data.data
}
export async function revokeTrustedDevice(id: string, payload: { currentPassword: string }) {
const response = await http.delete<{ code: string; message: string; data: { deleted: boolean } }>(`/auth/trusted-devices/${id}`, { data: payload })
return response.data.data
}
export async function logout() {
const response = await http.post<{ code: string; message: string; data: { loggedOut: boolean } }>('/auth/logout')
return response.data.data

View File

@@ -12,6 +12,7 @@ let unauthorizedHandler: (() => void) | null = null
export const http = axios.create({
baseURL: '/api',
timeout: 10000,
withCredentials: true,
})
export function setAccessToken(token: string) {

View File

@@ -7,8 +7,17 @@ export interface UserSummary {
username: string
displayName: string
email: string
phone: string
role: UserRole
disabled: boolean
mfaEnabled: boolean
twoFactorEnabled: boolean
twoFactorRecoveryCodesRemaining: number
webAuthnEnabled: boolean
webAuthnCredentialCount: number
trustedDeviceCount: number
emailOtpEnabled: boolean
smsOtpEnabled: boolean
createdAt: string
}
@@ -17,6 +26,7 @@ export interface UserUpsertPayload {
password?: string
displayName: string
email?: string
phone?: string
role: UserRole
disabled: boolean
}
@@ -40,3 +50,8 @@ export async function deleteUser(id: number) {
const response = await http.delete<ApiEnvelope<{ deleted: boolean }>>(`/users/${id}`)
return unwrapApiEnvelope(response.data)
}
export async function resetUserTwoFactor(id: number) {
const response = await http.post<ApiEnvelope<UserSummary>>(`/users/${id}/2fa/reset`)
return unwrapApiEnvelope(response.data)
}

View File

@@ -15,6 +15,7 @@ interface AuthState {
setup: (payload: SetupPayload) => Promise<void>
logout: () => void
applyAuth: (token: string, user: UserInfo) => void
setUser: (user: UserInfo) => void
}
function clearAuthState(set: (partial: Partial<AuthState>) => void) {
@@ -65,6 +66,9 @@ export const useAuthStore = create<AuthState>()(
setAccessToken(token)
set({ token, user, status: 'authenticated', bootstrapped: true })
},
setUser: (user) => {
set({ user })
},
}),
{
name: 'backupx-auth',

View File

@@ -2,12 +2,27 @@ export interface AuthUser {
id: number;
username: string;
displayName: string;
email?: string;
phone?: string;
role: string;
mfaEnabled?: boolean;
twoFactorEnabled?: boolean;
twoFactorRecoveryCodesRemaining?: number;
webAuthnEnabled?: boolean;
webAuthnCredentialCount?: number;
trustedDeviceCount?: number;
emailOtpEnabled?: boolean;
smsOtpEnabled?: boolean;
}
export interface LoginPayload {
username: string;
password: string;
twoFactorCode?: string;
webAuthnAssertion?: unknown;
trustedDeviceToken?: string;
rememberDevice?: boolean;
trustedDeviceName?: string;
}
export interface LoginResult {

88
web/src/utils/webauthn.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { WebAuthnAssertion, WebAuthnAttestation, WebAuthnLoginOptions, WebAuthnRegistrationOptions } from '../services/auth'
function base64UrlToBuffer(value: string) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return bytes.buffer
}
function bufferToBase64Url(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let index = 0; index < bytes.byteLength; index += 1) {
binary += String.fromCharCode(bytes[index])
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function assertWebAuthnAvailable() {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error('当前浏览器不支持通行密钥')
}
}
export async function createWebAuthnCredential(options: WebAuthnRegistrationOptions): Promise<WebAuthnAttestation> {
assertWebAuthnAvailable()
const credential = await navigator.credentials.create({
publicKey: {
...options,
challenge: base64UrlToBuffer(options.challenge),
user: {
...options.user,
id: base64UrlToBuffer(options.user.id),
},
excludeCredentials: options.excludeCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
})),
},
}) as PublicKeyCredential | null
if (!credential) {
throw new Error('通行密钥创建已取消')
}
const response = credential.response as AuthenticatorAttestationResponse
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: 'public-key',
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
},
}
}
export async function getWebAuthnAssertion(options: WebAuthnLoginOptions): Promise<WebAuthnAssertion> {
assertWebAuthnAvailable()
const credential = await navigator.credentials.get({
publicKey: {
challenge: base64UrlToBuffer(options.challenge),
rpId: options.rpId,
timeout: options.timeout,
userVerification: options.userVerification,
allowCredentials: options.allowCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
})),
},
}) as PublicKeyCredential | null
if (!credential) {
throw new Error('通行密钥验证已取消')
}
const response = credential.response as AuthenticatorAssertionResponse
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: 'public-key',
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : undefined,
},
}
}