diff --git a/app/Events/Auth/FailedCaptcha.php b/app/Events/Auth/FailedCaptcha.php index 0605629bda..6f508bd417 100644 --- a/app/Events/Auth/FailedCaptcha.php +++ b/app/Events/Auth/FailedCaptcha.php @@ -12,7 +12,7 @@ class FailedCaptcha extends Event /** * Create a new event instance. */ - public function __construct(public string $ip, public string $domain) + public function __construct(public string $ip, public ?string $message) { } } diff --git a/app/Filament/Pages/Auth/Login.php b/app/Filament/Pages/Auth/Login.php new file mode 100644 index 0000000000..a4f157a374 --- /dev/null +++ b/app/Filament/Pages/Auth/Login.php @@ -0,0 +1,36 @@ + $this->form( + $this->makeForm() + ->schema([ + $this->getEmailFormComponent(), + $this->getPasswordFormComponent(), + $this->getRememberFormComponent(), + Turnstile::make('captcha') + ->hidden(!config('turnstile.turnstile_enabled')) + ->validationMessages([ + 'required' => config('turnstile.error_messages.turnstile_check_message'), + ]), + ]) + ->statePath('data'), + ), + ]; + } + + protected function throwFailureValidationException(): never + { + $this->dispatch('reset-captcha'); + + parent::throwFailureValidationException(); + } +} diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index 7043fe9d7d..a5c3572791 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -8,6 +8,7 @@ use Exception; use Filament\Actions\Action; use Filament\Forms\Components\Actions\Action as FormAction; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Section; use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs\Tab; @@ -26,6 +27,7 @@ use Filament\Pages\Page; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Notification as MailNotification; +use Illuminate\Support\HtmlString; /** * @property Form $form @@ -67,10 +69,11 @@ protected function getFormSchema(): array ->label('General') ->icon('tabler-home') ->schema($this->generalSettings()), - Tab::make('recaptcha') - ->label('reCAPTCHA') + Tab::make('captcha') + ->label('Captcha') ->icon('tabler-shield') - ->schema($this->recaptchaSettings()), + ->schema($this->captchaSettings()) + ->columns(3), Tab::make('mail') ->label('Mail') ->icon('tabler-mail') @@ -180,35 +183,47 @@ private function generalSettings(): array ]; } - private function recaptchaSettings(): array + private function captchaSettings(): array { return [ - Toggle::make('RECAPTCHA_ENABLED') - ->label('Enable reCAPTCHA?') + Toggle::make('TURNSTILE_ENABLED') + ->label('Enable Turnstile Captcha?') ->inline(false) + ->columnSpan(1) ->onIcon('tabler-check') ->offIcon('tabler-x') ->onColor('success') ->offColor('danger') ->live() ->formatStateUsing(fn ($state): bool => (bool) $state) - ->afterStateUpdated(fn ($state, Set $set) => $set('RECAPTCHA_ENABLED', (bool) $state)) - ->default(env('RECAPTCHA_ENABLED', config('recaptcha.enabled'))), - TextInput::make('RECAPTCHA_DOMAIN') - ->label('Domain') + ->afterStateUpdated(fn ($state, Set $set) => $set('TURNSTILE_ENABLED', (bool) $state)) + ->default(env('TURNSTILE_ENABLED', config('turnstile.turnstile_enabled'))), + Placeholder::make('info') + ->columnSpan(2) + ->content(new HtmlString('
You can generate the keys on your Cloudflare Dashboard. A Cloudflare account is required.
')), + TextInput::make('TURNSTILE_SITE_KEY') + ->label('Site Key') ->required() - ->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED')) - ->default(env('RECAPTCHA_DOMAIN', config('recaptcha.domain'))), - TextInput::make('RECAPTCHA_WEBSITE_KEY') - ->label('Website Key') - ->required() - ->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED')) - ->default(env('RECAPTCHA_WEBSITE_KEY', config('recaptcha.website_key'))), - TextInput::make('RECAPTCHA_SECRET_KEY') + ->visible(fn (Get $get) => $get('TURNSTILE_ENABLED')) + ->default(env('TURNSTILE_SITE_KEY', config('turnstile.turnstile_site_key'))) + ->placeholder('1x00000000000000000000AA'), + TextInput::make('TURNSTILE_SECRET_KEY') ->label('Secret Key') ->required() - ->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED')) - ->default(env('RECAPTCHA_SECRET_KEY', config('recaptcha.secret_key'))), + ->visible(fn (Get $get) => $get('TURNSTILE_ENABLED')) + ->default(env('TURNSTILE_SECRET_KEY', config('turnstile.secret_key'))) + ->placeholder('1x0000000000000000000000000000000AA'), + Toggle::make('TURNSTILE_VERIFY_DOMAIN') + ->label('Verify domain?') + ->inline(false) + ->onIcon('tabler-check') + ->offIcon('tabler-x') + ->onColor('success') + ->offColor('danger') + ->visible(fn (Get $get) => $get('TURNSTILE_ENABLED')) + ->formatStateUsing(fn ($state): bool => (bool) $state) + ->afterStateUpdated(fn ($state, Set $set) => $set('TURNSTILE_VERIFY_DOMAIN', (bool) $state)) + ->default(env('TURNSTILE_VERIFY_DOMAIN', config('turnstile.turnstile_verify_domain'))), ]; } diff --git a/app/Http/Middleware/VerifyReCaptcha.php b/app/Http/Middleware/VerifyReCaptcha.php index 4ba454ce4c..b58906e90e 100644 --- a/app/Http/Middleware/VerifyReCaptcha.php +++ b/app/Http/Middleware/VerifyReCaptcha.php @@ -2,11 +2,11 @@ namespace App\Http\Middleware; -use GuzzleHttp\Client; use Illuminate\Foundation\Application; use Illuminate\Http\Request; use Illuminate\Http\Response; use App\Events\Auth\FailedCaptcha; +use Coderflex\LaravelTurnstile\Facades\LaravelTurnstile; use Symfony\Component\HttpKernel\Exception\HttpException; readonly class VerifyReCaptcha @@ -18,7 +18,7 @@ public function __construct(private Application $app) public function handle(Request $request, \Closure $next): mixed { - if (!config('recaptcha.enabled')) { + if (!config('turnstile.turnstile_enabled')) { return $next($request); } @@ -26,40 +26,30 @@ public function handle(Request $request, \Closure $next): mixed return $next($request); } - if ($request->filled('g-recaptcha-response')) { - $client = new Client(); - $res = $client->post(config('recaptcha.domain'), [ - 'form_params' => [ - 'secret' => config('recaptcha.secret_key'), - 'response' => $request->input('g-recaptcha-response'), - ], - ]); + if ($request->filled('cf-turnstile-response')) { + $response = LaravelTurnstile::validate($request->get('cf-turnstile-response')); - if ($res->getStatusCode() === 200) { - $result = json_decode($res->getBody()); - - if ($result->success && (!config('recaptcha.verify_domain') || $this->isResponseVerified($result, $request))) { - return $next($request); - } + if ($response['success'] && $this->isResponseVerified($response['hostname'] ?? '', $request)) { + return $next($request); } } - event(new FailedCaptcha($request->ip(), $result->hostname ?? null)); + event(new FailedCaptcha($request->ip(), $response['message'] ?? null)); - throw new HttpException(Response::HTTP_BAD_REQUEST, 'Failed to validate reCAPTCHA data.'); + throw new HttpException(Response::HTTP_BAD_REQUEST, 'Failed to validate turnstile captcha data.'); } /** * Determine if the response from the recaptcha servers was valid. */ - private function isResponseVerified(\stdClass $result, Request $request): bool + private function isResponseVerified(string $hostname, Request $request): bool { - if (!config('recaptcha.verify_domain')) { - return false; + if (!config('turnstile.turnstile_verify_domain')) { + return true; } $url = parse_url($request->url()); - return $result->hostname === array_get($url, 'host'); + return $hostname === array_get($url, 'host'); } } diff --git a/app/Http/ViewComposers/AssetComposer.php b/app/Http/ViewComposers/AssetComposer.php index 6908f41395..58cc53218b 100644 --- a/app/Http/ViewComposers/AssetComposer.php +++ b/app/Http/ViewComposers/AssetComposer.php @@ -24,8 +24,8 @@ public function compose(View $view): void 'name' => config('app.name', 'Panel'), 'locale' => config('app.locale') ?? 'en', 'recaptcha' => [ - 'enabled' => config('recaptcha.enabled', false), - 'siteKey' => config('recaptcha.website_key') ?? '', + 'enabled' => config('turnstile.turnstile_enabled', false), + 'siteKey' => config('turnstile.turnstile_site_key') ?? '', ], 'usesSyncDriver' => config('queue.default') === 'sync', 'serverDescriptionsEditable' => config('panel.editable_server_descriptions'), diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 3df1b101cb..829f893fbf 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,6 +2,7 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Auth\Login; use App\Filament\Resources\UserResource\Pages\EditProfile; use App\Http\Middleware\LanguageMiddleware; use Filament\Http\Middleware\Authenticate; @@ -36,7 +37,7 @@ public function panel(Panel $panel): Panel ->id('admin') ->path('admin') ->topNavigation(config('panel.filament.top-navigation', true)) - ->login() + ->login(Login::class) ->breadcrumbs(false) ->homeUrl('/') ->favicon(config('app.favicon', '/pelican.ico')) diff --git a/composer.json b/composer.json index 6b44652ffc..ab67ddd236 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "abdelhamiderrahmouni/filament-monaco-editor": "0.2.1", "aws/aws-sdk-php": "~3.288.1", "chillerlan/php-qrcode": "^5.0.2", + "coderflex/filament-turnstile": "^2.2", "dedoc/scramble": "^0.10.0", "doctrine/dbal": "~3.6.0", "filament/filament": "^3.2", diff --git a/composer.lock b/composer.lock index ca8c6f54ab..b629a15af8 100644 --- a/composer.lock +++ b/composer.lock @@ -743,6 +743,159 @@ ], "time": "2024-03-02T20:07:15+00:00" }, + { + "name": "coderflex/filament-turnstile", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/coderflexx/filament-turnstile.git", + "reference": "85735c61d414f67f8e3edca40af5d986c6eba496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coderflexx/filament-turnstile/zipball/85735c61d414f67f8e3edca40af5d986c6eba496", + "reference": "85735c61d414f67f8e3edca40af5d986c6eba496", + "shasum": "" + }, + "require": { + "coderflex/laravel-turnstile": "^1.0|^2.0", + "illuminate/contracts": "^10.0|^11.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.14.0" + }, + "require-dev": { + "filament/filament": "^3.0", + "larastan/larastan": "^2.0.1", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.9|^8.1", + "orchestra/testbench": "^8.0|^9.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "pestphp/pest-plugin-livewire": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Coderflex\\FilamentTurnstile\\FilamentTurnstileServiceProvider" + ], + "aliases": { + "FilamentTurnstile": "Coderflex\\FilamentTurnstile\\Facades\\FilamentTurnstile" + } + } + }, + "autoload": { + "psr-4": { + "Coderflex\\FilamentTurnstile\\": "src/", + "Coderflex\\FilamentTurnstile\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oussama", + "email": "oussama@coderflex.com", + "role": "Developer" + } + ], + "description": "Filament Plugin to help you implement Cloudflare Turnstile", + "homepage": "https://github.com/coderflex/filament-turnstile", + "keywords": [ + "cloudflare", + "coderflex", + "filament", + "filament-turnstile", + "laravel", + "laravel-turnstile", + "turnstile" + ], + "support": { + "issues": "https://github.com/coderflexx/filament-turnstile/issues", + "source": "https://github.com/coderflexx/filament-turnstile/tree/v2.2.0" + }, + "time": "2024-05-04T13:23:47+00:00" + }, + { + "name": "coderflex/laravel-turnstile", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/coderflexx/laravel-turnstile.git", + "reference": "02d5604e32f9ea578b5a40bc92b97c8b726ca34b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coderflexx/laravel-turnstile/zipball/02d5604e32f9ea578b5a40bc92b97c8b726ca34b", + "reference": "02d5604e32f9ea578b5a40bc92b97c8b726ca34b", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.7", + "illuminate/contracts": "^10.0|^11.0", + "php": "^8.1|^8.2", + "spatie/laravel-package-tools": "^1.14.0" + }, + "require-dev": { + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.0|^8.0", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^8.0|^9.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Coderflex\\LaravelTurnstile\\LaravelTurnstileServiceProvider" + ], + "aliases": { + "LaravelTurnstile": "Coderflex\\LaravelTurnstile\\Facades\\LaravelTurnstile" + } + } + }, + "autoload": { + "psr-4": { + "Coderflex\\LaravelTurnstile\\": "src/", + "Coderflex\\LaravelTurnstile\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "ousid", + "email": "oussama@coderflex.com", + "role": "Developer" + } + ], + "description": "A package to help you implement the Cloudflare turnstile \"CAPTCHA Alternative\"", + "homepage": "https://github.com/coderflexx/laravel-turnstile", + "keywords": [ + "cloudflare", + "coderflex", + "laravel", + "laravel-turnstile", + "turnstile" + ], + "support": { + "issues": "https://github.com/coderflexx/laravel-turnstile/issues", + "source": "https://github.com/coderflexx/laravel-turnstile/tree/v2.0.1" + }, + "time": "2024-04-08T16:05:46+00:00" + }, { "name": "danharrin/date-format-converter", "version": "v0.3.1", @@ -13483,4 +13636,4 @@ }, "platform-dev": [], "plugin-api-version": "2.6.0" -} +} \ No newline at end of file diff --git a/config/recaptcha.php b/config/recaptcha.php deleted file mode 100644 index 757e184a5b..0000000000 --- a/config/recaptcha.php +++ /dev/null @@ -1,31 +0,0 @@ - env('RECAPTCHA_ENABLED', true), - - /* - * API endpoint for recaptcha checks. You should not edit this. - */ - 'domain' => env('RECAPTCHA_DOMAIN', 'https://www.google.com/recaptcha/api/siteverify'), - - /* - * Use a custom secret key, we use our public one by default - */ - 'secret_key' => env('RECAPTCHA_SECRET_KEY', '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5'), - '_shipped_secret_key' => '6LcJcjwUAAAAALOcDJqAEYKTDhwELCkzUkNDQ0J5', - - /* - * Use a custom website key, we use our public one by default - */ - 'website_key' => env('RECAPTCHA_WEBSITE_KEY', '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn'), - '_shipped_website_key' => '6LcJcjwUAAAAAO_Xqjrtj9wWufUpYRnK6BW8lnfn', - - /* - * Domain verification is enabled by default and compares the domain used when solving the captcha - * as public keys can't have domain verification on google's side enabled (obviously). - */ - 'verify_domain' => true, -]; diff --git a/config/turnstile.php b/config/turnstile.php new file mode 100644 index 0000000000..2a2e6c8cbd --- /dev/null +++ b/config/turnstile.php @@ -0,0 +1,15 @@ + env('TURNSTILE_ENABLED', false), + + 'turnstile_site_key' => env('TURNSTILE_SITE_KEY', null), + 'turnstile_secret_key' => env('TURNSTILE_SECRET_KEY', null), + + 'turnstile_verify_domain' => env('TURNSTILE_VERIFY_DOMAIN', true), + + 'error_messages' => [ + 'turnstile_check_message' => 'Captcha failed! Please refresh and try again.', + ], +]; diff --git a/package.json b/package.json index 713b5cbafb..c08b6d02ed 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "react-i18next": "^11.2.1", "react-router-dom": "^5.1.2", "react-transition-group": "^4.4.1", - "reaptcha": "^1.7.2", + "react-turnstile": "^1.1.4", "rimraf": "^4", "sockette": "^2.0.6", "styled-components": "^5.2.1", diff --git a/resources/scripts/api/auth/login.ts b/resources/scripts/api/auth/login.ts index ff0de7ac62..d42f9b2764 100644 --- a/resources/scripts/api/auth/login.ts +++ b/resources/scripts/api/auth/login.ts @@ -19,7 +19,7 @@ export default ({ username, password, recaptchaData }: LoginData): Promise