228 lines
7.6 KiB
PHP
228 lines
7.6 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use App\Models\Setting;
|
||
|
|
|
||
|
|
class CaptchaService
|
||
|
|
{
|
||
|
|
private Setting $settingModel;
|
||
|
|
private array $captchaSettings;
|
||
|
|
|
||
|
|
// Verification endpoints
|
||
|
|
private const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||
|
|
private const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||
|
|
|
||
|
|
public function __construct()
|
||
|
|
{
|
||
|
|
$this->settingModel = new Setting();
|
||
|
|
$this->captchaSettings = $this->settingModel->getCaptchaSettings();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Verify CAPTCHA response based on configured provider
|
||
|
|
*
|
||
|
|
* @param string|null $response CAPTCHA response token from client
|
||
|
|
* @param string|null $remoteIp Remote IP address of the user
|
||
|
|
* @return array ['success' => bool, 'error' => string|null, 'score' => float|null]
|
||
|
|
*/
|
||
|
|
public function verifyCaptcha(?string $response, ?string $remoteIp = null): array
|
||
|
|
{
|
||
|
|
$provider = $this->captchaSettings['provider'] ?? 'disabled';
|
||
|
|
|
||
|
|
// If CAPTCHA is disabled, always return success
|
||
|
|
if ($provider === 'disabled') {
|
||
|
|
return ['success' => true, 'error' => null, 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate that response token is provided
|
||
|
|
if (empty($response)) {
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify based on provider
|
||
|
|
switch ($provider) {
|
||
|
|
case 'recaptcha_v2':
|
||
|
|
return $this->verifyRecaptchaV2($response, $remoteIp);
|
||
|
|
|
||
|
|
case 'recaptcha_v3':
|
||
|
|
return $this->verifyRecaptchaV3($response, $remoteIp);
|
||
|
|
|
||
|
|
case 'turnstile':
|
||
|
|
return $this->verifyTurnstile($response, $remoteIp);
|
||
|
|
|
||
|
|
default:
|
||
|
|
// Unknown provider - allow through but log
|
||
|
|
error_log("Unknown CAPTCHA provider: $provider");
|
||
|
|
return ['success' => true, 'error' => null, 'score' => null];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Verify reCAPTCHA v2 response
|
||
|
|
*/
|
||
|
|
private function verifyRecaptchaV2(string $response, ?string $remoteIp): array
|
||
|
|
{
|
||
|
|
$secretKey = $this->captchaSettings['secret_key'] ?? '';
|
||
|
|
|
||
|
|
if (empty($secretKey)) {
|
||
|
|
error_log('reCAPTCHA v2 secret key is not configured');
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'secret' => $secretKey,
|
||
|
|
'response' => $response
|
||
|
|
];
|
||
|
|
|
||
|
|
if ($remoteIp) {
|
||
|
|
$data['remoteip'] = $remoteIp;
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = $this->sendVerificationRequest(self::RECAPTCHA_VERIFY_URL, $data);
|
||
|
|
|
||
|
|
if ($result === null) {
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isset($result['success']) || !$result['success']) {
|
||
|
|
$errorCodes = $result['error-codes'] ?? [];
|
||
|
|
error_log('reCAPTCHA v2 verification failed: ' . json_encode($errorCodes));
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['success' => true, 'error' => null, 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Verify reCAPTCHA v3 response (score-based)
|
||
|
|
*/
|
||
|
|
private function verifyRecaptchaV3(string $response, ?string $remoteIp): array
|
||
|
|
{
|
||
|
|
$secretKey = $this->captchaSettings['secret_key'] ?? '';
|
||
|
|
$threshold = floatval($this->captchaSettings['score_threshold'] ?? 0.5);
|
||
|
|
|
||
|
|
if (empty($secretKey)) {
|
||
|
|
error_log('reCAPTCHA v3 secret key is not configured');
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'secret' => $secretKey,
|
||
|
|
'response' => $response
|
||
|
|
];
|
||
|
|
|
||
|
|
if ($remoteIp) {
|
||
|
|
$data['remoteip'] = $remoteIp;
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = $this->sendVerificationRequest(self::RECAPTCHA_VERIFY_URL, $data);
|
||
|
|
|
||
|
|
if ($result === null) {
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isset($result['success']) || !$result['success']) {
|
||
|
|
$errorCodes = $result['error-codes'] ?? [];
|
||
|
|
error_log('reCAPTCHA v3 verification failed: ' . json_encode($errorCodes));
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check score
|
||
|
|
$score = floatval($result['score'] ?? 0);
|
||
|
|
|
||
|
|
if ($score < $threshold) {
|
||
|
|
error_log("reCAPTCHA v3 score too low: $score (threshold: $threshold)");
|
||
|
|
return ['success' => false, 'error' => 'Security verification failed. Please try again or contact support.', 'score' => $score];
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['success' => true, 'error' => null, 'score' => $score];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Verify Cloudflare Turnstile response
|
||
|
|
*/
|
||
|
|
private function verifyTurnstile(string $response, ?string $remoteIp): array
|
||
|
|
{
|
||
|
|
$secretKey = $this->captchaSettings['secret_key'] ?? '';
|
||
|
|
|
||
|
|
if (empty($secretKey)) {
|
||
|
|
error_log('Turnstile secret key is not configured');
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA is misconfigured. Please contact administrator.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = [
|
||
|
|
'secret' => $secretKey,
|
||
|
|
'response' => $response
|
||
|
|
];
|
||
|
|
|
||
|
|
if ($remoteIp) {
|
||
|
|
$data['remoteip'] = $remoteIp;
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = $this->sendVerificationRequest(self::TURNSTILE_VERIFY_URL, $data);
|
||
|
|
|
||
|
|
if ($result === null) {
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA verification service unavailable. Please try again later.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!isset($result['success']) || !$result['success']) {
|
||
|
|
$errorCodes = $result['error-codes'] ?? [];
|
||
|
|
error_log('Turnstile verification failed: ' . json_encode($errorCodes));
|
||
|
|
return ['success' => false, 'error' => 'CAPTCHA verification failed. Please try again.', 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
return ['success' => true, 'error' => null, 'score' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send verification request to CAPTCHA provider API
|
||
|
|
*/
|
||
|
|
private function sendVerificationRequest(string $url, array $data): ?array
|
||
|
|
{
|
||
|
|
$options = [
|
||
|
|
'http' => [
|
||
|
|
'method' => 'POST',
|
||
|
|
'header' => 'Content-Type: application/x-www-form-urlencoded',
|
||
|
|
'content' => http_build_query($data),
|
||
|
|
'timeout' => 10
|
||
|
|
]
|
||
|
|
];
|
||
|
|
|
||
|
|
$context = stream_context_create($options);
|
||
|
|
$response = @file_get_contents($url, false, $context);
|
||
|
|
|
||
|
|
if ($response === false) {
|
||
|
|
error_log("Failed to connect to CAPTCHA verification service: $url");
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = json_decode($response, true);
|
||
|
|
|
||
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||
|
|
error_log("Failed to parse CAPTCHA verification response: " . json_last_error_msg());
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get current CAPTCHA settings for view rendering
|
||
|
|
*/
|
||
|
|
public function getCaptchaSettings(): array
|
||
|
|
{
|
||
|
|
return $this->captchaSettings;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if CAPTCHA is enabled
|
||
|
|
*/
|
||
|
|
public function isEnabled(): bool
|
||
|
|
{
|
||
|
|
$provider = $this->captchaSettings['provider'] ?? 'disabled';
|
||
|
|
return $provider !== 'disabled';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|