diff --git a/.env.example b/.env.example index 0c3ce2156..588a623c1 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,46 @@ FILESYSTEM_CLOUD=s3 ## Optional (WHEN_SUPPORTED (default) / WHEN_REQUIRED) https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html # AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_SUPPORTED # AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_SUPPORTED + +####################################### +# Captcha +####################################### +# Master switch. Must be true for any captcha to appear. +CAPTCHA_ENABLED=false +# Active provider: hcaptcha (default), turnstile, or cap +CAPTCHA_DRIVER=hcaptcha + +# Per-surface toggles (each also requires CAPTCHA_ENABLED=true) +CAPTCHA_ENABLED_ON_LOGIN=false +CAPTCHA_ENABLED_ON_REGISTER=false +CAPTCHA_ENABLED_ON_FORGOT_PASSWORD=false +CAPTCHA_ENABLED_ON_PASSWORD_RESET=false +CAPTCHA_ENABLED_ON_CURATED_REGISTER=false + +# Show a captcha on login only after N failed attempts +CAPTCHA_TRIGGERS_LOGIN_ENABLED=false +CAPTCHA_TRIGGERS_LOGIN_ATTEMPTS=2 + +# --- hCaptcha (driver: hcaptcha) --- +CAPTCHA_SECRET= +CAPTCHA_SITEKEY= + +# --- Cloudflare Turnstile (driver: turnstile) --- +CAPTCHA_TURNSTILE_SITEKEY= +CAPTCHA_TURNSTILE_SECRET= +CAPTCHA_TURNSTILE_TIMEOUT=5 +# Let requests through on network/5xx errors instead of blocking +CAPTCHA_TURNSTILE_FAIL_OPEN=false + +# --- Cap, self-hosted proof-of-work (driver: cap) --- +# Full URL of your Cap instance including the site key (trailing slash required) +CAP_ENDPOINT= +CAP_SECRET= +CAP_TOKEN_FIELD=cap-token +CAP_TIMEOUT=5 +CAP_FAIL_OPEN=false +# @cap.js/widget version from the jsDelivr CDN. Leave blank to track "latest". +CAP_WIDGET_VERSION==5 +CAPTCHA_TURNSTILE_FAIL_OPEN=false +# Cap widget version served from the jsDelivr CDN +CAP_WIDGET_VERSION=0.1.57 diff --git a/app/Contracts/CaptchaDriver.php b/app/Contracts/CaptchaDriver.php new file mode 100644 index 000000000..21c2b6ce6 --- /dev/null +++ b/app/Contracts/CaptchaDriver.php @@ -0,0 +1,47 @@ + 'dark']) may be passed through to the widget. + */ + public function render(array $attributes = []): string; + + /** + * Any '; + } +} diff --git a/app/Services/Captcha/CaptchaManager.php b/app/Services/Captcha/CaptchaManager.php new file mode 100644 index 000000000..7078fb5c6 --- /dev/null +++ b/app/Services/Captcha/CaptchaManager.php @@ -0,0 +1,128 @@ +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 + */ + 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 + */ + public function rules(): array + { + return [ + $this->active()->responseField() => 'required|captcha_verify', + ]; + } +} diff --git a/app/Services/Captcha/HCaptchaDriver.php b/app/Services/Captcha/HCaptchaDriver.php new file mode 100644 index 000000000..589f2c192 --- /dev/null +++ b/app/Services/Captcha/HCaptchaDriver.php @@ -0,0 +1,63 @@ +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 ''; + } +} diff --git a/app/Services/Captcha/TurnstileDriver.php b/app/Services/Captcha/TurnstileDriver.php new file mode 100644 index 000000000..77c5678a9 --- /dev/null +++ b/app/Services/Captcha/TurnstileDriver.php @@ -0,0 +1,80 @@ +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 '
'; + } + + public function scripts(): string + { + return ''; + } +} diff --git a/app/Services/ConfigCacheService.php b/app/Services/ConfigCacheService.php index 69eaaf614..0c94f791f 100644 --- a/app/Services/ConfigCacheService.php +++ b/app/Services/ConfigCacheService.php @@ -16,8 +16,12 @@ class ConfigCacheService 'filesystems.disks.s3.secret', 'filesystems.disks.spaces.key', 'filesystems.disks.spaces.secret', + 'captcha.hcaptcha.secret', + 'captcha.hcaptcha.sitekey', 'captcha.secret', 'captcha.sitekey', + 'captcha.turnstile.secret', + 'captcha.cap.secret', ]; public static function get($key) @@ -103,10 +107,20 @@ class ConfigCacheService 'instance.embed.post', 'captcha.enabled', + 'captcha.driver', + 'captcha.hcaptcha.secret', + 'captcha.hcaptcha.sitekey', 'captcha.secret', 'captcha.sitekey', + 'captcha.turnstile.secret', + 'captcha.turnstile.sitekey', + 'captcha.cap.endpoint', + 'captcha.cap.secret', 'captcha.active.login', 'captcha.active.register', + 'captcha.active.forgotpassword', + 'captcha.active.password_reset', + 'captcha.active.curated_register', 'captcha.triggers.login.enabled', 'captcha.triggers.login.attempts', 'federation.custom_emoji.enabled', diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 55339d1c6..c16056cc4 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,11 +1,13 @@ 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'), +]; diff --git a/config/captcha.php b/config/captcha.php index ef552a175..ca79f255c 100644 --- a/config/captcha.php +++ b/config/captcha.php @@ -3,7 +3,39 @@ use Buzz\LaravelHCaptcha\HttpClient; return [ + /* + |-------------------------------------------------------------------------- + | Global toggle + |-------------------------------------------------------------------------- + | Whether any captcha is enabled at all. Kept for backward compatibility. + */ 'enabled' => env('CAPTCHA_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Active driver + |-------------------------------------------------------------------------- + | Which provider to use: "hcaptcha", "turnstile", or "cap". Admin-selectable. + | Defaults to hcaptcha so existing instances keep their current behavior. + */ + 'driver' => env('CAPTCHA_DRIVER', 'hcaptcha'), + + /* + |-------------------------------------------------------------------------- + | hCaptcha + |-------------------------------------------------------------------------- + | Canonical, provider-namespaced credentials used throughout the app. + */ + 'hcaptcha' => [ + 'secret' => env('CAPTCHA_SECRET', 'default_secret'), + 'sitekey' => env('CAPTCHA_SITEKEY', 'default_sitekey'), + ], + + /* + | The top-level secret/sitekey keys below are what the buzz/laravel-h-captcha + | package reads internally (config('captcha.secret') / config('captcha.sitekey')). + | They mirror captcha.hcaptcha.* and are kept in sync when settings are saved. + */ 'secret' => env('CAPTCHA_SECRET', 'default_secret'), 'sitekey' => env('CAPTCHA_SITEKEY', 'default_sitekey'), 'http_client' => HttpClient::class, @@ -15,11 +47,53 @@ return [ 'theme' => 'light', ], + /* + |-------------------------------------------------------------------------- + | Cloudflare Turnstile + |-------------------------------------------------------------------------- + */ + 'turnstile' => [ + 'sitekey' => env('CAPTCHA_TURNSTILE_SITEKEY'), + 'secret' => env('CAPTCHA_TURNSTILE_SECRET'), + 'timeout' => (int) env('CAPTCHA_TURNSTILE_TIMEOUT', 5), + 'fail_open' => (bool) env('CAPTCHA_TURNSTILE_FAIL_OPEN', false), + ], + + /* + |-------------------------------------------------------------------------- + | Cap (self-hosted proof-of-work CAPTCHA) + |-------------------------------------------------------------------------- + | The endpoint must include the site key and a trailing slash, e.g. + | https://cap.example.com/your-site-key/ + */ + 'cap' => [ + 'endpoint' => env('CAP_ENDPOINT'), + 'secret' => env('CAP_SECRET'), + 'token_field' => env('CAP_TOKEN_FIELD', 'cap-token'), + 'timeout' => (int) env('CAP_TIMEOUT', 5), + 'fail_open' => (bool) env('CAP_FAIL_OPEN', false), + 'widget_version' => env('CAP_WIDGET_VERSION') ?: 'latest', + ], + + /* + |-------------------------------------------------------------------------- + | Where captcha is active + |-------------------------------------------------------------------------- + | Per-surface toggles. Each requires the global "enabled" flag to also be on. + */ 'active' => [ 'login' => env('CAPTCHA_ENABLED_ON_LOGIN', false), 'register' => env('CAPTCHA_ENABLED_ON_REGISTER', false), + 'forgotpassword' => env('CAPTCHA_ENABLED_ON_FORGOT_PASSWORD', false), + 'password_reset' => env('CAPTCHA_ENABLED_ON_PASSWORD_RESET', false), + 'curated_register' => env('CAPTCHA_ENABLED_ON_CURATED_REGISTER', false), ], + /* + |-------------------------------------------------------------------------- + | Login-attempt triggers + |-------------------------------------------------------------------------- + */ 'triggers' => [ 'login' => [ 'enabled' => env('CAPTCHA_TRIGGERS_LOGIN_ENABLED', false), diff --git a/config/instance.php b/config/instance.php index 0657efb9c..3d679169f 100644 --- a/config/instance.php +++ b/config/instance.php @@ -151,8 +151,6 @@ return [ 'resend_confirmation_limit' => env('INSTANCE_CUR_REG_RESEND_LIMIT', 5), - 'captcha_enabled' => env('INSTANCE_CUR_REG_CAPTCHA', env('CAPTCHA_ENABLED', false)), - 'state' => [ 'fallback_on_closed_reg' => true, 'only_enabled_on_closed_reg' => env('INSTANCE_CUR_REG_STATE_ONLY_ON_CLOSED', true), diff --git a/resources/assets/components/admin/AdminSettings.vue b/resources/assets/components/admin/AdminSettings.vue index deab138e0..b8f5c238a 100644 --- a/resources/assets/components/admin/AdminSettings.vue +++ b/resources/assets/components/admin/AdminSettings.vue @@ -389,20 +389,37 @@ class="custom-control-input" id="hcp" v-model="platform.captcha_enabled"> - +

- Enable hCaptcha on login and register pages + Enable a captcha provider on login and register pages

@@ -1391,10 +1490,18 @@ allow_post_embeds: this.platform.allow_post_embeds, allow_profile_embeds: this.platform.allow_profile_embeds, captcha_enabled: this.platform.captcha_enabled, - captcha_secret: this.platform.captcha_secret, - captcha_sitekey: this.platform.captcha_sitekey, + captcha_driver: this.platform.captcha_driver, + captcha_hcaptcha_secret: this.platform.captcha_hcaptcha_secret, + captcha_hcaptcha_sitekey: this.platform.captcha_hcaptcha_sitekey, + captcha_turnstile_secret: this.platform.captcha_turnstile_secret, + captcha_turnstile_sitekey: this.platform.captcha_turnstile_sitekey, + captcha_cap_endpoint: this.platform.captcha_cap_endpoint, + captcha_cap_secret: this.platform.captcha_cap_secret, captcha_on_login: this.platform.captcha_on_login, captcha_on_register: this.platform.captcha_on_register, + captcha_on_forgotpassword: this.platform.captcha_on_forgotpassword, + captcha_on_password_reset: this.platform.captcha_on_password_reset, + captcha_on_curated_register: this.platform.captcha_on_curated_register, custom_emoji_enabled: this.platform.custom_emoji_enabled, }).then(res => { this.platform = res.data; diff --git a/resources/views/auth/curated-register/concierge_form.blade.php b/resources/views/auth/curated-register/concierge_form.blade.php index 74e4c8259..89e9c4fa3 100644 --- a/resources/views/auth/curated-register/concierge_form.blade.php +++ b/resources/views/auth/curated-register/concierge_form.blade.php @@ -47,11 +47,7 @@ 0/1000 - @if($showCaptcha) -
- {!! Captcha::display() !!} -
- @endif +
diff --git a/resources/views/auth/curated-register/confirm_email.blade.php b/resources/views/auth/curated-register/confirm_email.blade.php index b6b7afecb..f1e0f8a3c 100644 --- a/resources/views/auth/curated-register/confirm_email.blade.php +++ b/resources/views/auth/curated-register/confirm_email.blade.php @@ -27,11 +27,7 @@ @csrf - @if(config('instance.curated_registration.captcha_enabled')) -
- {!! Captcha::display() !!} -
- @endif +
diff --git a/resources/views/auth/curated-register/partials/message-email-confirm.blade.php b/resources/views/auth/curated-register/partials/message-email-confirm.blade.php index 3f0711b63..81f780fc0 100644 --- a/resources/views/auth/curated-register/partials/message-email-confirm.blade.php +++ b/resources/views/auth/curated-register/partials/message-email-confirm.blade.php @@ -10,11 +10,7 @@ placeholder="Your email address" required /> - @if(config('instance.curated_registration.captcha_enabled')) -
- {!! Captcha::display() !!} -
- @endif +
diff --git a/resources/views/auth/curated-register/partials/step-3.blade.php b/resources/views/auth/curated-register/partials/step-3.blade.php index 5cb0541ac..efff8fee6 100644 --- a/resources/views/auth/curated-register/partials/step-3.blade.php +++ b/resources/views/auth/curated-register/partials/step-3.blade.php @@ -18,11 +18,7 @@ value="{{ request()->session()->get('cur-reg.form-email') }}" required> - @if(config('instance.curated_registration.captcha_enabled')) -
- {!! Captcha::display() !!} -
- @endif +
diff --git a/resources/views/auth/curated-register/resend-confirmation.blade.php b/resources/views/auth/curated-register/resend-confirmation.blade.php index e1b78590a..365a81f5d 100644 --- a/resources/views/auth/curated-register/resend-confirmation.blade.php +++ b/resources/views/auth/curated-register/resend-confirmation.blade.php @@ -33,11 +33,7 @@ placeholder="Your email address" required /> - @if(config('instance.curated_registration.captcha_enabled')) -
- {!! Captcha::display() !!} -
- @endif +
diff --git a/resources/views/auth/email/forgot.blade.php b/resources/views/auth/email/forgot.blade.php index 355daaaf5..8859816cc 100644 --- a/resources/views/auth/email/forgot.blade.php +++ b/resources/views/auth/email/forgot.blade.php @@ -65,17 +65,7 @@ - @if((bool) config_cache('captcha.enabled')) - -
- {!! Captcha::display(['data-theme' => 'dark']) !!} -
- @if ($errors->has('h-captcha-response')) -
- {{ $errors->first('h-captcha-response') }} -
- @endif - @endif +
diff --git a/resources/views/auth/iar-resend.blade.php b/resources/views/auth/iar-resend.blade.php index 76f9b7706..0f1dbad0d 100644 --- a/resources/views/auth/iar-resend.blade.php +++ b/resources/views/auth/iar-resend.blade.php @@ -35,11 +35,7 @@ @enderror
- @if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) -
- {!! Captcha::display() !!} -
- @endif +
- @if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) -
- {!! Captcha::display() !!} -
- @endif +