mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-27 19:19:35 +08:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '创建',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
88
web/src/utils/webauthn.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user