feat: passkey

This commit is contained in:
Seefs
2025-09-29 17:45:09 +08:00
parent 78b9b21a05
commit 1599a8403f
30 changed files with 2924 additions and 110 deletions

View File

@@ -32,6 +32,9 @@ import {
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
prepareCredentialRequestOptions,
buildAssertionResult,
isPasskeySupported,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
@@ -39,7 +42,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login';
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons';
import OIDCIcon from '../common/logo/OIDCIcon';
import WeChatIcon from '../common/logo/WeChatIcon';
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
@@ -74,6 +77,8 @@ const LoginForm = () => {
useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [showTwoFA, setShowTwoFA] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const logo = getLogo();
const systemName = getSystemName();
@@ -95,6 +100,12 @@ const LoginForm = () => {
}
}, [status]);
useEffect(() => {
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
}, []);
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录'));
@@ -266,6 +277,55 @@ const LoginForm = () => {
setEmailLoginLoading(false);
};
const handlePasskeyLogin = async () => {
if (!passkeySupported) {
showInfo('当前环境无法使用 Passkey 登录');
return;
}
if (!window.PublicKeyCredential) {
showInfo('当前浏览器不支持 Passkey');
return;
}
setPasskeyLoading(true);
try {
const beginRes = await API.post('/api/user/passkey/login/begin');
const { success, message, data } = beginRes.data;
if (!success) {
showError(message || '无法发起 Passkey 登录');
return;
}
const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data);
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
const payload = buildAssertionResult(assertion);
if (!payload) {
showError('Passkey 验证失败,请重试');
return;
}
const finishRes = await API.post('/api/user/passkey/login/finish', payload);
const finish = finishRes.data;
if (finish.success) {
userDispatch({ type: 'login', payload: finish.data });
setUserData(finish.data);
updateAPI();
showSuccess('登录成功!');
navigate('/console');
} else {
showError(finish.message || 'Passkey 登录失败,请重试');
}
} catch (error) {
if (error?.name === 'AbortError') {
showInfo('已取消 Passkey 登录');
} else {
showError('Passkey 登录失败,请重试');
}
} finally {
setPasskeyLoading(false);
}
};
// 包装的重置密码点击处理
const handleResetPasswordClick = () => {
setResetPasswordLoading(true);
@@ -385,6 +445,19 @@ const LoginForm = () => {
</div>
)}
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
@@ -437,6 +510,18 @@ const LoginForm = () => {
</Title>
</div>
<div className='px-2 py-8'>
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
type='tertiary'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Form className='space-y-3'>
<Form.Input
field='username'

View File

@@ -0,0 +1,117 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Modal } from '@douyinfe/semi-ui';
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
import { createApiCalls } from '../../../services/secureVerification';
import SecureVerificationModal from '../modals/SecureVerificationModal';
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
/**
* 渠道密钥查看组件使用示例
* 展示如何使用通用安全验证系统
*/
const ChannelKeyViewExample = ({ channelId }) => {
const { t } = useTranslation();
const [keyData, setKeyData] = useState('');
const [showKeyModal, setShowKeyModal] = useState(false);
// 使用通用安全验证 Hook
const {
isModalVisible,
verificationMethods,
verificationState,
startVerification,
executeVerification,
cancelVerification,
setVerificationCode,
switchVerificationMethod,
} = useSecureVerification({
onSuccess: (result) => {
// 验证成功后处理结果
if (result.success && result.data?.key) {
setKeyData(result.data.key);
setShowKeyModal(true);
}
},
successMessage: t('密钥获取成功'),
});
// 开始查看密钥流程
const handleViewKey = async () => {
const apiCall = createApiCalls.viewChannelKey(channelId);
await startVerification(apiCall, {
title: t('查看渠道密钥'),
description: t('为了保护账户安全,请验证您的身份。'),
preferredMethod: 'passkey', // 可以指定首选验证方式
});
};
return (
<>
{/* 查看密钥按钮 */}
<Button
type='primary'
theme='outline'
onClick={handleViewKey}
>
{t('查看密钥')}
</Button>
{/* 安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}
verificationMethods={verificationMethods}
verificationState={verificationState}
onVerify={executeVerification}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodSwitch={switchVerificationMethod}
title={verificationState.title}
description={verificationState.description}
/>
{/* 密钥显示模态框 */}
<Modal
title={t('渠道密钥信息')}
visible={showKeyModal}
onCancel={() => setShowKeyModal(false)}
footer={
<Button type='primary' onClick={() => setShowKeyModal(false)}>
{t('完成')}
</Button>
}
width={700}
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}
/>
</Modal>
</>
);
};
export default ChannelKeyViewExample;

View File

