feat: 添加CAPTCHA验证

This commit is contained in:
Baobhan Sith
2025-05-11 19:58:49 +08:00
parent 12260681b7
commit 7ee8ffb90a
9 changed files with 298 additions and 46 deletions
@@ -0,0 +1,49 @@
import { Request, Response } from 'express';
import { captchaService } from '../services/captcha.service';
interface VerifyCaptchaCredentialsBody {
provider: 'hcaptcha' | 'recaptcha';
siteKey?: string;
secretKey?: string;
}
export class CaptchaController {
async verifyCredentials(
request: Request<{}, {}, VerifyCaptchaCredentialsBody>, // Express Request type
reply: Response // Express Response type
): Promise<void> {
const { provider, siteKey, secretKey } = request.body;
if (!provider || (provider !== 'hcaptcha' && provider !== 'recaptcha')) {
reply.status(400).json({ message: '无效的 CAPTCHA 提供商。' }); // Use .json for Express
return;
}
if (!siteKey || !secretKey) {
let missingKeyMessage = `缺少 ${provider} 的 Site Key 或 Secret Key。`;
if (!siteKey && !secretKey) {
missingKeyMessage = `缺少 ${provider} 的 Site Key 和 Secret Key。`;
} else if (!siteKey) {
missingKeyMessage = `缺少 ${provider} 的 Site Key。`;
} else if (!secretKey) {
missingKeyMessage = `缺少 ${provider} 的 Secret Key。`;
}
reply.status(400).json({ message: missingKeyMessage }); // Use .json
return;
}
try {
const isValid = await captchaService.verifyCredentials(provider, siteKey, secretKey);
if (isValid) {
reply.status(200).json({ message: 'CAPTCHA 凭据验证成功。' }); // Use .json
} else {
reply.status(400).json({ message: 'CAPTCHA 凭据验证失败。请检查您的 Site Key 和 Secret Key 是否正确,并确保服务器可以访问 CAPTCHA 服务提供商。' }); // Use .json
}
} catch (error: any) {
console.error(`[CaptchaController] 凭据验证时发生意外错误: ${error.message}`);
reply.status(500).json({ message: error.message || 'CAPTCHA 凭据验证时发生服务器内部错误。' }); // Use .json
}
}
}
export const captchaController = new CaptchaController();
@@ -0,0 +1,11 @@
import express, { Router } from 'express';
import { captchaController } from './captcha.controller';
// import { requireAuth } from '../auth/auth.middleware'; // 假设的认证中间件
const router: Router = express.Router();
// POST /api/v1/settings/captcha/verify (路径将由 index.ts 中的 app.use 指定)
// 如果需要认证,可以在这里添加中间件: router.post('/verify', requireAuth, captchaController.verifyCredentials);
router.post('/verify', captchaController.verifyCredentials);
export default router;
+2
View File
@@ -44,6 +44,7 @@ import sftpRouter from './sftp/sftp.routes';
import proxyRoutes from './proxies/proxies.routes';
import tagsRouter from './tags/tags.routes';
import settingsRoutes from './settings/settings.routes';
import captchaRoutes from './captcha/captcha.routes'; // +++ Import CAPTCHA routes +++
import notificationRoutes from './notifications/notification.routes';
import auditRoutes from './audit/audit.routes';
import commandHistoryRoutes from './command-history/command-history.routes';
@@ -264,6 +265,7 @@ const startServer = () => {
app.use('/api/v1/proxies', proxyRoutes);
app.use('/api/v1/tags', tagsRouter);
app.use('/api/v1/settings', settingsRoutes);
app.use('/api/v1/settings/captcha', captchaRoutes); // +++ Register CAPTCHA routes under settings +++
app.use('/api/v1/notifications', notificationRoutes);
app.use('/api/v1/audit-logs', auditRoutes);
app.use('/api/v1/command-history', commandHistoryRoutes);
+112 -24
View File
@@ -47,37 +47,107 @@ export class CaptchaService {
}
}
/**
* 验证提供的 CAPTCHA 凭据 (Site Key 和 Secret Key)。
* @param provider - CAPTCHA 提供商 ('hcaptcha' 或 'recaptcha')
* @param siteKey - Site Key
* @param secretKey - Secret Key
* @returns Promise<boolean> - 凭据是否有效
* @throws Error 如果提供商不受支持或验证请求失败
*/
async verifyCredentials(provider: 'hcaptcha' | 'recaptcha', siteKey: string, secretKey: string): Promise<boolean> {
if (!siteKey || !secretKey) {
console.warn(`[CaptchaService] 凭据验证失败:${provider} 的 Site Key 或 Secret Key 为空。`);
return false;
}
// 使用一个固定的、已知的无效令牌或一个不太可能有效的测试令牌
const testToken = 'static_test_token_for_credential_verification_NexusTerminal';
console.log(`[CaptchaService] 正在验证 ${provider} 凭据 (SiteKey: ${siteKey.substring(0, 5)}...)`);
try {
let success = false;
if (provider === 'hcaptcha') {
success = await this._verifyHCaptcha(testToken, secretKey, siteKey, true);
} else if (provider === 'recaptcha') {
success = await this._verifyReCaptcha(testToken, secretKey, siteKey, true);
} else {
throw new Error(`不支持的 CAPTCHA 提供商: ${provider}`);
}
return success;
} catch (error: any) {
// _verifyHCaptcha/_verifyReCaptcha 在凭据检查模式下会抛出特定错误
console.error(`[CaptchaService] ${provider} 凭据验证期间发生错误:`, error.message);
return false; // 任何在验证方法内部捕获并重新抛出的错误都意味着凭据无效
}
}
/**
* 调用 hCaptcha API 验证令牌。
* @param token - h-captcha-response 令牌
* @param secretKey - hCaptcha Secret Key
* @returns Promise<boolean> - 令牌是否有效
* @param siteKey - (可选) hCaptcha Site Key, 用于凭据验证模式
* @param isCredentialVerification - (可选) 是否为凭据验证模式
* @returns Promise<boolean> - 令牌/凭据是否有效
*/
private async _verifyHCaptcha(token: string, secretKey: string): Promise<boolean> {
console.log('[CaptchaService] 正在验证 hCaptcha 令牌...');
private async _verifyHCaptcha(token: string, secretKey: string, siteKey?: string, isCredentialVerification = false): Promise<boolean> {
const mode = isCredentialVerification ? "凭据" : "令牌";
console.log(`[CaptchaService] 正在验证 hCaptcha ${mode}...`);
try {
// 正确方式:将数据放在 POST body 中,并使用 URLSearchParams 格式化
const params = new URLSearchParams();
params.append('secret', secretKey);
params.append('response', token);
// params.append('remoteip', userIpAddress); // 如果需要传递用户 IP
if (siteKey) { // hCaptcha 的 siteverify 也接受 sitekey 参数
params.append('sitekey', siteKey);
}
const response = await axios.post(HCAPTCHA_VERIFY_URL, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
console.log('[CaptchaService] hCaptcha 验证响应:', response.data);
console.log(`[CaptchaService] hCaptcha ${mode}验证响应:`, response.data);
const errorCodes: string[] = response.data['error-codes'] || [];
if (response.data && response.data.success === true) {
console.log('[CaptchaService] hCaptcha 令牌验证成功。');
console.log(`[CaptchaService] hCaptcha ${mode}验证成功。`);
return true;
} else {
console.warn('[CaptchaService] hCaptcha 令牌验证失败:', response.data['error-codes'] || '未知错误');
return false;
console.warn(`[CaptchaService] hCaptcha ${mode}验证失败:`, errorCodes.join(', ') || '未知错误');
if (isCredentialVerification) {
// 对于凭据验证,如果错误不是关于密钥本身的,我们可能仍然认为密钥“可能”有效。
// 关键的错误代码是 'invalid-input-secret'。'invalid-sitekey' 也是一个明确的凭据错误。
// 其他错误,如 'missing-input-response', 'invalid-input-response' 是关于测试令牌的,可以忽略。
if (errorCodes.includes('invalid-input-secret') || errorCodes.includes('invalid-sitekey')) {
throw new Error(`hCaptcha 凭据无效: ${errorCodes.join(', ')}`);
}
// 如果没有明确的密钥错误,并且不是成功,对于凭据验证,这仍可能意味着密钥组合是“可查询的”
// 但为了更严格,我们也可以返回 false,或根据具体错误代码决定。
// 此处,如果不是特定的密钥错误,我们乐观地认为凭据本身“可能”没问题,只是测试令牌无效。
// 然而,更安全的方式是,任何非 success 都视为凭据验证失败,除非 API 设计允许区分。
// 为了符合“校验成功才能保存”,这里如果 success 为 false,即使没有特定密钥错误,也应该返回 false
// 或者抛出错误让上层决定。我们在此处抛出,由 verifyCredentials 捕获并返回 false.
if (errorCodes.length > 0 && !errorCodes.includes('invalid-input-secret') && !errorCodes.includes('invalid-sitekey')) {
// 例如: 'invalid-input-response', 'sitekey-secret-mismatch' (如果 sitekey 和 secret 不匹配但格式正确)
// 'sitekey-secret-mismatch' 也是一个凭据问题
if (errorCodes.includes('sitekey-secret-mismatch')) {
throw new Error(`hCaptcha 凭据无效: sitekey 与 secret 不匹配`);
}
// 如果是 'invalid-input-response' 这类关于测试令牌的错误,我们认为密钥“可能”是对的。
// 但前端期望布尔值,如果不是 success:true,这里就返回false,表示“未严格验证通过”
console.warn(`[CaptchaService] hCaptcha ${mode}验证失败,但错误可能与测试令牌有关而非密钥本身: ${errorCodes.join(', ')}`);
return false; // 对于凭据验证,如果不是true,就严格返回false
}
return false; // 其他所有情况的失败
}
return false; // 令牌验证失败
}
} catch (error: any) {
console.error('[CaptchaService] 调用 hCaptcha 验证 API 时出错:', error.response?.data || error.message);
// 抛出错误,让上层处理(例如,提示用户稍后重试)
throw new Error(`hCaptcha 验证请求失败: ${error.message}`);
const errorMessage = error.response?.data?.message || error.message || '未知网络错误';
console.error(`[CaptchaService] 调用 hCaptcha ${mode}验证 API 时出错:`, errorMessage, error.response?.data || '');
// 抛出错误,让上层处理
throw new Error(`hCaptcha ${mode}验证请求失败: ${errorMessage}`);
}
}
@@ -85,33 +155,51 @@ export class CaptchaService {
* 调用 Google reCAPTCHA API 验证令牌。
* @param token - g-recaptcha-response 令牌
* @param secretKey - Google reCAPTCHA Secret Key
* @returns Promise<boolean> - 令牌是否有效
* @param siteKey - (可选) Google reCAPTCHA Site Key, reCAPTCHA 的 siteverify 不直接使用 sitekey 作为参数,但保留以保持接口一致性
* @param isCredentialVerification - (可选) 是否为凭据验证模式
* @returns Promise<boolean> - 令牌/凭据是否有效
*/
private async _verifyReCaptcha(token: string, secretKey: string): Promise<boolean> {
console.log('[CaptchaService] 正在验证 Google reCAPTCHA 令牌...');
private async _verifyReCaptcha(token: string, secretKey: string, siteKey?: string, isCredentialVerification = false): Promise<boolean> {
const mode = isCredentialVerification ? "凭据" : "令牌";
console.log(`[CaptchaService] 正在验证 Google reCAPTCHA ${mode}... (SiteKey: ${siteKey ? siteKey.substring(0,5)+'...' : 'N/A'})`);
try {
// 正确方式:将数据放在 POST body 中,并使用 URLSearchParams 格式化
const params = new URLSearchParams();
params.append('secret', secretKey);
params.append('response', token);
// reCAPTCHA 的 siteverify API 不像 hCaptcha 那样直接接受 sitekey 参数
// sitekey 的验证是隐式通过 secretKey 的。
const response = await axios.post(RECAPTCHA_VERIFY_URL, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
console.log('[CaptchaService] Google reCAPTCHA 验证响应:', response.data);
console.log(`[CaptchaService] Google reCAPTCHA ${mode}验证响应:`, response.data);
const errorCodes: string[] = response.data['error-codes'] || [];
if (response.data && response.data.success === true) {
// 可选:检查 hostname, score (v3), action (v3) 等
console.log('[CaptchaService] Google reCAPTCHA 令牌验证成功。');
console.log(`[CaptchaService] Google reCAPTCHA ${mode}验证成功。`);
return true;
} else {
console.warn('[CaptchaService] Google reCAPTCHA 令牌验证失败:', response.data['error-codes'] || '未知错误');
return false;
console.warn(`[CaptchaService] Google reCAPTCHA ${mode}验证失败:`, errorCodes.join(', ') || '未知错误');
if (isCredentialVerification) {
// 对于凭据验证,关注与密钥相关的错误
// 例如: 'invalid-input-secret', 'bad-request' (有时可能由错误的密钥导致), 'invalid-keys' (如果API支持)
// 'missing-input-response', 'invalid-input-response' 是关于测试令牌的。
if (errorCodes.includes('invalid-input-secret') || errorCodes.includes('invalid-keys') /* hypothetical */) {
throw new Error(`Google reCAPTCHA 凭据无效: ${errorCodes.join(', ')}`);
}
// 如果是 'missing-input-response', 'invalid-input-response'
// reCAPTCHA 倾向于对无效密钥返回 success: false 和 "invalid-input-secret"
// 如果没有明确的密钥错误,并且不是 success,严格返回 false
console.warn(`[CaptchaService] Google reCAPTCHA ${mode}验证失败,但错误可能与测试令牌有关而非密钥本身: ${errorCodes.join(', ')}`);
return false; // 对于凭据验证,如果不是true,就严格返回false
}
return false; // 令牌验证失败
}
} catch (error: any) {
console.error('[CaptchaService] 调用 Google reCAPTCHA 验证 API 时出错:', error.response?.data || error.message);
// 抛出错误,让上层处理
throw new Error(`Google reCAPTCHA 验证请求失败: ${error.message}`);
const errorMessage = error.response?.data?.message || error.message || '未知网络错误';
console.error(`[CaptchaService] 调用 Google reCAPTCHA ${mode}验证 API 时出错:`, errorMessage, error.response?.data || '');
throw new Error(`Google reCAPTCHA ${mode}验证请求失败: ${errorMessage}`);
}
}
}