Replace reCAPTCHA with Turnstile (#589)

* add laravel turnstile

* add config & settings for turnstile

* publish view to center captcha

* completely replace reCAPTCHA

* update FailedCaptcha event

* add back config for domain verification

* don't set language so browser lang is used
This commit is contained in:
Boy132 2024-11-01 23:15:04 +01:00 committed by GitHub
parent cf57c28c40
commit 9d02aeb130
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 372 additions and 136 deletions

View File

@ -12,7 +12,7 @@ class FailedCaptcha extends Event
/** /**
* Create a new event instance. * Create a new event instance.
*/ */
public function __construct(public string $ip, public string $domain) public function __construct(public string $ip, public ?string $message)
{ {
} }
} }

View File

@ -0,0 +1,36 @@
<?php
namespace App\Filament\Pages\Auth;
use Coderflex\FilamentTurnstile\Forms\Components\Turnstile;
use Filament\Pages\Auth\Login as BaseLogin;
class Login extends BaseLogin
{
protected function getForms(): array
{
return [
'form' => $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();
}
}

View File

@ -8,6 +8,7 @@ use App\Traits\EnvironmentWriterTrait;
use Exception; use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Actions\Action as FormAction; use Filament\Forms\Components\Actions\Action as FormAction;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab; use Filament\Forms\Components\Tabs\Tab;
@ -26,6 +27,7 @@ use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Notification as MailNotification; use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\HtmlString;
/** /**
* @property Form $form * @property Form $form
@ -67,10 +69,11 @@ class Settings extends Page implements HasForms
->label('General') ->label('General')
->icon('tabler-home') ->icon('tabler-home')
->schema($this->generalSettings()), ->schema($this->generalSettings()),
Tab::make('recaptcha') Tab::make('captcha')
->label('reCAPTCHA') ->label('Captcha')
->icon('tabler-shield') ->icon('tabler-shield')
->schema($this->recaptchaSettings()), ->schema($this->captchaSettings())
->columns(3),
Tab::make('mail') Tab::make('mail')
->label('Mail') ->label('Mail')
->icon('tabler-mail') ->icon('tabler-mail')
@ -180,35 +183,47 @@ class Settings extends Page implements HasForms
]; ];
} }
private function recaptchaSettings(): array private function captchaSettings(): array
{ {
return [ return [
Toggle::make('RECAPTCHA_ENABLED') Toggle::make('TURNSTILE_ENABLED')
->label('Enable reCAPTCHA?') ->label('Enable Turnstile Captcha?')
->inline(false) ->inline(false)
->columnSpan(1)
->onIcon('tabler-check') ->onIcon('tabler-check')
->offIcon('tabler-x') ->offIcon('tabler-x')
->onColor('success') ->onColor('success')
->offColor('danger') ->offColor('danger')
->live() ->live()
->formatStateUsing(fn ($state): bool => (bool) $state) ->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('RECAPTCHA_ENABLED', (bool) $state)) ->afterStateUpdated(fn ($state, Set $set) => $set('TURNSTILE_ENABLED', (bool) $state))
->default(env('RECAPTCHA_ENABLED', config('recaptcha.enabled'))), ->default(env('TURNSTILE_ENABLED', config('turnstile.turnstile_enabled'))),
TextInput::make('RECAPTCHA_DOMAIN') Placeholder::make('info')
->label('Domain') ->columnSpan(2)
->content(new HtmlString('<p>You can generate the keys on your <u><a href="https://developers.cloudflare.com/turnstile/get-started/#get-a-sitekey-and-secret-key" target="_blank">Cloudflare Dashboard</a></u>. A Cloudflare account is required.</p>')),
TextInput::make('TURNSTILE_SITE_KEY')
->label('Site Key')
->required() ->required()
->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED')) ->visible(fn (Get $get) => $get('TURNSTILE_ENABLED'))
->default(env('RECAPTCHA_DOMAIN', config('recaptcha.domain'))), ->default(env('TURNSTILE_SITE_KEY', config('turnstile.turnstile_site_key')))
TextInput::make('RECAPTCHA_WEBSITE_KEY') ->placeholder('1x00000000000000000000AA'),
->label('Website Key') TextInput::make('TURNSTILE_SECRET_KEY')
->required()
->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED'))
->default(env('RECAPTCHA_WEBSITE_KEY', config('recaptcha.website_key'))),
TextInput::make('RECAPTCHA_SECRET_KEY')
->label('Secret Key') ->label('Secret Key')
->required() ->required()
->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED')) ->visible(fn (Get $get) => $get('TURNSTILE_ENABLED'))
->default(env('RECAPTCHA_SECRET_KEY', config('recaptcha.secret_key'))), ->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'))),
]; ];
} }

View File

@ -2,11 +2,11 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use GuzzleHttp\Client;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use App\Events\Auth\FailedCaptcha; use App\Events\Auth\FailedCaptcha;
use Coderflex\LaravelTurnstile\Facades\LaravelTurnstile;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
readonly class VerifyReCaptcha readonly class VerifyReCaptcha
@ -18,7 +18,7 @@ readonly class VerifyReCaptcha
public function handle(Request $request, \Closure $next): mixed public function handle(Request $request, \Closure $next): mixed
{ {
if (!config('recaptcha.enabled')) { if (!config('turnstile.turnstile_enabled')) {
return $next($request); return $next($request);
} }
@ -26,40 +26,30 @@ readonly class VerifyReCaptcha
return $next($request); return $next($request);
} }
if ($request->filled('g-recaptcha-response')) { if ($request->filled('cf-turnstile-response')) {
$client = new Client(); $response = LaravelTurnstile::validate($request->get('cf-turnstile-response'));
$res = $client->post(config('recaptcha.domain'), [
'form_params' => [
'secret' => config('recaptcha.secret_key'),
'response' => $request->input('g-recaptcha-response'),
],
]);
if ($res->getStatusCode() === 200) { if ($response['success'] && $this->isResponseVerified($response['hostname'] ?? '', $request)) {
$result = json_decode($res->getBody());
if ($result->success && (!config('recaptcha.verify_domain') || $this->isResponseVerified($result, $request))) {
return $next($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. * 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')) { if (!config('turnstile.turnstile_verify_domain')) {
return false; return true;
} }
$url = parse_url($request->url()); $url = parse_url($request->url());
return $result->hostname === array_get($url, 'host'); return $hostname === array_get($url, 'host');
} }
} }

View File

@ -24,8 +24,8 @@ class AssetComposer
'name' => config('app.name', 'Panel'), 'name' => config('app.name', 'Panel'),
'locale' => config('app.locale') ?? 'en', 'locale' => config('app.locale') ?? 'en',
'recaptcha' => [ 'recaptcha' => [
'enabled' => config('recaptcha.enabled', false), 'enabled' => config('turnstile.turnstile_enabled', false),
'siteKey' => config('recaptcha.website_key') ?? '', 'siteKey' => config('turnstile.turnstile_site_key') ?? '',
], ],
'usesSyncDriver' => config('queue.default') === 'sync', 'usesSyncDriver' => config('queue.default') === 'sync',
'serverDescriptionsEditable' => config('panel.editable_server_descriptions'), 'serverDescriptionsEditable' => config('panel.editable_server_descriptions'),

View File

@ -2,6 +2,7 @@
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Auth\Login;
use App\Filament\Resources\UserResource\Pages\EditProfile; use App\Filament\Resources\UserResource\Pages\EditProfile;
use App\Http\Middleware\LanguageMiddleware; use App\Http\Middleware\LanguageMiddleware;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
@ -36,7 +37,7 @@ class AdminPanelProvider extends PanelProvider
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->topNavigation(config('panel.filament.top-navigation', true)) ->topNavigation(config('panel.filament.top-navigation', true))
->login() ->login(Login::class)
->breadcrumbs(false) ->breadcrumbs(false)
->homeUrl('/') ->homeUrl('/')
->favicon(config('app.favicon', '/pelican.ico')) ->favicon(config('app.favicon', '/pelican.ico'))

View File

@ -11,6 +11,7 @@
"abdelhamiderrahmouni/filament-monaco-editor": "0.2.1", "abdelhamiderrahmouni/filament-monaco-editor": "0.2.1",
"aws/aws-sdk-php": "~3.288.1", "aws/aws-sdk-php": "~3.288.1",
"chillerlan/php-qrcode": "^5.0.2", "chillerlan/php-qrcode": "^5.0.2",
"coderflex/filament-turnstile": "^2.2",
"dedoc/scramble": "^0.10.0", "dedoc/scramble": "^0.10.0",
"doctrine/dbal": "~3.6.0", "doctrine/dbal": "~3.6.0",
"filament/filament": "^3.2", "filament/filament": "^3.2",

153
composer.lock generated
View File

@ -743,6 +743,159 @@
], ],
"time": "2024-03-02T20:07:15+00:00" "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", "name": "danharrin/date-format-converter",
"version": "v0.3.1", "version": "v0.3.1",

View File

@ -1,31 +0,0 @@
<?php
return [
/*
* Enable or disable captchas
*/
'enabled' => 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,
];

