feat(auth): 新增手机号与 DCAuth 设备认证登录

- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064
- 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置
- 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录
- 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
This commit is contained in:
yml2213
2026-10-21 22:00:03 +08:00
parent f21362dfe3
commit 2f5a63ae27
24 changed files with 3049 additions and 130 deletions
+25 -1
View File
@@ -1,10 +1,34 @@
import api from './client';
import type { CurrentUser, LoginResult, MessageResponse } from './types';
import type {
CurrentUser,
DeviceChallengeStatus,
DeviceCompletePayload,
DevicePreparePayload,
DevicePrepareResult,
LoginResult,
MessageResponse,
SmsCodeResult,
} from './types';
export const authApi = {
login: (username: string, password: string) =>
api.post<LoginResult, LoginResult>('/auth/login', { username, password }),
sendSmsCode: (username: string, password: string) =>
api.post<SmsCodeResult, SmsCodeResult>('/auth/sms-code', { username, password }),
prepareDeviceLogin: (data: DevicePreparePayload) =>
api.post<DevicePrepareResult, DevicePrepareResult>('/auth/prepare', data),
completeDeviceLogin: (data: DeviceCompletePayload) =>
api.post<LoginResult, LoginResult>('/auth/complete', data),
deviceChallengeStatus: (challengeId: string, pollToken: string) =>
api.get<DeviceChallengeStatus, DeviceChallengeStatus>(
`/dcauth/challenge/${encodeURIComponent(challengeId)}/status`,
{ params: { pollToken } },
),
me: () => api.get<CurrentUser, CurrentUser>('/auth/me'),
logout: () => api.post<MessageResponse, MessageResponse>('/auth/logout'),
+10 -3
View File
@@ -10,9 +10,13 @@ const api = axios.create({
api.interceptors.response.use(
(response) => response.data,
(error) => {
const requestUrl: string = error.config?.url ?? '';
// 登录链路(含 DCAuth 轮询)里的 401 是业务错误,不能当成“登录态失效”跳转。
const isAuthFlow =
['/auth/login', '/auth/sms-code', '/auth/prepare', '/auth/complete'].includes(requestUrl) ||
requestUrl.startsWith('/dcauth/');
// 仅在“已登录后 token 失效”时清除登录态并跳转登录页。
// 登录接口本身返回 401(用户名/密码错误)时不应跳转,否则会吞掉错误提示。
if (error.response?.status === 401 && error.config?.url !== '/auth/login') {
if (error.response?.status === 401 && !isAuthFlow) {
localStorage.removeItem('user');
window.location.href = '/login';
}
@@ -25,7 +29,10 @@ api.interceptors.response.use(
} else if (detail && typeof detail === 'object') {
msg = JSON.stringify(detail);
}
return Promise.reject(new Error(msg));
const wrapped = new Error(msg) as Error & { errorCode?: string; status?: number };
wrapped.errorCode = error.response?.data?.errorCode;
wrapped.status = error.response?.status;
return Promise.reject(wrapped);
}
);
+44
View File
@@ -272,6 +272,43 @@ export interface CurrentUser {
permissions: string[];
}
export interface SmsCodeResult {
sent: boolean;
phoneMasked: string;
expiresInSeconds: number;
provider?: string;
}
export interface DevicePreparePayload {
username: string;
password: string;
smsCode: string;
apiBase?: string;
}
export interface DevicePrepareResult {
challengeId: string;
challenge: string;
expiresAt: string;
pollToken: string;
launchUrl: string;
phoneMasked: string;
app: string;
}
export interface DeviceChallengeStatus {
status: string;
deviceTicket?: string;
deviceId?: string;
message?: string;
}
export interface DeviceCompletePayload {
challengeId: string;
pollToken: string;
deviceTicket: string;
}
export interface UserInfo {
id: number;
username: string;
@@ -280,6 +317,13 @@ export interface UserInfo {
remark: string;
permissions: string[];
custom_permissions?: string[] | null;
phone: string;
phone_masked: string;
phone_bound_at?: string | null;
device_id: string;
device_id_masked: string;
device_bound_at?: string | null;
device_last_used_at?: string | null;
}
// ==================== Account ====================
+7
View File
@@ -10,5 +10,12 @@ export const userApi = {
rename: (id: number, username: string) =>
api.patch<UserInfo, UserInfo>(`/users/${id}/username`, { username }),
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/users/${id}`),
bindPhone: (id: number, phone: string) =>
api.post<UserInfo, UserInfo>(`/admin/users/${id}/phone`, { phone }),
resetDevice: (id: number) =>
api.post<
{ success: boolean; reset: boolean; revoked: number; message: string },
{ success: boolean; reset: boolean; revoked: number; message: string }
>(`/admin/users/${id}/device/reset`),
listPermissions: () => api.get<PermissionsListResult, PermissionsListResult>('/users/permissions/list'),
};
+352 -54
View File
@@ -1,10 +1,16 @@
import { useRef, useState } from 'react';
import { Card, Form, Input, Button, Typography } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Card, Divider, Form, Input, Space, Typography } from 'antd';
import type { InputRef } from 'antd';
import { message } from '../utils/antdMessage';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import {
LockOutlined,
MobileOutlined,
SafetyCertificateOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../api/modules';
import type { DevicePrepareResult, LoginResult } from '../api/modules';
import { setAuth, type AuthUser } from '../store/auth';
import { getErrorMessage } from '../utils/error';
import { useTheme } from '../store/useTheme';
@@ -12,78 +18,370 @@ import { useAppInfo } from '../hooks/useAppInfo';
const { Title, Text } = Typography;
type LoginMode = 'secure' | 'legacy';
type LoginValues = { username: string; password: string; smsCode?: string };
function errorCodeOf(error: unknown): string {
return (error as { errorCode?: string } | null)?.errorCode || '';
}
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));
}
export default function LoginPage({ onLogin }: { onLogin?: () => void }) {
const [mode, setMode] = useState<LoginMode>('secure');
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0);
const [phoneMasked, setPhoneMasked] = useState('');
const [waiting, setWaiting] = useState(false);
const [launchUrl, setLaunchUrl] = useState('');
const [form] = Form.useForm<LoginValues>();
const passwordRef = useRef<InputRef>(null);
const timerRef = useRef<number | null>(null);
const submittingRef = useRef(false);
const navigate = useNavigate();
const { isDark } = useTheme();
const appInfo = useAppInfo();
const versionText = appInfo?.version ? `v${appInfo.version}` : 'v--';
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
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);
}
function switchMode(next: LoginMode) {
if (loading || waiting) return;
setMode(next);
form.setFieldValue('smsCode', '');
setWaiting(false);
setLaunchUrl('');
setPhoneMasked('');
}
function applyLoginResult(result: LoginResult) {
const user: AuthUser = {
id: 0,
username: result.username,
role: result.role,
permissions: result.permissions,
};
setAuth(user);
message.success('登录成功');
onLogin?.();
navigate('/', { replace: true });
}
async function sendCode() {
let values: Pick<LoginValues, 'username' | 'password'>;
try {
const result = await authApi.login(values.username, values.password);
const user: AuthUser = {
id: 0,
username: result.username,
role: result.role,
permissions: result.permissions,
};
setAuth(user);
message.success('登录成功');
onLogin?.(); // 触发 App 重渲染
navigate('/', { replace: true });
} catch (e: unknown) {
const msg = getErrorMessage(e) || '登录失败';
message.error(msg);
// 密码错误(401)时清空密码并自动聚焦,方便快速重试
if (msg.includes('密码') || msg.includes('用户名')) {
form.setFieldValue('password', '');
passwordRef.current?.focus();
values = await form.validateFields(['username', 'password']);
} catch {
return;
}
setSendingCode(true);
try {
const result = await authApi.sendSmsCode(values.username.trim(), values.password);
setPhoneMasked(result.phoneMasked);
startCountdown(60);
message.success(`验证码已发送至 ${result.phoneMasked}`);
} catch (error) {
const code = errorCodeOf(error);
if (code === 'phone_unbound') {
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
} else {
message.error(getErrorMessage(error) || '验证码发送失败');
}
} finally {
setSendingCode(false);
}
}
async function handleSecureLogin(values: LoginValues) {
if (submittingRef.current) return;
submittingRef.current = true;
setLoading(true);
setWaiting(false);
try {
const prepared = await authApi.prepareDeviceLogin({
username: values.username.trim(),
password: values.password,
smsCode: (values.smsCode || '').trim(),
apiBase: window.location.origin,
});
setPhoneMasked(prepared.phoneMasked);
setLaunchUrl(prepared.launchUrl);
setWaiting(true);
message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认');
launchProtocol(prepared.launchUrl);
const completed = await waitForDevice(prepared);
applyLoginResult(completed);
} catch (error) {
const code = errorCodeOf(error);
if (code === 'phone_unbound') {
message.warning('该账号尚未绑定手机号,请使用「旧账号仅密码登录」或联系管理员绑定');
} else if (code === 'device_mismatch') {
message.error('当前设备与账号绑定设备不一致,请联系管理员重置设备');
} else if (code === 'challenge_expired' || code === 'challenge_rejected') {
message.error('本机验证已过期,请重新发起登录');
} else if (code === 'device_timeout') {
message.error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起');
} else {
const text = getErrorMessage(error) || '登录失败';
message.error(text);
if (text.includes('密码') || text.includes('用户名')) {
form.setFieldValue('password', '');
passwordRef.current?.focus();
}
}
} finally {
submittingRef.current = false;
setLoading(false);
setWaiting(false);
setLaunchUrl('');
}
}
async function waitForDevice(prepared: DevicePrepareResult): Promise<LoginResult> {
for (let attempt = 0; attempt < 120; attempt += 1) {
await sleep(1000);
const status = await authApi.deviceChallengeStatus(
prepared.challengeId,
prepared.pollToken,
);
if (status.status === 'authorized' && status.deviceTicket) {
return authApi.completeDeviceLogin({
challengeId: prepared.challengeId,
pollToken: prepared.pollToken,
deviceTicket: status.deviceTicket,
});
}
if (status.status === 'expired' || status.status === 'rejected') {
const error = new Error(status.message || '本机验证已过期,请重新登录') as Error & {
errorCode?: string;
};
error.errorCode = status.status === 'expired' ? 'challenge_expired' : 'challenge_rejected';
throw error;
}
if (status.status === 'ticket_issued' || status.status === 'consumed') {
const error = new Error('本机验证会话已被使用,请重新发起登录') as Error & {
errorCode?: string;
};
error.errorCode = 'challenge_used';
throw error;
}
if (attempt === 4) {
message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth');
}
}
const timeout = new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起') as Error & {
errorCode?: string;
};
timeout.errorCode = 'device_timeout';
throw timeout;
}
async function handleLegacyLogin(values: LoginValues) {
if (submittingRef.current) return;
submittingRef.current = true;
setLoading(true);
try {
const result = await authApi.login(values.username.trim(), values.password);
applyLoginResult(result);
} catch (error) {
const code = errorCodeOf(error);
if (code === 'secure_login_required') {
message.warning('该账号已启用安全登录,请使用短信验证码 + 本机验证登录');
} else {
message.error(getErrorMessage(error) || '登录失败');
}
} finally {
submittingRef.current = false;
setLoading(false);
}
};
}
return (
<div style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: isDark
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Card style={{ width: 380, boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)' }}>
<div
style={{
minHeight: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: isDark
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<Card
style={{
width: 420,
boxShadow: isDark ? '0 8px 24px rgba(0,0,0,0.4)' : '0 8px 24px rgba(0,0,0,0.15)',
}}
>
<Title level={3} style={{ textAlign: 'center', marginBottom: 32 }}>
直播运营后台
</Title>
<Text type="secondary" style={{
display: 'block',
textAlign: 'center',
marginTop: -20,
marginBottom: 24,
fontSize: 12,
}}>
<Text
type="secondary"
style={{
display: 'block',
textAlign: 'center',
marginTop: -20,
marginBottom: 24,
fontSize: 12,
}}
>
当前版本 {versionText}
</Text>
<Form form={form} onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" ref={passwordRef} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
登录
{mode === 'secure' ? (
<>
<Alert
type="info"
showIcon
message="安全登录"
description="用户名密码验证后,还需短信验证码和本机 DCAuth 设备签名。"
style={{ marginBottom: 16 }}
/>
{waiting ? (
<Alert
type="warning"
showIcon
message="等待本机确认"
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<span>请在弹出的 DCAuth 窗口中点击确认。若未弹出,请先安装并运行一次 DCAuth。</span>
{launchUrl ? (
<Text code copyable={{ text: launchUrl }} style={{ wordBreak: 'break-all' }}>
{launchUrl}
</Text>
) : null}
</Space>
}
style={{ marginBottom: 16 }}
/>
) : null}
<Form form={form} onFinish={handleSecureLogin} size="large" requiredMark={false}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
prefix={<UserOutlined />}
placeholder="用户名(中文 / 英文 / 数字)"
autoComplete="username"
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
autoComplete="current-password"
ref={passwordRef}
/>
</Form.Item>
<Form.Item
name="smsCode"
rules={[{ required: true, message: '请输入短信验证码' }]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder={phoneMasked ? `6 位验证码(${phoneMasked})` : '6 位短信验证码'}
maxLength={6}
autoComplete="one-time-code"
addonAfter={
<Button
type="link"
size="small"
htmlType="button"
loading={sendingCode}
disabled={countdown > 0 || loading}
onClick={() => void sendCode()}
style={{ padding: 0, height: 'auto' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
安全登录 · 拉起本机验证
</Button>
</Form.Item>
</Form>
<Divider plain style={{ margin: '4px 0 12px' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
未绑定手机号的旧账号
</Text>
</Divider>
<Button type="link" block onClick={() => switchMode('legacy')} disabled={loading}>
旧账号仅密码登录
</Button>
</Form.Item>
</Form>
</>
) : (
<>
<Alert
type="warning"
showIcon
message="旧账号仅密码登录"
description="仅未绑定手机号的账号可用;已绑定手机号的账号会被要求使用安全登录。"
style={{ marginBottom: 16 }}
/>
<Form form={form} onFinish={handleLegacyLogin} size="large" requiredMark={false}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
autoComplete="current-password"
ref={passwordRef}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
密码登录
</Button>
</Form.Item>
</Form>
<Button type="link" block onClick={() => switchMode('secure')} disabled={loading}>
<MobileOutlined /> 返回安全登录
</Button>
</>
)}
</Card>
</div>
);
+123 -2
View File
@@ -5,7 +5,7 @@ import {
} from 'antd';
import type { TableProps } from 'antd';
import { message } from '../utils/antdMessage';
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons';
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined, ReloadOutlined } from '@ant-design/icons';
import { userApi, type UserInfo } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { getErrorMessage } from '../utils/error';
@@ -59,6 +59,13 @@ export default function UsersPage() {
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
const [useCustom, setUseCustom] = useState(false);
const [permLoading, setPermLoading] = useState(false);
// 手机号绑定 / 设备重置
const [phoneModalOpen, setPhoneModalOpen] = useState(false);
const [phoneUser, setPhoneUser] = useState<UserInfo | null>(null);
const [phoneForm] = Form.useForm<{ phone: string }>();
const [phoneSaving, setPhoneSaving] = useState(false);
const [resettingId, setResettingId] = useState<number | null>(null);
const { can } = usePermissions();
const canAssignPerm = can('user:assign_permissions');
@@ -136,6 +143,42 @@ export default function UsersPage() {
}
};
// ---- 手机号 / 设备 ----
const handlePhoneClick = (user: UserInfo) => {
setPhoneUser(user);
phoneForm.setFieldsValue({ phone: user.phone || '' });
setPhoneModalOpen(true);
};
const handlePhoneSave = async () => {
if (!phoneUser) return;
try {
const values = await phoneForm.validateFields();
setPhoneSaving(true);
await userApi.bindPhone(phoneUser.id, values.phone.trim());
message.success('手机号已更新');
setPhoneModalOpen(false);
loadUsers();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setPhoneSaving(false);
}
};
const handleResetDevice = async (id: number) => {
setResettingId(id);
try {
const result = await userApi.resetDevice(id);
message.success(result.message || (result.reset ? '已重置设备' : '当前没有已绑定设备'));
loadUsers();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setResettingId(null);
}
};
// ---- 权限管理 ----
const handlePermClick = async (user: UserInfo) => {
setPermUser(user);
@@ -209,6 +252,55 @@ export default function UsersPage() {
width: 80,
render: (active: boolean) => active ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 170,
render: (_: unknown, record: UserInfo) => (
<Space size={4}>
{record.phone ? (
<span>{record.phone_masked || record.phone}</span>
) : (
<Tag>未绑定</Tag>
)}
{canEdit && (
<Button size="small" type="link" onClick={() => handlePhoneClick(record)}>
{record.phone ? '修改' : '绑定'}
</Button>
)}
</Space>
),
},
{
title: '绑定设备',
dataIndex: 'device_id',
width: 230,
render: (_: unknown, record: UserInfo) =>
record.device_id ? (
<Space size={4} wrap>
<span style={{ fontFamily: 'monospace' }}>{record.device_id_masked || record.device_id}</span>
{canEdit && (
<Popconfirm
title="确认重置设备?"
description="重置后该账号下次登录会重新绑定当前设备。"
onConfirm={() => handleResetDevice(record.id)}
>
<Button
size="small"
type="link"
danger
icon={<ReloadOutlined />}
loading={resettingId === record.id}
>
重置设备
</Button>
</Popconfirm>
)}
</Space>
) : (
<Tag>未绑定</Tag>
),
},
{ title: '备注', dataIndex: 'remark', width: 180, ellipsis: true },
{
title: '操作',
@@ -250,7 +342,7 @@ export default function UsersPage() {
loading={loading}
size="small"
tableLayout="fixed"
scroll={{ x: 844 }}
scroll={{ x: 1240 }}
pagination={{
current: currentPage,
pageSize,
@@ -378,6 +470,35 @@ export default function UsersPage() {
</>
)}
</Modal>
{/* 绑定 / 修改手机号 Modal */}
<Modal
title={phoneUser ? `绑定手机号 - ${phoneUser.username}` : '绑定手机号'}
open={phoneModalOpen}
onCancel={() => setPhoneModalOpen(false)}
onOk={handlePhoneSave}
okText="保存"
confirmLoading={phoneSaving}
width={420}
>
<Form form={phoneForm} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
]}
>
<Input placeholder="11 位手机号" maxLength={11} autoComplete="tel" />
</Form.Item>
<Alert
type="info"
showIcon
message="绑定后该账号必须使用「密码 + 短信验证码 + 本机 DCAuth 设备」登录。"
/>
</Form>
</Modal>
</div>
);
}