Files
live-hub-py/web/frontend/src/pages/LoginPage.tsx
T
yml2213 2f5a63ae27 feat(auth): 新增手机号与 DCAuth 设备认证登录
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 新增 login_sms_codes / user_devices / device_challenges 三张表,迁移 0063/0064
- 设备绑定只比对 deviceId + publicKey,每账号一个 active 设备,管理员可重置
- 保留未绑定手机号账号的密码登录兜底,绑定后强制安全登录
- 登录页与用户管理页支持验证码、DCAuth 轮询、手机号绑定与设备重置
2026-10-21 22:00:03 +08:00

389 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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';
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 [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--';
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 {
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: 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,
}}
>
当前版本 {versionText}
</Text>
{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>
</>
) : (
<>
<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>
);
}