feat(dcauth): 新增后台手机号与设备认证登录

登录链路改为账号密码 + 短信验证码 + DCAuth 设备签名,并新增后台手机号绑定、
登录日志、设备绑定与重置接口。
This commit is contained in:
yml2213
2026-10-21 20:46:24 +08:00
parent 7542d547bf
commit 040100f290
36 changed files with 3128 additions and 41 deletions
+215 -14
View File
@@ -5,16 +5,24 @@ import {
SettingOutlined,
UserOutlined,
} from '@ant-design/icons'
import { App, Button, Form, Input } from 'antd'
import { useState } from 'react'
import { Alert, App, Button, Divider, Form, Input, Space, Typography } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { loginAdmin } from '@/services/admin'
import {
completeAdminLogin,
fetchAdminDeviceStatus,
loginAdmin,
prepareAdminLogin,
sendAdminLoginSmsCode,
} from '@/services/admin'
import type { AdminDevicePrepareResponse } from '@/types/admin'
import { setAdminSession } from '@/utils/admin-auth'
type LoginForm = {
username: string
password: string
smsCode: string
}
const brandFeatures = [
@@ -25,8 +33,8 @@ const brandFeatures = [
},
{
icon: <SafetyCertificateOutlined />,
title: '权限管控',
desc: '按角色开放操作,操作可审计',
title: '本机认证',
desc: '账号密码 + 短信验证码 + DCAuth 设备签名',
},
{
icon: <SettingOutlined />,
@@ -38,11 +46,95 @@ const brandFeatures = [
export default function AdminLoginPage() {
const navigate = useNavigate()
const { message } = App.useApp()
const [form] = Form.useForm<LoginForm>()
const [loading, setLoading] = useState(false)
const [sendingCode, setSendingCode] = useState(false)
const [countdown, setCountdown] = useState(0)
const [phoneMasked, setPhoneMasked] = useState('')
const [waiting, setWaiting] = useState(false)
const [devLaunchUrl, setDevLaunchUrl] = useState('')
const timerRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (timerRef.current) window.clearInterval(timerRef.current)
}
}, [])
function startCountdown(seconds: number) {
setCountdown(seconds)
if (timerRef.current) window.clearInterval(timerRef.current)
timerRef.current = window.setInterval(() => {
setCountdown((current) => {
if (current <= 1) {
if (timerRef.current) window.clearInterval(timerRef.current)
return 0
}
return current - 1
})
}, 1000)
}
async function sendCode() {
try {
const values = await form.validateFields(['username', 'password'])
setSendingCode(true)
const response = await sendAdminLoginSmsCode({
username: values.username.trim(),
password: values.password.trim(),
})
setPhoneMasked(response.data.phoneMasked)
startCountdown(60)
message.success(`验证码已发送至 ${response.data.phoneMasked}`)
} catch (error) {
message.error(error instanceof Error ? error.message : '验证码发送失败')
} finally {
setSendingCode(false)
}
}
async function submitLogin(values: LoginForm) {
setLoading(true)
setWaiting(false)
try {
const prepared = await prepareAdminLogin({
username: values.username.trim(),
password: values.password.trim(),
smsCode: values.smsCode.trim(),
apiBase: window.location.origin,
})
setPhoneMasked(prepared.data.phoneMasked)
setWaiting(true)
setDevLaunchUrl(import.meta.env.DEV ? prepared.data.launchUrl : '')
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认')
launchProtocol(prepared.data.launchUrl)
const completed = await waitForDevice(prepared.data)
setAdminSession(completed.token, completed.expiresAt, completed.user)
message.success('登录成功')
void navigate('/admin/dashboard', { replace: true })
} catch (error) {
const code = (error as { errorCode?: string })?.errorCode
if (code === 'admin_phone_unbound') {
message.warning('该账号未绑定手机号,请联系管理员绑定后再使用安全登录')
} else {
message.error(error instanceof Error ? error.message : '登录失败')
}
} finally {
setLoading(false)
setWaiting(false)
setDevLaunchUrl('')
}
}
async function submitLegacyLogin() {
let values: Pick<LoginForm, 'username' | 'password'>
try {
values = await form.validateFields(['username', 'password'])
} catch {
return
}
setLoading(true)
try {
const response = await loginAdmin({
username: values.username.trim(),
@@ -52,16 +144,37 @@ export default function AdminLoginPage() {
message.success('登录成功')
void navigate('/admin/dashboard', { replace: true })
} catch (error) {
message.error(error instanceof Error ? error.message : '后台登录失败')
message.error(error instanceof Error ? error.message : '账号密码登录失败')
} finally {
setLoading(false)
}
}
async function waitForDevice(prepared: AdminDevicePrepareResponse) {
for (let attempt = 0; attempt < 120; attempt += 1) {
await sleep(1000)
const status = await fetchAdminDeviceStatus(prepared.challengeId, prepared.pollToken)
if (status.data.status === 'authorized' && status.data.deviceTicket) {
const completed = await completeAdminLogin({
challengeId: prepared.challengeId,
pollToken: prepared.pollToken,
deviceTicket: status.data.deviceTicket,
})
return completed.data
}
if (status.data.status === 'expired' || status.data.status === 'rejected') {
throw new Error(status.data.message || '本机验证已过期,请重新登录')
}
if (attempt === 4) {
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth.exe')
}
}
throw new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起')
}
return (
<main className="auth-page">
<div className="auth-shell">
{/* 品牌信息区:移动端隐藏 */}
<aside className="auth-brand">
<div className="auth-brand-logo">
<span className="auth-logo-mark">
@@ -72,7 +185,7 @@ export default function AdminLoginPage() {
<div className="auth-brand-body">
<h2>运营后台管理中心</h2>
<p>统一管理订单履约、打手调度与财务结算,按角色开放不同操作权限。</p>
<p>账号密码验证通过后,还需短信验证码和本机 DCAuth 设备签名才能进入后台。</p>
<div className="auth-brand-features">
{brandFeatures.map((item) => (
<div className="auth-brand-feature" key={item.title}>
@@ -91,10 +204,8 @@ export default function AdminLoginPage() {
</div>
</aside>
{/* 登录表单区 */}
<section className="auth-form-side">
<div className="auth-form-card">
{/* 移动端精简品牌栏 */}
<div className="auth-mobile-brand">
<span className="auth-logo-mark">
<DashboardOutlined />
@@ -104,16 +215,58 @@ export default function AdminLoginPage() {
<div className="auth-form-header">
<h1>运营后台登录</h1>
<p>使用账号密码进入后台,并按角色开放不同操作权限</p>
<p>账号密码 + 短信验证码 + 本机 DCAuth 设备验证</p>
</div>
<Form<LoginForm> layout="vertical" requiredMark={false} onFinish={submitLogin}>
{waiting ? (
<>
<Alert
type="info"
showIcon
message="等待本机确认"
description="请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth.exe。"
style={{ marginBottom: 16 }}
/>
{import.meta.env.DEV && devLaunchUrl ? (
<Alert
type="warning"
showIcon
message="macOS 开发调试"
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Typography.Text type="secondary">
浏览器无法直接唤起 Mac 程序。复制下面的链接,在终端执行:
</Typography.Text>
<Typography.Paragraph copyable={{ text: devLaunchUrl }} style={{ margin: 0 }}>
{devLaunchUrl}
</Typography.Paragraph>
<Typography.Text code copyable={{ text: `cd apps/dcauth && go run . "${devLaunchUrl}"` }}>
cd apps/dcauth && go run . "{devLaunchUrl}"
</Typography.Text>
</Space>
}
style={{ marginBottom: 16 }}
/>
) : null}
</>
) : null}
<Form<LoginForm>
form={form}
layout="vertical"
requiredMark={false}
onFinish={submitLogin}
>
<Form.Item
name="username"
label="账号"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input prefix={<UserOutlined />} placeholder="输入账号" autoComplete="username" />
<Input
prefix={<UserOutlined />}
placeholder="中文 / 英文 / 数字账号"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
@@ -126,11 +279,46 @@ export default function AdminLoginPage() {
autoComplete="current-password"
/>
</Form.Item>
<Form.Item
name="smsCode"
label={`短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`}
rules={[{ required: true, message: '请输入短信验证码' }]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="6 位短信验证码"
maxLength={6}
autoComplete="one-time-code"
addonAfter={
<Button
type="link"
size="small"
htmlType="button"
loading={sendingCode}
disabled={countdown > 0}
onClick={() => void sendCode()}
style={{ padding: 0, height: 'auto' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading} size="large">
登录后台
安全登录 · 拉起本机验证
</Button>
</Form>
<Divider plain style={{ margin: '18px 0 12px' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
技术测试账号 / 未绑定手机号
</Typography.Text>
</Divider>
<Button type="link" block onClick={() => void submitLegacyLogin()} disabled={loading}>
账号密码登录(测试 / 过渡)
</Button>
<div className="auth-form-footer">仅限授权运营人员访问</div>
</div>
</section>
@@ -138,3 +326,16 @@ export default function AdminLoginPage() {
</main>
)
}
function launchProtocol(url: string) {
const anchor = document.createElement('a')
anchor.href = url
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
window.setTimeout(() => anchor.remove(), 1000)
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
+174 -11
View File
@@ -11,6 +11,7 @@ import {
Result,
Select,
Space,
Switch,
Table,
Tag,
Typography,
@@ -27,7 +28,10 @@ import {
fetchAdminRolePermissions,
fetchAdminUsers,
resetAdminUserPassword,
resetAdminUserDevice,
updateAdminUserDisplayName,
updateAdminUserPhone,
updateAdminUserTestAccount,
updateAdminUserRole,
updateAdminUserStatus,
updateAdminRolePermissions,
@@ -53,6 +57,8 @@ type CreateUserForm = {
username: string
password: string
role: AdminRole
phone?: string
testAccount?: boolean
}
type DisplayNameForm = {
@@ -129,6 +135,8 @@ export default function AdminUsersPage() {
password: values.password.trim(),
role: values.role,
status: 'active',
phone: values.phone?.trim() || '',
testAccount: values.testAccount === true,
})
message.success('后台用户已创建')
createForm.resetFields()
@@ -293,6 +301,101 @@ export default function AdminUsersPage() {
})
}
function openBindPhoneModal(item: AdminUserListItem) {
const form = ModalFormStore.create<{ phone: string }>()
modal.confirm({
title: '绑定手机号',
content: (
<Form
layout="vertical"
preserve={false}
initialValues={{ phone: item.phone || '' }}
ref={form.bind}
className="modal-form"
>
<Form.Item
name="phone"
label={`${displayNameOf(item)} 的登录手机号`}
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
]}
extra="绑定后该账号必须使用账号密码 + 短信验证码 + 本机 DCAuth 登录"
>
<Input placeholder="11 位手机号" maxLength={11} />
</Form.Item>
</Form>
),
okText: '保存',
cancelText: '取消',
centered: true,
onOk: async () => {
const values = await form.validate()
setActionLoadingId(item.userId)
try {
await updateAdminUserPhone(item.userId, { phone: values.phone.trim() })
message.success('手机号已绑定')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '绑定手机号失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function confirmResetDevice(item: AdminUserListItem) {
if (!item.deviceBound) return
modal.confirm({
title: '重置设备绑定',
content: `确认解除 ${displayNameOf(item)} 的当前设备吗?解绑后该账号需要重新完成首次设备绑定。`,
okText: '确认解绑',
cancelText: '取消',
okButtonProps: { danger: true },
centered: true,
onOk: async () => {
setActionLoadingId(item.userId)
try {
await resetAdminUserDevice(item.userId)
message.success('设备绑定已重置')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '重置设备失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function confirmToggleTestAccount(item: AdminUserListItem) {
const next = !item.testAccount
modal.confirm({
title: next ? '设为技术测试账号' : '取消技术测试账号',
content: next
? `确认让 ${displayNameOf(item)} 跳过短信验证码和 DCAuth 设备验证,仅使用账号密码登录吗?仅建议用于技术测试。`
: `确认恢复 ${displayNameOf(item)} 的完整安全登录流程吗?`,
okText: next ? '设为测试账号' : '恢复正式账号',
cancelText: '取消',
okButtonProps: { danger: next },
centered: true,
onOk: async () => {
setActionLoadingId(item.userId)
try {
await updateAdminUserTestAccount(item.userId, { testAccount: next })
message.success(next ? '已设为技术测试账号' : '已恢复正式账号')
await reloadUsers()
} catch (error) {
message.error(error instanceof Error ? error.message : '更新测试账号设置失败')
} finally {
setActionLoadingId(null)
}
},
})
}
function applyFilters(values: UserFilterForm) {
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
}
@@ -334,6 +437,33 @@ export default function AdminUsersPage() {
minWidth: 140,
render: (value: string) => <Typography.Text type="secondary">{value}</Typography.Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 150,
render: (value: string) =>
value ? (
<Typography.Text>{value}</Typography.Text>
) : (
<Tag color="orange">未绑定</Tag>
),
},
{
title: '登录设备',
dataIndex: 'deviceBound',
width: 210,
render: (_: boolean, row) =>
row.deviceBound ? (
<Space direction="vertical" size={0}>
<Typography.Text>{row.machineCodeMask || row.deviceIdMasked}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
绑定于 {formatAdminDateTime(row.deviceBoundAt || '')}
</Typography.Text>
</Space>
) : (
<Tag>未绑定</Tag>
),
},
{
title: '角色',
dataIndex: 'role',
@@ -343,8 +473,13 @@ export default function AdminUsersPage() {
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (value) => <StatusTag value={value} />,
width: 170,
render: (value, row) => (
<Space wrap>
<StatusTag value={value} />
{row.testAccount ? <Tag color="purple">技术测试</Tag> : null}
</Space>
),
},
{
title: '创建时间',
@@ -360,7 +495,7 @@ export default function AdminUsersPage() {
},
{
title: '操作',
width: 390,
width: 760,
render: (_, row) => {
const isCurrent = row.userId === currentUserId
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
@@ -377,6 +512,26 @@ export default function AdminUsersPage() {
>
改名
</Button>
<Button
loading={actionLoadingId === row.userId}
onClick={() => openBindPhoneModal(row)}
>
绑定手机号
</Button>
<Button
disabled={!row.deviceBound}
danger
loading={actionLoadingId === row.userId}
onClick={() => confirmResetDevice(row)}
>
重置设备
</Button>
<Button
loading={actionLoadingId === row.userId}
onClick={() => confirmToggleTestAccount(row)}
>
{row.testAccount ? '取消测试账号' : '设为测试账号'}
</Button>
<Dropdown
disabled={isCurrent}
menu={{
@@ -412,7 +567,7 @@ export default function AdminUsersPage() {
<section className="page-stack">
<PageHeader
title="后台用户"
description="管理员可维护后台账号、姓名、角色和启停状态,避免继续使用单一口令。"
description="管理员可维护后台账号、姓名、手机号、测试账号、角色、设备绑定和启停状态。"
/>
<Card className="users-action-card">
@@ -462,7 +617,7 @@ export default function AdminUsersPage() {
<Form<CreateUserForm>
form={createForm}
layout="inline"
initialValues={{ role: 'operator' }}
initialValues={{ role: 'operator', testAccount: false }}
onFinish={submitCreate}
className="filter-form users-action-form"
>
@@ -470,20 +625,28 @@ export default function AdminUsersPage() {
<Input placeholder="姓名(可中文)" className="users-input-md" maxLength={32} />
</Form.Item>
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
<Input placeholder="登录账号(英文/数字)" className="users-input-md" />
<Input placeholder="登录账号(中文/英文/数字)" className="users-input-md" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入新密码' }, { min: 8, message: '密码至少 8 位' }]}>
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
</Form.Item>
<Form.Item
name="password"
name="phone"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的 11 位手机号',
},
]}
>
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
<Input placeholder="绑定手机号(可选)" className="users-input-md" maxLength={11} />
</Form.Item>
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
<Select options={USER_ROLE_OPTIONS} className="users-select-sm" />
</Form.Item>
<Form.Item name="testAccount" valuePropName="checked">
<Switch checkedChildren="技术测试" unCheckedChildren="正式账号" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={creating}>
新增用户
@@ -516,7 +679,7 @@ export default function AdminUsersPage() {
columns={columns}
dataSource={data?.items || []}
locale={{ emptyText: <Empty description="暂无用户" /> }}
scroll={{ x: 1220 }}
scroll={{ x: 1680 }}
pagination={buildAdminTablePagination({
current: data?.pagination.page || page,
pageSize: data?.pagination.pageSize || pageSize,
+35 -1
View File
@@ -1,10 +1,44 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminLoginResponse, AdminSessionSummary } from '@/types/admin'
import type {
AdminDevicePrepareResponse,
AdminDeviceStatusResponse,
AdminLoginResponse,
AdminSessionSummary,
AdminSmsCodeResponse,
} from '@/types/admin'
export function loginAdmin(payload: { username: string; password: string }) {
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login', payload)
}
export function sendAdminLoginSmsCode(payload: { username: string; password: string }) {
return apiPost<AdminSmsCodeResponse>('/api/v1/admin/auth/login/sms-code', payload)
}
export function prepareAdminLogin(payload: {
username: string
password: string
smsCode: string
apiBase: string
}) {
return apiPost<AdminDevicePrepareResponse>('/api/v1/admin/auth/login/prepare', payload)
}
export function fetchAdminDeviceStatus(challengeId: string, pollToken: string) {
return apiGet<AdminDeviceStatusResponse>(
`/api/dcauth/challenge/${encodeURIComponent(challengeId)}/status`,
{ pollToken },
)
}
export function completeAdminLogin(payload: {
challengeId: string
pollToken: string
deviceTicket: string
}) {
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login/complete', payload)
}
export function fetchAdminSession() {
return apiGet<AdminSessionSummary>('/api/v1/admin/auth/session')
}
+23
View File
@@ -39,6 +39,8 @@ export function createAdminUser(payload: {
password: string
role: string
status?: string
phone?: string
testAccount?: boolean
}) {
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
}
@@ -64,3 +66,24 @@ export function resetAdminUserPassword(userId: number | string, payload: { passw
payload,
)
}
export function updateAdminUserPhone(userId: number | string, payload: { phone: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/phone`, payload)
}
export function resetAdminUserDevice(userId: number | string) {
return apiPost<{ user: AdminUserListItem; reset: boolean }>(
`/api/v1/admin/users/${userId}/device/reset`,
{},
)
}
export function updateAdminUserTestAccount(
userId: number | string,
payload: { testAccount: boolean },
) {
return apiPost<{ user: AdminUserListItem; testAccount: boolean }>(
`/api/v1/admin/users/${userId}/test-account`,
payload,
)
}
+26
View File
@@ -9,10 +9,36 @@ export interface AdminLoginResponse {
username: string
displayName: string
role: AdminRole
deviceId?: string
permissions?: string[]
}
}
export interface AdminSmsCodeResponse {
sent: boolean
phoneMasked: string
expiresInSeconds: number
provider: string
}
export interface AdminDevicePrepareResponse {
challengeId: string
challenge: string
expiresAt: string
pollToken: string
launchUrl: string
phoneMasked: string
app: string
}
export interface AdminDeviceStatusResponse {
status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected'
deviceTicket?: string
deviceId?: string
machineCodeMask?: string
message?: string
}
export interface AdminSessionSummary {
authenticated: boolean
expiresAt: string
+5
View File
@@ -3,6 +3,11 @@ export type { AdminPagination } from './common'
// Auth types
export type { AdminRole, AdminUserStatus, AdminLoginResponse, AdminSessionSummary } from './auth'
export type {
AdminDevicePrepareResponse,
AdminDeviceStatusResponse,
AdminSmsCodeResponse,
} from './auth'
// Users types
export type { AdminUserListItem } from './users'
+10
View File
@@ -6,6 +6,16 @@ export interface AdminUserListItem {
displayName: string
role: AdminRole
status: AdminUserStatus
phone: string
phoneMasked: string
phoneBoundAt: string | null
testAccount: boolean
deviceBound: boolean
deviceId: string
deviceIdMasked: string
machineCodeMask: string
deviceBoundAt: string | null
deviceLastUsedAt: string | null
createdAt: string
updatedAt: string
}