(string) $request->query('email', ''), 'requiresTurnstile' => $this->shouldRequireTurnstile($request->ip()), 'turnstileSiteKey' => (string) config('services.turnstile.site_key', ''), ]); } public function notice(Request $request): View { $email = (string) session('registration_email', ''); $remaining = $email === '' ? 0 : $this->resendRemainingSeconds($email); return view('auth.register-notice', [ 'email' => $email, 'resendSeconds' => $remaining, ]); } /** * Handle an incoming registration request. * * @throws \Illuminate\Validation\ValidationException */ public function store(Request $request): RedirectResponse { $validated = $request->validate([ 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'website' => ['nullable', 'max:0'], 'cf-turnstile-response' => ['nullable', 'string'], ]); $email = strtolower(trim((string) $validated['email'])); $ip = $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' => 'Captcha verification failed. Please try again.']); } } if ($this->disposableEmailService->isDisposableEmail($email)) { $this->logEmailEvent($email, $ip, null, 'blocked', 'disposable'); return back() ->withInput($request->except('website')) ->withErrors(['email' => 'Please use a real email provider.']); } $user = User::query()->where('email', $email)->first(); if ($user && $user->email_verified_at !== null) { $this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'already-verified'); 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(), 'last_username_change_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 { $validated = $request->validate([ 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], ]); $email = strtolower(trim((string) $validated['email'])); $ip = $request->ip(); $user = User::query() ->where('email', $email) ->whereNull('email_verified_at') ->where('onboarding_step', 'email') ->first(); if (! $user) { $this->logEmailEvent($email, $ip, null, 'blocked', 'missing'); return $this->redirectToRegisterNotice($email); } 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); } private function redirectToRegisterNotice(string $email): RedirectResponse { return redirect(route('register.notice', absolute: false)) ->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(60, ((int) config('registration.email_cooldown_minutes', 30)) * 60); } private function resendRemainingSeconds(string $email): int { $user = User::query() ->where('email', strtolower(trim($email))) ->whereNull('email_verified_at') ->first(); if (! $user || $user->last_verification_sent_at === null) { return 0; } $remaining = $user->last_verification_sent_at ->copy() ->addSeconds($this->resendCooldownSeconds()) ->diffInSeconds(now(), false); return $remaining >= 0 ? 0 : abs((int) $remaining); } }