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
namespace App\Extensions\Captcha\Providers;
namespace App\Extensions\Captcha\Schemas;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput;
use Illuminate\Foundation\Application;
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 getComponent(): Component;
public function getName(): string
{
return Str::upper($this->getId());
}
/**
* @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
{
return true;

View File

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

View File

@ -1,16 +1,17 @@
<?php
namespace App\Rules;
namespace App\Extensions\Captcha\Schemas\Turnstile;
use App\Extensions\Captcha\Providers\CaptchaProvider;
use App\Extensions\Captcha\CaptchaProvider;
use Closure;
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
{
$response = CaptchaProvider::get('turnstile')->validateResponse($value);
$response = App::call(fn (CaptchaProvider $provider) => $provider->getActiveSchema()->validateResponse($value));
if (!$response['success']) {
$fail($response['message'] ?? 'Unknown error occurred, please try again');

View File

@ -1,26 +1,31 @@
<?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 Filament\Forms\Components\Component;
use Filament\Forms\Components\Component as BaseComponent;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Toggle;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString;
class TurnstileProvider extends CaptchaProvider
class TurnstileSchema extends CommonSchema implements CaptchaSchemaInterface
{
public function getId(): string
{
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
{
@ -52,20 +57,14 @@ class TurnstileProvider extends CaptchaProvider
->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<string, string|bool>
*/

View File

@ -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<mixed>|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),

View File

@ -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

View File

@ -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

View File

@ -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,

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\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,

View File

@ -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