@@ -0,0 +1,271 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Input, Typography, Tabs, TabPane, Card } from '@douyinfe/semi-ui';
/**
* 通用安全验证模态框组件
* 配合 useSecureVerification Hook 使用
* @param {Object} props
* @param {boolean} props.visible - 是否显示模态框
* @param {Object} props.verificationMethods - 可用的验证方式
* @param {Object} props.verificationState - 当前验证状态
* @param {Function} props.onVerify - 验证回调
* @param {Function} props.onCancel - 取消回调
* @param {Function} props.onCodeChange - 验证码变化回调
* @param {Function} props.onMethodSwitch - 验证方式切换回调
* @param {string} props.title - 模态框标题
* @param {string} props.description - 验证描述文本
*/
const SecureVerificationModal = ({
visible,
verificationMethods,
verificationState,
onVerify,
onCancel,
onCodeChange,
onMethodSwitch,
title,
description,
}) => {
const { t } = useTranslation();
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
const { method, loading, code } = verificationState;
const handleKeyDown = (e) => {
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
onVerify(method, code);
}
};
// 如果用户没有启用任何验证方式
if (visible && !has2FA && !hasPasskey) {
return (
<Modal
title={title || t('安全验证')}
visible={visible}
onCancel={onCancel}
footer={
<Button onClick={onCancel}>{t('确定')}</Button>
}
width={500}
style={{ maxWidth: '90vw' }}
>
<div className='text-center py-6'>
<div className='mb-4'>
<svg
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
</div>
<Typography.Title heading={4} className='mb-2'>
{t('需要安全验证')}
</Typography.Title>
<Typography.Text type='tertiary'>
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
</Typography.Text>
<br />
<Typography.Text type='tertiary'>
{t('请前往个人设置 → 安全设置进行配置。')}
</Typography.Text>
</div>
</Modal>
);
}
return (
<Modal
title={
<div className='flex items-center'>
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
<svg
className='w-4 h-4 text-blue-600 dark:text-blue-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
clipRule='evenodd'
/>
</svg>
</div>
{title || t('安全验证')}
</div>
}
visible={visible}
onCancel={onCancel}
footer={null}
width={600}
style={{ maxWidth: '90vw' }}
>
<div className='space-y-6'>
{/* 安全提示 */}
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
<div className='flex items-start'>
<svg
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
<div>
<Typography.Text
strong
className='text-blue-800 dark:text-blue-200'
>
{t('安全验证')}
</Typography.Text>
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
{description || t('为了保护账户安全,请选择一种方式进行验证。')}
</Typography.Text>
</div>
</div>
</div>
{/* 验证方式选择 */}
<Tabs activeKey={method} onChange={onMethodSwitch} type='card'>
{has2FA && (
<TabPane
tab={
<div className='flex items-center space-x-2'>
<svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
<path d='M10 12a2 2 0 100-4 2 2 0 000 4z' />
<path
fillRule='evenodd'
d='M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z'
clipRule='evenodd'
/>
</svg>
<span>{t('两步验证')}</span>
</div>
}
itemKey='2fa'
>
<Card className='border-0 shadow-none bg-transparent'>
<div className='space-y-4'>
<div>
<Typography.Text strong className='block mb-2'>
{t('验证码')}
</Typography.Text>
<Input
placeholder={t('请输入认证器验证码或备用码')}
value={code}
onChange={onCodeChange}
size='large'
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus={method === '2fa'}
/>
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
{t('支持6位TOTP验证码或8位备用码')}
</Typography.Text>
</div>
<div className='flex justify-end space-x-3'>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button
type='primary'
loading={loading}
disabled={!code.trim() || loading}
onClick={() => onVerify(method, code)}
>
{t('验证')}
</Button>
</div>
</div>
</Card>
</TabPane>
)}
{hasPasskey && passkeySupported && (
<TabPane
tab={
<div className='flex items-center space-x-2'>
<svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
clipRule='evenodd'
/>
</svg>
<span>{t('Passkey')}</span>
</div>
}
itemKey='passkey'
>
<Card className='border-0 shadow-none bg-transparent'>
<div className='space-y-4'>
<div className='text-center py-4'>
<div className='mb-4'>
<svg
className='w-16 h-16 text-blue-500 mx-auto'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
clipRule='evenodd'
/>
</svg>
</div>
<Typography.Text strong className='block mb-2'>
{t('使用 Passkey 验证')}
</Typography.Text>
<Typography.Text type='tertiary' className='block mb-4'>
{t('点击下方按钮,使用您的生物特征或安全密钥进行验证')}
</Typography.Text>
</div>
<div className='flex justify-end space-x-3'>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button
type='primary'
loading={loading}
disabled={loading}
onClick={() => onVerify(method)}
>
{loading ? t('验证中...') : t('验证 Passkey')}
</Button>
</div>
</div>
</Card>
</TabPane>
)}
</Tabs>
</div>
</Modal>
);
};
export default SecureVerificationModal;

View File

