Add CSRF, CAPTCHA, and input validation improvements
Introduces CSRF protection to all sensitive controller actions, integrates configurable CAPTCHA (reCAPTCHA v2/v3, Turnstile) for authentication and registration flows, and centralizes input validation via a new InputValidator helper. Adds new helpers and services for CSRF and CAPTCHA, updates settings and migration for CAPTCHA configuration, and enhances logging and error handling in TLD registry import processes. Also improves validation for user, domain, group, and profile inputs throughout the application.
This commit is contained in:
227
app/Services/CaptchaService.php
Normal file
227
app/Services/CaptchaService.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,9 +196,12 @@ class TldRegistryService
|
||||
|
||||
$stats['total_tlds'] = count($tlds);
|
||||
|
||||
// Normalize last updated date to UTC format
|
||||
$normalizedLastUpdated = $this->normalizeDate($lastUpdated);
|
||||
|
||||
// Update log with version and timestamp
|
||||
$this->importLogModel->update($logId, [
|
||||
'iana_publication_date' => $lastUpdated,
|
||||
'iana_publication_date' => $normalizedLastUpdated,
|
||||
'version' => $version
|
||||
]);
|
||||
|
||||
@@ -260,8 +263,11 @@ class TldRegistryService
|
||||
$publicationDate = $data['publication'] ?? null;
|
||||
$services = $data['services'] ?? [];
|
||||
|
||||
// Normalize publication date to UTC format before saving
|
||||
$normalizedPublicationDate = $this->normalizeDate($publicationDate);
|
||||
|
||||
// Update log with publication date
|
||||
$this->importLogModel->update($logId, ['iana_publication_date' => $publicationDate]);
|
||||
$this->importLogModel->update($logId, ['iana_publication_date' => $normalizedPublicationDate]);
|
||||
|
||||
foreach ($services as $service) {
|
||||
$tlds = $service[0] ?? []; // TLD patterns
|
||||
@@ -271,7 +277,7 @@ class TldRegistryService
|
||||
$stats['total_tlds']++;
|
||||
|
||||
try {
|
||||
$result = $this->processTldRdapData($tld, $rdapServers, $publicationDate);
|
||||
$result = $this->processTldRdapData($tld, $rdapServers, $normalizedPublicationDate);
|
||||
|
||||
if ($result['is_new']) {
|
||||
$stats['new_tlds']++;
|
||||
@@ -911,8 +917,28 @@ class TldRegistryService
|
||||
'failed_tlds' => 0
|
||||
]);
|
||||
|
||||
$message = $updateInfo['overall_needs_update'] ?
|
||||
'Updates available' : 'TLD registry is up to date';
|
||||
// Build detailed message
|
||||
$messages = [];
|
||||
|
||||
if ($updateInfo['tld_list']['needs_update'] ?? false) {
|
||||
$current = $updateInfo['tld_list']['current_version'] ?? 'Unknown';
|
||||
$last = $updateInfo['tld_list']['last_version'] ?? 'None';
|
||||
$messages[] = "TLD List: New version available (current: $current, previous: $last)";
|
||||
}
|
||||
|
||||
if ($updateInfo['rdap']['needs_update'] ?? false) {
|
||||
$current = $updateInfo['rdap']['current_publication'] ?? 'Unknown';
|
||||
$last = $updateInfo['rdap']['last_publication'] ?? 'None';
|
||||
$messages[] = "RDAP Data: New publication available (current: $current, previous: $last)";
|
||||
}
|
||||
|
||||
if ($updateInfo['overall_needs_update']) {
|
||||
$message = "🔔 Updates Available! " . implode(' • ', $messages) . " Click 'Import TLDs' to update your database.";
|
||||
} else {
|
||||
$tldVersion = $updateInfo['tld_list']['current_version'] ?? 'N/A';
|
||||
$rdapDate = $updateInfo['rdap']['current_publication'] ?? 'N/A';
|
||||
$message = "✅ TLD Registry is Up to Date! (TLD List version: $tldVersion, RDAP publication: $rdapDate)";
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'complete',
|
||||
@@ -921,7 +947,8 @@ class TldRegistryService
|
||||
'processed' => 2,
|
||||
'failed' => 0,
|
||||
'remaining' => 0,
|
||||
'message' => $message
|
||||
'message' => $message,
|
||||
'update_info' => $updateInfo
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$this->importLogModel->completeImport($logId, [
|
||||
@@ -1692,21 +1719,28 @@ class TldRegistryService
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
$currentPublication = $data['publication'] ?? null;
|
||||
|
||||
// Get last RDAP import
|
||||
$lastRdapImport = $this->importLogModel->query(
|
||||
// Get last RDAP import using database directly
|
||||
$db = \Core\Database::getConnection();
|
||||
$stmt = $db->prepare(
|
||||
"SELECT iana_publication_date FROM tld_import_logs
|
||||
WHERE import_type = 'rdap' AND status = 'completed'
|
||||
ORDER BY started_at DESC LIMIT 1"
|
||||
);
|
||||
$stmt->execute();
|
||||
$lastRdapImport = $stmt->fetch();
|
||||
|
||||
$lastPublication = $lastRdapImport[0]['iana_publication_date'] ?? null;
|
||||
$lastPublication = $lastRdapImport['iana_publication_date'] ?? null;
|
||||
|
||||
$needsUpdate = $currentPublication !== $lastPublication;
|
||||
// Normalize date formats for comparison (ISO 8601 vs MySQL datetime)
|
||||
$currentNormalized = $this->normalizeDate($currentPublication);
|
||||
$lastNormalized = $this->normalizeDate($lastPublication);
|
||||
|
||||
$needsUpdate = ($currentNormalized !== $lastNormalized) && ($currentNormalized !== null);
|
||||
|
||||
return [
|
||||
'needs_update' => $needsUpdate,
|
||||
'current_publication' => $currentPublication,
|
||||
'last_publication' => $lastPublication
|
||||
'current_publication' => $currentNormalized ?: $currentPublication,
|
||||
'last_publication' => $lastNormalized ?: $lastPublication
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -1863,4 +1897,32 @@ class TldRegistryService
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize date string for comparison
|
||||
* Converts both ISO 8601 and MySQL datetime to same format (UTC)
|
||||
*
|
||||
* @param string|null $date Date string to normalize
|
||||
* @return string|null Normalized date in UTC (Y-m-d H:i:s) or null
|
||||
*/
|
||||
private function normalizeDate(?string $date): ?string
|
||||
{
|
||||
if (empty($date)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create DateTime object (handles both ISO 8601 and MySQL datetime)
|
||||
$dateTime = new \DateTime($date);
|
||||
|
||||
// Convert to UTC to ensure consistent comparison
|
||||
$dateTime->setTimezone(new \DateTimeZone('UTC'));
|
||||
|
||||
// Return in MySQL datetime format (Y-m-d H:i:s) in UTC
|
||||
return $dateTime->format('Y-m-d H:i:s');
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to null if date parsing fails
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user