This commit is contained in:
Baobhan Sith
2025-04-25 10:03:56 +08:00
parent 452922724d
commit 5c2a159792
18 changed files with 995 additions and 66 deletions
@@ -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<boolean> - 令牌是否有效
* @throws Error 如果配置无效或验证请求失败
*/
async verifyToken(token: string): Promise<boolean> {
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<boolean> - 令牌是否有效
*/
private async _verifyHCaptcha(token: string, secretKey: string): Promise<boolean> {
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<boolean> - 令牌是否有效
*/
private async _verifyReCaptcha(token: string, secretKey: string): Promise<boolean> {
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();
@@ -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<CaptchaSettings>
*/
async getCaptchaConfig(): Promise<CaptchaSettings> {
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<void>
*/
async setCaptchaConfig(configDto: UpdateCaptchaSettingsDto): Promise<void> {
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