@@ -26,6 +26,9 @@ import {
showInfo,
showSuccess,
setStatusData,
prepareCredentialCreationOptions,
buildRegistrationResult,
isPasskeySupported,
} from '../../helpers';
import { UserContext } from '../../context/User';
import { Modal } from '@douyinfe/semi-ui';
@@ -66,6 +69,10 @@ const PersonalSetting = () => {
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [systemToken, setSystemToken] = useState('');
const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
const [notificationSettings, setNotificationSettings] = useState({
warningType: 'email',
warningThreshold: 100000,
@@ -112,6 +119,10 @@ const PersonalSetting = () => {
})();
getUserData();
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
}, []);
useEffect(() => {
@@ -160,11 +171,89 @@ const PersonalSetting = () => {
}
};
const loadPasskeyStatus = async () => {
try {
const res = await API.get('/api/user/passkey');
const { success, data, message } = res.data;
if (success) {
setPasskeyStatus({
enabled: data?.enabled || false,
last_used_at: data?.last_used_at || null,
backup_eligible: data?.backup_eligible || false,
backup_state: data?.backup_state || false,
});
} else {
showError(message);
}
} catch (error) {
// 忽略错误,保留默认状态
}
};
const handleRegisterPasskey = async () => {
if (!passkeySupported || !window.PublicKeyCredential) {
showInfo(t('当前设备不支持 Passkey'));
return;
}
setPasskeyRegisterLoading(true);
try {
const beginRes = await API.post('/api/user/passkey/register/begin');
const { success, message, data } = beginRes.data;
if (!success) {
showError(message || t('无法发起 Passkey 注册'));
return;
}
const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
const credential = await navigator.credentials.create({ publicKey });
const payload = buildRegistrationResult(credential);
if (!payload) {
showError(t('Passkey 注册失败,请重试'));
return;
}
const finishRes = await API.post('/api/user/passkey/register/finish', payload);
if (finishRes.data.success) {
showSuccess(t('Passkey 注册成功'));
await loadPasskeyStatus();
} else {
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
}
} catch (error) {
if (error?.name === 'AbortError') {
showInfo(t('已取消 Passkey 注册'));
} else {
showError(t('Passkey 注册失败,请重试'));
}
} finally {
setPasskeyRegisterLoading(false);
}
};
const handleRemovePasskey = async () => {
setPasskeyDeleteLoading(true);
try {
const res = await API.delete('/api/user/passkey');
const { success, message } = res.data;
if (success) {
showSuccess(t('Passkey 已解绑'));
await loadPasskeyStatus();
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
} finally {
setPasskeyDeleteLoading(false);
}
};
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
await loadPasskeyStatus();
} else {
showError(message);
}
@@ -352,6 +441,12 @@ const PersonalSetting = () => {
handleSystemTokenClick={handleSystemTokenClick}
setShowChangePasswordModal={setShowChangePasswordModal}
setShowAccountDeleteModal={setShowAccountDeleteModal}
passkeyStatus={passkeyStatus}
passkeySupported={passkeySupported}
passkeyRegisterLoading={passkeyRegisterLoading}
passkeyDeleteLoading={passkeyDeleteLoading}
onPasskeyRegister={handleRegisterPasskey}
onPasskeyDelete={handleRemovePasskey}
/>
{/* 右侧:其他设置 */}

View File

@@ -30,6 +30,7 @@ import {
Spin,
Card,
Radio,
Select,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
@@ -77,6 +78,13 @@ const SystemSetting = () => {
TurnstileSiteKey: '',
TurnstileSecretKey: '',
RegisterEnabled: '',
'passkey.enabled': '',
'passkey.rp_display_name': '',
'passkey.rp_id': '',
'passkey.origins': [],
'passkey.allow_insecure_origin': '',
'passkey.user_verification': 'preferred',
'passkey.attachment_preference': '',
EmailDomainRestrictionEnabled: '',
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
@@ -114,6 +122,7 @@ const SystemSetting = () => {
const [domainList, setDomainList] = useState([]);
const [ipList, setIpList] = useState([]);
const [allowedPorts, setAllowedPorts] = useState([]);
const [passkeyOrigins, setPasskeyOrigins] = useState([]);
const getOptions = async () => {
setLoading(true);
@@ -173,9 +182,28 @@ const SystemSetting = () => {
case 'SMTPSSLEnabled':
case 'LinuxDOOAuthEnabled':
case 'oidc.enabled':
case 'passkey.enabled':
case 'passkey.allow_insecure_origin':
case 'WorkerAllowHttpImageRequestEnabled':
item.value = toBoolean(item.value);
break;
case 'passkey.origins':
try {
const origins = item.value ? JSON.parse(item.value) : [];
setPasskeyOrigins(Array.isArray(origins) ? origins : []);
item.value = Array.isArray(origins) ? origins : [];
} catch (e) {
setPasskeyOrigins([]);
item.value = [];
}
break;
case 'passkey.rp_display_name':
case 'passkey.rp_id':
case 'passkey.user_verification':
case 'passkey.attachment_preference':
// 确保字符串字段不为null/undefined
item.value = item.value || '';
break;
case 'Price':
case 'MinTopUp':
item.value = parseFloat(item.value);
@@ -582,6 +610,45 @@ const SystemSetting = () => {
}
};
const submitPasskeySettings = async () => {
const options = [];
// 只在值有变化时才提交,并确保空值转换为空字符串
if (originInputs['passkey.rp_display_name'] !== inputs['passkey.rp_display_name']) {
options.push({
key: 'passkey.rp_display_name',
value: inputs['passkey.rp_display_name'] || '',
});
}
if (originInputs['passkey.rp_id'] !== inputs['passkey.rp_id']) {
options.push({
key: 'passkey.rp_id',
value: inputs['passkey.rp_id'] || '',
});
}
if (originInputs['passkey.user_verification'] !== inputs['passkey.user_verification']) {
options.push({
key: 'passkey.user_verification',
value: inputs['passkey.user_verification'] || 'preferred',
});
}
if (originInputs['passkey.attachment_preference'] !== inputs['passkey.attachment_preference']) {
options.push({
key: 'passkey.attachment_preference',
value: inputs['passkey.attachment_preference'] || '',
});
}
// Origins总是提交因为它们可能会被用户清空
options.push({
key: 'passkey.origins',
value: JSON.stringify(Array.isArray(passkeyOrigins) ? passkeyOrigins : []),
});
if (options.length > 0) {
await updateOptions(options);
}
};
const handleCheckboxChange = async (optionKey, event) => {
const value = event.target.checked;
@@ -957,6 +1024,126 @@ const SystemSetting = () => {
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置 Passkey')}>
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
<Banner
type='info'
description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field='passkey.enabled'
noLabel
onChange={(e) =>
handleCheckboxChange('passkey.enabled', e)
}
>
{t('允许通过 Passkey 登录 & 注册')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='passkey.rp_display_name'
label={t('服务显示名称')}
placeholder={t('默认使用系统名称')}
extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='passkey.rp_id'
label={t('网站域名标识')}
placeholder={t('例如example.com')}
extraText={t('留空自动使用当前域名')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field='passkey.user_verification'
label={t('安全验证级别')}
placeholder={t('是否要求指纹/面容等生物识别')}
optionList={[
{ label: t('推荐使用(用户可选)'), value: 'preferred' },
{ label: t('强制要求'), value: 'required' },
{ label: t('不建议使用'), value: 'discouraged' },
]}
extraText={t('推荐:用户可以选择是否使用指纹等验证')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Select
field='passkey.attachment_preference'
label={t('设备类型偏好')}
placeholder={t('选择支持的认证设备类型')}
optionList={[
{ label: t('不限制'), value: '' },
{ label: t('本设备内置'), value: 'platform' },
{ label: t('外接设备'), value: 'cross-platform' },
]}
extraText={t('本设备:手机指纹/面容外接USB安全密钥')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field='passkey.allow_insecure_origin'
noLabel
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
onChange={(e) =>
handleCheckboxChange('passkey.allow_insecure_origin', e)
}
>
{t('允许不安全的 OriginHTTP')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Text strong>{t('允许的 Origins')}</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')}
</Text>
<TagInput
value={passkeyOrigins}
onChange={(value) => {
setPasskeyOrigins(value);
setInputs(prev => ({
...prev,
'passkey.origins': value
}));
}}
placeholder={t('输入 Origin 后回车https://example.com')}
style={{ width: '100%' }}
/>
</Col>
</Row>
<Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
{t('保存 Passkey 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置邮箱域名白名单')}>
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>

View File

@@ -59,6 +59,12 @@ const AccountManagement = ({
handleSystemTokenClick,
setShowChangePasswordModal,
setShowAccountDeleteModal,
passkeyStatus,
passkeySupported,
passkeyRegisterLoading,
passkeyDeleteLoading,
onPasskeyRegister,
onPasskeyDelete,
}) => {
const renderAccountInfo = (accountId, label) => {
if (!accountId || accountId === '') {
@@ -86,6 +92,10 @@ const AccountManagement = ({
};
const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
const passkeyEnabled = passkeyStatus?.enabled;
const lastUsedLabel = passkeyStatus?.last_used_at
? new Date(passkeyStatus.last_used_at).toLocaleString()
: t('尚未使用');
return (
<Card className='!rounded-2xl'>
@@ -476,6 +486,58 @@ const AccountManagement = ({
</div>
</Card>
{/* Passkey 设置 */}
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconKey size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title heading={6} className='mb-1'>
{t('Passkey 登录')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{passkeyEnabled
? t('已启用 Passkey无需密码即可登录')
: t('使用 Passkey 实现免密且更安全的登录体验')}
</Typography.Text>
<div className='mt-2 text-xs text-gray-500 space-y-1'>
<div>
{t('最后使用时间')}{lastUsedLabel}
</div>
{/*{passkeyEnabled && (*/}
{/* <div>*/}
{/* {t('备份支持')}*/}
{/* {passkeyStatus?.backup_eligible*/}
{/* ? t('支持备份')*/}
{/* : t('不支持')}*/}
{/* {t('备份状态')}*/}
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
{/* </div>*/}
{/*)}*/}
{!passkeySupported && (
<div className='text-amber-600'>
{t('当前设备不支持 Passkey')}
</div>
)}
</div>
</div>
</div>
<Button
type='primary'
theme={passkeyEnabled ? 'outline' : 'solid'}
onClick={passkeyEnabled ? onPasskeyDelete : onPasskeyRegister}
className='w-full sm:w-auto'
icon={<IconKey />}
disabled={!passkeySupported && !passkeyEnabled}
loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
>
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
</Button>
</div>
</Card>
{/* 两步验证设置 */}
<TwoFASetting t={t} />

View File

@@ -56,8 +56,10 @@ import {
} from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
import { createApiCalls } from '../../../../services/secureVerification';
import {
IconSave,
IconClose,
@@ -193,43 +195,43 @@ const EditChannelModal = (props) => {
const [keyMode, setKeyMode] = useState('append'); // 密钥模式replace覆盖或 append追加
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
// 2FA验证查看密钥相关状态
const [twoFAState, setTwoFAState] = useState({
// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
showModal: false,
code: '',
loading: false,
showKey: false,
keyData: '',
});
// 专门的2FA验证状态用于TwoFactorAuthModal
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
const [verifyCode, setVerifyCode] = useState('');
const [verifyLoading, setVerifyLoading] = useState(false);
// 使用通用安全验证 Hook
const {
isModalVisible,
verificationMethods,
verificationState,
startVerification,
executeVerification,
cancelVerification,
setVerificationCode,
switchVerificationMethod,
} = useSecureVerification({
onSuccess: (result) => {
// 验证成功后显示密钥
if (result.success && result.data?.key) {
setKeyDisplayState({
showModal: true,
keyData: result.data.key,
});
}
},
successMessage: t('密钥获取成功'),
});
// 2FA状态更新辅助函数
const updateTwoFAState = (updates) => {
setTwoFAState((prev) => ({ ...prev, ...updates }));
};
// 重置2FA状态
const resetTwoFAState = () => {
setTwoFAState({
// 重置密钥显示状态
const resetKeyDisplayState = () => {
setKeyDisplayState({
showModal: false,
code: '',
loading: false,
showKey: false,
keyData: '',
});
};
// 重置2FA验证状态
const reset2FAVerifyState = () => {
setShow2FAVerifyModal(false);
setVerifyCode('');
setVerifyLoading(false);
};
// 渠道额外设置状态
const [channelSettings, setChannelSettings] = useState({
force_format: false,
@@ -602,42 +604,31 @@ const EditChannelModal = (props) => {
}
};
// 使用TwoFactorAuthModal的验证函数
const handleVerify2FA = async () => {
if (!verifyCode) {
showError(t('请输入验证码或备用码'));
return;
}
setVerifyLoading(true);
// 显示安全验证模态框并开始验证流程
const handleShow2FAModal = async () => {
try {
const res = await API.post(`/api/channel/${channelId}/key`, {
code: verifyCode,
console.log('=== handleShow2FAModal called ===');
console.log('channelId:', channelId);
console.log('startVerification function:', typeof startVerification);
// 测试模态框状态
console.log('Current modal state:', isModalVisible);
const apiCall = createApiCalls.viewChannelKey(channelId);
console.log('apiCall created:', typeof apiCall);
const result = await startVerification(apiCall, {
title: t('查看渠道密钥'),
description: t('为了保护账户安全,请验证您的身份。'),
preferredMethod: 'passkey', // 优先使用 Passkey
});
if (res.data.success) {
// 验证成功,显示密钥
updateTwoFAState({
showModal: true,
showKey: true,
keyData: res.data.data.key,
});
reset2FAVerifyState();
showSuccess(t('验证成功'));
} else {
showError(res.data.message);
}
console.log('startVerification result:', result);
} catch (error) {
showError(t('获取密钥失败'));
} finally {
setVerifyLoading(false);
console.error('handleShow2FAModal error:', error);
showError(error.message || t('启动验证失败'));
}
};
// 显示2FA验证模态框 - 使用TwoFactorAuthModal
const handleShow2FAModal = () => {
setShow2FAVerifyModal(true);
};
useEffect(() => {
const modelMap = new Map();
@@ -741,10 +732,8 @@ const EditChannelModal = (props) => {
}
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值
setInputs(getInitValues());
// 重置2FA状态
resetTwoFAState();
// 重置2FA验证状态
reset2FAVerifyState();
// 重置密钥显示状态
resetKeyDisplayState();
};
const handleVertexUploadChange = ({ fileList }) => {
@@ -2498,17 +2487,17 @@ const EditChannelModal = (props) => {
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</SideSheet>
{/* 使用TwoFactorAuthModal组件进行2FA验证 */}
<TwoFactorAuthModal
visible={show2FAVerifyModal}
code={verifyCode}
loading={verifyLoading}
onCodeChange={setVerifyCode}
onVerify={handleVerify2FA}
onCancel={reset2FAVerifyState}
title={t('查看渠道密钥')}
description={t('为了保护账户安全,请验证您的两步验证码。')}
placeholder={t('请输入验证码或备用码')}
{/* 使用通用安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}
verificationMethods={verificationMethods}
verificationState={verificationState}
onVerify={executeVerification}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodSwitch={switchVerificationMethod}
title={verificationState.title}
description={verificationState.description}
/>
{/* 使用ChannelKeyDisplay组件显示密钥 */}
@@ -2531,10 +2520,10 @@ const EditChannelModal = (props) => {
{t('渠道密钥信息')}
</div>
}
visible={twoFAState.showModal && twoFAState.showKey}
onCancel={resetTwoFAState}
visible={keyDisplayState.showModal}
onCancel={resetKeyDisplayState}
footer={
<Button type='primary' onClick={resetTwoFAState}>
<Button type='primary' onClick={resetKeyDisplayState}>
{t('完成')}
</Button>
}
@@ -2542,7 +2531,7 @@ const EditChannelModal = (props) => {
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={twoFAState.keyData}
keyData={keyDisplayState.keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}

View File

@@ -204,6 +204,8 @@ const renderOperations = (
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
t,
},
) => {
@@ -253,6 +255,20 @@ const renderOperations = (
>
{t('降级')}
</Button>
<Button
type='warning'
size='small'
onClick={() => showResetPasskeyModal(record)}
>
{t('重置 Passkey')}
</Button>
<Button
type='warning'
size='small'
onClick={() => showResetTwoFAModal(record)}
>
{t('重置 2FA')}
</Button>
<Button
type='danger'
size='small'
@@ -275,6 +291,8 @@ export const getUsersColumns = ({
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
}) => {
return [
{
@@ -329,6 +347,8 @@ export const getUsersColumns = ({
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
showResetPasskeyModal,
showResetTwoFAModal,
t,
}),
},

View File

@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
import DemoteUserModal from './modals/DemoteUserModal';
import EnableDisableUserModal from './modals/EnableDisableUserModal';
import DeleteUserModal from './modals/DeleteUserModal';
import ResetPasskeyModal from './modals/ResetPasskeyModal';
import ResetTwoFAModal from './modals/ResetTwoFAModal';
const UsersTable = (usersData) => {
const {
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
setShowEditUser,
manageUser,
refresh,
resetUserPasskey,
resetUserTwoFA,
t,
} = usersData;
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [modalUser, setModalUser] = useState(null);
const [enableDisableAction, setEnableDisableAction] = useState('');
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
// Modal handlers
const showPromoteUserModal = (user) => {
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
setShowDeleteModal(true);
};
const showResetPasskeyUserModal = (user) => {
setModalUser(user);
setShowResetPasskeyModal(true);
};
const showResetTwoFAUserModal = (user) => {
setModalUser(user);
setShowResetTwoFAModal(true);
};
// Modal confirm handlers
const handlePromoteConfirm = () => {
manageUser(modalUser.id, 'promote', modalUser);
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
setShowEnableDisableModal(false);
};
const handleResetPasskeyConfirm = async () => {
await resetUserPasskey(modalUser);
setShowResetPasskeyModal(false);
};
const handleResetTwoFAConfirm = async () => {
await resetUserTwoFA(modalUser);
setShowResetTwoFAModal(false);
};
// Get all columns
const columns = useMemo(() => {
return getUsersColumns({
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
showDemoteModal: showDemoteUserModal,
showEnableDisableModal: showEnableDisableUserModal,
showDeleteModal: showDeleteUserModal,
showResetPasskeyModal: showResetPasskeyUserModal,
showResetTwoFAModal: showResetTwoFAUserModal,
});
}, [t, setEditingUser, setShowEditUser]);
}, [
t,
setEditingUser,
setShowEditUser,
showPromoteUserModal,
showDemoteUserModal,
showEnableDisableUserModal,
showDeleteUserModal,
showResetPasskeyUserModal,
showResetTwoFAUserModal,
]);
// Handle compact mode by removing fixed positioning
const tableColumns = useMemo(() => {
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
manageUser={manageUser}
t={t}
/>
<ResetPasskeyModal
visible={showResetPasskeyModal}
onCancel={() => setShowResetPasskeyModal(false)}
onConfirm={handleResetPasskeyConfirm}
user={modalUser}
t={t}
/>
<ResetTwoFAModal
visible={showResetTwoFAModal}
onCancel={() => setShowResetTwoFAModal(false)}
onConfirm={handleResetTwoFAConfirm}
user={modalUser}
t={t}
/>
</>
);
};

View File

@@ -0,0 +1,39 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
return (
<Modal
title={t('确认重置 Passkey')}
visible={visible}
onCancel={onCancel}
onOk={onConfirm}
type='warning'
>
{t('此操作将解绑用户当前的 Passkey下次登录需要重新注册。')}{' '}
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
</Modal>
);
};
export default ResetPasskeyModal;

View File

@@ -0,0 +1,39 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal } from '@douyinfe/semi-ui';
const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
return (
<Modal
title={t('确认重置两步验证')}
visible={visible}
onCancel={onCancel}
onOk={onConfirm}
type='warning'
>
{t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
</Modal>
);
};
export default ResetTwoFAModal;

View File

@@ -27,3 +27,4 @@ export * from './data';
export * from './token';
export * from './boolean';
export * from './dashboard';
export * from './passkey';

137
web/src/helpers/passkey.js Normal file
View File

@@ -0,0 +1,137 @@
export function base64UrlToBuffer(base64url) {
if (!base64url) return new ArrayBuffer(0);
let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const buffer = new ArrayBuffer(rawData.length);
const uintArray = new Uint8Array(buffer);
for (let i = 0; i < rawData.length; i += 1) {
uintArray[i] = rawData.charCodeAt(i);
}
return buffer;
}
export function bufferToBase64Url(buffer) {
if (!buffer) return '';
const uintArray = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < uintArray.byteLength; i += 1) {
binary += String.fromCharCode(uintArray[i]);
}
return window
.btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function prepareCredentialCreationOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 注册参数');
}
const publicKey = {
...options,
challenge: base64UrlToBuffer(options.challenge),
user: {
...options.user,
id: base64UrlToBuffer(options.user?.id),
},
};
if (Array.isArray(options.excludeCredentials)) {
publicKey.excludeCredentials = options.excludeCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
}));
}
if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
delete publicKey.attestationFormats;
}
return publicKey;
}
export function prepareCredentialRequestOptions(payload) {
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
if (!options) {
throw new Error('无法从服务端响应中解析 Passkey 登录参数');
}
const publicKey = {
...options,
challenge: base64UrlToBuffer(options.challenge),
};
if (Array.isArray(options.allowCredentials)) {
publicKey.allowCredentials = options.allowCredentials.map((item) => ({
...item,
id: base64UrlToBuffer(item.id),
}));
}
return publicKey;
}
export function buildRegistrationResult(credential) {
if (!credential) return null;
const { response } = credential;
const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
return {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
authenticatorAttachment: credential.authenticatorAttachment,
response: {
attestationObject: bufferToBase64Url(response.attestationObject),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
transports,
},
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
};
}
export function buildAssertionResult(assertion) {
if (!assertion) return null;
const { response } = assertion;
return {
id: assertion.id,
rawId: bufferToBase64Url(assertion.rawId),
type: assertion.type,
authenticatorAttachment: assertion.authenticatorAttachment,
response: {
authenticatorData: bufferToBase64Url(response.authenticatorData),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
},
clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
};
}
export async function isPasskeySupported() {
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
return false;
}
if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
try {
const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
if (available) return true;
} catch (error) {
// ignore
}
}
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (error) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,225 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SecureVerificationService } from '../../services/secureVerification';
import { showError, showSuccess } from '../../helpers';
/**
* 通用安全验证 Hook
* @param {Object} options - 配置选项
* @param {Function} options.onSuccess - 验证成功回调
* @param {Function} options.onError - 验证失败回调
* @param {string} options.successMessage - 成功提示消息
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
*/
export const useSecureVerification = ({
onSuccess,
onError,
successMessage,
autoReset = true
} = {}) => {
const { t } = useTranslation();
// 验证方式可用性状态
const [verificationMethods, setVerificationMethods] = useState({
has2FA: false,
hasPasskey: false,
passkeySupported: false
});
// 模态框状态
const [isModalVisible, setIsModalVisible] = useState(false);
// 当前验证状态
const [verificationState, setVerificationState] = useState({
method: null, // '2fa' | 'passkey'
loading: false,
code: '',
apiCall: null
});
// 检查可用的验证方式
const checkVerificationMethods = useCallback(async () => {
const methods = await SecureVerificationService.checkAvailableVerificationMethods();
setVerificationMethods(methods);
return methods;
}, []);
// 初始化时检查验证方式
useEffect(() => {
checkVerificationMethods();
}, [checkVerificationMethods]);
// 重置状态
const resetState = useCallback(() => {
setVerificationState({
method: null,
loading: false,
code: '',
apiCall: null
});
setIsModalVisible(false);
}, []);
// 开始验证流程
const startVerification = useCallback(async (apiCall, options = {}) => {
console.log('startVerification called:', { apiCall, options });
const { preferredMethod, title, description } = options;
// 检查验证方式
console.log('Checking verification methods...');
const methods = await checkVerificationMethods();
console.log('Verification methods:', methods);
if (!methods.has2FA && !methods.hasPasskey) {
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
console.error('No verification methods available:', errorMessage);
showError(errorMessage);
onError?.(new Error(errorMessage));
return false;
}
// 设置默认验证方式
let defaultMethod = preferredMethod;
if (!defaultMethod) {
if (methods.hasPasskey && methods.passkeySupported) {
defaultMethod = 'passkey';
} else if (methods.has2FA) {
defaultMethod = '2fa';
}
}
console.log('Selected verification method:', defaultMethod);
setVerificationState(prev => ({
...prev,
method: defaultMethod,
apiCall,
title,
description
}));
setIsModalVisible(true);
console.log('Modal should be visible now');
return true;
}, [checkVerificationMethods, onError, t]);
// 执行验证
const executeVerification = useCallback(async (method, code = '') => {
if (!verificationState.apiCall) {
showError(t('验证配置错误'));
return;
}
setVerificationState(prev => ({ ...prev, loading: true }));
try {
const result = await SecureVerificationService.verify(method, {
code,
apiCall: verificationState.apiCall
});
// 显示成功消息
if (successMessage) {
showSuccess(successMessage);
}
// 调用成功回调
onSuccess?.(result, method);
// 自动重置状态
if (autoReset) {
resetState();
}
return result;
} catch (error) {
showError(error.message || t('验证失败,请重试'));
onError?.(error);
throw error;
} finally {
setVerificationState(prev => ({ ...prev, loading: false }));
}
}, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
// 设置验证码
const setVerificationCode = useCallback((code) => {
setVerificationState(prev => ({ ...prev, code }));
}, []);
// 切换验证方式
const switchVerificationMethod = useCallback((method) => {
setVerificationState(prev => ({ ...prev, method, code: '' }));
}, []);
// 取消验证
const cancelVerification = useCallback(() => {
resetState();
}, [resetState]);
// 检查是否可以使用某种验证方式
const canUseMethod = useCallback((method) => {
switch (method) {
case '2fa':
return verificationMethods.has2FA;
case 'passkey':
return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
default:
return false;
}
}, [verificationMethods]);
// 获取推荐的验证方式
const getRecommendedMethod = useCallback(() => {
if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
return 'passkey';
}
if (verificationMethods.has2FA) {
return '2fa';
}
return null;
}, [verificationMethods]);
return {
// 状态
isModalVisible,
verificationMethods,
verificationState,
// 方法
startVerification,
executeVerification,
cancelVerification,
resetState,
setVerificationCode,
switchVerificationMethod,
checkVerificationMethods,
// 辅助方法
canUseMethod,
getRecommendedMethod,
// 便捷属性
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
isLoading: verificationState.loading,
currentMethod: verificationState.method,
code: verificationState.code
};
};

View File

@@ -86,7 +86,7 @@ export const useUsersData = () => {
};
// Search users with keyword and group
const searchUsers = async (
const searchUsers = async (
startIdx,
pageSize,
searchKeyword = null,
@@ -154,6 +154,40 @@ export const useUsersData = () => {
setLoading(false);
};
const resetUserPasskey = async (user) => {
if (!user) {
return;
}
try {
const res = await API.delete(`/api/user/${user.id}/passkey`);
const { success, message } = res.data;
if (success) {
showSuccess(t('Passkey 已重置'));
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
}
};
const resetUserTwoFA = async (user) => {
if (!user) {
return;
}
try {
const res = await API.delete(`/api/user/${user.id}/2fa`);
const { success, message } = res.data;
if (success) {
showSuccess(t('二步验证已重置'));
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
}
};
// Handle page change
const handlePageChange = (page) => {
setActivePage(page);
@@ -271,6 +305,8 @@ export const useUsersData = () => {
loadUsers,
searchUsers,
manageUser,
resetUserPasskey,
resetUserTwoFA,
handlePageChange,
handlePageSizeChange,
handleRow,

View File

@@ -6,6 +6,7 @@
"登 录": "Log In",
"注 册": "Sign Up",
"使用 邮箱或用户名 登录": "Sign in with Email or Username",
"使用 Passkey 认证": "Authenticate with Passkey",
"使用 GitHub 继续": "Continue with GitHub",
"使用 OIDC 继续": "Continue with OIDC",
"使用 微信 继续": "Continue with WeChat",
@@ -2130,5 +2131,58 @@
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
"域名黑名单": "Domain Blacklist",
"白名单": "Whitelist",
"黑名单": "Blacklist"
"黑名单": "Blacklist",
"Passkey 登录": "Passkey Sign-in",
"已启用 Passkey无需密码即可登录": "Passkey enabled. Passwordless login available.",
"使用 Passkey 实现免密且更安全的登录体验": "Use Passkey for a passwordless and more secure login experience.",
"最后使用时间": "Last used time",
"备份支持": "Backup support",
"支持备份": "Supported",
"不支持": "Not supported",
"备份状态": "Backup state",
"已备份": "Backed up",
"未备份": "Not backed up",
"当前设备不支持 Passkey": "Passkey is not supported on this device",
"注册 Passkey": "Register Passkey",
"解绑 Passkey": "Remove Passkey",
"Passkey 注册成功": "Passkey registration successful",
"Passkey 注册失败,请重试": "Passkey registration failed. Please try again.",
"已取消 Passkey 注册": "Passkey registration cancelled",
"Passkey 已解绑": "Passkey removed",
"操作失败,请重试": "Operation failed, please retry",
"重置 Passkey": "Reset Passkey",
"重置 2FA": "Reset 2FA",
"确认重置 Passkey": "Confirm Passkey Reset",
"确认重置两步验证": "Confirm Two-Factor Reset",
"此操作将解绑用户当前的 Passkey下次登录需要重新注册。": "This will detach the user's current Passkey. They will need to register again on next login.",
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "This will disable the user's current two-factor setup. No verification code will be required until they enable it again.",
"目标用户:{{username}}": "Target user: {{username}}",
"Passkey 已重置": "Passkey has been reset",
"二步验证已重置": "Two-factor authentication has been reset",
"配置 Passkey": "Configure Passkey",
"用以支持基于 WebAuthn 的无密码登录注册": "Support WebAuthn-based passwordless login and registration",
"Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey is a passwordless authentication method based on WebAuthn standard, supporting fingerprint, face recognition, hardware keys and other authentication methods",
"服务显示名称": "Service Display Name",
"默认使用系统名称": "Default uses system name",
"用户注册时看到的网站名称,比如'我的网站'": "Website name users see during registration, e.g. 'My Website'",
"网站域名标识": "Website Domain ID",
"例如example.com": "e.g.: example.com",
"留空自动使用当前域名": "Leave blank to auto-use current domain",
"安全验证级别": "Security Verification Level",
"是否要求指纹/面容等生物识别": "Whether to require fingerprint/face recognition",
"preferred": "preferred",
"required": "required",
"discouraged": "discouraged",
"推荐:用户可以选择是否使用指纹等验证": "Recommended: Users can choose whether to use fingerprint verification",
"设备类型偏好": "Device Type Preference",
"选择支持的认证设备类型": "Choose supported authentication device types",
"platform": "platform",
"cross-platform": "cross-platform",
"本设备:手机指纹/面容外接USB安全密钥": "Built-in: phone fingerprint/face, External: USB security key",
"允许不安全的 OriginHTTP": "Allow insecure Origin (HTTP)",
"仅用于开发环境,生产环境应使用 HTTPS": "For development only, use HTTPS in production",
"允许的 Origins": "Allowed Origins",
"留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Leave blank to auto-use server address, multiple Origins for multi-domain deployment",
"输入 Origin 后回车https://example.com": "Enter Origin and press Enter, e.g.: https://example.com",
"保存 Passkey 设置": "Save Passkey Settings"
}

View File

@@ -5,6 +5,7 @@
"关于": "关于",
"登录": "登录",
"注册": "注册",
"使用 Passkey 认证": "使用 Passkey 认证",
"退出": "退出",
"语言": "语言",
"展开侧边栏": "展开侧边栏",
@@ -33,5 +34,58 @@
"输入端口后回车80 或 8000-8999": "输入端口后回车80 或 8000-8999",
"更新SSRF防护设置": "更新SSRF防护设置",
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
"Passkey 认证": "Passkey 认证",
"已启用 Passkey可进行无密码认证": "已启用 Passkey可进行无密码认证",
"使用 Passkey 实现免密且更安全的认证体验": "使用 Passkey 实现免密且更安全的认证体验",
"最后使用时间": "最后使用时间",
"备份支持": "备份支持",
"支持备份": "支持备份",
"不支持": "不支持",
"备份状态": "备份状态",
"已备份": "已备份",
"未备份": "未备份",
"当前设备不支持 Passkey": "当前设备不支持 Passkey",
"注册 Passkey": "注册 Passkey",
"解绑 Passkey": "解绑 Passkey",
"配置 Passkey": "配置 Passkey",
"用以支持基于 WebAuthn 的无密码登录注册": "用以支持基于 WebAuthn 的无密码登录注册",
"Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式",
"服务显示名称": "服务显示名称",
"默认使用系统名称": "默认使用系统名称",
"用户注册时看到的网站名称,比如'我的网站'": "用户注册时看到的网站名称,比如'我的网站'",
"网站域名标识": "网站域名标识",
"例如example.com": "例如example.com",
"留空自动使用当前域名": "留空自动使用当前域名",
"安全验证级别": "安全验证级别",
"是否要求指纹/面容等生物识别": "是否要求指纹/面容等生物识别",
"preferred": "preferred",
"required": "required",
"discouraged": "discouraged",
"推荐:用户可以选择是否使用指纹等验证": "推荐:用户可以选择是否使用指纹等验证",
"设备类型偏好": "设备类型偏好",
"选择支持的认证设备类型": "选择支持的认证设备类型",
"platform": "platform",
"cross-platform": "cross-platform",
"本设备:手机指纹/面容外接USB安全密钥": "本设备:手机指纹/面容外接USB安全密钥",
"允许不安全的 OriginHTTP": "允许不安全的 OriginHTTP",
"仅用于开发环境,生产环境应使用 HTTPS": "仅用于开发环境,生产环境应使用 HTTPS",
"允许的 Origins": "允许的 Origins",
"留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署",
"输入 Origin 后回车https://example.com": "输入 Origin 后回车https://example.com",
"保存 Passkey 设置": "保存 Passkey 设置",
"Passkey 注册成功": "Passkey 注册成功",
"Passkey 注册失败,请重试": "Passkey 注册失败,请重试",
"已取消 Passkey 注册": "已取消 Passkey 注册",
"Passkey 已解绑": "Passkey 已解绑",
"操作失败,请重试": "操作失败,请重试",
"重置 Passkey": "重置 Passkey",
"重置 2FA": "重置 2FA",
"确认重置 Passkey": "确认重置 Passkey",
"确认重置两步验证": "确认重置两步验证",
"此操作将解绑用户当前的 Passkey下次登录需要重新注册。": "此操作将解绑用户当前的 Passkey下次登录需要重新注册。",
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
"目标用户:{{username}}": "目标用户:{{username}}",
"Passkey 已重置": "Passkey 已重置",
"二步验证已重置": "二步验证已重置"
}

View File

@@ -0,0 +1,183 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { API, showError } from '../helpers';
import {
prepareCredentialRequestOptions,
buildAssertionResult,
isPasskeySupported
} from '../helpers/passkey';
/**
* 通用安全验证服务
*/
export class SecureVerificationService {
/**
* 检查用户可用的验证方式
* @returns {Promise<{has2FA: boolean, hasPasskey: boolean, passkeySupported: boolean}>}
*/
static async checkAvailableVerificationMethods() {
try {
console.log('Checking user verification methods...');
const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([
API.get('/api/user/2fa/status'),
API.get('/api/user/passkey'),
isPasskeySupported()
]);
console.log('2FA response:', twoFAResponse);
console.log('Passkey response:', passkeyResponse);
console.log('Passkey browser support:', passkeySupported);
const result = {
has2FA: twoFAResponse.success && twoFAResponse.data?.enabled === true,
hasPasskey: passkeyResponse.success && (passkeyResponse.data?.enabled === true || passkeyResponse.data?.status === 'enabled' || passkeyResponse.data !== null),
passkeySupported
};
console.log('Final verification methods result:', result);
return result;
} catch (error) {
console.error('Failed to check verification methods:', error);
return {
has2FA: false,
hasPasskey: false,
passkeySupported: false
};
}
}
/**
* 执行2FA验证
* @param {string} code - 验证码
* @param {Function} apiCall - API调用函数接收 {method: '2fa', code} 参数
* @returns {Promise<any>} API响应结果
*/
static async verify2FA(code, apiCall) {
if (!code?.trim()) {
throw new Error('请输入验证码或备用码');
}
return await apiCall({
method: '2fa',
code: code.trim()
});
}
/**
* 执行Passkey验证
* @param {Function} apiCall - API调用函数接收 {method: 'passkey'} 参数
* @returns {Promise<any>} API响应结果
*/
static async verifyPasskey(apiCall) {
try {
// 开始Passkey验证
const beginResponse = await API.post('/api/user/passkey/verify/begin');
if (!beginResponse.success) {
throw new Error(beginResponse.message);
}
// 准备WebAuthn选项
const publicKey = prepareCredentialRequestOptions(beginResponse.data);
// 执行WebAuthn验证
const credential = await navigator.credentials.get({ publicKey });
if (!credential) {
throw new Error('Passkey 验证被取消');
}
// 构建验证结果
const assertionResult = buildAssertionResult(credential);
// 完成验证
const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
if (!finishResponse.success) {
throw new Error(finishResponse.message);
}
// 调用业务API
return await apiCall({
method: 'passkey'
});
} catch (error) {
if (error.name === 'NotAllowedError') {
throw new Error('Passkey 验证被取消或超时');
} else if (error.name === 'InvalidStateError') {
throw new Error('Passkey 验证状态无效');
} else {
throw error;
}
}
}
/**
* 通用验证方法,根据验证类型执行相应的验证流程
* @param {string} method - 验证方式: '2fa' | 'passkey'
* @param {Object} params - 参数对象
* @param {string} params.code - 2FA验证码当method为'2fa'时必需)
* @param {Function} params.apiCall - API调用函数
* @returns {Promise<any>} API响应结果
*/
static async verify(method, { code, apiCall }) {
switch (method) {
case '2fa':
return await this.verify2FA(code, apiCall);
case 'passkey':
return await this.verifyPasskey(apiCall);
default:
throw new Error(`不支持的验证方式: ${method}`);
}
}
}
/**
* 预设的API调用函数工厂
*/
export const createApiCalls = {
/**
* 创建查看渠道密钥的API调用
* @param {number} channelId - 渠道ID
*/
viewChannelKey: (channelId) => async (verificationData) => {
return await API.post(`/api/channel/${channelId}/key`, verificationData);
},
/**
* 创建自定义API调用
* @param {string} url - API URL
* @param {string} method - HTTP方法默认为 'POST'
* @param {Object} extraData - 额外的请求数据
*/
custom: (url, method = 'POST', extraData = {}) => async (verificationData) => {
const data = { ...extraData, ...verificationData };
switch (method.toUpperCase()) {
case 'GET':
return await API.get(url, { params: data });
case 'POST':
return await API.post(url, data);
case 'PUT':
return await API.put(url, data);
case 'DELETE':
return await API.delete(url, { data });
default:
throw new Error(`不支持的HTTP方法: ${method}`);
}
}
};