diff --git a/app/Extensions/Captcha/CaptchaProvider.php b/app/Extensions/Captcha/CaptchaProvider.php new file mode 100644 index 000000000..5115b7360 --- /dev/null +++ b/app/Extensions/Captcha/CaptchaProvider.php @@ -0,0 +1,43 @@ + */ + private array $schemas = []; + + /** + * @return array | CaptchaSchemaInterface + */ + public function get(?string $id = null): array|CaptchaSchemaInterface + { + return $id ? $this->schemas[$id] : $this->schemas; + } + + public function register(CaptchaSchemaInterface $schema): void + { + if (array_key_exists($schema->getId(), $this->schemas)) { + return; + } + + config()->set('captcha.' . Str::lower($schema->getId()), $schema->getConfig()); + $this->schemas[$schema->getId()] = $schema; + } + + /** @return Collection */ + public function getActiveSchemas(): Collection + { + return collect($this->schemas) + ->filter(fn (CaptchaSchemaInterface $schema) => $schema->isEnabled()); + } + + public function getActiveSchema(): ?CaptchaSchemaInterface + { + return $this->getActiveSchemas()->first(); + } +} diff --git a/app/Extensions/Captcha/Schemas/CaptchaSchemaInterface.php b/app/Extensions/Captcha/Schemas/CaptchaSchemaInterface.php new file mode 100644 index 000000000..908ecaa08 --- /dev/null +++ b/app/Extensions/Captcha/Schemas/CaptchaSchemaInterface.php @@ -0,0 +1,35 @@ + + */ + public function getConfig(): array; + + public function isEnabled(): bool; + + public function getFormComponent(): Component; + + /** + * @return Component[] + */ + public function getSettingsForm(): array; + + public function getIcon(): ?string; + + /** + * @return array + */ + public function validateResponse(?string $captchaResponse = null): array; + + public function verifyDomain(string $hostname, ?string $requestUrl = null): bool; +} diff --git a/app/Extensions/Captcha/Providers/CaptchaProvider.php b/app/Extensions/Captcha/Schemas/CommonSchema.php similarity index 51% rename from app/Extensions/Captcha/Providers/CaptchaProvider.php rename to app/Extensions/Captcha/Schemas/CommonSchema.php index 7c5db3009..3539a6f31 100644 --- a/app/Extensions/Captcha/Providers/CaptchaProvider.php +++ b/app/Extensions/Captcha/Schemas/CommonSchema.php @@ -1,45 +1,19 @@ - */ - protected static array $providers = []; - - /** - * @return self|static[] - */ - public static function get(?string $id = null): array|self - { - return $id ? static::$providers[$id] : static::$providers; - } - - protected function __construct(protected Application $app) - { - if (array_key_exists($this->getId(), static::$providers)) { - if (!$this->app->runningUnitTests()) { - logger()->warning("Tried to create duplicate Captcha provider with id '{$this->getId()}'"); - } - - return; - } - - config()->set('captcha.' . Str::lower($this->getId()), $this->getConfig()); - - static::$providers[$this->getId()] = $this; - } - abstract public function getId(): string; - abstract public function getComponent(): Component; + public function getName(): string + { + return Str::upper($this->getId()); + } /** * @return array @@ -83,34 +57,6 @@ abstract class CaptchaProvider ]; } - public function getName(): string - { - return Str::title($this->getId()); - } - - public function getIcon(): ?string - { - return null; - } - - public function isEnabled(): bool - { - $id = Str::upper($this->getId()); - - return env("CAPTCHA_{$id}_ENABLED", false); - } - - /** - * @return array - */ - public function validateResponse(?string $captchaResponse = null): array - { - return [ - 'success' => false, - 'message' => 'validateResponse not defined', - ]; - } - public function verifyDomain(string $hostname, ?string $requestUrl = null): bool { return true; diff --git a/app/Filament/Components/Forms/Fields/TurnstileCaptcha.php b/app/Extensions/Captcha/Schemas/Turnstile/Component.php similarity index 54% rename from app/Filament/Components/Forms/Fields/TurnstileCaptcha.php rename to app/Extensions/Captcha/Schemas/Turnstile/Component.php index 694b34f00..107e134cf 100644 --- a/app/Filament/Components/Forms/Fields/TurnstileCaptcha.php +++ b/app/Extensions/Captcha/Schemas/Turnstile/Component.php @@ -1,11 +1,10 @@ required(); - $this->after(function (TurnstileCaptcha $component) { - $component->rule(new ValidTurnstileCaptcha()); - }); + $this->rule(new Rule()); } } diff --git a/app/Rules/ValidTurnstileCaptcha.php b/app/Extensions/Captcha/Schemas/Turnstile/Rule.php similarity index 51% rename from app/Rules/ValidTurnstileCaptcha.php rename to app/Extensions/Captcha/Schemas/Turnstile/Rule.php index 83783e063..70ca32295 100644 --- a/app/Rules/ValidTurnstileCaptcha.php +++ b/app/Extensions/Captcha/Schemas/Turnstile/Rule.php @@ -1,16 +1,17 @@ validateResponse($value); + $response = App::call(fn (CaptchaProvider $provider) => $provider->getActiveSchema()->validateResponse($value)); if (!$response['success']) { $fail($response['message'] ?? 'Unknown error occurred, please try again'); diff --git a/app/Extensions/Captcha/Providers/TurnstileProvider.php b/app/Extensions/Captcha/Schemas/Turnstile/TurnstileSchema.php similarity index 82% rename from app/Extensions/Captcha/Providers/TurnstileProvider.php rename to app/Extensions/Captcha/Schemas/Turnstile/TurnstileSchema.php index 70980a249..a908cd9e7 100644 --- a/app/Extensions/Captcha/Providers/TurnstileProvider.php +++ b/app/Extensions/Captcha/Schemas/Turnstile/TurnstileSchema.php @@ -1,26 +1,31 @@ label(trans('admin/setting.captcha.info_label')) ->columnSpan(2) ->content(new HtmlString(trans('admin/setting.captcha.info'))), - ]); } - public function getIcon(): string + public function getIcon(): ?string { return 'tabler-brand-cloudflare'; } - public static function register(Application $app): self - { - return new self($app); - } - /** * @return array */ diff --git a/app/Filament/Admin/Pages/Settings.php b/app/Filament/Admin/Pages/Settings.php index 03f1ec3c3..d08ca8697 100644 --- a/app/Filament/Admin/Pages/Settings.php +++ b/app/Filament/Admin/Pages/Settings.php @@ -3,7 +3,7 @@ namespace App\Filament\Admin\Pages; use App\Extensions\Avatar\AvatarProvider; -use App\Extensions\Captcha\Providers\CaptchaProvider; +use App\Extensions\Captcha\CaptchaProvider; use App\Extensions\OAuth\OAuthProvider; use App\Models\Backup; use App\Notifications\MailTested; @@ -56,6 +56,8 @@ class Settings extends Page implements HasForms protected AvatarProvider $avatarProvider; + protected CaptchaProvider $captchaProvider; + /** @var array|null */ public ?array $data = []; @@ -64,10 +66,11 @@ class Settings extends Page implements HasForms $this->form->fill(); } - public function boot(OAuthProvider $oauthProvider, AvatarProvider $avatarProvider): void + public function boot(OAuthProvider $oauthProvider, AvatarProvider $avatarProvider, CaptchaProvider $captchaProvider): void { $this->oauthProvider = $oauthProvider; $this->avatarProvider = $avatarProvider; + $this->captchaProvider = $captchaProvider; } public static function canAccess(): bool @@ -268,15 +271,14 @@ class Settings extends Page implements HasForms { $formFields = []; - $captchaProviders = CaptchaProvider::get(); - foreach ($captchaProviders as $captchaProvider) { - $id = Str::upper($captchaProvider->getId()); - $name = Str::title($captchaProvider->getId()); + $captchaSchemas = $this->captchaProvider->get(); + foreach ($captchaSchemas as $captchaSchema) { + $id = Str::upper($captchaSchema->getId()); - $formFields[] = Section::make($name) + $formFields[] = Section::make($captchaSchema->getName()) ->columns(5) - ->icon($captchaProvider->getIcon() ?? 'tabler-shield') - ->collapsed(fn () => !env("CAPTCHA_{$id}_ENABLED", false)) + ->icon($captchaSchema->getIcon() ?? 'tabler-shield') + ->collapsed(fn () => !$captchaSchema->isEnabled()) ->collapsible() ->schema([ Hidden::make("CAPTCHA_{$id}_ENABLED") @@ -287,21 +289,14 @@ class Settings extends Page implements HasForms ->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED")) ->label(trans('admin/setting.captcha.disable')) ->color('danger') - ->action(function (Set $set) use ($id) { - $set("CAPTCHA_{$id}_ENABLED", false); - }), + ->action(fn (Set $set) => $set("CAPTCHA_{$id}_ENABLED", false)), FormAction::make("enable_captcha_$id") ->visible(fn (Get $get) => !$get("CAPTCHA_{$id}_ENABLED")) ->label(trans('admin/setting.captcha.enable')) ->color('success') - ->action(function (Set $set) use ($id, $captchaProviders) { - foreach ($captchaProviders as $captchaProvider) { - $loopId = Str::upper($captchaProvider->getId()); - $set("CAPTCHA_{$loopId}_ENABLED", $loopId === $id); - } - }), + ->action(fn (Set $set) => $set("CAPTCHA_{$id}_ENABLED", true)), ])->columnSpan(1), - Group::make($captchaProvider->getSettingsForm()) + Group::make($captchaSchema->getSettingsForm()) ->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED")) ->columns(4) ->columnSpan(4), diff --git a/app/Filament/Pages/Auth/Login.php b/app/Filament/Pages/Auth/Login.php index ed1914b35..0b2b4f635 100644 --- a/app/Filament/Pages/Auth/Login.php +++ b/app/Filament/Pages/Auth/Login.php @@ -3,7 +3,7 @@ namespace App\Filament\Pages\Auth; use App\Events\Auth\ProvidedAuthenticationToken; -use App\Extensions\Captcha\Providers\CaptchaProvider; +use App\Extensions\Captcha\CaptchaProvider; use App\Extensions\OAuth\OAuthProvider; use App\Facades\Activity; use App\Models\User; @@ -29,10 +29,13 @@ class Login extends BaseLogin protected OAuthProvider $oauthProvider; - public function boot(Google2FA $google2FA, OAuthProvider $oauthProvider): void + protected CaptchaProvider $captchaProvider; + + public function boot(Google2FA $google2FA, OAuthProvider $oauthProvider, CaptchaProvider $captchaProvider): void { $this->google2FA = $google2FA; $this->oauthProvider = $oauthProvider; + $this->captchaProvider = $captchaProvider; } public function authenticate(): ?LoginResponse @@ -145,13 +148,7 @@ class Login extends BaseLogin private function getCaptchaComponent(): ?Component { - $captchaProvider = collect(CaptchaProvider::get())->filter(fn (CaptchaProvider $provider) => $provider->isEnabled())->first(); - - if (!$captchaProvider) { - return null; - } - - return $captchaProvider->getComponent(); + return $this->captchaProvider->getActiveSchema()?->getFormComponent(); } protected function throwFailureValidationException(): never diff --git a/app/Http/Middleware/VerifyCaptcha.php b/app/Http/Middleware/VerifyCaptcha.php index 5836682b7..88b649c83 100644 --- a/app/Http/Middleware/VerifyCaptcha.php +++ b/app/Http/Middleware/VerifyCaptcha.php @@ -2,35 +2,35 @@ namespace App\Http\Middleware; +use App\Extensions\Captcha\CaptchaProvider; use Closure; use Illuminate\Foundation\Application; use Illuminate\Http\Request; use Illuminate\Http\Response; use App\Events\Auth\FailedCaptcha; -use App\Extensions\Captcha\Providers\CaptchaProvider; use Symfony\Component\HttpKernel\Exception\HttpException; readonly class VerifyCaptcha { public function __construct(private Application $app) {} - public function handle(Request $request, Closure $next): mixed + public function handle(Request $request, Closure $next, CaptchaProvider $captchaProvider): mixed { if ($this->app->isLocal()) { return $next($request); } - $captchaProviders = collect(CaptchaProvider::get())->filter(fn (CaptchaProvider $provider) => $provider->isEnabled())->all(); - foreach ($captchaProviders as $captchaProvider) { - $response = $captchaProvider->validateResponse(); + $schemas = $captchaProvider->getActiveSchemas(); + foreach ($schemas as $schema) { + $response = $schema->validateResponse(); - if ($response['success'] && $captchaProvider->verifyDomain($response['hostname'] ?? '', $request->url())) { + if ($response['success'] && $schema->verifyDomain($response['hostname'] ?? '', $request->url())) { return $next($request); } event(new FailedCaptcha($request->ip(), $response['message'] ?? null)); - throw new HttpException(Response::HTTP_BAD_REQUEST, "Failed to validate {$captchaProvider->getId()} captcha data."); + throw new HttpException(Response::HTTP_BAD_REQUEST, "Failed to validate {$schema->getId()} captcha data."); } // No captcha enabled diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9a0721956..16c2f48ef 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,7 +10,6 @@ use App\Checks\NodeVersionsCheck; use App\Checks\PanelVersionCheck; use App\Checks\ScheduleCheck; use App\Checks\UsedDiskSpaceCheck; -use App\Extensions\Captcha\Providers\TurnstileProvider; use App\Models; use App\Services\Helpers\SoftwareVersionService; use Dedoc\Scramble\Scramble; @@ -92,9 +91,6 @@ class AppServiceProvider extends ServiceProvider Scramble::registerApi('application', ['api_path' => 'api/application', 'info' => ['version' => '1.0']])->afterOpenApiGenerated($bearerTokens); Scramble::registerApi('client', ['api_path' => 'api/client', 'info' => ['version' => '1.0']])->afterOpenApiGenerated($bearerTokens); - // Default Captcha provider - TurnstileProvider::register($app); - FilamentColor::register([ 'danger' => Color::Red, 'gray' => Color::Zinc, diff --git a/app/Providers/Extensions/CaptchaServiceProvider.php b/app/Providers/Extensions/CaptchaServiceProvider.php new file mode 100644 index 000000000..cef38357a --- /dev/null +++ b/app/Providers/Extensions/CaptchaServiceProvider.php @@ -0,0 +1,22 @@ +app->singleton(CaptchaProvider::class, function ($app) { + $service = new CaptchaProvider(); + + // Default Captcha providers + $service->register(new TurnstileSchema()); + + return $service; + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 59ee09abf..62fd6a953 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -9,6 +9,7 @@ return [ App\Providers\Filament\AppPanelProvider::class, App\Providers\Filament\ServerPanelProvider::class, App\Providers\Extensions\AvatarServiceProvider::class, + App\Providers\Extensions\CaptchaServiceProvider::class, App\Providers\Extensions\FeatureServiceProvider::class, App\Providers\Extensions\OAuthServiceProvider::class, App\Providers\RouteServiceProvider::class, diff --git a/phpstan.neon b/phpstan.neon index fc6673e16..7611b9b73 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -20,6 +20,6 @@ parameters: identifier: larastan.noEnvCallsOutsideOfConfig paths: - app/Console/Commands/Environment/*.php - - app/Extensions/Captcha/Providers/*.php + - app/Extensions/Captcha/Schemas/*.php - app/Extensions/OAuth/Schemas/*.php - app/Filament/Admin/Pages/Settings.php