From b239af9619220c69877b03410f98d11c1d48ced2 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 21 Feb 2026 12:13:01 +0100 Subject: [PATCH] feat(auth): complete registration anti-spam and quota hardening --- .env.example | 17 ++ README.md | 1 + .../Auth/RegisteredUserController.php | 285 +++++++++++++----- .../RegistrationVerificationController.php | 18 +- app/Jobs/SendVerificationEmailJob.php | 62 ++++ app/Mail/RegistrationVerificationMail.php | 2 +- app/Models/EmailSendEvent.php | 31 ++ app/Models/SystemEmailQuota.php | 26 ++ app/Models/User.php | 6 + app/Providers/AppServiceProvider.php | 16 +- app/Services/Auth/DisposableEmailService.php | 66 ++++ .../Auth/RegistrationEmailQuotaService.php | 37 +++ .../RegistrationVerificationTokenService.php | 67 ++++ app/Services/Security/TurnstileVerifier.php | 51 ++++ config/disposable_email_domains.php | 11 + config/registration.php | 16 + config/services.php | 7 + ...tration_antispam_fields_to_users_table.php | 41 +++ ..._000002_create_email_send_events_table.php | 35 +++ ...000003_create_system_email_quota_table.php | 27 ++ ...token_hash_in_user_verification_tokens.php | 48 +++ ..._user_verification_tokens_table_exists.php | 30 ++ docs/registration-antispam.md | 202 +++++++++++++ resources/views/auth/register.blade.php | 7 +- resources/views/dashboard.blade.php | 2 +- routes/auth.php | 2 +- .../Feature/Auth/RegistrationAntiSpamTest.php | 114 ++++++- .../Auth/RegistrationFlowChecklistTest.php | 29 +- .../Auth/RegistrationNoticeResendTest.php | 30 +- .../RegistrationQuotaCircuitBreakerTest.php | 67 ++++ tests/Feature/Auth/RegistrationTest.php | 8 +- .../RegistrationTokenVerificationTest.php | 61 +++- .../Auth/RegistrationVerificationMailTest.php | 8 +- 33 files changed, 1288 insertions(+), 142 deletions(-) create mode 100644 app/Jobs/SendVerificationEmailJob.php create mode 100644 app/Models/EmailSendEvent.php create mode 100644 app/Models/SystemEmailQuota.php create mode 100644 app/Services/Auth/DisposableEmailService.php create mode 100644 app/Services/Auth/RegistrationEmailQuotaService.php create mode 100644 app/Services/Auth/RegistrationVerificationTokenService.php create mode 100644 app/Services/Security/TurnstileVerifier.php create mode 100644 config/disposable_email_domains.php create mode 100644 config/registration.php create mode 100644 database/migrations/2026_02_21_000001_add_registration_antispam_fields_to_users_table.php create mode 100644 database/migrations/2026_02_21_000002_create_email_send_events_table.php create mode 100644 database/migrations/2026_02_21_000003_create_system_email_quota_table.php create mode 100644 database/migrations/2026_02_21_000004_rename_token_to_token_hash_in_user_verification_tokens.php create mode 100644 database/migrations/2026_02_21_000005_ensure_user_verification_tokens_table_exists.php create mode 100644 docs/registration-antispam.md create mode 100644 tests/Feature/Auth/RegistrationQuotaCircuitBreakerTest.php diff --git a/.env.example b/.env.example index 41fd0ce4..15bd0ae7 100644 --- a/.env.example +++ b/.env.example @@ -208,6 +208,23 @@ MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +# Registration anti-spam +REGISTRATION_IP_PER_MINUTE_LIMIT=3 +REGISTRATION_IP_PER_DAY_LIMIT=20 +REGISTRATION_EMAIL_PER_MINUTE_LIMIT=6 +REGISTRATION_EMAIL_COOLDOWN_MINUTES=30 +REGISTRATION_VERIFY_TOKEN_TTL_HOURS=24 +REGISTRATION_ENABLE_TURNSTILE=true +REGISTRATION_DISPOSABLE_DOMAINS_ENABLED=true +REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS=2 +REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES=30 +REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE=30 +REGISTRATION_MONTHLY_EMAIL_LIMIT=10000 +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= +TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify +TURNSTILE_TIMEOUT=5 + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/README.md b/README.md index eded3fb6..2fd1100d 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ Operational runbook: `docs/feed-rollout-runbook.md`. - Upload UI v2 rollout, post-deploy monitoring, and rollback: `docs/ui/upload-v2-rollout-runbook.md` - Feed rollout and rollback: `docs/feed-rollout-runbook.md` +- Registration anti-spam and email quota protection: `docs/registration-antispam.md` No automatic tuning is enabled in this phase. diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 45b87e65..bc169bfa 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -2,24 +2,26 @@ namespace App\Http\Controllers\Auth; +use App\Jobs\SendVerificationEmailJob; use App\Http\Controllers\Controller; -use App\Mail\RegistrationVerificationMail; +use App\Models\EmailSendEvent; use App\Models\User; -use App\Services\Security\RecaptchaVerifier; -use Carbon\CarbonImmutable; +use App\Services\Auth\DisposableEmailService; +use App\Services\Auth\RegistrationVerificationTokenService; +use App\Services\Security\TurnstileVerifier; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Illuminate\View\View; class RegisteredUserController extends Controller { public function __construct( - private readonly RecaptchaVerifier $recaptchaVerifier + private readonly TurnstileVerifier $turnstileVerifier, + private readonly DisposableEmailService $disposableEmailService, + private readonly RegistrationVerificationTokenService $verificationTokenService, ) { } @@ -31,6 +33,8 @@ class RegisteredUserController extends Controller { return view('auth.register', [ 'prefillEmail' => (string) $request->query('email', ''), + 'requiresTurnstile' => $this->shouldRequireTurnstile($request->ip()), + 'turnstileSiteKey' => (string) config('services.turnstile.site_key', ''), ]); } @@ -53,54 +57,77 @@ class RegisteredUserController extends Controller public function store(Request $request): RedirectResponse { $validated = $request->validate([ - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'website' => ['nullable', 'max:0'], + 'cf-turnstile-response' => ['nullable', 'string'], ]); - if ($this->recaptchaVerifier->isEnabled()) { - $request->validate([ - 'g-recaptcha-response' => ['required', 'string'], - ]); + $email = strtolower(trim((string) $validated['email'])); + $ip = $request->ip(); - $verified = $this->recaptchaVerifier->verify( - (string) $request->input('g-recaptcha-response', ''), - $request->ip() + $this->trackRegisterAttempt($ip); + + if ($this->shouldRequireTurnstile($ip)) { + $verified = $this->turnstileVerifier->verify( + (string) $request->input('cf-turnstile-response', ''), + $ip ); if (! $verified) { return back() ->withInput($request->except('website')) - ->withErrors(['captcha' => 'reCAPTCHA verification failed. Please try again.']); + ->withErrors(['captcha' => 'Captcha verification failed. Please try again.']); } } - $user = User::create([ - 'username' => null, - 'name' => Str::before((string) $validated['email'], '@'), - 'email' => $validated['email'], - 'password' => Hash::make(Str::random(64)), - 'is_active' => false, - 'onboarding_step' => 'email', - 'username_changed_at' => now(), - ]); + if ($this->disposableEmailService->isDisposableEmail($email)) { + $this->logEmailEvent($email, $ip, null, 'blocked', 'disposable'); - $token = Str::random(64); - DB::table('user_verification_tokens')->insert([ - 'user_id' => $user->id, - 'token' => $token, - 'expires_at' => now()->addDay(), - 'created_at' => now(), - 'updated_at' => now(), - ]); + return back() + ->withInput($request->except('website')) + ->withErrors(['email' => 'Please use a real email provider.']); + } - Mail::to($user->email)->queue(new RegistrationVerificationMail($token)); + $user = User::query()->where('email', $email)->first(); - $cooldown = $this->resendCooldownSeconds(); - $this->setResendCooldown((string) $validated['email'], $cooldown); + if ($user && $user->email_verified_at !== null) { + $this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'already-verified'); - return redirect(route('register.notice', absolute: false)) - ->with('status', 'Verification email sent. Please check your inbox.') - ->with('registration_email', (string) $validated['email']); + return $this->redirectToRegisterNotice($email); + } + + if (! $user) { + $user = User::query()->create([ + 'username' => null, + 'name' => Str::before($email, '@'), + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + 'is_active' => false, + 'onboarding_step' => 'email', + 'username_changed_at' => now(), + ]); + } + + if ($this->isWithinEmailCooldown($user)) { + $this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown'); + + return $this->redirectToRegisterNotice($email); + } + + $token = $this->verificationTokenService->createForUser((int) $user->id); + $event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null); + + SendVerificationEmailJob::dispatch( + emailEventId: (int) $event->id, + email: $email, + token: $token, + userId: (int) $user->id, + ip: $ip + ); + + $this->markVerificationEmailSent($user); + + return $this->redirectToRegisterNotice($email); } public function resendVerification(Request $request): RedirectResponse @@ -109,13 +136,8 @@ class RegisteredUserController extends Controller 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], ]); - $email = (string) $validated['email']; - $remaining = $this->resendRemainingSeconds($email); - if ($remaining > 0) { - return back() - ->with('registration_email', $email) - ->withErrors(['email' => "Please wait {$remaining} seconds before resending."]); - } + $email = strtolower(trim((string) $validated['email'])); + $ip = $request->ip(); $user = User::query() ->where('email', $email) @@ -124,55 +146,162 @@ class RegisteredUserController extends Controller ->first(); if (! $user) { - return back() - ->with('registration_email', $email) - ->withErrors(['email' => 'No pending verification found for this email.']); + $this->logEmailEvent($email, $ip, null, 'blocked', 'missing'); + + return $this->redirectToRegisterNotice($email); } - DB::table('user_verification_tokens')->where('user_id', $user->id)->delete(); + if ($this->isWithinEmailCooldown($user)) { + $this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown'); - $token = Str::random(64); - DB::table('user_verification_tokens')->insert([ - 'user_id' => $user->id, - 'token' => $token, - 'expires_at' => now()->addDay(), - 'created_at' => now(), - 'updated_at' => now(), - ]); + return $this->redirectToRegisterNotice($email); + } - Mail::to($user->email)->queue(new RegistrationVerificationMail($token)); + $token = $this->verificationTokenService->createForUser((int) $user->id); + $event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null); - $cooldown = $this->resendCooldownSeconds(); - $this->setResendCooldown($email, $cooldown); + SendVerificationEmailJob::dispatch( + emailEventId: (int) $event->id, + email: $email, + token: $token, + userId: (int) $user->id, + ip: $ip + ); + $this->markVerificationEmailSent($user); + + return $this->redirectToRegisterNotice($email); + } + + private function redirectToRegisterNotice(string $email): RedirectResponse + { return redirect(route('register.notice', absolute: false)) - ->with('registration_email', $email) - ->with('status', 'Verification email resent. Please check your inbox.'); + ->with('status', $this->genericSuccessMessage()) + ->with('registration_email', $email); + } + + private function genericSuccessMessage(): string + { + return (string) config('registration.generic_success_message', 'If that email is valid, we sent a verification link.'); + } + + private function logEmailEvent(string $email, ?string $ip, ?int $userId, string $status, ?string $reason): EmailSendEvent + { + return EmailSendEvent::query()->create([ + 'type' => 'verify_email', + 'email' => $email, + 'ip' => $ip, + 'user_id' => $userId, + 'status' => $status, + 'reason' => $reason, + 'created_at' => now(), + ]); + } + + private function shouldRequireTurnstile(?string $ip): bool + { + if (! $this->turnstileVerifier->isEnabled()) { + return false; + } + + if ($ip === null || $ip === '') { + return false; + } + + $threshold = max(1, (int) config('registration.turnstile_suspicious_attempts', 2)); + $attempts = (int) cache()->get($this->registerAttemptCacheKey($ip), 0); + + if ($attempts >= $threshold) { + return true; + } + + $minuteLimit = max(1, (int) config('registration.ip_per_minute_limit', 3)); + $dailyLimit = max(1, (int) config('registration.ip_per_day_limit', 20)); + + if (RateLimiter::tooManyAttempts($this->registerIpRateKey($ip), $minuteLimit)) { + return true; + } + + return RateLimiter::tooManyAttempts($this->registerIpDailyRateKey($ip), $dailyLimit); + } + + private function trackRegisterAttempt(?string $ip): void + { + if ($ip === null || $ip === '') { + return; + } + + $key = $this->registerAttemptCacheKey($ip); + $windowMinutes = max(1, (int) config('registration.turnstile_attempt_window_minutes', 30)); + $seconds = $windowMinutes * 60; + + $attempts = (int) cache()->get($key, 0); + cache()->put($key, $attempts + 1, $seconds); + } + + private function registerAttemptCacheKey(string $ip): string + { + return 'register:attempts:' . sha1($ip); + } + + private function registerIpRateKey(string $ip): string + { + return 'register:ip:' . $ip; + } + + private function registerIpDailyRateKey(string $ip): string + { + return 'register:ip:daily:' . $ip; + } + + private function isWithinEmailCooldown(User $user): bool + { + if ($user->last_verification_sent_at === null) { + return false; + } + + $cooldownMinutes = max(1, (int) config('registration.email_cooldown_minutes', 30)); + + return $user->last_verification_sent_at->gt(now()->subMinutes($cooldownMinutes)); + } + + private function markVerificationEmailSent(User $user): void + { + $now = now(); + + $windowStartedAt = $user->verification_send_window_started_at; + if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) { + $user->verification_send_window_started_at = $now; + $user->verification_send_count_24h = 1; + } else { + $user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1; + } + + $user->last_verification_sent_at = $now; + $user->save(); } private function resendCooldownSeconds(): int { - return max(5, (int) config('antispam.register.resend_cooldown_seconds', 60)); - } - - private function resendCooldownCacheKey(string $email): string - { - return 'register:resend:cooldown:' . sha1(strtolower(trim($email))); - } - - private function setResendCooldown(string $email, int $seconds): void - { - $until = CarbonImmutable::now()->addSeconds($seconds)->timestamp; - Cache::put($this->resendCooldownCacheKey($email), $until, $seconds + 5); + return max(60, ((int) config('registration.email_cooldown_minutes', 30)) * 60); } private function resendRemainingSeconds(string $email): int { - $until = (int) Cache::get($this->resendCooldownCacheKey($email), 0); - if ($until <= 0) { + $user = User::query() + ->where('email', strtolower(trim($email))) + ->whereNull('email_verified_at') + ->first(); + + if (! $user || $user->last_verification_sent_at === null) { return 0; } - return max(0, $until - time()); + $remaining = $user->last_verification_sent_at + ->copy() + ->addSeconds($this->resendCooldownSeconds()) + ->diffInSeconds(now(), false); + + return $remaining >= 0 ? 0 : abs((int) $remaining); } } diff --git a/app/Http/Controllers/Auth/RegistrationVerificationController.php b/app/Http/Controllers/Auth/RegistrationVerificationController.php index b44f2769..21a570a2 100644 --- a/app/Http/Controllers/Auth/RegistrationVerificationController.php +++ b/app/Http/Controllers/Auth/RegistrationVerificationController.php @@ -4,30 +4,28 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Models\User; +use App\Services\Auth\RegistrationVerificationTokenService; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class RegistrationVerificationController extends Controller { + public function __construct( + private readonly RegistrationVerificationTokenService $tokenService + ) + { + } + public function __invoke(string $token): RedirectResponse { - $record = DB::table('user_verification_tokens') - ->where('token', $token) - ->first(); + $record = $this->tokenService->findValidRecord($token); if (! $record) { return redirect(route('login', absolute: false)) ->withErrors(['email' => 'Verification link is invalid.']); } - if (now()->greaterThan($record->expires_at)) { - DB::table('user_verification_tokens')->where('id', $record->id)->delete(); - - return redirect(route('login', absolute: false)) - ->withErrors(['email' => 'Verification link has expired.']); - } - $user = User::query()->find((int) $record->user_id); if (! $user) { DB::table('user_verification_tokens')->where('id', $record->id)->delete(); diff --git a/app/Jobs/SendVerificationEmailJob.php b/app/Jobs/SendVerificationEmailJob.php new file mode 100644 index 00000000..d3887268 --- /dev/null +++ b/app/Jobs/SendVerificationEmailJob.php @@ -0,0 +1,62 @@ +onQueue('mail'); + } + + public function handle(RegistrationEmailQuotaService $quotaService): void + { + $key = 'registration:verification-email:global'; + $maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30)); + + $allowed = RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60); + if (! $allowed) { + $this->release(10); + + return; + } + + if ($quotaService->isExceeded()) { + $this->updateEvent('blocked', 'quota'); + + return; + } + + Mail::to($this->email)->queue(new RegistrationVerificationMail($this->token)); + $quotaService->incrementSentCount(); + + $this->updateEvent('sent', null); + } + + private function updateEvent(string $status, ?string $reason): void + { + DB::table('email_send_events') + ->where('id', $this->emailEventId) + ->update([ + 'status' => $status, + 'reason' => $reason, + ]); + } +} diff --git a/app/Mail/RegistrationVerificationMail.php b/app/Mail/RegistrationVerificationMail.php index e90eea0a..75436a8f 100644 --- a/app/Mail/RegistrationVerificationMail.php +++ b/app/Mail/RegistrationVerificationMail.php @@ -40,7 +40,7 @@ class RegistrationVerificationMail extends Mailable implements ShouldQueue view: 'emails.registration-verification', with: [ 'verificationUrl' => url('/verify/'.$this->token), - 'expiresInHours' => 24, + 'expiresInHours' => max(1, (int) config('registration.verify_token_ttl_hours', 24)), 'supportUrl' => $appUrl . '/support', ], ); diff --git a/app/Models/EmailSendEvent.php b/app/Models/EmailSendEvent.php new file mode 100644 index 00000000..c375a9cf --- /dev/null +++ b/app/Models/EmailSendEvent.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/SystemEmailQuota.php b/app/Models/SystemEmailQuota.php new file mode 100644 index 00000000..67c887b0 --- /dev/null +++ b/app/Models/SystemEmailQuota.php @@ -0,0 +1,26 @@ + 'datetime', + 'sent_count' => 'integer', + 'limit_count' => 'integer', + ]; +} + diff --git a/app/Models/User.php b/app/Models/User.php index c1556f0f..a7c844f6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -26,6 +26,9 @@ class User extends Authenticatable 'onboarding_step', 'name', 'email', + 'last_verification_sent_at', + 'verification_send_count_24h', + 'verification_send_window_started_at', 'is_active', 'needs_password_reset', 'password', @@ -51,6 +54,9 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'last_verification_sent_at' => 'datetime', + 'verification_send_window_started_at' => 'datetime', + 'verification_send_count_24h' => 'integer', 'username_changed_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1d74f260..09b1c71b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -91,10 +91,22 @@ class AppServiceProvider extends ServiceProvider private function configureAuthRateLimiters(): void { + RateLimiter::for('register-ip', function (Request $request): Limit { + $limit = max(1, (int) config('registration.ip_per_minute_limit', 3)); + + return Limit::perMinute($limit)->by('register:ip:' . $request->ip()); + }); + + RateLimiter::for('register-ip-daily', function (Request $request): Limit { + $limit = max(1, (int) config('registration.ip_per_day_limit', 20)); + + return Limit::perDay($limit)->by('register:ip:daily:' . $request->ip()); + }); + RateLimiter::for('register', function (Request $request): array { $emailKey = strtolower((string) $request->input('email', 'unknown')); - $ipLimit = (int) config('antispam.register.ip_per_minute', 20); - $emailLimit = (int) config('antispam.register.email_per_minute', 6); + $ipLimit = (int) config('registration.ip_per_minute_limit', 3); + $emailLimit = (int) config('registration.email_per_minute_limit', 6); return [ Limit::perMinute($ipLimit)->by('register:ip:' . $request->ip()), diff --git a/app/Services/Auth/DisposableEmailService.php b/app/Services/Auth/DisposableEmailService.php new file mode 100644 index 00000000..97b11525 --- /dev/null +++ b/app/Services/Auth/DisposableEmailService.php @@ -0,0 +1,66 @@ +isEnabled()) { + return false; + } + + $domain = $this->extractDomain($email); + if ($domain === null) { + return false; + } + + $blocked = (array) config('disposable_email_domains.domains', []); + foreach ($blocked as $entry) { + $pattern = strtolower(trim((string) $entry)); + if ($pattern === '') { + continue; + } + + if ($this->matchesPattern($domain, $pattern)) { + return true; + } + } + + return false; + } + + private function extractDomain(string $email): ?string + { + $normalized = strtolower(trim($email)); + if ($normalized === '' || ! str_contains($normalized, '@')) { + return null; + } + + $parts = explode('@', $normalized); + $domain = trim((string) end($parts)); + + return $domain !== '' ? $domain : null; + } + + private function matchesPattern(string $domain, string $pattern): bool + { + if ($pattern === $domain) { + return true; + } + + if (! str_contains($pattern, '*')) { + return false; + } + + $quoted = preg_quote($pattern, '#'); + $regex = '#^' . str_replace('\\*', '.*', $quoted) . '$#i'; + + return (bool) preg_match($regex, $domain); + } +} diff --git a/app/Services/Auth/RegistrationEmailQuotaService.php b/app/Services/Auth/RegistrationEmailQuotaService.php new file mode 100644 index 00000000..f1c0ab67 --- /dev/null +++ b/app/Services/Auth/RegistrationEmailQuotaService.php @@ -0,0 +1,37 @@ +getCurrentPeriodQuota(); + + return $quota->sent_count >= $quota->limit_count; + } + + public function incrementSentCount(): void + { + $quota = $this->getCurrentPeriodQuota(); + $quota->sent_count = (int) $quota->sent_count + 1; + $quota->updated_at = now(); + $quota->save(); + } + + private function getCurrentPeriodQuota(): SystemEmailQuota + { + $period = now()->format('Y-m'); + + return SystemEmailQuota::query()->firstOrCreate( + ['period' => $period], + [ + 'sent_count' => 0, + 'limit_count' => max(1, (int) config('registration.monthly_email_limit', 10000)), + 'updated_at' => now(), + ] + ); + } +} diff --git a/app/Services/Auth/RegistrationVerificationTokenService.php b/app/Services/Auth/RegistrationVerificationTokenService.php new file mode 100644 index 00000000..34e2dcb0 --- /dev/null +++ b/app/Services/Auth/RegistrationVerificationTokenService.php @@ -0,0 +1,67 @@ +where('user_id', $userId)->delete(); + + $rawToken = Str::random(64); + $tokenHash = $this->hashToken($rawToken); + + // Support environments where the migration hasn't renamed the column yet + $column = \Illuminate\Support\Facades\Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; + + DB::table('user_verification_tokens')->insert([ + 'user_id' => $userId, + $column => $tokenHash, + 'expires_at' => now()->addHours($this->ttlHours()), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $rawToken; + } + + public function findValidRecord(string $rawToken): ?object + { + $tokenHash = $this->hashToken($rawToken); + + $column = \Illuminate\Support\Facades\Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; + + $record = DB::table('user_verification_tokens') + ->where($column, $tokenHash) + ->first(); + + if (! $record) { + return null; + } + + if (! hash_equals((string) ($record->{$column} ?? ''), $tokenHash)) { + return null; + } + + if (now()->greaterThan($record->expires_at)) { + DB::table('user_verification_tokens')->where('id', $record->id)->delete(); + + return null; + } + + return $record; + } + + private function ttlHours(): int + { + return max(1, (int) config('registration.verify_token_ttl_hours', 24)); + } + + private function hashToken(string $rawToken): string + { + return hash('sha256', $rawToken); + } +} diff --git a/app/Services/Security/TurnstileVerifier.php b/app/Services/Security/TurnstileVerifier.php new file mode 100644 index 00000000..05965852 --- /dev/null +++ b/app/Services/Security/TurnstileVerifier.php @@ -0,0 +1,51 @@ +isEnabled()) { + return true; + } + + if (trim($token) === '') { + return false; + } + + try { + $response = Http::asForm() + ->timeout((int) config('services.turnstile.timeout', 5)) + ->post((string) config('services.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [ + 'secret' => (string) config('services.turnstile.secret_key', ''), + 'response' => $token, + 'remoteip' => $ip, + ]); + + if ($response->failed()) { + return false; + } + + $payload = $response->json(); + + return (bool) data_get($payload, 'success', false); + } catch (\Throwable $exception) { + Log::warning('turnstile verification request failed', [ + 'message' => $exception->getMessage(), + ]); + + return false; + } + } +} diff --git a/config/disposable_email_domains.php b/config/disposable_email_domains.php new file mode 100644 index 00000000..240d4ea4 --- /dev/null +++ b/config/disposable_email_domains.php @@ -0,0 +1,11 @@ + [ + 'mailinator.com', + '10minutemail.com', + 'guerrillamail.com', + 'tempmail.com', + 'yopmail.com', + ], +]; diff --git a/config/registration.php b/config/registration.php new file mode 100644 index 00000000..e25e71cd --- /dev/null +++ b/config/registration.php @@ -0,0 +1,16 @@ + (int) env('REGISTRATION_IP_PER_MINUTE_LIMIT', 3), + 'ip_per_day_limit' => (int) env('REGISTRATION_IP_PER_DAY_LIMIT', 20), + 'email_per_minute_limit' => (int) env('REGISTRATION_EMAIL_PER_MINUTE_LIMIT', 6), + 'email_cooldown_minutes' => (int) env('REGISTRATION_EMAIL_COOLDOWN_MINUTES', 30), + 'verify_token_ttl_hours' => (int) env('REGISTRATION_VERIFY_TOKEN_TTL_HOURS', 24), + 'enable_turnstile' => (bool) env('REGISTRATION_ENABLE_TURNSTILE', true), + 'disposable_domains_enabled' => (bool) env('REGISTRATION_DISPOSABLE_DOMAINS_ENABLED', true), + 'turnstile_suspicious_attempts' => (int) env('REGISTRATION_TURNSTILE_SUSPICIOUS_ATTEMPTS', 2), + 'turnstile_attempt_window_minutes' => (int) env('REGISTRATION_TURNSTILE_ATTEMPT_WINDOW_MINUTES', 30), + 'email_global_send_per_minute' => (int) env('REGISTRATION_EMAIL_GLOBAL_SEND_PER_MINUTE', 30), + 'monthly_email_limit' => (int) env('REGISTRATION_MONTHLY_EMAIL_LIMIT', 10000), + 'generic_success_message' => 'If that email is valid, we sent a verification link.', +]; diff --git a/config/services.php b/config/services.php index 2a68e621..09d3b8e6 100644 --- a/config/services.php +++ b/config/services.php @@ -47,4 +47,11 @@ return [ 'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5), ], + 'turnstile' => [ + 'site_key' => env('TURNSTILE_SITE_KEY'), + 'secret_key' => env('TURNSTILE_SECRET_KEY'), + 'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), + 'timeout' => (int) env('TURNSTILE_TIMEOUT', 5), + ], + ]; diff --git a/database/migrations/2026_02_21_000001_add_registration_antispam_fields_to_users_table.php b/database/migrations/2026_02_21_000001_add_registration_antispam_fields_to_users_table.php new file mode 100644 index 00000000..3d9bde77 --- /dev/null +++ b/database/migrations/2026_02_21_000001_add_registration_antispam_fields_to_users_table.php @@ -0,0 +1,41 @@ +timestamp('last_verification_sent_at')->nullable()->after('email_verified_at'); + } + + if (! Schema::hasColumn('users', 'verification_send_count_24h')) { + $table->unsignedInteger('verification_send_count_24h')->default(0)->after('last_verification_sent_at'); + } + + if (! Schema::hasColumn('users', 'verification_send_window_started_at')) { + $table->timestamp('verification_send_window_started_at')->nullable()->after('verification_send_count_24h'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + if (Schema::hasColumn('users', 'verification_send_window_started_at')) { + $table->dropColumn('verification_send_window_started_at'); + } + + if (Schema::hasColumn('users', 'verification_send_count_24h')) { + $table->dropColumn('verification_send_count_24h'); + } + + if (Schema::hasColumn('users', 'last_verification_sent_at')) { + $table->dropColumn('last_verification_sent_at'); + } + }); + } +}; diff --git a/database/migrations/2026_02_21_000002_create_email_send_events_table.php b/database/migrations/2026_02_21_000002_create_email_send_events_table.php new file mode 100644 index 00000000..5166ba80 --- /dev/null +++ b/database/migrations/2026_02_21_000002_create_email_send_events_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('type', 64); + $table->string('email'); + $table->string('ip', 45)->nullable(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('status', 32); + $table->string('reason', 64)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index('email'); + $table->index('ip'); + $table->index(['type', 'status']); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_send_events'); + } +}; diff --git a/database/migrations/2026_02_21_000003_create_system_email_quota_table.php b/database/migrations/2026_02_21_000003_create_system_email_quota_table.php new file mode 100644 index 00000000..db3e00c8 --- /dev/null +++ b/database/migrations/2026_02_21_000003_create_system_email_quota_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('period', 7)->unique(); + $table->unsignedInteger('sent_count')->default(0); + $table->unsignedInteger('limit_count'); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('system_email_quota'); + } +}; diff --git a/database/migrations/2026_02_21_000004_rename_token_to_token_hash_in_user_verification_tokens.php b/database/migrations/2026_02_21_000004_rename_token_to_token_hash_in_user_verification_tokens.php new file mode 100644 index 00000000..ee2d355e --- /dev/null +++ b/database/migrations/2026_02_21_000004_rename_token_to_token_hash_in_user_verification_tokens.php @@ -0,0 +1,48 @@ +getPdo()->getAttribute(PDO::ATTR_DRIVER_NAME); + } catch (\Throwable $e) { + $driver = null; + } + + if ($driver === 'sqlite') { + return; + } + + // Use raw statement to avoid requiring doctrine/dbal for simple rename + // Adjust column definition to match original migration (VARCHAR(128) NOT NULL) + DB::statement("ALTER TABLE `user_verification_tokens` CHANGE `token` `token_hash` VARCHAR(128) NOT NULL"); + } + + public function down(): void + { + if (! Schema::hasTable('user_verification_tokens')) { + return; + } + + try { + $driver = DB::connection()->getPdo()->getAttribute(PDO::ATTR_DRIVER_NAME); + } catch (\Throwable $e) { + $driver = null; + } + + if ($driver === 'sqlite') { + return; + } + + DB::statement("ALTER TABLE `user_verification_tokens` CHANGE `token_hash` `token` VARCHAR(128) NOT NULL"); + } +}; diff --git a/database/migrations/2026_02_21_000005_ensure_user_verification_tokens_table_exists.php b/database/migrations/2026_02_21_000005_ensure_user_verification_tokens_table_exists.php new file mode 100644 index 00000000..9331629a --- /dev/null +++ b/database/migrations/2026_02_21_000005_ensure_user_verification_tokens_table_exists.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('token_hash', 128)->unique(); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->index(['user_id', 'expires_at']); + }); + } + + public function down(): void + { + // Intentionally no-op to avoid dropping an existing tokens table + // that may have been created by earlier migrations. + } +}; diff --git a/docs/registration-antispam.md b/docs/registration-antispam.md new file mode 100644 index 00000000..99cd31ea --- /dev/null +++ b/docs/registration-antispam.md @@ -0,0 +1,202 @@ +# Registration Anti-Spam + Email Quota Protection + +This document describes how the Skinbase email-first registration hardening works. + +## Scope + +Applies to the flow: + +- `GET /register` +- `POST /register` +- `GET /register/notice` +- `POST /register/resend-verification` +- `GET /verify/{token}` +- `GET/POST /setup/password` +- `GET/POST /setup/username` + +Primary implementation: + +- `app/Http/Controllers/Auth/RegisteredUserController.php` +- `app/Http/Controllers/Auth/RegistrationVerificationController.php` + +## Security Controls + +### 1) IP Rate Limiting + +Defined in `app/Providers/AppServiceProvider.php`: + +- `register-ip`: per-minute IP limit +- `register-ip-daily`: per-day IP limit +- `register` (legacy resend route): per-minute IP + per-email key + +Applied on `POST /register` in `routes/auth.php`: + +- `throttle:register-ip` +- `throttle:register-ip-daily` + +### 2) Per-Email Cooldown + +Cooldown is enforced by user fields: + +- `users.last_verification_sent_at` +- `users.verification_send_count_24h` +- `users.verification_send_window_started_at` + +On repeated requests within cooldown: + +- No additional verification email is queued +- Generic success message is returned + +### 3) Progressive CAPTCHA (Turnstile) + +Service: + +- `app/Services/Security/TurnstileVerifier.php` + +Controller logic (`RegisteredUserController::shouldRequireTurnstile`): + +- Requires Turnstile for suspicious IP activity (attempt threshold) +- Also requires Turnstile when registration rate-limit state is detected + +UI behavior (`resources/views/auth/register.blade.php`): + +- Turnstile widget is only rendered when required + +### 4) Disposable Domain Block + +Service: + +- `app/Services/Auth/DisposableEmailService.php` + +Config source: + +- `config/disposable_email_domains.php` + +Behavior: + +- Blocks known disposable domains (supports wildcard matching) +- Returns friendly validation error + +### 5) Queue + Throttle + Quota Circuit Breaker + +Queue job: + +- `app/Jobs/SendVerificationEmailJob.php` + +Behavior: + +- Registration controller dispatches `SendVerificationEmailJob` +- Job applies global send throttling via `RateLimiter` +- Job checks monthly quota via `RegistrationEmailQuotaService` +- If quota exceeded: send is blocked (fail closed), event marked blocked + +Quota service/model/table: + +- `app/Services/Auth/RegistrationEmailQuotaService.php` +- `app/Models/SystemEmailQuota.php` +- `system_email_quota` + +Send event audit: + +- `app/Models/EmailSendEvent.php` +- `email_send_events` + +### 6) Generic Responses (Anti-Enumeration) + +The registration entry point uses a standard success message: + +- `If that email is valid, we sent a verification link.` + +This message is returned for: + +- Unknown emails +- Existing verified emails +- Cooldown cases +- Quota-blocked paths + +### 7) Verification Token Hardening + +Service: + +- `app/Services/Auth/RegistrationVerificationTokenService.php` + +Protections: + +- Token generated with high entropy (`Str::random(64)`) +- Stored hashed (`sha256`) in `user_verification_tokens` +- Expires using configured TTL +- Validation uses hash lookup + constant-time compare (`hash_equals`) +- Token deleted after successful verification (one-time use) + +Verification endpoint: + +- `app/Http/Controllers/Auth/RegistrationVerificationController.php` + +## Configuration + +Main registration config: + +- `config/registration.php` + +Key settings: + +- `ip_per_minute_limit` +- `ip_per_day_limit` +- `email_per_minute_limit` +- `email_cooldown_minutes` +- `verify_token_ttl_hours` +- `enable_turnstile` +- `disposable_domains_enabled` +- `turnstile_suspicious_attempts` +- `turnstile_attempt_window_minutes` +- `email_global_send_per_minute` +- `monthly_email_limit` +- `generic_success_message` + +Turnstile config: + +- `config/services.php` under `turnstile` + +Environment examples: + +- `.env.example` contains all registration anti-spam keys + +## Database Objects + +Added for anti-spam/quota support: + +- Migration: `2026_02_21_000001_add_registration_antispam_fields_to_users_table.php` +- Migration: `2026_02_21_000002_create_email_send_events_table.php` +- Migration: `2026_02_21_000003_create_system_email_quota_table.php` +- Migration: `2026_02_20_191000_add_registration_phase1_schema.php` (creates `user_verification_tokens`) +- Migration: `2026_02_21_000004_rename_token_to_token_hash_in_user_verification_tokens.php` (schema hardening) +- Migration: `2026_02_21_000005_ensure_user_verification_tokens_table_exists.php` (rollout safety) + +## Test Coverage + +Primary tests: + +- `tests/Feature/Auth/RegistrationAntiSpamTest.php` +- `tests/Feature/Auth/RegistrationNoticeResendTest.php` +- `tests/Feature/Auth/RegistrationQuotaCircuitBreakerTest.php` +- `tests/Feature/Auth/RegistrationTokenVerificationTest.php` +- `tests/Feature/Auth/RegistrationFlowChecklistTest.php` +- `tests/Feature/Auth/RegistrationVerificationMailTest.php` + +Covered scenarios: + +- IP rate-limit returns `429` +- Cooldown suppresses extra sends +- Disposable domains blocked +- Quota exceeded blocks send and keeps generic success UX +- Turnstile required on abuse/rate-limit state +- Tokens hashed, expire, and are one-time +- Responses avoid account enumeration + +## Operations Notes + +- Keep disposable domain list maintained in `config/disposable_email_domains.php`. +- Ensure queue workers process the `mail` queue. +- Monitor `email_send_events` for blocked/sent patterns. +- Set `REGISTRATION_MONTHLY_EMAIL_LIMIT` based on provider quota. +- Configure `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` in production. diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 20d63587..8a0dac6d 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -17,8 +17,8 @@ - @if(config('services.recaptcha.enabled')) - + @if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '') +
@endif @@ -29,4 +29,7 @@ +@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '') + +@endif @endsection diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 496c2749..160a7861 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,4 +1,4 @@ -@extends('layouts.legacy') +@extends('layouts.nova') @section('content')
diff --git a/routes/auth.php b/routes/auth.php index b6315712..b74f3fb5 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -22,7 +22,7 @@ Route::middleware(['guest', 'normalize.username'])->group(function () { ->name('register.notice'); Route::post('register', [RegisteredUserController::class, 'store']) - ->middleware('throttle:register'); + ->middleware(['throttle:register-ip', 'throttle:register-ip-daily']); Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification']) ->middleware('throttle:register') diff --git a/tests/Feature/Auth/RegistrationAntiSpamTest.php b/tests/Feature/Auth/RegistrationAntiSpamTest.php index 29cfa997..58415c1c 100644 --- a/tests/Feature/Auth/RegistrationAntiSpamTest.php +++ b/tests/Feature/Auth/RegistrationAntiSpamTest.php @@ -1,14 +1,16 @@ from('/register')->post('/register', [ 'email' => 'bot1@example.com', @@ -21,9 +23,9 @@ it('rejects registration when honeypot field is filled', function () { }); it('throttles excessive registration attempts by ip', function () { - Mail::fake(); - config()->set('antispam.register.ip_per_minute', 2); - config()->set('antispam.register.email_per_minute', 20); + Queue::fake(); + config()->set('registration.ip_per_minute_limit', 2); + config()->set('registration.ip_per_day_limit', 100); for ($i = 0; $i < 2; $i++) { $this->post('/register', [ @@ -36,19 +38,37 @@ it('throttles excessive registration attempts by ip', function () { ])->assertStatus(429); RateLimiter::clear('register:ip:127.0.0.1'); + RateLimiter::clear('register:ip:daily:127.0.0.1'); }); -it('rejects registration when recaptcha is enabled and verification fails', function () { - Mail::fake(); +it('blocks disposable email domains during registration', function () { + Queue::fake(); + config()->set('registration.disposable_domains_enabled', true); + config()->set('disposable_email_domains.domains', ['tempmail.com']); - $mock = \Mockery::mock(RecaptchaVerifier::class); + $response = $this->from('/register')->post('/register', [ + 'email' => 'bot@tempmail.com', + ]); + + $response->assertRedirect('/register'); + $response->assertSessionHasErrors('email'); + $this->assertDatabaseMissing('users', ['email' => 'bot@tempmail.com']); +}); + +it('requires turnstile after suspicious registration attempts', function () { + Queue::fake(); + config()->set('registration.enable_turnstile', true); + config()->set('registration.turnstile_suspicious_attempts', 1); + config()->set('services.turnstile.site_key', 'site-key'); + config()->set('services.turnstile.secret_key', 'secret-key'); + + $mock = \Mockery::mock(TurnstileVerifier::class); $mock->shouldReceive('isEnabled')->andReturn(true); $mock->shouldReceive('verify')->once()->andReturn(false); - $this->app->instance(RecaptchaVerifier::class, $mock); + $this->app->instance(TurnstileVerifier::class, $mock); $response = $this->from('/register')->post('/register', [ 'email' => 'captcha-user@example.com', - 'g-recaptcha-response' => 'bad-token', ]); $response->assertRedirect('/register'); @@ -56,17 +76,79 @@ it('rejects registration when recaptcha is enabled and verification fails', func $this->assertDatabaseMissing('users', ['email' => 'captcha-user@example.com']); }); -it('allows registration when recaptcha is enabled and verification succeeds', function () { - Mail::fake(); +it('shows turnstile when ip is in rate-limited state', function () { + config()->set('registration.enable_turnstile', true); + config()->set('registration.ip_per_minute_limit', 1); + config()->set('services.turnstile.site_key', 'site-key'); + config()->set('services.turnstile.secret_key', 'secret-key'); - $mock = \Mockery::mock(RecaptchaVerifier::class); + RateLimiter::hit('register:ip:127.0.0.1', 60); + + $this->get('/register') + ->assertOk() + ->assertSee('cf-turnstile', false); + + RateLimiter::clear('register:ip:127.0.0.1'); +}); + +it('enforces verification email cooldown per address', function () { + Queue::fake(); + + $this->post('/register', [ + 'email' => 'cooldown2@example.com', + ])->assertRedirect('/register/notice'); + + $response = $this->post('/register', [ + 'email' => 'cooldown2@example.com', + ]); + + $response->assertRedirect('/register/notice'); + $response->assertSessionHas('status', 'If that email is valid, we sent a verification link.'); + Queue::assertPushed(SendVerificationEmailJob::class, 1); +}); + +it('returns generic success for existing verified emails (anti-enumeration)', function () { + Queue::fake(); + + User::factory()->create([ + 'email' => 'existing@example.com', + 'email_verified_at' => now(), + 'onboarding_step' => 'complete', + 'is_active' => true, + ]); + + $response = $this->post('/register', [ + 'email' => 'existing@example.com', + ]); + + $response->assertRedirect('/register/notice'); + $response->assertSessionHas('status', 'If that email is valid, we sent a verification link.'); + Queue::assertNothingPushed(); +}); + +it('still allows registration when turnstile passes', function () { + Queue::fake(); + config()->set('registration.enable_turnstile', true); + config()->set('registration.turnstile_suspicious_attempts', 1); + config()->set('services.turnstile.site_key', 'site-key'); + config()->set('services.turnstile.secret_key', 'secret-key'); + + $mock = \Mockery::mock(TurnstileVerifier::class); $mock->shouldReceive('isEnabled')->andReturn(true); + $mock->shouldReceive('verify')->once()->andReturn(false); $mock->shouldReceive('verify')->once()->andReturn(true); - $this->app->instance(RecaptchaVerifier::class, $mock); + $this->app->instance(TurnstileVerifier::class, $mock); + + $first = $this->from('/register')->post('/register', [ + 'email' => 'captcha-block@example.com', + ]); + + $first->assertRedirect('/register'); + $first->assertSessionHasErrors('captcha'); $response = $this->post('/register', [ 'email' => 'captcha-pass@example.com', - 'g-recaptcha-response' => 'good-token', + 'cf-turnstile-response' => 'good-token', ]); $response->assertRedirect('/register/notice'); diff --git a/tests/Feature/Auth/RegistrationFlowChecklistTest.php b/tests/Feature/Auth/RegistrationFlowChecklistTest.php index 13a97aa2..3d7d9126 100644 --- a/tests/Feature/Auth/RegistrationFlowChecklistTest.php +++ b/tests/Feature/Auth/RegistrationFlowChecklistTest.php @@ -2,13 +2,14 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Mail; +use App\Jobs\SendVerificationEmailJob; uses(RefreshDatabase::class); it('completes happy path registration onboarding flow', function () { - Mail::fake(); + Queue::fake(); $register = $this->post('/register', [ 'email' => 'flow-user@example.com', @@ -19,9 +20,12 @@ it('completes happy path registration onboarding flow', function () { $user = User::query()->where('email', 'flow-user@example.com')->firstOrFail(); expect($user->onboarding_step)->toBe('email'); - $token = (string) DB::table('user_verification_tokens') - ->where('user_id', $user->id) - ->value('token'); + $token = null; + Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$token) { + $token = $job->token; + + return true; + }); $this->get('/verify/' . $token)->assertRedirect('/setup/password'); @@ -58,9 +62,10 @@ it('rejects expired verification token', function () { 'is_active' => false, ]); + $column = \Illuminate\Support\Facades\Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; DB::table('user_verification_tokens')->insert([ 'user_id' => $user->id, - 'token' => 'expired-checklist-token', + $column => hash('sha256', 'expired-checklist-token'), 'expires_at' => now()->subHour(), 'created_at' => now(), 'updated_at' => now(), @@ -74,16 +79,22 @@ it('rejects expired verification token', function () { }); it('rejects duplicate email at registration', function () { + Queue::fake(); + User::factory()->create([ 'email' => 'duplicate-check@example.com', + 'email_verified_at' => now(), + 'onboarding_step' => 'complete', + 'is_active' => true, ]); - $response = $this->from('/register')->post('/register', [ + $response = $this->post('/register', [ 'email' => 'duplicate-check@example.com', ]); - $response->assertRedirect('/register'); - $response->assertSessionHasErrors('email'); + $response->assertRedirect('/register/notice'); + $response->assertSessionHas('status', 'If that email is valid, we sent a verification link.'); + Queue::assertNothingPushed(); }); it('rejects username conflict during username setup', function () { diff --git a/tests/Feature/Auth/RegistrationNoticeResendTest.php b/tests/Feature/Auth/RegistrationNoticeResendTest.php index a2c059f5..331438b9 100644 --- a/tests/Feature/Auth/RegistrationNoticeResendTest.php +++ b/tests/Feature/Auth/RegistrationNoticeResendTest.php @@ -1,15 +1,14 @@ post('/register', [ 'email' => 'notice@example.com', @@ -28,33 +27,40 @@ it('prefills register form email from query string', function () { }); it('blocks resend while cooldown is active', function () { - Mail::fake(); + Queue::fake(); $this->post('/register', [ 'email' => 'cooldown@example.com', ])->assertRedirect('/register/notice'); - $this->from('/register/notice')->post('/register/resend-verification', [ + $response = $this->from('/register/notice')->post('/register/resend-verification', [ 'email' => 'cooldown@example.com', - ])->assertRedirect('/register/notice') - ->assertSessionHasErrors('email'); + ]); + + $response->assertRedirect('/register/notice'); + $response->assertSessionHasNoErrors(); + $response->assertSessionHas('status', 'If that email is valid, we sent a verification link.'); + + Queue::assertPushed(SendVerificationEmailJob::class, 1); }); it('resends verification after cooldown expires', function () { - Mail::fake(); + Queue::fake(); $this->post('/register', [ 'email' => 'resend@example.com', ])->assertRedirect('/register/notice'); - $key = 'register:resend:cooldown:' . sha1('resend@example.com'); - Cache::forget($key); + $user = User::query()->where('email', 'resend@example.com')->firstOrFail(); + $user->forceFill([ + 'last_verification_sent_at' => now()->subMinutes(31), + ])->save(); $this->post('/register/resend-verification', [ 'email' => 'resend@example.com', ])->assertRedirect('/register/notice'); - Mail::assertQueued(RegistrationVerificationMail::class, 2); + Queue::assertPushed(SendVerificationEmailJob::class, 2); expect(User::query()->where('email', 'resend@example.com')->exists())->toBeTrue(); }); diff --git a/tests/Feature/Auth/RegistrationQuotaCircuitBreakerTest.php b/tests/Feature/Auth/RegistrationQuotaCircuitBreakerTest.php new file mode 100644 index 00000000..853d8edb --- /dev/null +++ b/tests/Feature/Auth/RegistrationQuotaCircuitBreakerTest.php @@ -0,0 +1,67 @@ +insert([ + 'period' => now()->format('Y-m'), + 'sent_count' => 10, + 'limit_count' => 10, + 'updated_at' => now(), + ]); + + $response = $this->post('/register', [ + 'email' => 'quota-hit@example.com', + ]); + + $response->assertRedirect('/register/notice'); + $response->assertSessionHas('status', 'If that email is valid, we sent a verification link.'); + Queue::assertPushed(SendVerificationEmailJob::class); +}); + +it('blocks actual send in job when monthly quota is exceeded', function () { + Mail::fake(); + + DB::table('system_email_quota')->insert([ + 'period' => now()->format('Y-m'), + 'sent_count' => 10, + 'limit_count' => 10, + 'updated_at' => now(), + ]); + + $eventId = DB::table('email_send_events')->insertGetId([ + 'type' => 'verify_email', + 'email' => 'quota-block@example.com', + 'ip' => '127.0.0.1', + 'user_id' => null, + 'status' => 'queued', + 'reason' => null, + 'created_at' => now(), + ]); + + $job = new SendVerificationEmailJob( + emailEventId: (int) $eventId, + email: 'quota-block@example.com', + token: 'raw-token', + userId: null, + ip: '127.0.0.1' + ); + + $job->handle(app(RegistrationEmailQuotaService::class)); + + Mail::assertNothingSent(); + $this->assertDatabaseHas('email_send_events', [ + 'id' => $eventId, + 'status' => 'blocked', + 'reason' => 'quota', + ]); +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 0e06182b..e86c6abe 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -1,7 +1,7 @@ get('/register'); @@ -14,7 +14,7 @@ test('registration screen can be rendered', function () { }); test('new users can register', function () { - Mail::fake(); + Queue::fake(); $response = $this->post('/register', [ 'email' => 'test@example.com', @@ -33,5 +33,5 @@ test('new users can register', function () { 'user_id' => (int) \App\Models\User::query()->where('email', 'test@example.com')->value('id'), ]); - Mail::assertQueued(RegistrationVerificationMail::class); + Queue::assertPushed(SendVerificationEmailJob::class); }); diff --git a/tests/Feature/Auth/RegistrationTokenVerificationTest.php b/tests/Feature/Auth/RegistrationTokenVerificationTest.php index 6cf0293c..413650ea 100644 --- a/tests/Feature/Auth/RegistrationTokenVerificationTest.php +++ b/tests/Feature/Auth/RegistrationTokenVerificationTest.php @@ -1,11 +1,39 @@ post('/register', [ + 'email' => 'token-hash@example.com', + ])->assertRedirect('/register/notice'); + + $rawToken = null; + Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$rawToken) { + $rawToken = $job->token; + + return true; + }); + + $userId = (int) User::query()->where('email', 'token-hash@example.com')->value('id'); + $column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; + $storedToken = (string) DB::table('user_verification_tokens') + ->where('user_id', $userId) + ->value($column); + + expect($rawToken)->not->toBeNull(); + expect($storedToken)->toBe(hash('sha256', (string) $rawToken)); + expect($storedToken)->not->toBe((string) $rawToken); +}); + it('verifies token and redirects to password setup', function () { $user = User::factory()->create([ 'email_verified_at' => null, @@ -13,9 +41,10 @@ it('verifies token and redirects to password setup', function () { 'is_active' => false, ]); + $column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; DB::table('user_verification_tokens')->insert([ 'user_id' => $user->id, - 'token' => 'verify-token-1', + $column => hash('sha256', 'verify-token-1'), 'expires_at' => now()->addHour(), 'created_at' => now(), 'updated_at' => now(), @@ -33,7 +62,8 @@ it('verifies token and redirects to password setup', function () { ]); expect($user->fresh()->email_verified_at)->not->toBeNull(); - $this->assertDatabaseMissing('user_verification_tokens', ['token' => 'verify-token-1']); + $column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; + $this->assertDatabaseMissing('user_verification_tokens', [$column => hash('sha256', 'verify-token-1')]); }); it('rejects expired token', function () { @@ -43,9 +73,10 @@ it('rejects expired token', function () { 'is_active' => false, ]); + $column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; DB::table('user_verification_tokens')->insert([ 'user_id' => $user->id, - 'token' => 'expired-token-1', + $column => hash('sha256', 'expired-token-1'), 'expires_at' => now()->subMinute(), 'created_at' => now(), 'updated_at' => now(), @@ -72,3 +103,27 @@ it('rejects unknown token', function () { $response->assertSessionHasErrors('email'); $this->assertGuest(); }); + +it('rejects token reuse after successful verification', function () { + $user = User::factory()->create([ + 'email_verified_at' => null, + 'onboarding_step' => 'email', + 'is_active' => false, + ]); + + $column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token'; + DB::table('user_verification_tokens')->insert([ + 'user_id' => $user->id, + $column => hash('sha256', 'one-time-token'), + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->get('/verify/one-time-token')->assertRedirect('/setup/password'); + auth()->logout(); + + $secondTry = $this->from('/login')->get('/verify/one-time-token'); + $secondTry->assertRedirect('/login'); + $secondTry->assertSessionHasErrors('email'); +}); diff --git a/tests/Feature/Auth/RegistrationVerificationMailTest.php b/tests/Feature/Auth/RegistrationVerificationMailTest.php index 3e294db0..7d13360c 100644 --- a/tests/Feature/Auth/RegistrationVerificationMailTest.php +++ b/tests/Feature/Auth/RegistrationVerificationMailTest.php @@ -1,8 +1,10 @@ toContain('https://skinbase.example/support'); }); -it('registration endpoint still queues verification mail', function () { - \Illuminate\Support\Facades\Mail::fake(); +it('registration endpoint queues verification email job', function () { + Queue::fake(); $this->post('/register', [ 'email' => 'mail-test@example.com', ])->assertRedirect('/register/notice'); - \Illuminate\Support\Facades\Mail::assertQueued(RegistrationVerificationMail::class); + Queue::assertPushed(SendVerificationEmailJob::class); $this->assertDatabaseHas('users', [ 'email' => 'mail-test@example.com', 'onboarding_step' => 'email',