feat: 添加CAPTCHA验证
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user