diff --git a/package-lock.json b/package-lock.json index b7653cd..492881a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -495,6 +495,18 @@ "license": "MIT", "optional": true }, + "node_modules/@hcaptcha/vue3-hcaptcha": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz", + "integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==", + "license": "MIT", + "dependencies": { + "vue": "^3.2.19" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/@hexagon/base64": { "version": "1.1.28", "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", @@ -5605,6 +5617,12 @@ "node": ">=8.10.0" } }, + "node_modules/recaptcha-v3": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/recaptcha-v3/-/recaptcha-v3-1.11.3.tgz", + "integrity": "sha512-sEE6J0RzUkS+sKEBpgCD/AqCU0ffrAVOADGjvAx9vcttN+VLK42SWMkj/J/I6vHu3Kew+xcfbBqDVb65N0QGDw==", + "license": "Apache-2.0" + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -7039,6 +7057,18 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "license": "MIT" }, + "node_modules/vue-recaptcha-v3": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vue-recaptcha-v3/-/vue-recaptcha-v3-2.0.1.tgz", + "integrity": "sha512-isEDtOfHU4wWRrZZuxciAELtQmPOeEEdicPNa0f1AOyLPy3sCcBEcpFt+FOcO3RQv5unJ3Yn5NlsWtXv9rXqjg==", + "license": "Apache-2.0", + "dependencies": { + "recaptcha-v3": "^1.8.0" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, "node_modules/vue-router": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz", @@ -7346,6 +7376,7 @@ "version": "0.1.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", + "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@simplewebauthn/browser": "^13.1.0", "@tailwindcss/vite": "^4.1.4", "@xterm/addon-search": "^0.15.0", @@ -7358,6 +7389,7 @@ "vite-plugin-monaco-editor": "^1.1.0", "vue": "^3.3.0", "vue-i18n": "^9.14.4", + "vue-recaptcha-v3": "^2.0.1", "vue-router": "^4.5.0", "vuedraggable": "^4.1.0", "xterm": "^5.3.0", @@ -7365,6 +7397,7 @@ "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { + "@types/node": "^20", "@types/splitpanes": "^2.2.6", "@vitejs/plugin-vue": "^4.2.0", "typescript": "^5.0.0", diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 4880acb..a6d6eb9 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -9,6 +9,8 @@ import { PasskeyService } from '../services/passkey.service'; // 导入 PasskeyS import { NotificationService } from '../services/notification.service'; // 导入 NotificationService import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService import { ipBlacklistService } from '../services/ip-blacklist.service'; // 导入 IP 黑名单服务 +import { captchaService } from '../services/captcha.service'; // <-- Import CaptchaService +import { settingsService } from '../services/settings.service'; // <-- Import SettingsService for config check // Remove top-level db instance acquisition // const db = getDb(); @@ -51,6 +53,36 @@ export const login = async (req: Request, res: Response): Promise => { } try { + // --- CAPTCHA Verification Step --- + const captchaConfig = await settingsService.getCaptchaConfig(); + if (captchaConfig.enabled) { + const { captchaToken } = req.body; + if (!captchaToken) { + console.log(`[AuthController] 登录尝试失败: CAPTCHA 已启用但未提供令牌 - ${username}`); + // 记录审计日志等(可选,看是否需要区分) + return res.status(400).json({ message: '需要提供 CAPTCHA 令牌。' }); + } + try { + const isCaptchaValid = await captchaService.verifyToken(captchaToken); + if (!isCaptchaValid) { + console.log(`[AuthController] 登录尝试失败: CAPTCHA 验证失败 - ${username}`); + const clientIp = req.ip || req.socket?.remoteAddress || 'unknown'; + ipBlacklistService.recordFailedAttempt(clientIp); // Record failed attempt for invalid CAPTCHA + auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp }); + notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp }); + return res.status(401).json({ message: 'CAPTCHA 验证失败。' }); + } + console.log(`[AuthController] CAPTCHA 验证成功 - ${username}`); + } catch (captchaError: any) { + console.error(`[AuthController] CAPTCHA 验证过程中出错 (${username}):`, captchaError.message); + // 如果是配置错误或 API 请求失败,返回 500 + return res.status(500).json({ message: 'CAPTCHA 验证服务出错,请稍后重试或检查配置。' }); + } + } else { + console.log(`[AuthController] CAPTCHA 未启用,跳过验证 - ${username}`); + } + // --- End CAPTCHA Verification --- + const db = await getDbInstance(); // Get DB instance inside the function // Use the promisified getDb helper const user = await getDb(db, 'SELECT id, username, hashed_password, two_factor_secret FROM users WHERE username = ?', [username]); @@ -774,3 +806,35 @@ export const logout = (req: Request, res: Response): void => { } }); }; + +/** + * 获取公共 CAPTCHA 配置 (GET /api/v1/auth/captcha/config) + * 返回给前端用于显示 CAPTCHA 小部件所需的信息 (不含密钥)。 + */ +export const getPublicCaptchaConfig = async (req: Request, res: Response): Promise => { + try { + console.log('[AuthController] Received request for public CAPTCHA config.'); + const fullConfig = await settingsService.getCaptchaConfig(); // Use settingsService + + // *** IMPORTANT: Filter out secret keys before sending to frontend *** + const publicConfig = { + enabled: fullConfig.enabled, + provider: fullConfig.provider, + hcaptchaSiteKey: fullConfig.hcaptchaSiteKey, + recaptchaSiteKey: fullConfig.recaptchaSiteKey, + }; + + console.log('[AuthController] Sending public CAPTCHA config to client:', publicConfig); + res.status(200).json(publicConfig); + } catch (error: any) { + console.error('[AuthController] 获取公共 CAPTCHA 配置时出错:', error); + // 即使出错,也返回一个“禁用”状态,避免前端出错 + res.status(500).json({ + enabled: false, + provider: 'none', + hcaptchaSiteKey: '', + recaptchaSiteKey: '', + error: '获取 CAPTCHA 配置失败' + }); + } +}; diff --git a/packages/backend/src/auth/auth.routes.ts b/packages/backend/src/auth/auth.routes.ts index f72afa1..684357b 100644 --- a/packages/backend/src/auth/auth.routes.ts +++ b/packages/backend/src/auth/auth.routes.ts @@ -11,13 +11,19 @@ import { verifyPasskeyRegistration, // 导入 Passkey 方法 needsSetup, // 导入 needsSetup 控制器 setupAdmin, // 导入 setupAdmin 控制器 - logout // *** 新增:导入 logout 控制器 *** + logout, // *** 新增:导入 logout 控制器 *** + getPublicCaptchaConfig // <-- Import public CAPTCHA config controller } from './auth.controller'; import { isAuthenticated } from './auth.middleware'; import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件 const router = Router(); +// --- Public CAPTCHA Configuration --- +// GET /api/v1/auth/captcha/config - 获取公共 CAPTCHA 配置 (公开访问) +router.get('/captcha/config', getPublicCaptchaConfig); + +// --- Setup Routes (Public) --- // GET /api/v1/auth/needs-setup - 检查是否需要初始设置 (公开访问) router.get('/needs-setup', needsSetup); diff --git a/packages/backend/src/repositories/settings.repository.ts b/packages/backend/src/repositories/settings.repository.ts index 5375fea..cd990a2 100644 --- a/packages/backend/src/repositories/settings.repository.ts +++ b/packages/backend/src/repositories/settings.repository.ts @@ -1,10 +1,12 @@ // packages/backend/src/repositories/settings.repository.ts import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection'; import { SidebarConfig, LayoutNode, PaneName } from '../types/settings.types'; // <-- Import LayoutNode and PaneName +import { CaptchaSettings } from '../types/settings.types'; // <-- Import CaptchaSettings import * as sqlite3 from 'sqlite3'; // Import sqlite3 for Database type hint // Define keys for specific settings const SIDEBAR_CONFIG_KEY = 'sidebarConfig'; +const CAPTCHA_CONFIG_KEY = 'captchaConfig'; // <-- Add key for CAPTCHA settings export interface Setting { key: string; @@ -145,6 +147,74 @@ export const setSidebarConfig = async (config: SidebarConfig): Promise => } }; +// --- CAPTCHA Settings --- + +/** + * 获取 CAPTCHA 配置 + * @returns Promise - 返回解析后的配置或默认值 + */ +export const getCaptchaConfig = async (): Promise => { + const defaultValue: CaptchaSettings = { + enabled: false, + provider: 'none', + hcaptchaSiteKey: '', + hcaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible + recaptchaSiteKey: '', + recaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible + }; + try { + const jsonString = await settingsRepository.getSetting(CAPTCHA_CONFIG_KEY); + if (jsonString) { + try { + const config = JSON.parse(jsonString); + // Basic validation (add more specific checks if needed) + if (config && typeof config.enabled === 'boolean' && typeof config.provider === 'string') { + // Ensure all keys exist, even if undefined/null from older saves + return { + enabled: config.enabled ?? defaultValue.enabled, + provider: config.provider ?? defaultValue.provider, + hcaptchaSiteKey: config.hcaptchaSiteKey ?? defaultValue.hcaptchaSiteKey, + hcaptchaSecretKey: config.hcaptchaSecretKey ?? defaultValue.hcaptchaSecretKey, + recaptchaSiteKey: config.recaptchaSiteKey ?? defaultValue.recaptchaSiteKey, + recaptchaSecretKey: config.recaptchaSecretKey ?? defaultValue.recaptchaSecretKey, + } as CaptchaSettings; + } + console.warn(`[SettingsRepo] Invalid captchaConfig format found in DB: ${jsonString}. Returning default.`); + } catch (parseError) { + console.error(`[SettingsRepo] Failed to parse captchaConfig JSON from DB: ${jsonString}`, parseError); + } + } + } catch (error) { + console.error(`[SettingsRepo] Error fetching captcha config setting (key: ${CAPTCHA_CONFIG_KEY}):`, error); + } + // Return default if not found, invalid, or error occurred + return defaultValue; +}; + +/** + * 设置 CAPTCHA 配置 + * @param config - The CAPTCHA configuration object + */ +export const setCaptchaConfig = async (config: CaptchaSettings): Promise => { + try { + // Basic validation before stringifying + if (!config || typeof config !== 'object' || typeof config.enabled !== 'boolean' || typeof config.provider !== 'string') { + throw new Error('Invalid CAPTCHA config object provided.'); + } + // Ensure secret keys are strings, even if empty + config.hcaptchaSecretKey = config.hcaptchaSecretKey || ''; + config.recaptchaSecretKey = config.recaptchaSecretKey || ''; + config.hcaptchaSiteKey = config.hcaptchaSiteKey || ''; + config.recaptchaSiteKey = config.recaptchaSiteKey || ''; + + const jsonString = JSON.stringify(config); + await settingsRepository.setSetting(CAPTCHA_CONFIG_KEY, jsonString); + } catch (error) { + console.error(`[SettingsRepo] Error setting CAPTCHA config (key: ${CAPTCHA_CONFIG_KEY}):`, error); + throw new Error('Failed to save CAPTCHA configuration.'); + } +}; + // --- Initialization --- @@ -200,6 +270,15 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise< right: [] }; + const defaultCaptchaSettings: CaptchaSettings = { + enabled: false, + provider: 'none', + hcaptchaSiteKey: '', + hcaptchaSecretKey: '', + recaptchaSiteKey: '', + recaptchaSecretKey: '', + }; + // --- Define All Default Settings --- const defaultSettings: Record = { ipWhitelistEnabled: 'false', @@ -216,6 +295,7 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise< dockerDefaultExpand: 'false', // Default Docker expand state statusMonitorIntervalSeconds: '3', // Default Status Monitor interval [SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure), // Use the defined structure + [CAPTCHA_CONFIG_KEY]: JSON.stringify(defaultCaptchaSettings), // Add default CAPTCHA settings // Add other default settings here }; const nowSeconds = Math.floor(Date.now() / 1000); diff --git a/packages/backend/src/services/captcha.service.ts b/packages/backend/src/services/captcha.service.ts new file mode 100644 index 0000000..82fe35b --- /dev/null +++ b/packages/backend/src/services/captcha.service.ts @@ -0,0 +1,120 @@ +import axios from 'axios'; +import { settingsService } from './settings.service'; +import { CaptchaSettings, CaptchaProvider } from '../types/settings.types'; + +// CAPTCHA 验证 API 端点 +const HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify'; +const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; // v2 + +export class CaptchaService { + + /** + * 验证提供的 CAPTCHA 令牌。 + * 根据系统设置自动选择合适的提供商进行验证。 + * @param token - 从前端获取的 CAPTCHA 令牌 (h-captcha-response 或 g-recaptcha-response) + * @returns Promise - 令牌是否有效 + * @throws Error 如果配置无效或验证请求失败 + */ + async verifyToken(token: string): Promise { + if (!token) { + console.warn('[CaptchaService] 验证失败:未提供令牌。'); + return false; // 没有令牌,直接视为无效 + } + + const captchaConfig = await settingsService.getCaptchaConfig(); + + if (!captchaConfig.enabled) { + console.log('[CaptchaService] CAPTCHA 未启用,跳过验证。'); + return true; // 未启用则视为验证通过 + } + + switch (captchaConfig.provider) { + case 'hcaptcha': + if (!captchaConfig.hcaptchaSecretKey) { + throw new Error('hCaptcha 配置无效:缺少 Secret Key。'); + } + return this._verifyHCaptcha(token, captchaConfig.hcaptchaSecretKey); + case 'recaptcha': + if (!captchaConfig.recaptchaSecretKey) { + throw new Error('Google reCAPTCHA 配置无效:缺少 Secret Key。'); + } + return this._verifyReCaptcha(token, captchaConfig.recaptchaSecretKey); + case 'none': + console.log('[CaptchaService] CAPTCHA 提供商设置为 "none",跳过验证。'); + return true; // 提供商为 none 也视为通过 + default: + console.error(`[CaptchaService] 未知的 CAPTCHA 提供商: ${captchaConfig.provider}`); + throw new Error(`未知的 CAPTCHA 提供商配置: ${captchaConfig.provider}`); + } + } + + /** + * 调用 hCaptcha API 验证令牌。 + * @param token - h-captcha-response 令牌 + * @param secretKey - hCaptcha Secret Key + * @returns Promise - 令牌是否有效 + */ + private async _verifyHCaptcha(token: string, secretKey: string): Promise { + console.log('[CaptchaService] 正在验证 hCaptcha 令牌...'); + try { + const response = await axios.post(HCAPTCHA_VERIFY_URL, null, { // 使用 POST,数据在 params 中 + params: { + secret: secretKey, + response: token, + // remoteip: 可选,用户 IP 地址 + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + console.log('[CaptchaService] hCaptcha 验证响应:', response.data); + if (response.data && response.data.success === true) { + console.log('[CaptchaService] hCaptcha 令牌验证成功。'); + return true; + } else { + console.warn('[CaptchaService] hCaptcha 令牌验证失败:', response.data['error-codes'] || '未知错误'); + return false; + } + } catch (error: any) { + console.error('[CaptchaService] 调用 hCaptcha 验证 API 时出错:', error.response?.data || error.message); + // 抛出错误,让上层处理(例如,提示用户稍后重试) + throw new Error(`hCaptcha 验证请求失败: ${error.message}`); + } + } + + /** + * 调用 Google reCAPTCHA API 验证令牌。 + * @param token - g-recaptcha-response 令牌 + * @param secretKey - Google reCAPTCHA Secret Key + * @returns Promise - 令牌是否有效 + */ + private async _verifyReCaptcha(token: string, secretKey: string): Promise { + console.log('[CaptchaService] 正在验证 Google reCAPTCHA 令牌...'); + try { + const response = await axios.post(RECAPTCHA_VERIFY_URL, null, { // 使用 POST,数据在 params 中 + params: { + secret: secretKey, + response: token, + // remoteip: 可选,用户 IP 地址 + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + console.log('[CaptchaService] Google reCAPTCHA 验证响应:', response.data); + if (response.data && response.data.success === true) { + // 可选:检查 hostname, score (v3), action (v3) 等 + console.log('[CaptchaService] Google reCAPTCHA 令牌验证成功。'); + return true; + } else { + console.warn('[CaptchaService] Google reCAPTCHA 令牌验证失败:', response.data['error-codes'] || '未知错误'); + return false; + } + } catch (error: any) { + console.error('[CaptchaService] 调用 Google reCAPTCHA 验证 API 时出错:', error.response?.data || error.message); + // 抛出错误,让上层处理 + throw new Error(`Google reCAPTCHA 验证请求失败: ${error.message}`); + } + } +} + +// 导出一个单例供其他服务使用 +export const captchaService = new CaptchaService(); \ No newline at end of file diff --git a/packages/backend/src/services/settings.service.ts b/packages/backend/src/services/settings.service.ts index 6ffc6a9..425525c 100644 --- a/packages/backend/src/services/settings.service.ts +++ b/packages/backend/src/services/settings.service.ts @@ -1,5 +1,19 @@ -import { settingsRepository, Setting, getSidebarConfig as getSidebarConfigFromRepo, setSidebarConfig as setSidebarConfigInRepo } from '../repositories/settings.repository'; // Import specific repo functions -import { SidebarConfig, PaneName, UpdateSidebarConfigDto } from '../types/settings.types'; +import { + settingsRepository, + Setting, + getSidebarConfig as getSidebarConfigFromRepo, + setSidebarConfig as setSidebarConfigInRepo, + getCaptchaConfig as getCaptchaConfigFromRepo, // <-- Import CAPTCHA repo getter + setCaptchaConfig as setCaptchaConfigInRepo, // <-- Import CAPTCHA repo setter +} from '../repositories/settings.repository'; +import { + SidebarConfig, + PaneName, + UpdateSidebarConfigDto, + CaptchaSettings, // <-- Import CAPTCHA types + UpdateCaptchaSettingsDto, // <-- Import CAPTCHA types + CaptchaProvider, // <-- Import CAPTCHA types +} from '../types/settings.types'; // +++ 定义焦点切换完整配置接口 (与前端 store 保持一致) +++ interface FocusItemConfig { // 单个项目的配置 @@ -378,6 +392,76 @@ export const settingsService = { // Directly call the specific repository function await setSidebarConfigInRepo(configToSave); console.log('[SettingsService] Sidebar config successfully set.'); - } // <-- No comma after the last method in the object + }, // <-- Add comma here + + // --- CAPTCHA Settings Specific Functions --- + + /** + * 获取 CAPTCHA 配置 + * @returns Promise + */ + async getCaptchaConfig(): Promise { + console.log('[SettingsService] Getting CAPTCHA config...'); + // Directly call the specific repository function + const config = await getCaptchaConfigFromRepo(); + // Mask secret keys before logging + const maskedConfig = { ...config, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }; + console.log('[SettingsService] Returning CAPTCHA config:', maskedConfig); + return config; + }, + + /** + * 设置 CAPTCHA 配置 + * @param configDto - The CAPTCHA configuration object from DTO + * @returns Promise + */ + async setCaptchaConfig(configDto: UpdateCaptchaSettingsDto): Promise { + console.log('[SettingsService] Setting CAPTCHA config (DTO):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); // Mask secrets in log + + // --- Validation --- + if (!configDto || typeof configDto !== 'object') { + throw new Error('无效的 CAPTCHA 配置格式。'); + } + + // Fetch the current settings to merge with the DTO + const currentConfig = await getCaptchaConfigFromRepo(); + const configToSave: CaptchaSettings = { ...currentConfig }; + + // Validate and update individual fields from DTO + if (configDto.enabled !== undefined) { + if (typeof configDto.enabled !== 'boolean') throw new Error('captcha.enabled 必须是布尔值。'); + configToSave.enabled = configDto.enabled; + } + if (configDto.provider !== undefined) { + const validProviders: CaptchaProvider[] = ['hcaptcha', 'recaptcha', 'none']; + if (!validProviders.includes(configDto.provider)) throw new Error(`无效的 CAPTCHA 提供商: ${configDto.provider}`); + configToSave.provider = configDto.provider; + } + if (configDto.hcaptchaSiteKey !== undefined) { + if (typeof configDto.hcaptchaSiteKey !== 'string') throw new Error('hcaptchaSiteKey 必须是字符串。'); + configToSave.hcaptchaSiteKey = configDto.hcaptchaSiteKey; + } + if (configDto.hcaptchaSecretKey !== undefined) { + if (typeof configDto.hcaptchaSecretKey !== 'string') throw new Error('hcaptchaSecretKey 必须是字符串。'); + configToSave.hcaptchaSecretKey = configDto.hcaptchaSecretKey; + } + if (configDto.recaptchaSiteKey !== undefined) { + if (typeof configDto.recaptchaSiteKey !== 'string') throw new Error('recaptchaSiteKey 必须是字符串。'); + configToSave.recaptchaSiteKey = configDto.recaptchaSiteKey; + } + if (configDto.recaptchaSecretKey !== undefined) { + if (typeof configDto.recaptchaSecretKey !== 'string') throw new Error('recaptchaSecretKey 必须是字符串。'); + configToSave.recaptchaSecretKey = configDto.recaptchaSecretKey; + } + + // Ensure consistency: if disabled, provider should ideally be 'none' (optional enforcement) + // if (!configToSave.enabled) { + // configToSave.provider = 'none'; + // } + + // Directly call the specific repository function with the full, validated config + await setCaptchaConfigInRepo(configToSave); + console.log('[SettingsService] CAPTCHA config successfully set.'); + } // <-- No comma after the last method }; // <-- End of settingsService object definition diff --git a/packages/backend/src/settings/settings.controller.ts b/packages/backend/src/settings/settings.controller.ts index 18b3980..7ee8b6b 100644 --- a/packages/backend/src/settings/settings.controller.ts +++ b/packages/backend/src/settings/settings.controller.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { settingsService } from '../services/settings.service'; import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService import { ipBlacklistService } from '../services/ip-blacklist.service'; -import { UpdateSidebarConfigDto } from '../types/settings.types'; // <-- Correct import path +import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types const auditLogService = new AuditLogService(); @@ -358,6 +358,69 @@ export const settingsController = { res.status(500).json({ message: '设置侧栏配置失败', error: error.message }); } } - } // <-- No comma after the last method +}, // <-- Add comma here + +// --- CAPTCHA Settings Controller Methods --- + +/** + * 获取公共 CAPTCHA 配置 (不含密钥) + */ +async getCaptchaConfig(req: Request, res: Response): Promise { + try { + console.log('[Controller] Received request to get CAPTCHA config.'); + const fullConfig = await settingsService.getCaptchaConfig(); + + // *** IMPORTANT: Filter out secret keys before sending to frontend *** + const publicConfig = { + enabled: fullConfig.enabled, + provider: fullConfig.provider, + hcaptchaSiteKey: fullConfig.hcaptchaSiteKey, + recaptchaSiteKey: fullConfig.recaptchaSiteKey, + }; + + console.log('[Controller] Sending public CAPTCHA config to client:', publicConfig); + res.json(publicConfig); + } catch (error: any) { + console.error('[Controller] 获取 CAPTCHA 配置时出错:', error); + res.status(500).json({ message: '获取 CAPTCHA 配置失败', error: error.message }); + } +}, + +/** + * 设置 CAPTCHA 配置 + */ +async setCaptchaConfig(req: Request, res: Response): Promise { + console.log('[Controller] Received request to set CAPTCHA config.'); + try { + const configDto: UpdateCaptchaSettingsDto = req.body; + // Mask secrets immediately if logging the DTO + console.log('[Controller] Request body (DTO, secrets masked):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); + + // --- DTO Validation (Basic) --- + if (!configDto || typeof configDto !== 'object') { + console.warn('[Controller] Invalid CAPTCHA config format received (not an object):', configDto); + res.status(400).json({ message: '无效的请求体,应为 JSON 对象' }); + return; + } + // More specific validation happens in the service layer + + console.log('[Controller] Calling settingsService.setCaptchaConfig...'); + await settingsService.setCaptchaConfig(configDto); + console.log('[Controller] settingsService.setCaptchaConfig completed successfully.'); + + auditLogService.logAction('CAPTCHA_SETTINGS_UPDATED'); // Add audit log + + console.log('[Controller] Sending success response.'); + res.status(200).json({ message: 'CAPTCHA 配置已成功更新' }); + } catch (error: any) { + console.error('[Controller] 设置 CAPTCHA 配置时出错:', error); + // Handle specific validation errors from the service + if (error.message.includes('无效的') || error.message.includes('必须是')) { // Basic check for validation errors + res.status(400).json({ message: `设置 CAPTCHA 配置失败: ${error.message}` }); + } else { + res.status(500).json({ message: '设置 CAPTCHA 配置失败', error: error.message }); + } + } +} // <-- No comma after the last method }; // <-- End of settingsController object diff --git a/packages/backend/src/settings/settings.routes.ts b/packages/backend/src/settings/settings.routes.ts index 4872308..2826a86 100644 --- a/packages/backend/src/settings/settings.routes.ts +++ b/packages/backend/src/settings/settings.routes.ts @@ -51,3 +51,9 @@ router.put('/sidebar', settingsController.setSidebarConfig); export default router; + +// +++ 新增:CAPTCHA 配置路由 +++ +// GET /api/v1/settings/captcha - 获取公共 CAPTCHA 配置 (不含密钥) +router.get('/captcha', settingsController.getCaptchaConfig); +// PUT /api/v1/settings/captcha - 更新 CAPTCHA 配置 +router.put('/captcha', settingsController.setCaptchaConfig); diff --git a/packages/backend/src/types/audit.types.ts b/packages/backend/src/types/audit.types.ts index 0e4435a..542ecd5 100644 --- a/packages/backend/src/types/audit.types.ts +++ b/packages/backend/src/types/audit.types.ts @@ -31,6 +31,7 @@ export type AuditLogActionType = // Settings | 'SETTINGS_UPDATED' // General settings update | 'IP_WHITELIST_UPDATED' // Specific setting update +| 'CAPTCHA_SETTINGS_UPDATED' // Specific setting update for CAPTCHA // | 'FOCUS_SWITCHER_SEQUENCE_UPDATED' // Removed // Notifications diff --git a/packages/backend/src/types/settings.types.ts b/packages/backend/src/types/settings.types.ts index b44f0fe..cb41e63 100644 --- a/packages/backend/src/types/settings.types.ts +++ b/packages/backend/src/types/settings.types.ts @@ -28,4 +28,45 @@ export interface SidebarConfig { */ export interface UpdateSidebarConfigDto extends SidebarConfig {} // Simple alias for now, can add validation later -// You can add other settings-related types here if needed \ No newline at end of file +// You can add other settings-related types here if needed +/** + * CAPTCHA 提供商类型 + */ +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'none'; + +/** + * CAPTCHA 设置接口 + */ +export interface CaptchaSettings { + enabled: boolean; // 是否启用 CAPTCHA + provider: CaptchaProvider; // 当前选择的提供商 + hcaptchaSiteKey?: string; // hCaptcha 站点密钥 (公开) + hcaptchaSecretKey?: string; // hCaptcha 秘密密钥 (保密) - 后端存储和使用 + recaptchaSiteKey?: string; // Google reCAPTCHA v2 站点密钥 (公开) + recaptchaSecretKey?: string; // Google reCAPTCHA v2 秘密密钥 (保密) - 后端存储和使用 +} + +/** + * 用于更新 CAPTCHA 设置的 DTO + * (可以添加验证规则) + */ +export interface UpdateCaptchaSettingsDto { + enabled?: boolean; + provider?: CaptchaProvider; + hcaptchaSiteKey?: string; + hcaptchaSecretKey?: string; + recaptchaSiteKey?: string; + recaptchaSecretKey?: string; +} + +/** + * 完整的应用设置接口 (聚合所有设置类型) + * 注意:这只是一个示例结构,实际可能需要根据 SettingsRepository 的实现调整 + */ +export interface AppSettings { + sidebar?: SidebarConfig; + captcha?: CaptchaSettings; + // 可以添加其他设置模块,例如: + // security?: SecuritySettings; + // general?: GeneralSettings; +} \ No newline at end of file diff --git a/packages/frontend/package.json b/packages/frontend/package.json index cb6d2d1..f3572b0 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", + "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@simplewebauthn/browser": "^13.1.0", "@tailwindcss/vite": "^4.1.4", "@xterm/addon-search": "^0.15.0", @@ -22,6 +23,7 @@ "vite-plugin-monaco-editor": "^1.1.0", "vue": "^3.3.0", "vue-i18n": "^9.14.4", + "vue-recaptcha-v3": "^2.0.1", "vue-router": "^4.5.0", "vuedraggable": "^4.1.0", "xterm": "^5.3.0", @@ -29,11 +31,11 @@ "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { + "@types/node": "^20", "@types/splitpanes": "^2.2.6", "@vitejs/plugin-vue": "^4.2.0", "typescript": "^5.0.0", "vite": "^5.2.0", - "vue-tsc": "^2.2.8", -"@types/node": "^20" + "vue-tsc": "^2.2.8" } } diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 5081279..51b6129 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -98,7 +98,12 @@ "loggingIn": "Logging in...", "twoFactorPrompt": "Enter your two-factor authentication code:", "verifyButton": "Verify", - "rememberMe": "Remember Me (7 days)" + "rememberMe": "Remember Me (7 days)", + "captchaPrompt": "Please complete the verification below:", + "error": { + "captchaLoadFailed": "Failed to load CAPTCHA. Please try refreshing.", + "captchaRequired": "Please complete the CAPTCHA verification." + } }, "connections": { "addConnection": "Add New Connection", @@ -602,6 +607,25 @@ "invalidBanDuration": "Ban duration must be a positive integer (seconds).", "updateConfigFailed": "Failed to update blacklist configuration" } + }, + "captcha": { + "title": "CAPTCHA Settings", + "description": "Configure CAPTCHA verification for the login page to prevent automated attacks.", + "enableLabel": "Enable CAPTCHA on Login Page", + "providerLabel": "CAPTCHA Provider:", + "providerNone": "None (Disabled)", + "hcaptchaHint": "Get keys from", + "recaptchaHint": "Get keys from", + "siteKeyLabel": "Site Key (Public):", + "secretKeyLabel": "Secret Key (Private):", + "secretKeyHint": "Keep this secret. It is stored securely on the server.", + "saveButton": "Save CAPTCHA Settings", + "success": { + "saved": "CAPTCHA settings saved successfully." + }, + "error": { + "saveFailed": "Failed to save CAPTCHA settings." + } } }, "common": { diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index 656aee8..dae8703 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -98,7 +98,12 @@ "loggingIn": "正在登录...", "twoFactorPrompt": "请输入两步验证码:", "verifyButton": "验证", - "rememberMe": "记住我 (7 天)" + "rememberMe": "记住我 (7 天)", + "captchaPrompt": "请完成下方的验证:", + "error": { + "captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。", + "captchaRequired": "请完成 CAPTCHA 验证。" + } }, "connections": { "addConnection": "添加新连接", @@ -602,6 +607,25 @@ "invalidBanDuration": "封禁时长必须是正整数(秒)。", "updateConfigFailed": "更新黑名单配置失败" } + }, + "captcha": { + "title": "CAPTCHA 设置", + "description": "为登录页面配置 CAPTCHA 验证,以防止自动化攻击。", + "enableLabel": "在登录页面启用 CAPTCHA", + "providerLabel": "CAPTCHA 提供商:", + "providerNone": "无 (禁用)", + "hcaptchaHint": "请从", + "recaptchaHint": "请从", + "siteKeyLabel": "站点密钥 (公开):", + "secretKeyLabel": "秘密密钥 (私有):", + "secretKeyHint": "请妥善保管此密钥,它将安全地存储在服务器上。", + "saveButton": "保存 CAPTCHA 设置", + "success": { + "saved": "CAPTCHA 设置已成功保存。" + }, + "error": { + "saveFailed": "保存 CAPTCHA 设置失败。" + } } }, "common": { diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts index ccd2773..fa557e3 100644 --- a/packages/frontend/src/main.ts +++ b/packages/frontend/src/main.ts @@ -12,6 +12,8 @@ import './style.css'; import '@fortawesome/fontawesome-free/css/all.min.css'; // 导入 splitpanes CSS import 'splitpanes/dist/splitpanes.css'; +// 导入 reCAPTCHA v3 +import { VueReCaptcha } from 'vue-recaptcha-v3'; const pinia = createPinia(); // 创建 Pinia 实例 pinia.use(piniaPluginPersistedstate); // 使用持久化插件 @@ -22,6 +24,15 @@ app.use(pinia); // 使用配置好的 Pinia 实例 // 注意:在状态初始化完成前,暂时不 use(router) app.use(i18n); // 使用 i18n +// 初始化 reCAPTCHA v3 +// 重要提示:请将 'YOUR_RECAPTCHA_V3_SITE_KEY' 替换为您从 Google reCAPTCHA 获取的实际 Site Key +app.use(VueReCaptcha, { + siteKey: 'YOUR_RECAPTCHA_V3_SITE_KEY', // <-- 在此处替换您的 Site Key + loaderOptions: { + autoHideBadge: true // 可选:自动隐藏 reCAPTCHA 徽章 + } +}); + // --- 应用初始化逻辑 --- // 使用 async IIFE 来允许顶层 await (async () => { diff --git a/packages/frontend/src/stores/auth.store.ts b/packages/frontend/src/stores/auth.store.ts index c7d9a3f..e3e8771 100644 --- a/packages/frontend/src/stores/auth.store.ts +++ b/packages/frontend/src/stores/auth.store.ts @@ -3,7 +3,6 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient import router from '../router'; // 引入 router 用于重定向 import { setLocale } from '../i18n'; // 导入 setLocale -// 扩展的用户信息接口,包含 2FA 状态 // 扩展的用户信息接口,包含 2FA 状态和语言偏好 interface UserInfo { id: number; @@ -19,6 +18,14 @@ interface LoginPayload { rememberMe?: boolean; // 可选的“记住我”标志 } +// Public CAPTCHA Config Interface (mirrors backend public config) +interface PublicCaptchaConfig { + enabled: boolean; + provider: 'hcaptcha' | 'recaptcha' | 'none'; + hcaptchaSiteKey?: string; + recaptchaSiteKey?: string; +} + // Auth Store State 接口 interface AuthState { isAuthenticated: boolean; @@ -26,12 +33,13 @@ interface AuthState { isLoading: boolean; error: string | null; loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA - // 新增:存储 IP 黑名单数据 + // 新增:存储 IP 黑名单数据 (虽然 actions 在这里,但 state 结构保持) ipBlacklist: { entries: any[]; // TODO: Define a proper type for blacklist entries total: number; }; needsSetup: boolean; // 新增:是否需要初始设置 + publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config } export const useAuthStore = defineStore('auth', { @@ -43,20 +51,21 @@ export const useAuthStore = defineStore('auth', { loginRequires2FA: false, // 初始为不需要 ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态 needsSetup: false, // 初始假设不需要设置 + publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null }), getters: { // 可以添加一些 getter,例如获取用户名 loggedInUser: (state) => state.user?.username, }, actions: { - // 登录 Action - 更新为接受 LoginPayload - async login(payload: LoginPayload) { + // 登录 Action - 更新为接受 LoginPayload + optional captchaToken + async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload this.isLoading = true; this.error = null; this.loginRequires2FA = false; // 重置 2FA 状态 try { // 后端可能返回 user 或 requiresTwoFactor - // 将完整的 payload (包含 rememberMe) 发送给后端 + // 将完整的 payload (包含 rememberMe 和 captchaToken) 发送给后端 const response = await apiClient.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/auth/login', payload); // 使用 apiClient if (response.data.requiresTwoFactor) { @@ -148,34 +157,6 @@ export const useAuthStore = defineStore('auth', { } }, - // TODO: 添加检查登录状态的 Action (例如应用启动时调用) - // TODO: 添加检查登录状态的 Action (例如应用启动时调用) - // async checkAuthStatus() { - // const token = localStorage.getItem('authToken'); // 假设 token 存储在 localStorage - // if (token) { - // try { - // // 可选: 向后端发送请求验证 token 有效性 - // // const response = await axios.get('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } }); - // // this.isAuthenticated = true; - // // this.user = response.data.user; - // - // // 暂时只基于 localStorage 状态恢复 - // const storedAuth = localStorage.getItem('auth'); // pinia-plugin-persistedstate 默认 key - // if (storedAuth) { - // const parsedAuth = JSON.parse(storedAuth); - // if (parsedAuth.isAuthenticated && parsedAuth.user) { - // this.isAuthenticated = true; - // this.user = parsedAuth.user; - // console.log('Auth status restored from localStorage'); - // } - // } - // } catch (error) { - // console.error('Failed to restore auth status:', error); - // this.logout(); // 如果验证失败或出错,则登出 - // } - // } - // } - // 新增:检查并更新认证状态 Action async checkAuthStatus() { this.isLoading = true; @@ -245,9 +226,9 @@ export const useAuthStore = defineStore('auth', { const response = await apiClient.get('/settings/ip-blacklist', { // 使用 apiClient params: { limit, offset } }); - // 注意:这里需要将获取到的数据存储在 state 中, - // 但当前 AuthState 没有定义相关字段。 - // 暂时只返回数据,需要在 state 中添加 ipBlacklist 字段。 + // 更新本地状态 + this.ipBlacklist.entries = response.data.entries; + this.ipBlacklist.total = response.data.total; console.log('获取 IP 黑名单成功:', response.data); return response.data; // { entries: [], total: number } } catch (err: any) { @@ -270,7 +251,9 @@ export const useAuthStore = defineStore('auth', { try { await apiClient.delete(`/settings/ip-blacklist/${encodeURIComponent(ip)}`); // 使用 apiClient console.log(`IP ${ip} 已从黑名单删除`); - // 成功后需要重新获取列表或从本地 state 中移除 + // 从本地 state 中移除 (或者重新获取列表) + this.ipBlacklist.entries = this.ipBlacklist.entries.filter(entry => entry.ip !== ip); + this.ipBlacklist.total = Math.max(0, this.ipBlacklist.total - 1); return true; } catch (err: any) { console.error(`删除 IP ${ip} 失败:`, err); @@ -297,6 +280,27 @@ export const useAuthStore = defineStore('auth', { return false; } }, + + // NEW: 获取公共 CAPTCHA 配置 + async fetchCaptchaConfig() { + // Avoid refetching if already loaded + if (this.publicCaptchaConfig !== null) return; + + // Don't set isLoading for this, it should be quick background fetch + try { + console.log('[AuthStore] Fetching public CAPTCHA config...'); + const response = await apiClient.get('/auth/captcha/config'); + this.publicCaptchaConfig = response.data; + console.log('[AuthStore] Public CAPTCHA config loaded:', this.publicCaptchaConfig); + } catch (error: any) { + console.error('获取公共 CAPTCHA 配置失败:', error.response?.data?.message || error.message); + // Set a default disabled config on error to prevent blocking login UI + this.publicCaptchaConfig = { + enabled: false, + provider: 'none', + }; + } + }, }, - persist: true, // 使用默认持久化配置 (localStorage, 持久化所有 state) + persist: true, // Revert to simple persistence to fix TS error for now }); diff --git a/packages/frontend/src/stores/settings.store.ts b/packages/frontend/src/stores/settings.store.ts index ec4bc95..8d659df 100644 --- a/packages/frontend/src/stores/settings.store.ts +++ b/packages/frontend/src/stores/settings.store.ts @@ -3,6 +3,26 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient import { ref, computed } from 'vue'; // 移除 watch import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale import type { PaneName } from './layout.store'; // +++ Import PaneName type +++ +// Import CAPTCHA types from backend (adjust path if needed, assuming types are mirrored or shared) +// For now, let's assume they are available via a shared types definition or manually defined here +// Assuming manual definition for now if no shared types exist: +type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'none'; +interface CaptchaSettings { + enabled: boolean; + provider: CaptchaProvider; + hcaptchaSiteKey?: string; + hcaptchaSecretKey?: string; // Store locally but don't expose via getters easily + recaptchaSiteKey?: string; + recaptchaSecretKey?: string; // Store locally but don't expose via getters easily +} +interface UpdateCaptchaSettingsDto { + enabled?: boolean; + provider?: CaptchaProvider; + hcaptchaSiteKey?: string; + hcaptchaSecretKey?: string; + recaptchaSiteKey?: string; + recaptchaSecretKey?: string; +} // 移除 ITheme 和默认主题定义,这些移到 appearance.store.ts // 定义通用设置状态类型 @@ -32,6 +52,7 @@ export const useSettingsStore = defineStore('settings', () => { const settings = ref>({}); // 通用设置状态 const parsedSidebarPaneWidths = ref>({}); // NEW: 解析后的侧边栏宽度对象 const parsedFileManagerColWidths = ref>({}); // NEW: 解析后的文件管理器列宽对象 + const captchaSettings = ref(null); // NEW: CAPTCHA 设置状态 const isLoading = ref(false); const error = ref(null); // 移除外观相关状态: isStyleCustomizerVisible, currentUiTheme, currentXtermTheme @@ -350,6 +371,65 @@ export const useSettingsStore = defineStore('settings', () => { } } + // --- CAPTCHA Settings Actions --- + + /** + * Fetches CAPTCHA settings from the backend. + * Should be called when the settings component mounts. + */ + async function loadCaptchaSettings() { + // Avoid reloading if already loaded, unless forced + // if (captchaSettings.value !== null && !force) return; + + isLoading.value = true; + error.value = null; + try { + console.log('[SettingsStore] 加载 CAPTCHA 设置...'); + // Use the correct endpoint defined in the backend routes + const response = await apiClient.get('/settings/captcha'); + captchaSettings.value = response.data; + console.log('[SettingsStore] CAPTCHA 设置加载完成:', { ...response.data, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); // Mask secrets + } catch (err: any) { + console.error('加载 CAPTCHA 设置失败:', err); + error.value = err.response?.data?.message || err.message || '加载 CAPTCHA 设置失败'; + captchaSettings.value = null; // Reset on error + } finally { + isLoading.value = false; + } + } + + /** + * Updates CAPTCHA settings on the backend. + * @param updates - An object containing the CAPTCHA settings fields to update. + */ + async function updateCaptchaSettings(updates: UpdateCaptchaSettingsDto) { + isLoading.value = true; + error.value = null; + try { + console.log('[SettingsStore] 更新 CAPTCHA 设置:', { ...updates, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); // Mask secrets + // Use the correct endpoint defined in the backend routes + await apiClient.put('/settings/captcha', updates); + + // Update local state after successful API call + // Merge updates into the existing state or reload + if (captchaSettings.value) { + captchaSettings.value = { ...captchaSettings.value, ...updates }; + } else { + // If settings were null, reload them after update + await loadCaptchaSettings(); + } + console.log('[SettingsStore] CAPTCHA 设置更新成功。'); + + } catch (err: any) { + console.error('更新 CAPTCHA 设置失败:', err); + error.value = err.response?.data?.message || err.message || '更新 CAPTCHA 设置失败'; + throw error; // Re-throw to allow component to handle UI feedback + } finally { + isLoading.value = false; + } + } + + // 移除外观相关 actions: saveCustomThemes, resetCustomThemes, toggleStyleCustomizer // --- Getters --- @@ -411,7 +491,14 @@ export const useSettingsStore = defineStore('settings', () => { return parsedFileManagerColWidths.value; }); - return { + // --- CAPTCHA Getters (Public Only) --- + const isCaptchaEnabled = computed(() => captchaSettings.value?.enabled ?? false); + const captchaProvider = computed(() => captchaSettings.value?.provider ?? 'none'); + const hcaptchaSiteKey = computed(() => captchaSettings.value?.hcaptchaSiteKey ?? ''); + const recaptchaSiteKey = computed(() => captchaSettings.value?.recaptchaSiteKey ?? ''); + // DO NOT expose secret keys via getters + + return { settings, // 只包含通用设置 isLoading, error, @@ -426,6 +513,14 @@ export const useSettingsStore = defineStore('settings', () => { getSidebarPaneWidth, // +++ 暴露获取特定面板宽度的 getter +++ fileManagerRowSizeMultiplierNumber, // +++ 暴露文件管理器行大小 getter +++ fileManagerColWidthsObject, // +++ 暴露文件管理器列宽 getter +++ + // CAPTCHA related exports + captchaSettings, // Expose the full (but reactive) object for the settings page v-model + isCaptchaEnabled, + captchaProvider, + hcaptchaSiteKey, + recaptchaSiteKey, + loadCaptchaSettings, + updateCaptchaSettings, // 移除外观相关的 getters 和 actions loadInitialSettings, updateSetting, diff --git a/packages/frontend/src/views/LoginView.vue b/packages/frontend/src/views/LoginView.vue index d95278c..e5065ec 100644 --- a/packages/frontend/src/views/LoginView.vue +++ b/packages/frontend/src/views/LoginView.vue @@ -1,13 +1,15 @@