resolveCspNonce($request); return view('auth.register', [ 'prefillEmail' => (string) $request->query('email', ''), 'turnstile' => [ 'enabled' => $this->turnstileVerifier->isEnabled(), 'siteKey' => $this->turnstileVerifier->siteKey(), 'scriptUrl' => $this->turnstileVerifier->scriptUrl(), 'cspNonce' => $cspNonce, ], ]); } 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 { $turnstileResponse = (string) ($request->input('turnstile_token') ?: $request->input('cf-turnstile-response', '')); $rules = [ 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'website' => ['nullable', 'max:0'], 'turnstile_token' => ['nullable', 'string'], 'cf-turnstile-response' => [$this->turnstileVerifier->isEnabled() ? 'required_without:turnstile_token' : 'nullable', 'string'], ]; $validator = Validator::make($request->all(), $rules); if ($validator->fails()) { $errors = $validator->errors()->toArray(); if (array_key_exists('cf-turnstile-response', $errors) && ! array_key_exists('turnstile_token', $errors)) { $errors['turnstile_token'] = $errors['cf-turnstile-response']; unset($errors['cf-turnstile-response']); } $this->authAuditLogger->log( eventType: 'register', request: $request, status: 'failed', reason: 'validation_failed', identifier: (string) $request->input('email'), metadata: ['fields' => array_keys($errors)], ); throw ValidationException::withMessages($errors); } $validated = $validator->validated(); $email = strtolower(trim((string) $validated['email'])); $ip = $request->ip(); $this->trackRegisterAttempt($ip); if ($this->turnstileVerifier->isEnabled() && ! $this->turnstileVerifier->verify($turnstileResponse, $ip)) { $this->authAuditLogger->log( eventType: 'register', request: $request, status: 'failed', reason: 'captcha_failed', identifier: $email, ); return back() ->withInput($request->except('website', 'turnstile_token', 'cf-turnstile-response')) ->withErrors(['turnstile_token' => 'Security verification failed. Please try again.']); } if ($this->disposableEmailService->isDisposableEmail($email)) { $this->logEmailEvent($email, $ip, null, 'blocked', 'disposable'); $this->authAuditLogger->log( eventType: 'register', request: $request, status: 'failed', reason: 'disposable_email', identifier: $email, ); return back() ->withInput($request->except('website')) ->withErrors(['email' => 'Please use a real email provider.']); } $user = User::query()->where('email', $email)->first(); if ($user && $user->hasCompletedOnboarding()) { $this->authAuditLogger->log( eventType: 'register', request: $request, status: 'failed', reason: 'email_exists', identifier: $email, user: $user, ); return back() ->withInput($request->except('website')) ->withErrors(['email' => 'An account with this email already exists.']); } if (! $user) { $user = new User(); $user->forceFill([ 'username' => null, 'name' => Str::before($email, '@'), 'email' => $email, 'password' => Hash::make(Str::random(64)), 'is_active' => true, 'email_verified_at' => null, 'onboarding_step' => 'verified', 'needs_password_reset' => true, 'username_changed_at' => now(), 'last_username_change_at' => now(), ]); $user->save(); } else { $user->forceFill([ 'email_verified_at' => $user->email_verified_at, 'is_active' => true, 'onboarding_step' => strtolower((string) ($user->onboarding_step ?? '')) === 'password' ? 'password' : 'verified', 'needs_password_reset' => strtolower((string) ($user->onboarding_step ?? '')) === 'password' ? (bool) $user->needs_password_reset : true, ])->save(); } Auth::login($user); $this->authAuditLogger->log( eventType: 'register', request: $request, status: 'success', reason: $user->wasRecentlyCreated ? 'user_created' : 'resume_onboarding', identifier: $email, user: $user, ); $needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password' || (bool) $user->needs_password_reset; return redirect(route($needsPasswordSetup ? 'setup.password.create' : 'setup.username.create', absolute: false)) ->with('status', $needsPasswordSetup ? 'Continue with password setup.' : 'Continue with username setup.'); } 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 shouldRequireCaptchaForIp(?string $ip): bool { if (! $this->captchaVerifier->isEnabled() && ! $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); } private function resolveCspNonce(Request $request): ?string { $candidates = [ $request->attributes->get('csp_nonce'), $request->attributes->get('cspNonce'), $request->headers->get('X-CSP-Nonce'), $request->server('HTTP_X_CSP_NONCE'), ]; foreach ($candidates as $candidate) { if (! is_string($candidate)) { continue; } $nonce = trim($candidate); if ($nonce !== '') { return $nonce; } } return null; } }