mirror of https://github.com/pixelfed/pixelfed
polish
parent
7edc6fe0bb
commit
163cd9f589
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
/**
|
||||
* Contract every captcha provider (hCaptcha, Turnstile, Cap, ...) must implement
|
||||
* so the rest of the application can stay provider-agnostic.
|
||||
*/
|
||||
interface CaptchaDriver
|
||||
{
|
||||
/**
|
||||
* The machine name of the driver (e.g. "hcaptcha", "turnstile", "cap").
|
||||
*/
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* Whether the driver has the credentials/config it needs to operate.
|
||||
*/
|
||||
public function isConfigured(): bool;
|
||||
|
||||
/**
|
||||
* The name of the request field that carries the response token for this
|
||||
* provider (e.g. "h-captcha-response", "cf-turnstile-response", "cap-token").
|
||||
*/
|
||||
public function responseField(): string;
|
||||
|
||||
/**
|
||||
* Verify a submitted request against the provider.
|
||||
*
|
||||
* Implementations should pull the response token out of the given input
|
||||
* array using responseField().
|
||||
*/
|
||||
public function verify(array $input): bool;
|
||||
|
||||
/**
|
||||
* Render the widget markup to embed in a form. Optional HTML attributes
|
||||
* (e.g. ['data-theme' => 'dark']) may be passed through to the widget.
|
||||
*/
|
||||
public function render(array $attributes = []): string;
|
||||
|
||||
/**
|
||||
* Any <script>/<link> tags the widget needs. Returned separately so callers
|
||||
* can place them in <head> or before </body> as appropriate. May be empty
|
||||
* when render() already includes everything.
|
||||
*/
|
||||
public function scripts(): string;
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Facades;
|
||||
|
||||
use App\Contracts\CaptchaDriver;
|
||||
use App\Services\Captcha\CaptchaManager;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* Provider-agnostic captcha facade.
|
||||
*
|
||||
* @method static CaptchaDriver active()
|
||||
* @method static bool enabled()
|
||||
* @method static bool activeOn(string $surface)
|
||||
* @method static bool activeOnLogin()
|
||||
* @method static array available()
|
||||
* @method static array rules()
|
||||
* @method static CaptchaDriver driver(string|null $driver = null)
|
||||
*
|
||||
* @see CaptchaManager
|
||||
*/
|
||||
class Captcha extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'captcha.manager';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Captcha\CaptchaManager;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class CaptchaServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton('captcha.manager', fn ($app) => new CaptchaManager($app));
|
||||
$this->app->alias('captcha.manager', CaptchaManager::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerValidationRule();
|
||||
$this->registerBladeDirectives();
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver-agnostic validation rule.
|
||||
*
|
||||
* Usage: 'some_field' => 'captcha_verify'
|
||||
*
|
||||
* It ignores $value (each provider uses a different field name) and instead
|
||||
* validates the whole request against the active driver's own response field.
|
||||
*/
|
||||
private function registerValidationRule(): void
|
||||
{
|
||||
Validator::extend('captcha_verify', function ($attribute, $value, $parameters, $validator) {
|
||||
/** @var CaptchaManager $manager */
|
||||
$manager = app('captcha.manager');
|
||||
|
||||
return $manager->active()->verify($validator->getData());
|
||||
}, 'The captcha verification failed. Please try again.');
|
||||
}
|
||||
|
||||
private function registerBladeDirectives(): void
|
||||
{
|
||||
// @captcha or @captcha(['data-theme' => 'dark']) -> renders active widget
|
||||
Blade::directive('captcha', function ($expression) {
|
||||
$args = trim((string) $expression) === '' ? '[]' : $expression;
|
||||
|
||||
return "<?php echo app('captcha.manager')->active()->render($args); ?>";
|
||||
});
|
||||
|
||||
// @captchaScripts -> any <script>/<link> the active widget needs
|
||||
Blade::directive('captchaScripts', function () {
|
||||
return "<?php echo app('captcha.manager')->active()->scripts(); ?>";
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Captcha;
|
||||
|
||||
use App\Contracts\CaptchaDriver;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use LaravelCap\Cap;
|
||||
|
||||
/**
|
||||
* Cap driver (self-hosted proof-of-work CAPTCHA).
|
||||
*
|
||||
* Wraps the oliweb/laravel-cap package for verification, and renders the
|
||||
* locally-published widget (public/vendor/cap/) so no external CDN is used.
|
||||
*
|
||||
* @see https://github.com/oliweb-ch/laravel-cap
|
||||
*/
|
||||
class CapDriver implements CaptchaDriver
|
||||
{
|
||||
/**
|
||||
* Default @cap.js/widget version served from the CDN. "latest" tracks the
|
||||
* newest stable release; override via captcha.cap.widget_version.
|
||||
*/
|
||||
private const DEFAULT_WIDGET_VERSION = 'latest';
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'cap';
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return ! empty(config_cache('captcha.cap.endpoint'))
|
||||
&& ! empty(config_cache('captcha.cap.secret'));
|
||||
}
|
||||
|
||||
public function responseField(): string
|
||||
{
|
||||
return (string) config('captcha.cap.token_field', 'cap-token');
|
||||
}
|
||||
|
||||
public function verify(array $input): bool
|
||||
{
|
||||
$token = $input[$this->responseField()] ?? null;
|
||||
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cap = new Cap(app(HttpFactory::class), [
|
||||
'endpoint' => config_cache('captcha.cap.endpoint'),
|
||||
'secret' => config_cache('captcha.cap.secret'),
|
||||
'timeout' => (int) config('captcha.cap.timeout', 5),
|
||||
'fail_open' => (bool) config('captcha.cap.fail_open', false),
|
||||
]);
|
||||
|
||||
return $cap->verify((string) $token);
|
||||
}
|
||||
|
||||
public function render(array $attributes = []): string
|
||||
{
|
||||
$endpoint = e((string) config_cache('captcha.cap.endpoint'));
|
||||
$field = e($this->responseField());
|
||||
|
||||
$attrs = '';
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attrs .= ' '.e($key).'="'.e($value).'"';
|
||||
}
|
||||
|
||||
return '<cap-widget data-cap-api-endpoint="'.$endpoint.'"'
|
||||
.' data-cap-hidden-field-name="'.$field.'"'.$attrs.'></cap-widget>';
|
||||
}
|
||||
|
||||
public function scripts(): string
|
||||
{
|
||||
// Load the widget from the jsDelivr CDN. Defaults to the "latest"
|
||||
// stable release; pin a specific version via captcha.cap.widget_version.
|
||||
$version = trim((string) config('captcha.cap.widget_version')) ?: self::DEFAULT_WIDGET_VERSION;
|
||||
$src = 'https://cdn.jsdelivr.net/npm/@cap.js/widget@'.$version;
|
||||
|
||||
return '<script src="'.e($src).'"></script>';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Captcha;
|
||||
|
||||
use App\Contracts\CaptchaDriver;
|
||||
use Illuminate\Support\Manager;
|
||||
|
||||
/**
|
||||
* Resolves the active captcha provider based on the "captcha.driver" config
|
||||
* value and proxies the provider-agnostic operations to it.
|
||||
*
|
||||
* @method string name()
|
||||
* @method bool isConfigured()
|
||||
* @method string responseField()
|
||||
* @method bool verify(array $input)
|
||||
* @method string render(array $attributes = [])
|
||||
* @method string scripts()
|
||||
*/
|
||||
class CaptchaManager extends Manager
|
||||
{
|
||||
/**
|
||||
* The default driver name, resolved from config. Falls back to hcaptcha to
|
||||
* preserve existing behavior for instances that never set captcha.driver.
|
||||
*/
|
||||
public function getDefaultDriver(): string
|
||||
{
|
||||
return (string) (config_cache('captcha.driver') ?: config('captcha.driver', 'hcaptcha'));
|
||||
}
|
||||
|
||||
public function createHcaptchaDriver(): CaptchaDriver
|
||||
{
|
||||
return new HCaptchaDriver;
|
||||
}
|
||||
|
||||
public function createTurnstileDriver(): CaptchaDriver
|
||||
{
|
||||
return new TurnstileDriver;
|
||||
}
|
||||
|
||||
public function createCapDriver(): CaptchaDriver
|
||||
{
|
||||
return new CapDriver;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active driver instance.
|
||||
*/
|
||||
public function active(): CaptchaDriver
|
||||
{
|
||||
return $this->driver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether captcha is globally enabled for this instance.
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config_cache('captcha.enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether captcha should be enforced on a given surface.
|
||||
*
|
||||
* Requires the global toggle plus the per-surface "active" flag. Supported
|
||||
* surfaces: login, register, forgotpassword, password_reset,
|
||||
* curated_register.
|
||||
*/
|
||||
public function activeOn(string $surface): bool
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) config_cache('captcha.active.'.$surface);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the login form should show/enforce a captcha right now.
|
||||
*
|
||||
* True when the login surface is active, or when the failed-login trigger
|
||||
* has reached its configured attempt threshold for the current session.
|
||||
*/
|
||||
public function activeOnLogin(): bool
|
||||
{
|
||||
if ($this->activeOn('login')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! (bool) config_cache('captcha.triggers.login.enabled')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$request = request();
|
||||
if (! $request->hasSession()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$session = $request->session();
|
||||
|
||||
return $session->has('login_attempts')
|
||||
&& $session->get('login_attempts') >= config('captcha.triggers.login.attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* List of supported driver machine names.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function available(): array
|
||||
{
|
||||
return ['hcaptcha', 'turnstile', 'cap'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation rules for the active driver, keyed by its response field.
|
||||
*
|
||||
* Merge the result into a controller's rule set to enforce captcha with
|
||||
* whatever provider is currently selected.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$this->active()->responseField() => 'required|captcha_verify',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Captcha;
|
||||
|
||||
use App\Contracts\CaptchaDriver;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* hCaptcha driver. Wraps the existing buzz/laravel-h-captcha package so behavior
|
||||
* is identical to the previous hardcoded integration.
|
||||
*/
|
||||
class HCaptchaDriver implements CaptchaDriver
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'hcaptcha';
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$secret = config_cache('captcha.hcaptcha.secret');
|
||||
$sitekey = config_cache('captcha.hcaptcha.sitekey');
|
||||
|
||||
return ! empty($secret)
|
||||
&& ! empty($sitekey)
|
||||
&& $secret !== 'default_secret'
|
||||
&& $sitekey !== 'default_sitekey';
|
||||
}
|
||||
|
||||
public function responseField(): string
|
||||
{
|
||||
return 'h-captcha-response';
|
||||
}
|
||||
|
||||
public function verify(array $input): bool
|
||||
{
|
||||
$token = $input[$this->responseField()] ?? null;
|
||||
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reuse the package's registered "captcha" validation rule so we get the
|
||||
// exact same server-side verification as before.
|
||||
return Validator::make(
|
||||
[$this->responseField() => $token],
|
||||
[$this->responseField() => 'required|captcha']
|
||||
)->passes();
|
||||
}
|
||||
|
||||
public function render(array $attributes = []): string
|
||||
{
|
||||
// Resolve the buzz/laravel-h-captcha service (bound as "captcha").
|
||||
// display() already emits the widget script tag inline.
|
||||
return app('captcha')->display($attributes);
|
||||
}
|
||||
|
||||
public function scripts(): string
|
||||
{
|
||||
// The hCaptcha widget script is injected by display() output/config.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Captcha;
|
||||
|
||||
use App\Contracts\CaptchaDriver;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile driver.
|
||||
*
|
||||
* @see https://developers.cloudflare.com/turnstile/
|
||||
*/
|
||||
class TurnstileDriver implements CaptchaDriver
|
||||
{
|
||||
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
private const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'turnstile';
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return ! empty(config_cache('captcha.turnstile.secret'))
|
||||
&& ! empty(config_cache('captcha.turnstile.sitekey'));
|
||||
}
|
||||
|
||||
public function responseField(): string
|
||||
{
|
||||
return 'cf-turnstile-response';
|
||||
}
|
||||
|
||||
public function verify(array $input): bool
|
||||
{
|
||||
$token = $input[$this->responseField()] ?? null;
|
||||
|
||||
if (empty($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout((int) config('captcha.turnstile.timeout', 5))
|
||||
->post(self::VERIFY_URL, [
|
||||
'secret' => config_cache('captcha.turnstile.secret'),
|
||||
'response' => $token,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[captcha:turnstile] verify request failed: '.$e->getMessage());
|
||||
|
||||
return (bool) config('captcha.turnstile.fail_open', false);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return (bool) config('captcha.turnstile.fail_open', false);
|
||||
}
|
||||
|
||||
return (bool) $response->json('success', false);
|
||||
}
|
||||
|
||||
public function render(array $attributes = []): string
|
||||
{
|
||||
$sitekey = e((string) config_cache('captcha.turnstile.sitekey'));
|
||||
|
||||
$attrs = '';
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attrs .= ' '.e($key).'="'.e($value).'"';
|
||||
}
|
||||
|
||||
return '<div class="cf-turnstile" data-sitekey="'.$sitekey.'"'.$attrs.'></div>';
|
||||
}
|
||||
|
||||
public function scripts(): string
|
||||
{
|
||||
return '<script src="'.self::SCRIPT_URL.'" async defer></script>';
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\CaptchaServiceProvider;
|
||||
use App\Providers\HorizonServiceProvider;
|
||||
use App\Providers\PassportServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
CaptchaServiceProvider::class,
|
||||
HorizonServiceProvider::class,
|
||||
PassportServiceProvider::class,
|
||||
];
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cap instance endpoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The full URL of your self-hosted Cap instance, including the site key.
|
||||
| Example: https://cap.example.com/your-site-key/
|
||||
|
|
||||
*/
|
||||
'endpoint' => env('CAP_ENDPOINT'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Secret key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The secret key generated in your Cap dashboard.
|
||||
| Never expose this value on the client side.
|
||||
|
|
||||
*/
|
||||
'secret' => env('CAP_SECRET'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token field name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The name of the hidden field automatically injected by the Cap widget
|
||||
| into the parent form. Can be overridden via the data-cap-hidden-field-name
|
||||
| attribute.
|
||||
|
|
||||
*/
|
||||
'token_field' => env('CAP_TOKEN_FIELD', 'cap-token'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Verification timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Time (in seconds) before giving up on the request to /siteverify.
|
||||
|
|
||||
*/
|
||||
'timeout' => (int) env('CAP_TIMEOUT', 5),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fail-open mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When true, any communication error with the Cap instance (network,
|
||||
| timeout, 5xx server error) lets the request through instead of blocking
|
||||
| it. An explicitly invalid token (success: false) is always rejected,
|
||||
| regardless of this setting.
|
||||
|
|
||||
| Recommended in production when service availability matters more than
|
||||
| anti-spam protection.
|
||||
|
|
||||
*/
|
||||
'fail_open' => (bool) env('CAP_FAIL_OPEN', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Iframe widget route
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Path of the route serving the Cap widget in iframe mode (permissive CSP).
|
||||
| Use @capFrame in your Blade templates to embed this mode.
|
||||
|
|
||||
*/
|
||||
'frame_route' => env('CAP_FRAME_ROUTE', 'cap-frame'),
|
||||
];
|
||||
@ -0,0 +1,41 @@
|
||||
@props([
|
||||
/* Surface name to gate on (login, register, forgotpassword, password_reset,
|
||||
curated_register). Ignored when :show is passed explicitly. */
|
||||
'surface' => null,
|
||||
/* Explicit visibility override for compound conditions (login triggers,
|
||||
curated registration). When null, visibility is derived from $surface. */
|
||||
'show' => null,
|
||||
/* Optional widget theme, e.g. "dark". */
|
||||
'theme' => null,
|
||||
/* Render a "Captcha" label above the widget. */
|
||||
'label' => false,
|
||||
/* Classes for the label element. */
|
||||
'labelClass' => 'font-weight-bold small text-muted',
|
||||
/* Render the validation error message below the widget. */
|
||||
'showError' => false,
|
||||
/* Wrapper element classes around the widget itself. */
|
||||
'wrapperClass' => 'd-flex justify-content-center my-3',
|
||||
])
|
||||
|
||||
@php
|
||||
$visible = ! is_null($show)
|
||||
? (bool) $show
|
||||
: ($surface ? \App\Facades\Captcha::activeOn($surface) : false);
|
||||
$captchaAttrs = $theme ? ['data-theme' => $theme] : [];
|
||||
$captchaField = \App\Facades\Captcha::active()->responseField();
|
||||
@endphp
|
||||
|
||||
@if($visible)
|
||||
@if($label)
|
||||
<label class="{{ $labelClass }}">Captcha</label>
|
||||
@endif
|
||||
<div class="{{ $wrapperClass }}">
|
||||
@captcha($captchaAttrs)
|
||||
@captchaScripts
|
||||
</div>
|
||||
@if($showError && $errors->has($captchaField))
|
||||
<div class="text-danger small mb-3">
|
||||
<strong>{{ $errors->first($captchaField) }}</strong>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\Captcha\CapDriver;
|
||||
use App\Services\Captcha\CaptchaManager;
|
||||
use App\Services\Captcha\HCaptchaDriver;
|
||||
use App\Services\Captcha\TurnstileDriver;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CaptchaManagerTest extends TestCase
|
||||
{
|
||||
private function manager(): CaptchaManager
|
||||
{
|
||||
// Build a fresh manager so it re-reads the current config each time
|
||||
// (the base Manager caches resolved drivers internally).
|
||||
return new CaptchaManager($this->app);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_resolves_the_hcaptcha_driver_by_default(): void
|
||||
{
|
||||
config(['captcha.driver' => 'hcaptcha']);
|
||||
|
||||
$driver = $this->manager()->active();
|
||||
|
||||
$this->assertInstanceOf(HCaptchaDriver::class, $driver);
|
||||
$this->assertSame('hcaptcha', $driver->name());
|
||||
$this->assertSame('h-captcha-response', $driver->responseField());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hcaptcha_is_configured_from_namespaced_keys(): void
|
||||
{
|
||||
config([
|
||||
'captcha.driver' => 'hcaptcha',
|
||||
'captcha.hcaptcha.secret' => 'a-real-secret',
|
||||
'captcha.hcaptcha.sitekey' => 'a-real-sitekey',
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->manager()->active()->isConfigured());
|
||||
|
||||
// Placeholder defaults should read as not configured.
|
||||
config([
|
||||
'captcha.hcaptcha.secret' => 'default_secret',
|
||||
'captcha.hcaptcha.sitekey' => 'default_sitekey',
|
||||
]);
|
||||
$this->assertFalse($this->manager()->active()->isConfigured());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_resolves_the_turnstile_driver(): void
|
||||
{
|
||||
config(['captcha.driver' => 'turnstile']);
|
||||
|
||||
$driver = $this->manager()->active();
|
||||
|
||||
$this->assertInstanceOf(TurnstileDriver::class, $driver);
|
||||
$this->assertSame('turnstile', $driver->name());
|
||||
$this->assertSame('cf-turnstile-response', $driver->responseField());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_resolves_the_cap_driver(): void
|
||||
{
|
||||
config(['captcha.driver' => 'cap']);
|
||||
|
||||
$driver = $this->manager()->active();
|
||||
|
||||
$this->assertInstanceOf(CapDriver::class, $driver);
|
||||
$this->assertSame('cap', $driver->name());
|
||||
$this->assertSame('cap-token', $driver->responseField());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_lists_available_drivers(): void
|
||||
{
|
||||
$this->assertSame(['hcaptcha', 'turnstile', 'cap'], $this->manager()->available());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function active_on_requires_the_global_toggle(): void
|
||||
{
|
||||
config([
|
||||
'captcha.enabled' => false,
|
||||
'captcha.active.login' => true,
|
||||
]);
|
||||
|
||||
$this->assertFalse($this->manager()->activeOn('login'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function active_on_honors_each_surface_flag(): void
|
||||
{
|
||||
config([
|
||||
'captcha.enabled' => true,
|
||||
'captcha.active.login' => true,
|
||||
'captcha.active.register' => false,
|
||||
'captcha.active.forgotpassword' => true,
|
||||
'captcha.active.password_reset' => false,
|
||||
'captcha.active.curated_register' => true,
|
||||
]);
|
||||
|
||||
$manager = $this->manager();
|
||||
|
||||
$this->assertTrue($manager->activeOn('login'));
|
||||
$this->assertFalse($manager->activeOn('register'));
|
||||
$this->assertTrue($manager->activeOn('forgotpassword'));
|
||||
$this->assertFalse($manager->activeOn('password_reset'));
|
||||
$this->assertTrue($manager->activeOn('curated_register'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function active_on_login_honors_surface_and_attempt_trigger(): void
|
||||
{
|
||||
// Surface directly active
|
||||
config([
|
||||
'captcha.enabled' => true,
|
||||
'captcha.active.login' => true,
|
||||
'captcha.triggers.login.enabled' => false,
|
||||
]);
|
||||
$this->assertTrue($this->manager()->activeOnLogin());
|
||||
|
||||
// Surface off, trigger disabled -> false
|
||||
config(['captcha.active.login' => false]);
|
||||
$this->assertFalse($this->manager()->activeOnLogin());
|
||||
|
||||
// Trigger enabled but below threshold -> false
|
||||
config([
|
||||
'captcha.triggers.login.enabled' => true,
|
||||
'captcha.triggers.login.attempts' => 2,
|
||||
]);
|
||||
$session = $this->app['session']->driver();
|
||||
request()->setLaravelSession($session);
|
||||
$session->put('login_attempts', 1);
|
||||
$this->assertFalse($this->manager()->activeOnLogin());
|
||||
|
||||
// Trigger enabled and at threshold -> true
|
||||
$session->put('login_attempts', 2);
|
||||
$this->assertTrue($this->manager()->activeOnLogin());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cap_widget_defaults_to_latest_when_no_version_set(): void
|
||||
{
|
||||
config([
|
||||
'captcha.driver' => 'cap',
|
||||
'captcha.cap.endpoint' => 'https://cap.example.com/site-key/',
|
||||
'captcha.cap.widget_version' => null,
|
||||
]);
|
||||
|
||||
$scripts = $this->manager()->active()->scripts();
|
||||
|
||||
$this->assertStringContainsString('@cap.js/widget@latest', $scripts);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cap_widget_loads_from_cdn_with_pinned_version(): void
|
||||
{
|
||||
config([
|
||||
'captcha.driver' => 'cap',
|
||||
'captcha.cap.endpoint' => 'https://cap.example.com/site-key/',
|
||||
'captcha.cap.widget_version' => '0.1.57',
|
||||
]);
|
||||
|
||||
$driver = $this->manager()->active();
|
||||
$scripts = $driver->scripts();
|
||||
|
||||
$this->assertStringContainsString('cdn.jsdelivr.net/npm/@cap.js/widget@0.1.57', $scripts);
|
||||
// Must not reference self-hosted assets anymore.
|
||||
$this->assertStringNotContainsString('vendor/cap/', $scripts);
|
||||
|
||||
$markup = $driver->render(['data-theme' => 'dark']);
|
||||
$this->assertStringContainsString('<cap-widget', $markup);
|
||||
$this->assertStringContainsString('data-cap-api-endpoint="https://cap.example.com/site-key/"', $markup);
|
||||
$this->assertStringContainsString('data-cap-hidden-field-name="cap-token"', $markup);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function turnstile_renders_widget_with_sitekey(): void
|
||||
{
|
||||
config([
|
||||
'captcha.driver' => 'turnstile',
|
||||
'captcha.turnstile.sitekey' => '0xTESTSITEKEY',
|
||||
]);
|
||||
|
||||
$markup = $this->manager()->active()->render();
|
||||
|
||||
$this->assertStringContainsString('cf-turnstile', $markup);
|
||||
$this->assertStringContainsString('data-sitekey="0xTESTSITEKEY"', $markup);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\Captcha\TurnstileDriver;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CaptchaVerificationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function captcha_verify_rule_fails_when_token_is_missing(): void
|
||||
{
|
||||
config(['captcha.driver' => 'turnstile']);
|
||||
$this->app->forgetInstance('captcha.manager');
|
||||
|
||||
Http::fake(); // no verify request should be made for a missing token
|
||||
|
||||
// Mirrors real controller usage: required|captcha_verify.
|
||||
$validator = Validator::make(
|
||||
[],
|
||||
['cf-turnstile-response' => 'required|captcha_verify']
|
||||
);
|
||||
|
||||
$this->assertTrue($validator->fails());
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captcha_verify_rule_passes_when_provider_confirms(): void
|
||||
{
|
||||
config([
|
||||
'captcha.driver' => 'turnstile',
|
||||
'captcha.turnstile.secret' => 'sekret',
|
||||
]);
|
||||
$this->app->forgetInstance('captcha.manager');
|
||||
|
||||
Http::fake([
|
||||
'challenges.cloudflare.com/*' => Http::response(['success' => true], 200),
|
||||
]);
|
||||
|
||||
$validator = Validator::make(
|
||||
['cf-turnstile-response' => 'a-token'],
|
||||
['cf-turnstile-response' => 'captcha_verify']
|
||||
);
|
||||
|
||||
$this->assertTrue($validator->passes());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function captcha_verify_rule_fails_when_provider_rejects(): void
|
||||
{
|
||||
config([
|
||||
'captcha.driver' => 'turnstile',
|
||||
'captcha.turnstile.secret' => 'sekret',
|
||||
]);
|
||||
$this->app->forgetInstance('captcha.manager');
|
||||
|
||||
Http::fake([
|
||||
'challenges.cloudflare.com/*' => Http::response(['success' => false], 200),
|
||||
]);
|
||||
|
||||
$validator = Validator::make(
|
||||
['cf-turnstile-response' => 'bad-token'],
|
||||
['cf-turnstile-response' => 'captcha_verify']
|
||||
);
|
||||
|
||||
$this->assertTrue($validator->fails());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function turnstile_fail_open_lets_requests_through_on_network_error(): void
|
||||
{
|
||||
config([
|
||||
'captcha.turnstile.secret' => 'sekret',
|
||||
'captcha.turnstile.fail_open' => true,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'challenges.cloudflare.com/*' => Http::response('boom', 500),
|
||||
]);
|
||||
|
||||
$driver = new TurnstileDriver;
|
||||
|
||||
$this->assertTrue($driver->verify(['cf-turnstile-response' => 'anything']));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function turnstile_fail_closed_blocks_requests_on_network_error(): void
|
||||
{
|
||||
config([
|
||||
'captcha.turnstile.secret' => 'sekret',
|
||||
'captcha.turnstile.fail_open' => false,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'challenges.cloudflare.com/*' => Http::response('boom', 500),
|
||||
]);
|
||||
|
||||
$driver = new TurnstileDriver;
|
||||
|
||||
$this->assertFalse($driver->verify(['cf-turnstile-response' => 'anything']));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function turnstile_sends_secret_and_response(): void
|
||||
{
|
||||
config(['captcha.turnstile.secret' => 'my-secret']);
|
||||
|
||||
Http::fake([
|
||||
'challenges.cloudflare.com/*' => Http::response(['success' => true], 200),
|
||||
]);
|
||||
|
||||
(new TurnstileDriver)->verify(['cf-turnstile-response' => 'my-token']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
&& $request['secret'] === 'my-secret'
|
||||
&& $request['response'] === 'my-token';
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue