This commit is contained in:
Vehikl 2025-05-29 17:40:25 -04:00
parent 09e3506efa
commit 4a89e75a09
13 changed files with 157 additions and 125 deletions

View File

@ -0,0 +1,43 @@
<?php
namespace App\Extensions\Captcha;
use App\Extensions\Captcha\Schemas\CaptchaSchemaInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class CaptchaProvider
{
/** @var array<string, CaptchaSchemaInterface> */
private array $schemas = [];
/**
* @return array<string, CaptchaSchemaInterface> | 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<CaptchaSchemaInterface> */
public function getActiveSchemas(): Collection
{
return collect($this->schemas)
->filter(fn (CaptchaSchemaInterface $schema) => $schema->isEnabled());
}
public function getActiveSchema(): ?CaptchaSchemaInterface
{
return $this->getActiveSchemas()->first();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Extensions\Captcha\Schemas;
use Filament\Forms\Components\Component;
interface CaptchaSchemaInterface
{
public function getId(): string;
public function getName(): string;
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array;
public function isEnabled(): bool;
public function getFormComponent(): Component;
/**
* @return Component[]
*/
public function getSettingsForm(): array;
public function getIcon(): ?string;
/**
* @return array<string, string|bool>
*/
public function validateResponse(?string $captchaResponse = null): array;
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool;
}

View File

@ -1,45 +1,19 @@
<?php <?php
namespace App\Extensions\Captcha\Providers; namespace App\Extensions\Captcha\Schemas;
use Filament\Forms\Components\Component; use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Illuminate\Foundation\Application;
use Illuminate\Support\Str; use Illuminate\Support\Str;
abstract class CaptchaProvider abstract class CommonSchema
{ {
/**
* @var array<string, static>
*/
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 getId(): string;
abstract public function getComponent(): Component; public function getName(): string
{
return Str::upper($this->getId());
}
/** /**
* @return array<string, string|string[]|bool|null> * @return array<string, string|string[]|bool|null>
@ -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<string, string|bool>
*/
public function validateResponse(?string $captchaResponse = null): array
{
return [
'success' => false,
'message' => 'validateResponse not defined',
];
}
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool public function verifyDomain(string $hostname, ?string $requestUrl = null): bool
{ {
return true; return true;

View File

@ -1,11 +1,10 @@
<?php <?php
namespace App\Filament\Components\Forms\Fields; namespace App\Extensions\Captcha\Schemas\Turnstile;
use App\Rules\ValidTurnstileCaptcha;
use Filament\Forms\Components\Field; use Filament\Forms\Components\Field;
class TurnstileCaptcha extends Field class Component extends Field
{ {
protected string $viewIdentifier = 'turnstile'; protected string $viewIdentifier = 'turnstile';
@ -19,8 +18,6 @@ class TurnstileCaptcha extends Field
$this->required(); $this->required();
$this->after(function (TurnstileCaptcha $component) { $this->rule(new Rule());
$component->rule(new ValidTurnstileCaptcha());
});
} }
} }

View File

@ -1,16 +1,17 @@
<?php <?php
namespace App\Rules; namespace App\Extensions\Captcha\Schemas\Turnstile;
use App\Extensions\Captcha\Providers\CaptchaProvider; use App\Extensions\Captcha\CaptchaProvider;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\App;
class ValidTurnstileCaptcha implements ValidationRule class Rule implements ValidationRule
{ {
public function validate(string $attribute, mixed $value, Closure $fail): void public function validate(string $attribute, mixed $value, Closure $fail): void
{ {
$response = CaptchaProvider::get('turnstile')->validateResponse($value); $response = App::call(fn (CaptchaProvider $provider) => $provider->getActiveSchema()->validateResponse($value));
if (!$response['success']) { if (!$response['success']) {
$fail($response['message'] ?? 'Unknown error occurred, please try again'); $fail($response['message'] ?? 'Unknown error occurred, please try again');

View File

@ -1,26 +1,31 @@
<?php <?php
namespace App\Extensions\Captcha\Providers; namespace App\Extensions\Captcha\Schemas\Turnstile;
use App\Filament\Components\Forms\Fields\TurnstileCaptcha; use App\Extensions\Captcha\Schemas\CaptchaSchemaInterface;
use App\Extensions\Captcha\Schemas\CommonSchema;
use Exception; use Exception;
use Filament\Forms\Components\Component; use Filament\Forms\Components\Component as BaseComponent;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
class TurnstileProvider extends CaptchaProvider class TurnstileSchema extends CommonSchema implements CaptchaSchemaInterface
{ {
public function getId(): string public function getId(): string
{ {
return 'turnstile'; return 'turnstile';
} }
public function getComponent(): Component public function isEnabled(): bool
{ {
return TurnstileCaptcha::make('turnstile'); return env('CAPTCHA_TURNSTILE_ENABLED', false);
}
public function getFormComponent(): BaseComponent
{
return Component::make('turnstile');
} }
/** /**
@ -34,7 +39,7 @@ class TurnstileProvider extends CaptchaProvider
} }
/** /**
* @return Component[] * @return BaseComponent[]
*/ */
public function getSettingsForm(): array public function getSettingsForm(): array
{ {
@ -52,20 +57,14 @@ class TurnstileProvider extends CaptchaProvider
->label(trans('admin/setting.captcha.info_label')) ->label(trans('admin/setting.captcha.info_label'))
->columnSpan(2) ->columnSpan(2)
->content(new HtmlString(trans('admin/setting.captcha.info'))), ->content(new HtmlString(trans('admin/setting.captcha.info'))),
]); ]);
} }
public function getIcon(): string public function getIcon(): ?string
{ {
return 'tabler-brand-cloudflare'; return 'tabler-brand-cloudflare';
} }
public static function register(Application $app): self
{
return new self($app);
}
/** /**
* @return array<string, string|bool> * @return array<string, string|bool>
*/ */

View File

@ -3,7 +3,7 @@
namespace App\Filament\Admin\Pages; namespace App\Filament\Admin\Pages;
use App\Extensions\Avatar\AvatarProvider; use App\Extensions\Avatar\AvatarProvider;
use App\Extensions\Captcha\Providers\CaptchaProvider; use App\Extensions\Captcha\CaptchaProvider;
use App\Extensions\OAuth\OAuthProvider; use App\Extensions\OAuth\OAuthProvider;
use App\Models\Backup; use App\Models\Backup;
use App\Notifications\MailTested; use App\Notifications\MailTested;
@ -56,6 +56,8 @@ class Settings extends Page implements HasForms
protected AvatarProvider $avatarProvider; protected AvatarProvider $avatarProvider;
protected CaptchaProvider $captchaProvider;
/** @var array<mixed>|null */ /** @var array<mixed>|null */
public ?array $data = []; public ?array $data = [];
@ -64,10 +66,11 @@ class Settings extends Page implements HasForms
$this->form->fill(); $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->oauthProvider = $oauthProvider;
$this->avatarProvider = $avatarProvider; $this->avatarProvider = $avatarProvider;
$this->captchaProvider = $captchaProvider;
} }
public static function canAccess(): bool public static function canAccess(): bool
@ -268,15 +271,14 @@ class Settings extends Page implements HasForms
{ {
$formFields = []; $formFields = [];
$captchaProviders = CaptchaProvider::get(); $captchaSchemas = $this->captchaProvider->get();
foreach ($captchaProviders as $captchaProvider) { foreach ($captchaSchemas as $captchaSchema) {
$id = Str::upper($captchaProvider->getId()); $id = Str::upper($captchaSchema->getId());
$name = Str::title($captchaProvider->getId());
$formFields[] = Section::make($name) $formFields[] = Section::make($captchaSchema->getName())
->columns(5) ->columns(5)
->icon($captchaProvider->getIcon() ?? 'tabler-shield') ->icon($captchaSchema->getIcon() ?? 'tabler-shield')
->collapsed(fn () => !env("CAPTCHA_{$id}_ENABLED", false)) ->collapsed(fn () => !$captchaSchema->isEnabled())
->collapsible() ->collapsible()
->schema([ ->schema([
Hidden::make("CAPTCHA_{$id}_ENABLED") Hidden::make("CAPTCHA_{$id}_ENABLED")
@ -287,21 +289,14 @@ class Settings extends Page implements HasForms
->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED")) ->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED"))
->label(trans('admin/setting.captcha.disable')) ->label(trans('admin/setting.captcha.disable'))
->color('danger') ->color('danger')
->action(function (Set $set) use ($id) { ->action(fn (Set $set) => $set("CAPTCHA_{$id}_ENABLED", false)),
$set("CAPTCHA_{$id}_ENABLED", false);
}),
FormAction::make("enable_captcha_$id") FormAction::make("enable_captcha_$id")
->visible(fn (Get $get) => !$get("CAPTCHA_{$id}_ENABLED")) ->visible(fn (Get $get) => !$get("CAPTCHA_{$id}_ENABLED"))
->label(trans('admin/setting.captcha.enable')) ->label(trans('admin/setting.captcha.enable'))
->color('success') ->color('success')
->action(function (Set $set) use ($id, $captchaProviders) { ->action(fn (Set $set) => $set("CAPTCHA_{$id}_ENABLED", true)),
foreach ($captchaProviders as $captchaProvider) {
$loopId = Str::upper($captchaProvider->getId());
$set("CAPTCHA_{$loopId}_ENABLED", $loopId === $id);
}
}),
])->columnSpan(1), ])->columnSpan(1),
Group::make($captchaProvider->getSettingsForm()) Group::make($captchaSchema->getSettingsForm())
->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED")) ->visible(fn (Get $get) => $get("CAPTCHA_{$id}_ENABLED"))
->columns(4) ->columns(4)
->columnSpan(4), ->columnSpan(4),

View File

@ -3,7 +3,7 @@
namespace App\Filament\Pages\Auth; namespace App\Filament\Pages\Auth;
use App\Events\Auth\ProvidedAuthenticationToken; use App\Events\Auth\ProvidedAuthenticationToken;
use App\Extensions\Captcha\Providers\CaptchaProvider; use App\Extensions\Captcha\CaptchaProvider;
use App\Extensions\OAuth\OAuthProvider; use App\Extensions\OAuth\OAuthProvider;
use App\Facades\Activity; use App\Facades\Activity;
use App\Models\User; use App\Models\User;
@ -29,10 +29,13 @@ class Login extends BaseLogin
protected OAuthProvider $oauthProvider; 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->google2FA = $google2FA;
$this->oauthProvider = $oauthProvider; $this->oauthProvider = $oauthProvider;
$this->captchaProvider = $captchaProvider;
} }
public function authenticate(): ?LoginResponse public function authenticate(): ?LoginResponse
@ -145,13 +148,7 @@ class Login extends BaseLogin
private function getCaptchaComponent(): ?Component private function getCaptchaComponent(): ?Component
{ {
$captchaProvider = collect(CaptchaProvider::get())->filter(fn (CaptchaProvider $provider) => $provider->isEnabled())->first(); return $this->captchaProvider->getActiveSchema()?->getFormComponent();
if (!$captchaProvider) {
return null;
}
return $captchaProvider->getComponent();
} }
protected function throwFailureValidationException(): never protected function throwFailureValidationException(): never

View File

@ -2,35 +2,35 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Extensions\Captcha\CaptchaProvider;
use Closure; use Closure;
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 App\Extensions\Captcha\Providers\CaptchaProvider;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
readonly class VerifyCaptcha readonly class VerifyCaptcha
{ {
public function __construct(private Application $app) {} 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()) { if ($this->app->isLocal()) {
return $next($request); return $next($request);
} }
$captchaProviders = collect(CaptchaProvider::get())->filter(fn (CaptchaProvider $provider) => $provider->isEnabled())->all(); $schemas = $captchaProvider->getActiveSchemas();
foreach ($captchaProviders as $captchaProvider) { foreach ($schemas as $schema) {
$response = $captchaProvider->validateResponse(); $response = $schema->validateResponse();
if ($response['success'] && $captchaProvider->verifyDomain($response['hostname'] ?? '', $request->url())) { if ($response['success'] && $schema->verifyDomain($response['hostname'] ?? '', $request->url())) {
return $next($request); return $next($request);
} }
event(new FailedCaptcha($request->ip(), $response['message'] ?? null)); 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 // No captcha enabled

View File

@ -10,7 +10,6 @@ use App\Checks\NodeVersionsCheck;
use App\Checks\PanelVersionCheck; use App\Checks\PanelVersionCheck;
use App\Checks\ScheduleCheck; use App\Checks\ScheduleCheck;
use App\Checks\UsedDiskSpaceCheck; use App\Checks\UsedDiskSpaceCheck;
use App\Extensions\Captcha\Providers\TurnstileProvider;
use App\Models; use App\Models;
use App\Services\Helpers\SoftwareVersionService; use App\Services\Helpers\SoftwareVersionService;
use Dedoc\Scramble\Scramble; 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('application', ['api_path' => 'api/application', 'info' => ['version' => '1.0']])->afterOpenApiGenerated($bearerTokens);
Scramble::registerApi('client', ['api_path' => 'api/client', '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([ FilamentColor::register([
'danger' => Color::Red, 'danger' => Color::Red,
'gray' => Color::Zinc, 'gray' => Color::Zinc,

View File

@ -0,0 +1,22 @@
<?php
namespace App\Providers\Extensions;
use App\Extensions\Captcha\CaptchaProvider;
use App\Extensions\Captcha\Schemas\Turnstile\TurnstileSchema;
use Illuminate\Support\ServiceProvider;
class CaptchaServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(CaptchaProvider::class, function ($app) {
$service = new CaptchaProvider();
// Default Captcha providers
$service->register(new TurnstileSchema());
return $service;
});
}
}

View File

@ -9,6 +9,7 @@ return [
App\Providers\Filament\AppPanelProvider::class, App\Providers\Filament\AppPanelProvider::class,
App\Providers\Filament\ServerPanelProvider::class, App\Providers\Filament\ServerPanelProvider::class,
App\Providers\Extensions\AvatarServiceProvider::class, App\Providers\Extensions\AvatarServiceProvider::class,
App\Providers\Extensions\CaptchaServiceProvider::class,
App\Providers\Extensions\FeatureServiceProvider::class, App\Providers\Extensions\FeatureServiceProvider::class,
App\Providers\Extensions\OAuthServiceProvider::class, App\Providers\Extensions\OAuthServiceProvider::class,
App\Providers\RouteServiceProvider::class, App\Providers\RouteServiceProvider::class,

View File

@ -20,6 +20,6 @@ parameters:
identifier: larastan.noEnvCallsOutsideOfConfig identifier: larastan.noEnvCallsOutsideOfConfig
paths: paths:
- app/Console/Commands/Environment/*.php - app/Console/Commands/Environment/*.php
- app/Extensions/Captcha/Providers/*.php - app/Extensions/Captcha/Schemas/*.php
- app/Extensions/OAuth/Schemas/*.php - app/Extensions/OAuth/Schemas/*.php
- app/Filament/Admin/Pages/Settings.php - app/Filament/Admin/Pages/Settings.php