feat: add captcha-backed forum security hardening

This commit is contained in:
2026-03-17 16:06:28 +01:00
parent 980a15f66e
commit b3fc889452
40 changed files with 2849 additions and 108 deletions

View File

@@ -16,10 +16,23 @@
</div>
@endif
@if($errors->has('bot'))
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
{{ $errors->first('bot') }}
</div>
@endif
@include('auth.partials.social-login')
<form method="POST" action="{{ route('login') }}" class="space-y-5">
<form method="POST" action="{{ route('login') }}" class="space-y-5" data-bot-form>
@csrf
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
<input type="hidden" name="_bot_fingerprint" value="">
@php
$captchaProvider = $captcha['provider'] ?? 'turnstile';
$captchaSiteKey = $captcha['siteKey'] ?? '';
@endphp
<div>
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
@@ -33,6 +46,17 @@
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
@if(($requiresCaptcha ?? false) && $captchaSiteKey !== '')
@if($captchaProvider === 'recaptcha')
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@elseif($captchaProvider === 'hcaptcha')
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@else
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@endif
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif
<div class="flex items-center justify-between text-sm text-white/60">
<label class="flex items-center gap-2">
<input type="checkbox" name="remember" class="rounded bg-slate-800 border-white/20" />
@@ -51,4 +75,8 @@
</div>
</div>
</div>
@if(($requiresCaptcha ?? false) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
@endif
@include('partials.bot-fingerprint-script')
@endsection

View File

@@ -13,9 +13,22 @@
</div>
@endif
@if($errors->has('bot'))
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
{{ $errors->first('bot') }}
</div>
@endif
@include('auth.partials.social-login', ['dividerLabel' => 'or register with email'])
<form method="POST" action="{{ route('register') }}" class="space-y-5">
<form method="POST" action="{{ route('register') }}" class="space-y-5" data-bot-form>
@csrf
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
<input type="hidden" name="_bot_fingerprint" value="">
@php
$captchaProvider = $captcha['provider'] ?? 'turnstile';
$captchaSiteKey = $captcha['siteKey'] ?? '';
@endphp
<div>
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
@@ -23,8 +36,14 @@
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
<div class="cf-turnstile" data-sitekey="{{ $turnstileSiteKey }}"></div>
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '')
@if($captchaProvider === 'recaptcha')
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@elseif($captchaProvider === 'hcaptcha')
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@else
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
@endif
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
@endif
@@ -35,7 +54,8 @@
</div>
</div>
</div>
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
@endif
@include('partials.bot-fingerprint-script')
@endsection

View File

@@ -0,0 +1,55 @@
<script>
(() => {
const forms = document.querySelectorAll('[data-bot-form]');
if (!forms.length || !window.crypto?.subtle) {
return;
}
const readWebglVendor = () => {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
return 'no-webgl';
}
const extension = gl.getExtension('WEBGL_debug_renderer_info');
if (!extension) {
return 'webgl-hidden';
}
return [
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
].join(':');
} catch {
return 'webgl-error';
}
};
const fingerprintPayload = [
navigator.userAgent || 'unknown-ua',
navigator.language || 'unknown-language',
navigator.platform || 'unknown-platform',
Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown-timezone',
`${window.screen?.width || 0}x${window.screen?.height || 0}x${window.devicePixelRatio || 1}`,
readWebglVendor(),
].join('|');
const encodeHex = (buffer) => Array.from(new Uint8Array(buffer))
.map((part) => part.toString(16).padStart(2, '0'))
.join('');
window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprintPayload))
.then((buffer) => {
const fingerprint = encodeHex(buffer);
forms.forEach((form) => {
const input = form.querySelector('input[name="_bot_fingerprint"]');
if (input) {
input.value = fingerprint;
}
});
})
.catch(() => {});
})();
</script>