15
config/turnstile.php Normal file
View File

@ -0,0 +1,15 @@
<?php
return [
'turnstile_enabled' => 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.',
],
];

View File

@ -39,7 +39,7 @@
"react-i18next": "^11.2.1", "react-i18next": "^11.2.1",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"reaptcha": "^1.7.2", "react-turnstile": "^1.1.4",
"rimraf": "^4", "rimraf": "^4",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"styled-components": "^5.2.1", "styled-components": "^5.2.1",

View File

@ -19,7 +19,7 @@ export default ({ username, password, recaptchaData }: LoginData): Promise<Login
http.post('/auth/login', { http.post('/auth/login', {
user: username, user: username,
password, password,
'g-recaptcha-response': recaptchaData, 'cf-turnstile-response': recaptchaData,
}) })
) )
.then((response) => { .then((response) => {

View File

@ -2,7 +2,7 @@ import http from '@/api/http';
export default (email: string, recaptchaData?: string): Promise<string> => { export default (email: string, recaptchaData?: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData }) http.post('/auth/password', { email, 'cf-turnstile-response': recaptchaData })
.then((response) => resolve(response.data.status || '')) .then((response) => resolve(response.data.status || ''))
.catch(reject); .catch(reject);
}); });

View File

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail'; import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
@ -11,7 +11,7 @@ import { object, string } from 'yup';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Reaptcha from 'reaptcha'; import Turnstile, { useTurnstile } from 'react-turnstile';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
interface Values { interface Values {
@ -21,10 +21,10 @@ interface Values {
export default () => { export default () => {
const { t } = useTranslation('auth'); const { t } = useTranslation('auth');
const ref = useRef<Reaptcha>(null); const turnstile = useTurnstile();
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const { clearFlashes, addFlash } = useFlash(); const { clearFlashes, addFlash, addError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha); const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
useEffect(() => { useEffect(() => {
@ -34,16 +34,10 @@ export default () => {
const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => { const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes(); clearFlashes();
// If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) { if (recaptchaEnabled && !token) {
ref.current!.execute().catch((error) => { addError({ message: 'No captcha token found.' });
console.error(error);
setSubmitting(false); setSubmitting(false);
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
});
return; return;
} }
@ -58,7 +52,7 @@ export default () => {
}) })
.then(() => { .then(() => {
setToken(''); setToken('');
if (ref.current) ref.current.reset(); turnstile.reset();
setSubmitting(false); setSubmitting(false);
}); });
@ -74,7 +68,7 @@ export default () => {
.required(t('forgot_password.required.email')), .required(t('forgot_password.required.email')),
})} })}
> >
{({ isSubmitting, setSubmitting, submitForm }) => ( {({ isSubmitting, setSubmitting }) => (
<LoginFormContainer title={t('forgot_password.title')} css={tw`w-full flex`}> <LoginFormContainer title={t('forgot_password.title')} css={tw`w-full flex`}>
<Field <Field
light light
@ -83,19 +77,20 @@ export default () => {
name={'email'} name={'email'}
type={'email'} type={'email'}
/> />
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} disabled={isSubmitting} isLoading={isSubmitting}>
{t('forgot_password.button')}
</Button>
</div>
{recaptchaEnabled && ( {recaptchaEnabled && (
<Reaptcha <Turnstile
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'} sitekey={siteKey || '_invalid_key'}
onVerify={(response) => { className='mt-6 flex justify-center'
setToken(response); retry='never'
submitForm(); onVerify={(token) => {
setToken(token);
}}
onError={(error) => {
console.error('Error verifying captcha: ' + error);
addError({ message: 'Error verifying captcha: ' + error });
setSubmitting(false);
setToken('');
}} }}
onExpire={() => { onExpire={() => {
setSubmitting(false); setSubmitting(false);
@ -103,6 +98,11 @@ export default () => {
}} }}
/> />
)} )}
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} disabled={isSubmitting} isLoading={isSubmitting}>
{t('forgot_password.button')}
</Button>
</div>
<div css={tw`mt-6 text-center`}> <div css={tw`mt-6 text-center`}>
<Link <Link
to={'/auth/login'} to={'/auth/login'}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom'; import { Link, RouteComponentProps } from 'react-router-dom';
import login from '@/api/auth/login'; import login from '@/api/auth/login';
import LoginFormContainer from '@/components/auth/LoginFormContainer'; import LoginFormContainer from '@/components/auth/LoginFormContainer';
@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Reaptcha from 'reaptcha'; import Turnstile, { useTurnstile } from 'react-turnstile';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
interface Values { interface Values {
@ -20,10 +20,10 @@ interface Values {
const LoginContainer = ({ history }: RouteComponentProps) => { const LoginContainer = ({ history }: RouteComponentProps) => {
const { t } = useTranslation(['auth', 'strings']); const { t } = useTranslation(['auth', 'strings']);
const ref = useRef<Reaptcha>(null); const turnstile = useTurnstile();
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError, addError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha); const { enabled: recaptchaEnabled, siteKey } = useStoreState((state) => state.settings.data!.recaptcha);
useEffect(() => { useEffect(() => {
@ -33,16 +33,10 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => { const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes(); clearFlashes();
// If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) { if (recaptchaEnabled && !token) {
ref.current!.execute().catch((error) => { addError({ message: 'No captcha token found.' });
console.error(error);
setSubmitting(false); setSubmitting(false);
clearAndAddHttpError({ error });
});
return; return;
} }
@ -60,7 +54,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
console.error(error); console.error(error);
setToken(''); setToken('');
if (ref.current) ref.current.reset(); turnstile.reset();
setSubmitting(false); setSubmitting(false);
clearAndAddHttpError({ error }); clearAndAddHttpError({ error });
@ -76,7 +70,7 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
password: string().required(t('login.required.password')), password: string().required(t('login.required.password')),
})} })}
> >
{({ isSubmitting, setSubmitting, submitForm }) => ( {({ isSubmitting, setSubmitting }) => (
<LoginFormContainer title={t('login.title')} css={tw`w-full flex`}> <LoginFormContainer title={t('login.title')} css={tw`w-full flex`}>
<Field <Field
light light
@ -94,19 +88,20 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
disabled={isSubmitting} disabled={isSubmitting}
/> />
</div> </div>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting} disabled={isSubmitting}>
{t('login.button')}
</Button>
</div>
{recaptchaEnabled && ( {recaptchaEnabled && (
<Reaptcha <Turnstile
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'} sitekey={siteKey || '_invalid_key'}
onVerify={(response) => { className='mt-6 flex justify-center'
setToken(response); retry='never'
submitForm(); onVerify={(token) => {
setToken(token);
}}
onError={(error) => {
console.error('Error verifying captcha: ' + error);
addError({ message: 'Error verifying captcha: ' + error });
setSubmitting(false);
setToken('');
}} }}
onExpire={() => { onExpire={() => {
setSubmitting(false); setSubmitting(false);
@ -114,6 +109,11 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
}} }}
/> />
)} )}
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting} disabled={isSubmitting}>
{t('login.button')}
</Button>
</div>
<div css={tw`mt-6 text-center`}> <div css={tw`mt-6 text-center`}>
<Link <Link
to={'/auth/password'} to={'/auth/password'}

View File

@ -0,0 +1,56 @@
@php
$statePath = $getStatePath();
$fieldWrapperView = $getFieldWrapperView();
$theme = $getTheme();
$size = $getSize();
$language = $getLanguage();
@endphp
<x-dynamic-component class="flex justify-center" :component="$fieldWrapperView" :field="$turnstile">
<div x-data="{
state: $wire.entangle('{{ $statePath }}').defer
}"
wire:ignore
x-init="(() => {
let options= {
callback: function (token) {
$wire.set('{{ $statePath }}', token)
},
errorCallback: function () {
$wire.set('{{ $statePath }}', null)
},
}
window.onloadTurnstileCallback = () => {
turnstile.render($refs.turnstile, options)
}
resetCaptcha = () => {
turnstile.reset($refs.turnstile)
}
})()"
>
<div data-sitekey="{{config('turnstile.turnstile_site_key')}}"
data-theme="{{ $theme }}"
data-language="{{ $language }}"
data-size="{{ $size }}"
x-ref="turnstile"
>
</div>
</div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback" defer></script>
@push('scripts')
<script>
document.addEventListener('livewire:init', () => {
Livewire.on('reset-captcha', (event) => {
resetCaptcha()
})
})
</script>
@endpush
</x-dynamic-component>

View File

@ -7730,6 +7730,11 @@ react-transition-group@^4.4.1:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" prop-types "^15.6.2"
react-turnstile@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/react-turnstile/-/react-turnstile-1.1.4.tgz#0c23b2f4b55f83b929407ae9bfbd211fbe5df362"
integrity sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ==
react@^16.14.0: react@^16.14.0:
version "16.14.0" version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
@ -7794,11 +7799,6 @@ readdirp@~3.6.0:
dependencies: dependencies:
picomatch "^2.2.1" picomatch "^2.2.1"
reaptcha@^1.7.2:
version "1.7.2"
resolved "https://registry.yarnpkg.com/reaptcha/-/reaptcha-1.7.2.tgz#d829f54270c241f46501e92a5a7badeb1fcf372d"
integrity sha512-/RXiPeMd+fPUGByv+kAaQlCXCsSflZ9bKX5Fcwv9IYGS1oyT2nntL/8zn9IaiUFHL66T1jBtOABcb92g2+3w8w==
redent@^3.0.0: redent@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"