mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
fix: cloudflare turnstile implementation EXACTLY to the documentation
This commit is contained in:
@@ -59,6 +59,11 @@ class CaptchaController extends Controller
|
||||
'turnstile' => [
|
||||
'site_key' => $this->settings->get('settings::captcha:turnstile:site_key', ''),
|
||||
'secret_key' => $this->settings->get('settings::captcha:turnstile:secret_key', ''),
|
||||
'theme' => $this->settings->get('settings::captcha:turnstile:theme', 'auto'),
|
||||
'size' => $this->settings->get('settings::captcha:turnstile:size', 'normal'),
|
||||
'appearance' => $this->settings->get('settings::captcha:turnstile:appearance', 'always'),
|
||||
'action' => $this->settings->get('settings::captcha:turnstile:action', ''),
|
||||
'cdata' => $this->settings->get('settings::captcha:turnstile:cdata', ''),
|
||||
],
|
||||
'friendly' => [
|
||||
'site_key' => $this->settings->get('settings::captcha:friendly:site_key', ''),
|
||||
@@ -94,6 +99,13 @@ class CaptchaController extends Controller
|
||||
if ($provider === 'mcaptcha') {
|
||||
$this->settings->set("settings::captcha:{$provider}:endpoint", '');
|
||||
}
|
||||
if ($provider === 'turnstile') {
|
||||
$this->settings->set("settings::captcha:{$provider}:theme", '');
|
||||
$this->settings->set("settings::captcha:{$provider}:size", '');
|
||||
$this->settings->set("settings::captcha:{$provider}:appearance", '');
|
||||
$this->settings->set("settings::captcha:{$provider}:action", '');
|
||||
$this->settings->set("settings::captcha:{$provider}:cdata", '');
|
||||
}
|
||||
}
|
||||
|
||||
// Save the selected provider's config if enabled
|
||||
|
||||
@@ -2,206 +2,165 @@
|
||||
|
||||
namespace Pterodactyl\Http\Middleware;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Pterodactyl\Events\Auth\FailedCaptcha;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Pterodactyl\Events\Auth\FailedCaptcha;
|
||||
use Pterodactyl\Services\Captcha\TurnstileService;
|
||||
use Pterodactyl\Services\Captcha\TurnstileException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class VerifyCaptcha
|
||||
{
|
||||
private const PROVIDER_ENDPOINTS = [
|
||||
'turnstile' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
'hcaptcha' => 'https://hcaptcha.com/siteverify',
|
||||
'friendly' => 'https://api.friendlycaptcha.com/api/v1/siteverify',
|
||||
'mcaptcha' => 'https://mcaptcha.org/api/siteverify',
|
||||
];
|
||||
|
||||
private const PROVIDER_FIELDS = [
|
||||
'turnstile' => 'cf-turnstile-response',
|
||||
'hcaptcha' => 'h-captcha-response',
|
||||
'friendly' => 'frc-captcha-response',
|
||||
'mcaptcha' => 'mcaptcha-response',
|
||||
];
|
||||
|
||||
|
||||
public function __construct(
|
||||
private Dispatcher $dispatcher,
|
||||
private Repository $config,
|
||||
private Client $client
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Request $request, \Closure $next): mixed
|
||||
{
|
||||
$driver = $this->config->get('captcha.driver');
|
||||
|
||||
if (!$this->shouldVerifyCaptcha($driver)) {
|
||||
return $next($request);
|
||||
public function __construct(
|
||||
private Dispatcher $dispatcher,
|
||||
private Repository $config,
|
||||
private TurnstileService $turnstileService
|
||||
) {
|
||||
}
|
||||
|
||||
$fieldName = self::PROVIDER_FIELDS[$driver];
|
||||
$captchaResponse = $this->getCaptchaResponseFromRequest($request, $fieldName);
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
$driver = $this->config->get('captcha.driver');
|
||||
|
||||
|
||||
|
||||
if (empty($captchaResponse)) {
|
||||
\Log::warning('CAPTCHA Middleware - Missing response token', [
|
||||
'expected_field' => $fieldName,
|
||||
'available_fields' => array_keys($request->all()),
|
||||
]);
|
||||
$this->logAndTriggerFailure($request, $driver, 'missing_response');
|
||||
throw new HttpException(400, 'Please complete the CAPTCHA challenge.');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->verifyWithProvider($driver, $captchaResponse, $request->ip());
|
||||
|
||||
if ($this->isResponseValid($result, $request, $driver)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$this->logAndTriggerFailure($request, $driver, 'verification_failed', $result);
|
||||
throw new HttpException(400, 'CAPTCHA verification failed. Please try again.');
|
||||
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
$this->logAndTriggerFailure($request, $driver, 'service_error');
|
||||
\Log::error('CAPTCHA service error', ['error' => $e->getMessage()]);
|
||||
throw new HttpException(503, 'CAPTCHA service unavailable. Please try again later.');
|
||||
} catch (\Exception $e) {
|
||||
$this->logAndTriggerFailure($request, $driver, 'unexpected_error');
|
||||
\Log::error('CAPTCHA unexpected error', ['error' => $e->getMessage()]);
|
||||
throw new HttpException(500, 'An unexpected error occurred during CAPTCHA verification.');
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldVerifyCaptcha(?string $driver): bool
|
||||
{
|
||||
return $driver && array_key_exists($driver, self::PROVIDER_FIELDS);
|
||||
}
|
||||
|
||||
private function getCaptchaResponseFromRequest(Request $request, string $fieldName): ?string
|
||||
{
|
||||
|
||||
if ($request->isJson()) {
|
||||
$data = $request->json()->all();
|
||||
return $data['captchaData'] ?? $data[$fieldName] ?? null;
|
||||
}
|
||||
|
||||
$response = $request->input($fieldName) ?? $request->input('captchaData');
|
||||
|
||||
if (empty($response) && in_array($request->method(), ['POST', 'PUT', 'PATCH'])) {
|
||||
$rawInput = file_get_contents('php://input');
|
||||
if (!empty($rawInput)) {
|
||||
parse_str($rawInput, $parsed);
|
||||
$response = $parsed[$fieldName] ?? $parsed['captchaData'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function verifyWithProvider(string $driver, string $response, string $remoteIp): \stdClass
|
||||
{
|
||||
$secretKey = $this->config->get("captcha.{$driver}.secret_key");
|
||||
|
||||
if (empty($secretKey)) {
|
||||
throw new \RuntimeException("No secret key configured for CAPTCHA driver: {$driver}");
|
||||
}
|
||||
|
||||
$params = ['secret' => $secretKey];
|
||||
|
||||
if ($driver === 'turnstile') {
|
||||
$params['response'] = $response;
|
||||
$params['remoteip'] = $remoteIp;
|
||||
} elseif ($driver === 'hcaptcha') {
|
||||
$params['response'] = $response;
|
||||
$params['remoteip'] = $remoteIp;
|
||||
} elseif ($driver === 'friendly') {
|
||||
$params['solution'] = $response;
|
||||
$params['sitekey'] = $this->config->get("captcha.{$driver}.site_key");
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
$res = $this->client->post(self::PROVIDER_ENDPOINTS[$driver], [
|
||||
'timeout' => $this->config->get('captcha.timeout', 5),
|
||||
'json' => $params,
|
||||
]);
|
||||
|
||||
$body = $res->getBody()->getContents();
|
||||
$result = json_decode($body);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
\Log::error('Invalid JSON response from CAPTCHA provider', [
|
||||
'provider' => $driver,
|
||||
'response_body' => $body,
|
||||
'json_error' => json_last_error_msg()
|
||||
]);
|
||||
throw new \RuntimeException("Invalid JSON response from {$driver} CAPTCHA provider");
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('CAPTCHA verification error', [
|
||||
'provider' => $driver,
|
||||
'error' => $e->getMessage(),
|
||||
'response' => $e->getResponse() ? $e->getResponse()->getBody()->getContents() : null
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function isResponseValid(\stdClass $result, Request $request, string $driver): bool
|
||||
{
|
||||
if (!($result->success ?? false)) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($driver) {
|
||||
case 'turnstile':
|
||||
if ($this->config->get('captcha.verify_domain', false)) {
|
||||
$expectedHost = parse_url($request->url(), PHP_URL_HOST);
|
||||
if (($result->hostname ?? null) !== $expectedHost) {
|
||||
\Log::warning('Domain verification failed', [
|
||||
'expected' => $expectedHost,
|
||||
'actual' => $result->hostname ?? 'null'
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
// Skip verification if captcha is disabled or not Turnstile
|
||||
if ($driver !== 'turnstile' || !$this->turnstileService->isEnabled()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->verifyTurnstileToken($request);
|
||||
return $next($request);
|
||||
} catch (TurnstileException $e) {
|
||||
$this->logAndTriggerFailure($request, 'turnstile', $e->getMessage());
|
||||
throw new HttpException(400, 'CAPTCHA verification failed: ' . $e->getMessage());
|
||||
} catch (\Exception $e) {
|
||||
$this->logAndTriggerFailure($request, 'turnstile', 'unexpected_error');
|
||||
Log::error('CAPTCHA unexpected error', ['error' => $e->getMessage()]);
|
||||
throw new HttpException(500, 'An unexpected error occurred during CAPTCHA verification.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Verify Turnstile token according to official documentation
|
||||
*/
|
||||
private function verifyTurnstileToken(Request $request): void
|
||||
{
|
||||
// Extract token from request
|
||||
$token = $this->turnstileService->getTokenFromRequest($request);
|
||||
|
||||
private function logAndTriggerFailure(
|
||||
Request $request,
|
||||
string $driver,
|
||||
string $reason,
|
||||
?\stdClass $result = null
|
||||
): void {
|
||||
$errorCodes = $result->{'error-codes'} ?? [];
|
||||
if (empty($token)) {
|
||||
throw new TurnstileException('Please complete the CAPTCHA challenge.');
|
||||
}
|
||||
|
||||
\Log::warning("CAPTCHA verification failed", [
|
||||
'driver' => $driver,
|
||||
'reason' => $reason,
|
||||
'ip' => $request->ip(),
|
||||
'path' => $request->path(),
|
||||
'method' => $request->method(),
|
||||
'error_codes' => $errorCodes,
|
||||
]);
|
||||
// Get visitor's IP address
|
||||
$remoteIp = $this->getVisitorIpAddress($request);
|
||||
|
||||
$this->dispatcher->dispatch(new FailedCaptcha(
|
||||
$request->ip(),
|
||||
$driver,
|
||||
$reason,
|
||||
$errorCodes
|
||||
));
|
||||
}
|
||||
// Generate idempotency key for potential retries
|
||||
$idempotencyKey = $this->turnstileService->generateIdempotencyKey();
|
||||
|
||||
// Verify token with Turnstile Siteverify API
|
||||
$result = $this->turnstileService->verify($token, $remoteIp, $idempotencyKey);
|
||||
|
||||
if ($result->isFailed()) {
|
||||
// Handle specific error cases
|
||||
if ($result->isTokenInvalid()) {
|
||||
throw new TurnstileException('Invalid or expired CAPTCHA token. Please try again.');
|
||||
}
|
||||
|
||||
if ($result->isTokenConsumed()) {
|
||||
throw new TurnstileException('CAPTCHA token has already been used or has timed out. Please refresh and try again.');
|
||||
}
|
||||
|
||||
if ($result->hasInternalError()) {
|
||||
throw new TurnstileException('CAPTCHA service temporarily unavailable. Please try again.');
|
||||
}
|
||||
|
||||
if ($result->hasSecretKeyError()) {
|
||||
Log::error('Turnstile secret key configuration error', [
|
||||
'error_codes' => $result->getErrorCodes()
|
||||
]);
|
||||
throw new TurnstileException('CAPTCHA configuration error. Please contact support.');
|
||||
}
|
||||
|
||||
// Generic error message for other failures
|
||||
throw new TurnstileException($result->getErrorMessage() ?: 'CAPTCHA verification failed. Please try again.');
|
||||
}
|
||||
|
||||
// Additional validation: verify hostname if domain verification is enabled
|
||||
if ($this->config->get('captcha.verify_domain', false)) {
|
||||
$expectedHostname = parse_url($request->url(), PHP_URL_HOST);
|
||||
if (!$result->validateHostname($expectedHostname)) {
|
||||
Log::warning('Turnstile hostname verification failed', [
|
||||
'expected' => $expectedHostname,
|
||||
'actual' => $result->getHostname()
|
||||
]);
|
||||
throw new TurnstileException('CAPTCHA domain verification failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// Log successful verification for monitoring
|
||||
Log::info('Turnstile verification successful', [
|
||||
'hostname' => $result->getHostname(),
|
||||
'challenge_ts' => $result->getChallengeTimestamp()?->toISOString(),
|
||||
'action' => $result->getAction(),
|
||||
'ip' => $remoteIp,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visitor's IP address with proper header handling
|
||||
*/
|
||||
private function getVisitorIpAddress(Request $request): string
|
||||
{
|
||||
// Check for Cloudflare's CF-Connecting-IP header first
|
||||
if ($request->hasHeader('CF-Connecting-IP')) {
|
||||
return $request->header('CF-Connecting-IP');
|
||||
}
|
||||
|
||||
// Check for other common proxy headers
|
||||
$headers = [
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'HTTP_CLIENT_IP',
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if ($request->server($header)) {
|
||||
$ips = explode(',', $request->server($header));
|
||||
return trim($ips[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to standard IP
|
||||
return $request->ip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failure and trigger event
|
||||
*/
|
||||
private function logAndTriggerFailure(Request $request, string $driver, string $reason): void
|
||||
{
|
||||
Log::warning('CAPTCHA verification failed', [
|
||||
'driver' => $driver,
|
||||
'reason' => $reason,
|
||||
'ip' => $request->ip(),
|
||||
'path' => $request->path(),
|
||||
'method' => $request->method(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
$this->dispatcher->dispatch(new FailedCaptcha(
|
||||
$request->ip(),
|
||||
$driver,
|
||||
$reason,
|
||||
[]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ class CaptchaSettingsFormRequest extends AdminFormRequest
|
||||
} elseif ($driver === 'turnstile') {
|
||||
$rules['turnstile.site_key'] = 'required|string';
|
||||
$rules['turnstile.secret_key'] = 'required|string';
|
||||
$rules['turnstile.theme'] = 'nullable|in:auto,light,dark';
|
||||
$rules['turnstile.size'] = 'nullable|in:normal,compact,flexible';
|
||||
$rules['turnstile.appearance'] = 'nullable|in:always,execute,interaction-only';
|
||||
$rules['turnstile.action'] = 'nullable|string|max:32|regex:/^[a-zA-Z0-9_-]*$/';
|
||||
$rules['turnstile.cdata'] = 'nullable|string|max:255|regex:/^[a-zA-Z0-9_-]*$/';
|
||||
} elseif ($driver === 'friendly') {
|
||||
$rules['friendly.site_key'] = 'required|string';
|
||||
$rules['friendly.secret_key'] = 'required|string';
|
||||
@@ -48,6 +53,11 @@ class CaptchaSettingsFormRequest extends AdminFormRequest
|
||||
'mcaptcha.endpoint' => 'mCaptcha Endpoint',
|
||||
'turnstile.site_key' => 'Turnstile Site Key',
|
||||
'turnstile.secret_key' => 'Turnstile Secret Key',
|
||||
'turnstile.theme' => 'Turnstile Theme',
|
||||
'turnstile.size' => 'Turnstile Size',
|
||||
'turnstile.appearance' => 'Turnstile Appearance',
|
||||
'turnstile.action' => 'Turnstile Action',
|
||||
'turnstile.cdata' => 'Turnstile Custom Data',
|
||||
'proton.site_key' => 'Proton Site Key',
|
||||
'proton.secret_key' => 'Proton Secret Key',
|
||||
'friendly.site_key' => 'Friendly Site Key',
|
||||
|
||||
@@ -19,7 +19,12 @@ class AssetComposer
|
||||
'captcha' => [
|
||||
'driver' => config('captcha.driver', 'none'),
|
||||
'turnstile' => [
|
||||
'siteKey' => config('captcha.turnstile.site_key', '')
|
||||
'siteKey' => config('captcha.turnstile.site_key', ''),
|
||||
'theme' => config('captcha.turnstile.theme', 'auto'),
|
||||
'size' => config('captcha.turnstile.size', 'normal'),
|
||||
'appearance' => config('captcha.turnstile.appearance', 'always'),
|
||||
'action' => config('captcha.turnstile.action', ''),
|
||||
'cdata' => config('captcha.turnstile.cdata', ''),
|
||||
],
|
||||
'hcaptcha' => [
|
||||
'siteKey' => config('captcha.hcaptcha.site_key', '')
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Pterodactyl\Extensions\Themes\Theme;
|
||||
use Pterodactyl\Services\Captcha\TurnstileService;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -65,6 +66,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton('extensions.themes', function () {
|
||||
return new Theme();
|
||||
});
|
||||
|
||||
// Register Turnstile service
|
||||
$this->app->singleton(TurnstileService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
14
app/Services/Captcha/TurnstileException.php
Normal file
14
app/Services/Captcha/TurnstileException.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Captcha;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
class TurnstileException extends Exception
|
||||
{
|
||||
public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
133
app/Services/Captcha/TurnstileService.php
Normal file
133
app/Services/Captcha/TurnstileService.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Captcha;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
|
||||
class TurnstileService
|
||||
{
|
||||
private const SITEVERIFY_ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
private const TOKEN_FIELD = 'cf-turnstile-response';
|
||||
private const MAX_TOKEN_LENGTH = 2048;
|
||||
private const TOKEN_VALIDITY_SECONDS = 300;
|
||||
|
||||
public function __construct(
|
||||
private Client $client,
|
||||
private Repository $config
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Turnstile token using the Siteverify API
|
||||
*
|
||||
* @param string $token The Turnstile response token
|
||||
* @param string|null $remoteIp The visitor's IP address
|
||||
* @param string|null $idempotencyKey Optional UUID for retry functionality
|
||||
* @return TurnstileVerificationResult
|
||||
* @throws TurnstileException
|
||||
*/
|
||||
public function verify(string $token, ?string $remoteIp = null, ?string $idempotencyKey = null): TurnstileVerificationResult
|
||||
{
|
||||
$this->validateToken($token);
|
||||
|
||||
$secretKey = $this->config->get('captcha.turnstile.secret_key');
|
||||
if (empty($secretKey)) {
|
||||
throw new TurnstileException('Turnstile secret key is not configured');
|
||||
}
|
||||
|
||||
$params = [
|
||||
'secret' => $secretKey,
|
||||
'response' => $token,
|
||||
];
|
||||
|
||||
// Add optional parameters
|
||||
if ($remoteIp) {
|
||||
$params['remoteip'] = $remoteIp;
|
||||
}
|
||||
|
||||
if ($idempotencyKey) {
|
||||
$params['idempotency_key'] = $idempotencyKey;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->post(self::SITEVERIFY_ENDPOINT, [
|
||||
'form_params' => $params, // Use form_params for application/x-www-form-urlencoded
|
||||
'timeout' => $this->config->get('captcha.timeout', 5),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
]);
|
||||
|
||||
$body = $response->getBody()->getContents();
|
||||
$result = json_decode($body, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new TurnstileException('Invalid JSON response from Turnstile API: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
return new TurnstileVerificationResult($result);
|
||||
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
throw new TurnstileException('Turnstile API request failed: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Turnstile token from request
|
||||
*/
|
||||
public function getTokenFromRequest(Request $request): ?string
|
||||
{
|
||||
// Try different methods to get the token
|
||||
$token = $request->input(self::TOKEN_FIELD)
|
||||
?? $request->input('captchaData')
|
||||
?? $request->json(self::TOKEN_FIELD)
|
||||
?? $request->json('captchaData');
|
||||
|
||||
// Fallback: parse raw input for form data
|
||||
if (empty($token) && in_array($request->method(), ['POST', 'PUT', 'PATCH'])) {
|
||||
$rawInput = file_get_contents('php://input');
|
||||
if (!empty($rawInput)) {
|
||||
parse_str($rawInput, $parsed);
|
||||
$token = $parsed[self::TOKEN_FIELD] ?? $parsed['captchaData'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token format and length
|
||||
*/
|
||||
private function validateToken(string $token): void
|
||||
{
|
||||
if (empty($token)) {
|
||||
throw new TurnstileException('Token cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($token) > self::MAX_TOKEN_LENGTH) {
|
||||
throw new TurnstileException('Token exceeds maximum length of ' . self::MAX_TOKEN_LENGTH . ' characters');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UUID for idempotency
|
||||
*/
|
||||
public function generateIdempotencyKey(): string
|
||||
{
|
||||
return Str::uuid()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Turnstile is enabled
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->config->get('captcha.driver') === 'turnstile'
|
||||
&& !empty($this->config->get('captcha.turnstile.secret_key'))
|
||||
&& !empty($this->config->get('captcha.turnstile.site_key'));
|
||||
}
|
||||
}
|
||||
179
app/Services/Captcha/TurnstileVerificationResult.php
Normal file
179
app/Services/Captcha/TurnstileVerificationResult.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Captcha;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TurnstileVerificationResult
|
||||
{
|
||||
private array $data;
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the verification was successful
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->data['success'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the challenge timestamp
|
||||
*/
|
||||
public function getChallengeTimestamp(): ?Carbon
|
||||
{
|
||||
$timestamp = $this->data['challenge_ts'] ?? null;
|
||||
return $timestamp ? Carbon::parse($timestamp) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hostname for which the challenge was served
|
||||
*/
|
||||
public function getHostname(): ?string
|
||||
{
|
||||
return $this->data['hostname'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action identifier
|
||||
*/
|
||||
public function getAction(): ?string
|
||||
{
|
||||
return $this->data['action'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the customer data
|
||||
*/
|
||||
public function getCdata(): ?string
|
||||
{
|
||||
return $this->data['cdata'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error codes
|
||||
*/
|
||||
public function getErrorCodes(): array
|
||||
{
|
||||
return $this->data['error-codes'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ephemeral ID (Enterprise only)
|
||||
*/
|
||||
public function getEphemeralId(): ?string
|
||||
{
|
||||
return $this->data['metadata']['ephemeral_id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the verification failed
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return !$this->isSuccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token was invalid or expired
|
||||
*/
|
||||
public function isTokenInvalid(): bool
|
||||
{
|
||||
return in_array('invalid-input-response', $this->getErrorCodes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token was already consumed or timed out
|
||||
*/
|
||||
public function isTokenConsumed(): bool
|
||||
{
|
||||
return in_array('timeout-or-duplicate', $this->getErrorCodes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there was an internal error
|
||||
*/
|
||||
public function hasInternalError(): bool
|
||||
{
|
||||
return in_array('internal-error', $this->getErrorCodes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the secret key was missing or invalid
|
||||
*/
|
||||
public function hasSecretKeyError(): bool
|
||||
{
|
||||
return in_array('missing-input-secret', $this->getErrorCodes()) ||
|
||||
in_array('invalid-input-secret', $this->getErrorCodes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the response parameter was missing
|
||||
*/
|
||||
public function hasResponseMissing(): bool
|
||||
{
|
||||
return in_array('missing-input-response', $this->getErrorCodes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request was malformed
|
||||
*/
|
||||
public function isBadRequest(): bool
|
||||
{
|
||||
return in_array('bad-request', $this->getErrorCodes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable error message
|
||||
*/
|
||||
public function getErrorMessage(): string
|
||||
{
|
||||
if ($this->isSuccess()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$errorCodes = $this->getErrorCodes();
|
||||
|
||||
if (empty($errorCodes)) {
|
||||
return 'Verification failed for unknown reason';
|
||||
}
|
||||
|
||||
$messages = [
|
||||
'missing-input-secret' => 'Secret key was not provided',
|
||||
'invalid-input-secret' => 'Secret key is invalid or does not exist',
|
||||
'missing-input-response' => 'Response token was not provided',
|
||||
'invalid-input-response' => 'Response token is invalid or has expired',
|
||||
'bad-request' => 'Request was malformed',
|
||||
'timeout-or-duplicate' => 'Response token has already been validated or has timed out',
|
||||
'internal-error' => 'Internal error occurred during validation',
|
||||
];
|
||||
|
||||
$errorMessages = [];
|
||||
foreach ($errorCodes as $code) {
|
||||
$errorMessages[] = $messages[$code] ?? "Unknown error: {$code}";
|
||||
}
|
||||
|
||||
return implode('; ', $errorMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw response data
|
||||
*/
|
||||
public function getRawData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the hostname matches the expected domain
|
||||
*/
|
||||
public function validateHostname(string $expectedHostname): bool
|
||||
{
|
||||
$actualHostname = $this->getHostname();
|
||||
return $actualHostname === $expectedHostname;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,16 @@ return [
|
||||
'site_key' => env('TURNSTILE_SITE_KEY'),
|
||||
'secret_key' => env('TURNSTILE_SECRET_KEY'),
|
||||
'endpoint' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
'theme' => env('TURNSTILE_THEME', 'auto'), // auto, light, dark
|
||||
'size' => env('TURNSTILE_SIZE', 'normal'), // normal, compact, flexible
|
||||
'action' => env('TURNSTILE_ACTION', null), // Optional action identifier
|
||||
'cdata' => env('TURNSTILE_CDATA', null), // Optional customer data
|
||||
'retry' => env('TURNSTILE_RETRY', 'auto'), // auto, never
|
||||
'retry_interval' => env('TURNSTILE_RETRY_INTERVAL', 8000), // milliseconds
|
||||
'refresh_expired' => env('TURNSTILE_REFRESH_EXPIRED', 'auto'), // auto, manual, never
|
||||
'refresh_timeout' => env('TURNSTILE_REFRESH_TIMEOUT', 'auto'), // auto, manual, never
|
||||
'appearance' => env('TURNSTILE_APPEARANCE', 'always'), // always, execute, interaction-only
|
||||
'execution' => env('TURNSTILE_EXECUTION', 'render'), // render, execute
|
||||
],
|
||||
|
||||
'proton' => [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// components/Captcha.tsx
|
||||
import React from 'react';
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useEffect, useState } from 'react';
|
||||
import TurnstileWidget from './TurnstileWidget';
|
||||
import FriendlyCaptcha from './FriendlyCaptcha';
|
||||
|
||||
interface CaptchaProps {
|
||||
sitekey?: string;
|
||||
@@ -10,45 +10,83 @@ interface CaptchaProps {
|
||||
onVerify: (token: string) => void;
|
||||
onError: () => void;
|
||||
onExpire: () => void;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible';
|
||||
action?: string;
|
||||
cData?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Captcha = ({ driver, sitekey, endpoint, onVerify, onError, onExpire }: CaptchaProps) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (driver !== 'none' && !loaded) {
|
||||
// Load any required external scripts here if needed
|
||||
setLoaded(true);
|
||||
const Captcha = React.forwardRef<any, CaptchaProps>(
|
||||
({ driver, sitekey, theme = 'auto', size = 'normal', action, cData, onVerify, onError, onExpire, className }, ref) => {
|
||||
if (driver === 'hcaptcha' && sitekey) {
|
||||
return (
|
||||
<HCaptcha
|
||||
ref={ref}
|
||||
sitekey={sitekey}
|
||||
onVerify={onVerify}
|
||||
onError={onError}
|
||||
onExpire={onExpire}
|
||||
theme={theme === 'auto' ? 'dark' : theme}
|
||||
size={size === 'flexible' ? 'normal' : size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [driver]);
|
||||
|
||||
if (driver === 'hcaptcha') {
|
||||
return <HCaptcha sitekey={sitekey || ''} onVerify={onVerify} onError={onError} onExpire={onExpire} />;
|
||||
if (driver === 'turnstile' && sitekey) {
|
||||
return (
|
||||
<TurnstileWidget
|
||||
ref={ref}
|
||||
siteKey={sitekey}
|
||||
theme={theme}
|
||||
size={size}
|
||||
action={action}
|
||||
cData={cData}
|
||||
onSuccess={onVerify}
|
||||
onError={onError}
|
||||
onExpire={onExpire}
|
||||
className={className}
|
||||
appearance="always"
|
||||
execution="render"
|
||||
retry="auto"
|
||||
refreshExpired="auto"
|
||||
refreshTimeout="auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (driver === 'friendly' && sitekey) {
|
||||
return (
|
||||
<FriendlyCaptcha
|
||||
ref={ref}
|
||||
sitekey={sitekey}
|
||||
onComplete={onVerify}
|
||||
onError={onError}
|
||||
onExpire={onExpire}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (driver === 'mcaptcha') {
|
||||
return (
|
||||
<div className="text-red-500 text-sm">
|
||||
mCaptcha implementation needed
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (driver === 'recaptcha') {
|
||||
return (
|
||||
<div className="text-red-500 text-sm">
|
||||
reCAPTCHA implementation needed
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (driver === 'turnstile') {
|
||||
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
|
||||
}
|
||||
|
||||
if (driver === 'mcaptcha') {
|
||||
// TODO: Maybe make this work one day
|
||||
// @mcaptcha/vanilla-glue
|
||||
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
|
||||
}
|
||||
|
||||
if (driver === 'recaptcha') {
|
||||
// TODO: Maybe make this work one day
|
||||
// react-google-recaptcha-v3
|
||||
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
|
||||
}
|
||||
|
||||
if (driver === 'friendly') {
|
||||
// TODO: Maybe make this work one day
|
||||
// @friendlycaptcha/sdk
|
||||
return <Turnstile siteKey={sitekey || ''} onSuccess={onVerify} onError={onError} onExpire={onExpire} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
Captcha.displayName = 'Captcha';
|
||||
|
||||
export default Captcha;
|
||||
|
||||
229
resources/scripts/components/TurnstileWidget.tsx
Normal file
229
resources/scripts/components/TurnstileWidget.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
|
||||
interface TurnstileWidgetProps {
|
||||
siteKey: string;
|
||||
action?: string;
|
||||
cData?: string;
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
size?: 'normal' | 'compact' | 'flexible';
|
||||
tabIndex?: number;
|
||||
responseField?: boolean;
|
||||
responseFieldName?: string;
|
||||
retry?: 'auto' | 'never';
|
||||
retryInterval?: number;
|
||||
refreshExpired?: 'auto' | 'manual' | 'never';
|
||||
refreshTimeout?: 'auto' | 'manual' | 'never';
|
||||
appearance?: 'always' | 'execute' | 'interaction-only';
|
||||
execution?: 'render' | 'execute';
|
||||
feedbackEnabled?: boolean;
|
||||
onSuccess?: (token: string) => void;
|
||||
onError?: (error?: string) => void;
|
||||
onExpire?: () => void;
|
||||
onTimeout?: () => void;
|
||||
onBeforeInteractive?: () => void;
|
||||
onAfterInteractive?: () => void;
|
||||
onUnsupported?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface TurnstileWidgetRef {
|
||||
reset: () => void;
|
||||
remove: () => void;
|
||||
getResponse: () => string | undefined;
|
||||
isExpired: () => boolean;
|
||||
execute: () => void;
|
||||
}
|
||||
|
||||
|
||||
const TurnstileWidget = forwardRef<TurnstileWidgetRef, TurnstileWidgetProps>(
|
||||
(
|
||||
{
|
||||
siteKey,
|
||||
action,
|
||||
cData,
|
||||
theme = 'auto',
|
||||
size = 'normal',
|
||||
tabIndex = 0,
|
||||
responseField = true,
|
||||
responseFieldName = 'cf-turnstile-response',
|
||||
retry = 'auto',
|
||||
retryInterval = 8000,
|
||||
refreshExpired = 'auto',
|
||||
refreshTimeout = 'auto',
|
||||
appearance = 'always',
|
||||
execution = 'render',
|
||||
feedbackEnabled = true,
|
||||
onSuccess,
|
||||
onError,
|
||||
onExpire,
|
||||
onTimeout,
|
||||
onBeforeInteractive,
|
||||
onAfterInteractive,
|
||||
onUnsupported,
|
||||
className = '',
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const widgetIdRef = useRef<string | undefined>(undefined);
|
||||
const scriptLoadedRef = useRef<boolean>(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
if ((window as any).turnstile && widgetIdRef.current) {
|
||||
(window as any).turnstile.reset(widgetIdRef.current);
|
||||
}
|
||||
},
|
||||
remove: () => {
|
||||
if ((window as any).turnstile && widgetIdRef.current) {
|
||||
(window as any).turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = undefined;
|
||||
}
|
||||
},
|
||||
getResponse: () => {
|
||||
if ((window as any).turnstile && widgetIdRef.current) {
|
||||
return (window as any).turnstile.getResponse(widgetIdRef.current);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
isExpired: () => {
|
||||
if ((window as any).turnstile && widgetIdRef.current) {
|
||||
return (window as any).turnstile.isExpired(widgetIdRef.current);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
execute: () => {
|
||||
if ((window as any).turnstile && containerRef.current) {
|
||||
(window as any).turnstile.execute(containerRef.current);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const loadTurnstileScript = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).turnstile) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (scriptLoadedRef.current) {
|
||||
// Script is already loading, wait for it
|
||||
const checkInterval = setInterval(() => {
|
||||
if ((window as any).turnstile) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
scriptLoadedRef.current = true;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
// Note: Do NOT use async/defer when using turnstile.ready()
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Failed to load Turnstile script'));
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
const renderWidget = () => {
|
||||
if (!(window as any).turnstile || !containerRef.current || widgetIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params: any = {
|
||||
sitekey: siteKey,
|
||||
callback: onSuccess,
|
||||
'error-callback': onError,
|
||||
'expired-callback': onExpire,
|
||||
'timeout-callback': onTimeout,
|
||||
'before-interactive-callback': onBeforeInteractive,
|
||||
'after-interactive-callback': onAfterInteractive,
|
||||
'unsupported-callback': onUnsupported,
|
||||
theme,
|
||||
size,
|
||||
tabindex: tabIndex,
|
||||
'response-field': responseField,
|
||||
'response-field-name': responseFieldName,
|
||||
retry,
|
||||
'retry-interval': retryInterval,
|
||||
'refresh-expired': refreshExpired,
|
||||
'refresh-timeout': refreshTimeout,
|
||||
appearance,
|
||||
execution,
|
||||
'feedback-enabled': feedbackEnabled,
|
||||
};
|
||||
|
||||
// Add optional parameters only if they are provided
|
||||
if (action) params.action = action;
|
||||
if (cData) params.cdata = cData;
|
||||
|
||||
try {
|
||||
const widgetId = (window as any).turnstile.render(containerRef.current, params);
|
||||
widgetIdRef.current = widgetId || undefined;
|
||||
} catch (error) {
|
||||
console.error('Failed to render Turnstile widget:', error);
|
||||
onError?.('Failed to render widget');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initializeTurnstile = async () => {
|
||||
try {
|
||||
await loadTurnstileScript();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if ((window as any).turnstile) {
|
||||
// Script is loaded, render widget directly
|
||||
renderWidget();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Turnstile:', error);
|
||||
onError?.('Failed to load Turnstile');
|
||||
}
|
||||
};
|
||||
|
||||
initializeTurnstile();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if ((window as any).turnstile && widgetIdRef.current) {
|
||||
try {
|
||||
(window as any).turnstile.remove(widgetIdRef.current);
|
||||
} catch (error) {
|
||||
console.warn('Failed to remove Turnstile widget:', error);
|
||||
}
|
||||
}
|
||||
widgetIdRef.current = undefined;
|
||||
};
|
||||
}, [siteKey]);
|
||||
|
||||
// Re-render widget if critical props change
|
||||
useEffect(() => {
|
||||
if (widgetIdRef.current && (window as any).turnstile) {
|
||||
// Remove existing widget and create new one
|
||||
(window as any).turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = undefined;
|
||||
renderWidget();
|
||||
}
|
||||
}, [siteKey, theme, size, action, cData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`turnstile-widget ${className}`}
|
||||
data-testid="turnstile-widget"
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TurnstileWidget.displayName = 'TurnstileWidget';
|
||||
|
||||
export default TurnstileWidget;
|
||||
@@ -1,19 +1,16 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import type { FormikHelpers } from 'formik';
|
||||
import { Formik } from 'formik';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import FriendlyCaptcha from '@/components/FriendlyCaptcha';
|
||||
import Captcha from '@/components/Captcha';
|
||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||
import Button from '@/components/elements/Button';
|
||||
import ContentBox from '@/components/elements/ContentBox';
|
||||
import Field from '@/components/elements/Field';
|
||||
|
||||
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import http from '@/api/http';
|
||||
|
||||
@@ -26,42 +23,39 @@ interface Values {
|
||||
}
|
||||
|
||||
const ForgotPasswordContainer = () => {
|
||||
const turnstileRef = useRef(null);
|
||||
const friendlyCaptchaRef = useRef<{ reset: () => void }>(null);
|
||||
const hCaptchaRef = useRef<HCaptcha>(null);
|
||||
|
||||
const captchaRef = useRef<any>(null);
|
||||
const [token, setToken] = useState('');
|
||||
const [friendlyLoaded, setFriendlyLoaded] = useState(false);
|
||||
|
||||
const { clearFlashes, addFlash } = useFlash();
|
||||
const { captcha } = useStoreState((state) => state.settings.data!);
|
||||
const isTurnstileEnabled = captcha.driver === 'turnstile' && captcha.turnstile?.siteKey;
|
||||
const isFriendlyEnabled = captcha.driver === 'friendly' && captcha.friendly?.siteKey;
|
||||
const isHCaptchaEnabled = captcha.driver === 'hcaptcha' && captcha.hcaptcha?.siteKey;
|
||||
const isMCaptchaEnabled = captcha.driver === 'mcaptcha' && captcha.mcaptcha?.siteKey;
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes();
|
||||
const isCaptchaEnabled = captcha.driver !== 'none' && captcha.driver !== undefined;
|
||||
|
||||
let siteKey = '';
|
||||
if (captcha.driver === 'turnstile') {
|
||||
siteKey = captcha.turnstile?.siteKey || '';
|
||||
} else if (captcha.driver === 'hcaptcha') {
|
||||
siteKey = captcha.hcaptcha?.siteKey || '';
|
||||
} else if (captcha.driver === 'friendly') {
|
||||
siteKey = captcha.friendly?.siteKey || '';
|
||||
} else if (captcha.driver === 'mcaptcha') {
|
||||
siteKey = captcha.mcaptcha?.siteKey || '';
|
||||
}
|
||||
|
||||
if (isFriendlyEnabled && !window.friendlyChallenge) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/friendly-challenge@0.9.12/widget.module.min.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setFriendlyLoaded(true);
|
||||
document.body.appendChild(script);
|
||||
} else if (isFriendlyEnabled) {
|
||||
setFriendlyLoaded(true);
|
||||
const resetCaptcha = () => {
|
||||
setToken('');
|
||||
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
|
||||
captchaRef.current.reset();
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleCaptchaComplete = (response: string) => {
|
||||
const handleCaptchaSuccess = (response: string) => {
|
||||
setToken(response);
|
||||
};
|
||||
|
||||
const handleCaptchaError = (provider: string) => {
|
||||
const handleCaptchaError = () => {
|
||||
setToken('');
|
||||
addFlash({ type: 'error', title: 'CAPTCHA Error', message: `${provider} challenge failed.` });
|
||||
addFlash({ type: 'error', title: 'CAPTCHA Error', message: 'CAPTCHA challenge failed. Please try again.' });
|
||||
};
|
||||
|
||||
const handleCaptchaExpire = () => {
|
||||
@@ -71,22 +65,31 @@ const ForgotPasswordContainer = () => {
|
||||
const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
|
||||
clearFlashes();
|
||||
|
||||
if ((isTurnstileEnabled || isFriendlyEnabled || isHCaptchaEnabled) && !token) {
|
||||
// Validate CAPTCHA if enabled
|
||||
if (isCaptchaEnabled && !token) {
|
||||
addFlash({ type: 'error', title: 'Error', message: 'Please complete the CAPTCHA challenge.' });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData: Record<string, string> = { email };
|
||||
const requestData: any = { email };
|
||||
|
||||
if (isTurnstileEnabled) {
|
||||
requestData['cf-turnstile-response'] = token;
|
||||
} else if (isHCaptchaEnabled) {
|
||||
requestData['h-captcha-response'] = token;
|
||||
} else if (isFriendlyEnabled) {
|
||||
requestData['frc-captcha-response'] = token;
|
||||
} else if (isMCaptchaEnabled) {
|
||||
requestData['g-recaptcha-response'] = token; // Fallback or mCaptcha field
|
||||
// Add CAPTCHA token based on provider
|
||||
if (isCaptchaEnabled && token) {
|
||||
switch (captcha.driver) {
|
||||
case 'turnstile':
|
||||
requestData['cf-turnstile-response'] = token;
|
||||
break;
|
||||
case 'hcaptcha':
|
||||
requestData['h-captcha-response'] = token;
|
||||
break;
|
||||
case 'friendly':
|
||||
requestData['frc-captcha-response'] = token;
|
||||
break;
|
||||
case 'mcaptcha':
|
||||
requestData['mcaptcha-response'] = token;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
http.post('/auth/password', requestData)
|
||||
@@ -99,8 +102,7 @@ const ForgotPasswordContainer = () => {
|
||||
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
|
||||
})
|
||||
.finally(() => {
|
||||
setToken('');
|
||||
// Reset CAPTCHAs...
|
||||
resetCaptcha();
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
@@ -128,50 +130,24 @@ const ForgotPasswordContainer = () => {
|
||||
</div>
|
||||
<Field id='email' label={'Email'} name={'email'} type={'email'} />
|
||||
|
||||
{/* CAPTCHA Components */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-6'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={captcha.turnstile.siteKey}
|
||||
onSuccess={handleCaptchaComplete}
|
||||
onError={() => handleCaptchaError('Turnstile')}
|
||||
{/* CAPTCHA Component */}
|
||||
{isCaptchaEnabled && siteKey && (
|
||||
<div className='mt-6 flex justify-center'>
|
||||
<Captcha
|
||||
ref={captchaRef}
|
||||
driver={captcha.driver}
|
||||
sitekey={siteKey}
|
||||
theme={(captcha.turnstile as any)?.theme || 'dark'}
|
||||
size={(captcha.turnstile as any)?.size || 'flexible'}
|
||||
action={(captcha.turnstile as any)?.action}
|
||||
cData={(captcha.turnstile as any)?.cdata}
|
||||
onVerify={handleCaptchaSuccess}
|
||||
onError={handleCaptchaError}
|
||||
onExpire={handleCaptchaExpire}
|
||||
options={{
|
||||
theme: 'dark',
|
||||
size: 'flexible',
|
||||
}}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isFriendlyEnabled && friendlyLoaded && (
|
||||
<div className='mt-6 w-full'>
|
||||
<FriendlyCaptcha
|
||||
ref={friendlyCaptchaRef}
|
||||
sitekey={captcha.friendly.siteKey}
|
||||
onComplete={handleCaptchaComplete}
|
||||
onError={() => handleCaptchaError('FriendlyCaptcha')}
|
||||
onExpire={handleCaptchaExpire}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isHCaptchaEnabled && (
|
||||
<div className='mt-6'>
|
||||
<HCaptcha
|
||||
ref={hCaptchaRef}
|
||||
sitekey={captcha.hcaptcha.siteKey}
|
||||
onVerify={handleCaptchaComplete}
|
||||
onError={() => handleCaptchaError('hCaptcha')}
|
||||
onExpire={handleCaptchaExpire}
|
||||
theme='dark'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isMCaptchaEnabled && (
|
||||
<div className='mt-6'>
|
||||
<p className='text-red-500'>mCaptcha implementation needed</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-6'>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import type { FormikHelpers } from 'formik';
|
||||
import { Formik } from 'formik';
|
||||
@@ -7,7 +5,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { object, string } from 'yup';
|
||||
|
||||
import FriendlyCaptcha from '@/components/FriendlyCaptcha';
|
||||
import Captcha from '@/components/Captcha';
|
||||
import LoginFormContainer from '@/components/auth/LoginFormContainer';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Field from '@/components/elements/Field';
|
||||
@@ -24,87 +22,81 @@ interface Values {
|
||||
|
||||
function LoginContainer() {
|
||||
const [token, setToken] = useState('');
|
||||
const [friendlyLoaded, setFriendlyLoaded] = useState(false);
|
||||
const turnstileRef = useRef(null);
|
||||
const friendlyCaptchaRef = useRef<{ reset: () => void }>(null);
|
||||
const hCaptchaRef = useRef<HCaptcha>(null);
|
||||
const mCaptchaRef = useRef<{ reset: () => void }>(null);
|
||||
const captchaRef = useRef<any>(null);
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { captcha } = useStoreState((state) => state.settings.data!);
|
||||
const isTurnstileEnabled = captcha.driver === 'turnstile' && captcha.turnstile?.siteKey;
|
||||
const isFriendlyEnabled = captcha.driver === 'friendly' && captcha.friendly?.siteKey;
|
||||
const isHCaptchaEnabled = captcha.driver === 'hcaptcha' && captcha.hcaptcha?.siteKey;
|
||||
const isMCaptchaEnabled = captcha.driver === 'mcaptcha' && captcha.mcaptcha?.siteKey;
|
||||
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes();
|
||||
|
||||
// Load FriendlyCaptcha script if needed
|
||||
if (isFriendlyEnabled && !window.friendlyChallenge) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://unpkg.com/friendly-challenge@0.9.12/widget.module.min.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setFriendlyLoaded(true);
|
||||
document.body.appendChild(script);
|
||||
} else if (isFriendlyEnabled) {
|
||||
setFriendlyLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetCaptcha = () => {
|
||||
setToken('');
|
||||
if (isTurnstileEnabled && turnstileRef.current) {
|
||||
// @ts-expect-error - The type doesn't expose the reset method directly
|
||||
turnstileRef.current.reset();
|
||||
}
|
||||
if (isFriendlyEnabled && friendlyCaptchaRef.current) {
|
||||
friendlyCaptchaRef.current.reset();
|
||||
}
|
||||
if (isHCaptchaEnabled && hCaptchaRef.current) {
|
||||
hCaptchaRef.current.resetCaptcha();
|
||||
if (captchaRef.current && typeof captchaRef.current.reset === 'function') {
|
||||
captchaRef.current.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCaptchaComplete = (response: string) => {
|
||||
const handleCaptchaSuccess = (response: string) => {
|
||||
setToken(response);
|
||||
};
|
||||
|
||||
const handleCaptchaError = (provider: string) => {
|
||||
const handleCaptchaError = () => {
|
||||
setToken('');
|
||||
clearAndAddHttpError({ error: new Error(`${provider} challenge failed.`) });
|
||||
clearAndAddHttpError({ error: new Error('CAPTCHA challenge failed. Please try again.') });
|
||||
};
|
||||
|
||||
const handleCaptchaExpire = () => {
|
||||
setToken('');
|
||||
};
|
||||
|
||||
const isCaptchaEnabled = captcha.driver !== 'none' && captcha.driver !== undefined;
|
||||
|
||||
let siteKey = '';
|
||||
if (captcha.driver === 'turnstile') {
|
||||
siteKey = captcha.turnstile?.siteKey || '';
|
||||
} else if (captcha.driver === 'hcaptcha') {
|
||||
siteKey = captcha.hcaptcha?.siteKey || '';
|
||||
} else if (captcha.driver === 'friendly') {
|
||||
siteKey = captcha.friendly?.siteKey || '';
|
||||
} else if (captcha.driver === 'mcaptcha') {
|
||||
siteKey = captcha.mcaptcha?.siteKey || '';
|
||||
}
|
||||
|
||||
const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes();
|
||||
|
||||
if ((isTurnstileEnabled || isFriendlyEnabled || isHCaptchaEnabled) && !token) {
|
||||
// Validate CAPTCHA if enabled
|
||||
if (isCaptchaEnabled && !token) {
|
||||
setSubmitting(false);
|
||||
clearAndAddHttpError({ error: new Error('Please complete the CAPTCHA challenge.') });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData: Record<string, string> = {
|
||||
const requestData: any = {
|
||||
user: values.user,
|
||||
password: values.password,
|
||||
};
|
||||
|
||||
if (isTurnstileEnabled) {
|
||||
requestData['cf-turnstile-response'] = token;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
requestData['cf-turnstile-remoteip'] = 'localhost';
|
||||
// Add CAPTCHA token based on provider
|
||||
if (isCaptchaEnabled && token) {
|
||||
switch (captcha.driver) {
|
||||
case 'turnstile':
|
||||
requestData['cf-turnstile-response'] = token;
|
||||
break;
|
||||
case 'hcaptcha':
|
||||
requestData['h-captcha-response'] = token;
|
||||
break;
|
||||
case 'friendly':
|
||||
requestData['frc-captcha-response'] = token;
|
||||
break;
|
||||
case 'mcaptcha':
|
||||
requestData['mcaptcha-response'] = token;
|
||||
break;
|
||||
}
|
||||
} else if (isHCaptchaEnabled) {
|
||||
requestData['h-captcha-response'] = token;
|
||||
} else if (isFriendlyEnabled) {
|
||||
requestData['frc-captcha-response'] = token;
|
||||
}
|
||||
|
||||
login(requestData)
|
||||
@@ -152,7 +144,15 @@ function LoginContainer() {
|
||||
</div>
|
||||
<div aria-hidden className='my-8 bg-[#ffffff33] min-h-[1px]'></div>
|
||||
<h2 className='text-xl font-extrabold mb-2'>Login</h2>
|
||||
<Field id='user' type={'text'} label={'Username or Email'} name={'user'} disabled={isSubmitting} />
|
||||
|
||||
<Field
|
||||
id='user'
|
||||
type={'text'}
|
||||
label={'Username or Email'}
|
||||
name={'user'}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<div className={`relative mt-6`}>
|
||||
<Field
|
||||
id='password'
|
||||
@@ -169,55 +169,25 @@ function LoginContainer() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* CAPTCHA Providers */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-6'>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={captcha.turnstile.siteKey}
|
||||
onSuccess={handleCaptchaComplete}
|
||||
onError={() => handleCaptchaError('Turnstile')}
|
||||
{/* CAPTCHA Component */}
|
||||
{isCaptchaEnabled && siteKey && (
|
||||
<div className='mt-6 flex justify-center'>
|
||||
<Captcha
|
||||
ref={captchaRef}
|
||||
driver={captcha.driver}
|
||||
sitekey={siteKey}
|
||||
theme={(captcha.turnstile as any)?.theme || 'dark'}
|
||||
size={(captcha.turnstile as any)?.size || 'flexible'}
|
||||
action={(captcha.turnstile as any)?.action}
|
||||
cData={(captcha.turnstile as any)?.cdata}
|
||||
onVerify={handleCaptchaSuccess}
|
||||
onError={handleCaptchaError}
|
||||
onExpire={handleCaptchaExpire}
|
||||
options={{
|
||||
theme: 'dark',
|
||||
size: 'flexible',
|
||||
}}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFriendlyEnabled && friendlyLoaded && (
|
||||
<div className='mt-6 w-full'>
|
||||
<FriendlyCaptcha
|
||||
ref={friendlyCaptchaRef}
|
||||
sitekey={captcha.friendly.siteKey}
|
||||
onComplete={handleCaptchaComplete}
|
||||
onError={() => handleCaptchaError('FriendlyCaptcha')}
|
||||
onExpire={handleCaptchaExpire}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHCaptchaEnabled && (
|
||||
<div className='mt-6'>
|
||||
<HCaptcha
|
||||
ref={hCaptchaRef}
|
||||
sitekey={captcha.hcaptcha.siteKey}
|
||||
onVerify={handleCaptchaComplete}
|
||||
onError={() => handleCaptchaError('hCaptcha')}
|
||||
onExpire={handleCaptchaExpire}
|
||||
theme='dark'
|
||||
size='normal'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMCaptchaEnabled && (
|
||||
<div className='mt-6'>
|
||||
<p className='text-red-500'>mCaptcha implementation needed</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`mt-6`}>
|
||||
<Button
|
||||
className={`relative mt-4 w-full rounded-full bg-brand border-0 ring-0 outline-hidden capitalize font-bold text-sm py-2 hover:cursor-pointer`}
|
||||
|
||||
@@ -143,6 +143,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Theme</label>
|
||||
<div>
|
||||
<select class="form-control" name="turnstile[theme]">
|
||||
<option value="auto" @if(old('turnstile.theme', isset($current) ? $current['turnstile']['theme'] ?? 'auto' : 'auto') === 'auto') selected @endif>Auto</option>
|
||||
<option value="light" @if(old('turnstile.theme', isset($current) ? $current['turnstile']['theme'] ?? 'auto' : 'auto') === 'light') selected @endif>Light</option>
|
||||
<option value="dark" @if(old('turnstile.theme', isset($current) ? $current['turnstile']['theme'] ?? 'auto' : 'auto') === 'dark') selected @endif>Dark</option>
|
||||
</select>
|
||||
<p class="text-muted small">Widget color theme.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Size</label>
|
||||
<div>
|
||||
<select class="form-control" name="turnstile[size]">
|
||||
<option value="normal" @if(old('turnstile.size', isset($current) ? $current['turnstile']['size'] ?? 'normal' : 'normal') === 'normal') selected @endif>Normal</option>
|
||||
<option value="compact" @if(old('turnstile.size', isset($current) ? $current['turnstile']['size'] ?? 'normal' : 'normal') === 'compact') selected @endif>Compact</option>
|
||||
<option value="flexible" @if(old('turnstile.size', isset($current) ? $current['turnstile']['size'] ?? 'normal' : 'normal') === 'flexible') selected @endif>Flexible</option>
|
||||
</select>
|
||||
<p class="text-muted small">Widget size.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label class="control-label">Appearance</label>
|
||||
<div>
|
||||
<select class="form-control" name="turnstile[appearance]">
|
||||
<option value="always" @if(old('turnstile.appearance', isset($current) ? $current['turnstile']['appearance'] ?? 'always' : 'always') === 'always') selected @endif>Always</option>
|
||||
<option value="execute" @if(old('turnstile.appearance', isset($current) ? $current['turnstile']['appearance'] ?? 'always' : 'always') === 'execute') selected @endif>Execute</option>
|
||||
<option value="interaction-only" @if(old('turnstile.appearance', isset($current) ? $current['turnstile']['appearance'] ?? 'always' : 'always') === 'interaction-only') selected @endif>Interaction Only</option>
|
||||
</select>
|
||||
<p class="text-muted small">When the widget becomes visible.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="form-group col-md-6">
|
||||
<label class="control-label">Action (Optional)</label>
|
||||
<div>
|
||||
<input type="text" class="form-control" name="turnstile[action]"
|
||||
value="{{ old('turnstile.action', isset($current) ? $current['turnstile']['action'] ?? '' : '') }}">
|
||||
<p class="text-muted small">Custom action identifier for analytics (max 32 chars, alphanumeric + _ -).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label class="control-label">Custom Data (Optional)</label>
|
||||
<div>
|
||||
<input type="text" class="form-control" name="turnstile[cdata]"
|
||||
value="{{ old('turnstile.cdata', isset($current) ? $current['turnstile']['cdata'] ?? '' : '') }}">
|
||||
<p class="text-muted small">Custom data passed to widget (max 255 chars, alphanumeric + _ -).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user