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
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\CaptchaServiceProvider;
|
||||||
use App\Providers\HorizonServiceProvider;
|
use App\Providers\HorizonServiceProvider;
|
||||||
use App\Providers\PassportServiceProvider;
|
use App\Providers\PassportServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
|
CaptchaServiceProvider::class,
|
||||||
HorizonServiceProvider::class,
|
HorizonServiceProvider::class,
|
||||||
PassportServiceProvider::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