Save workspace changes
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login', [
|
||||
'requiresCaptcha' => session('bot_captcha_required', false),
|
||||
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Throwable;
|
||||
|
||||
class OAuthController extends Controller
|
||||
{
|
||||
/** Providers enabled for OAuth login. */
|
||||
private const ALLOWED_PROVIDERS = ['google', 'discord'];
|
||||
|
||||
/**
|
||||
* Redirect the user to the provider's OAuth page.
|
||||
*/
|
||||
public function redirectToProvider(string $provider): RedirectResponse
|
||||
{
|
||||
$this->abortIfInvalidProvider($provider);
|
||||
|
||||
return Socialite::driver($provider)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the provider callback and authenticate the user.
|
||||
*/
|
||||
public function handleProviderCallback(string $provider): RedirectResponse
|
||||
{
|
||||
$this->abortIfInvalidProvider($provider);
|
||||
|
||||
try {
|
||||
/** @var SocialiteUser $socialUser */
|
||||
$socialUser = Socialite::driver($provider)->user();
|
||||
} catch (Throwable) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Authentication failed. Please try again.']);
|
||||
}
|
||||
|
||||
$providerId = (string) $socialUser->getId();
|
||||
$providerEmail = $this->resolveEmail($socialUser);
|
||||
$verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
|
||||
|
||||
// ── 1. Provider account already linked → login ───────────────────────
|
||||
$existing = SocialAccount::query()
|
||||
->where('provider', $provider)
|
||||
->where('provider_id', $providerId)
|
||||
->with('user')
|
||||
->first();
|
||||
|
||||
if ($existing !== null && $existing->user !== null) {
|
||||
return $this->loginAndRedirect($existing->user);
|
||||
}
|
||||
|
||||
// ── 2. Email match → link to existing account ────────────────────────
|
||||
// Covers both verified and unverified users: if the OAuth provider
|
||||
// has confirmed this email we can safely link it and mark it verified,
|
||||
// preventing a duplicate-email insert when the user had started
|
||||
// registration via email but never finished verification.
|
||||
if ($providerEmail !== null && $verified) {
|
||||
$userByEmail = User::query()
|
||||
->where('email', strtolower($providerEmail))
|
||||
->first();
|
||||
|
||||
if ($userByEmail !== null) {
|
||||
// If their email was not yet verified, promote it now — the
|
||||
// OAuth provider has already verified it on our behalf.
|
||||
if ($userByEmail->email_verified_at === null) {
|
||||
$userByEmail->forceFill([
|
||||
'email_verified_at' => now(),
|
||||
'is_active' => true,
|
||||
// Keep their onboarding step unless already complete
|
||||
'onboarding_step' => $userByEmail->onboarding_step === 'email'
|
||||
? 'username'
|
||||
: ($userByEmail->onboarding_step ?? 'username'),
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||
|
||||
return $this->loginAndRedirect($userByEmail);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Provider email not verified → reject auto-link ────────────────
|
||||
if ($providerEmail !== null && ! $verified) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
|
||||
}
|
||||
|
||||
// ── 4. No email at all → cannot proceed ──────────────────────────────
|
||||
if ($providerEmail === null) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
|
||||
}
|
||||
|
||||
// ── 5. New user creation ──────────────────────────────────────────────
|
||||
try {
|
||||
$user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
// Race condition: another request inserted the same email between
|
||||
// the lookup above and this insert. Fetch and link instead.
|
||||
$user = User::query()->where('email', strtolower($providerEmail))->first();
|
||||
|
||||
if ($user === null) {
|
||||
return redirect()->route('login')
|
||||
->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
|
||||
}
|
||||
|
||||
$this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
|
||||
}
|
||||
|
||||
return $this->loginAndRedirect($user);
|
||||
}
|
||||
|
||||
// ─── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private function abortIfInvalidProvider(string $provider): void
|
||||
{
|
||||
abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create social_accounts row linked to a user.
|
||||
*/
|
||||
private function createSocialAccount(
|
||||
User $user,
|
||||
string $provider,
|
||||
string $providerId,
|
||||
?string $providerEmail,
|
||||
?string $avatar
|
||||
): void {
|
||||
SocialAccount::query()->updateOrCreate(
|
||||
['provider' => $provider, 'provider_id' => $providerId],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
|
||||
'avatar' => $avatar,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand-new user from OAuth data.
|
||||
*/
|
||||
private function createOAuthUser(
|
||||
SocialiteUser $socialUser,
|
||||
string $provider,
|
||||
string $providerId,
|
||||
string $providerEmail
|
||||
): User {
|
||||
$user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
|
||||
$name = $this->resolveDisplayName($socialUser, $providerEmail);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => null,
|
||||
'name' => $name,
|
||||
'email' => strtolower($providerEmail),
|
||||
'email_verified_at' => now(),
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'is_active' => true,
|
||||
'onboarding_step' => 'username',
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
]);
|
||||
|
||||
$this->createSocialAccount(
|
||||
$user,
|
||||
$provider,
|
||||
$providerId,
|
||||
$providerEmail,
|
||||
$socialUser->getAvatar()
|
||||
);
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login the user and redirect appropriately.
|
||||
*/
|
||||
private function loginAndRedirect(User $user): RedirectResponse
|
||||
{
|
||||
Auth::login($user, remember: true);
|
||||
|
||||
request()->session()->regenerate();
|
||||
|
||||
$step = strtolower((string) ($user->onboarding_step ?? ''));
|
||||
|
||||
if (in_array($step, ['username', 'password'], true)) {
|
||||
return redirect()->route('setup.username.create');
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a usable display name from the social user.
|
||||
*/
|
||||
private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
|
||||
{
|
||||
$name = trim((string) ($socialUser->getName() ?? ''));
|
||||
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return Str::before($email, '@');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort email resolution. Apple can return null email on repeat logins.
|
||||
*/
|
||||
private function resolveEmail(SocialiteUser $socialUser): ?string
|
||||
{
|
||||
$email = $socialUser->getEmail();
|
||||
|
||||
if ($email === null || $email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower(trim($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the provider has verified the user's email.
|
||||
*
|
||||
* - Google: returns email_verified flag in raw data
|
||||
* - Discord: returns verified flag in raw data
|
||||
* - Apple: only issues tokens for verified Apple IDs
|
||||
*/
|
||||
private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
|
||||
{
|
||||
$raw = (array) ($socialUser->getRaw() ?? []);
|
||||
|
||||
return match ($provider) {
|
||||
'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
'discord' => (bool) ($raw['verified'] ?? false),
|
||||
'apple' => true, // Apple only issues tokens for verified Apple IDs
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailSendEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\DisposableEmailService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\Security\TurnstileVerifier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CaptchaVerifier $captchaVerifier,
|
||||
private readonly TurnstileVerifier $turnstileVerifier,
|
||||
private readonly DisposableEmailService $disposableEmailService,
|
||||
private readonly RegistrationVerificationTokenService $verificationTokenService,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.register', [
|
||||
'prefillEmail' => (string) $request->query('email', ''),
|
||||
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
|
||||
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$rules = [
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
||||
'website' => ['nullable', 'max:0'],
|
||||
];
|
||||
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
$email = strtolower(trim((string) $validated['email']));
|
||||
$ip = $request->ip();
|
||||
|
||||
$this->trackRegisterAttempt($ip);
|
||||
|
||||
if ($this->shouldRequireCaptcha($ip)) {
|
||||
$verified = $this->captchaVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$ip
|
||||
);
|
||||
|
||||
if ($this->turnstileVerifier->isEnabled()) {
|
||||
$verified = $this->turnstileVerifier->verify(
|
||||
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||
$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 shouldRequireCaptcha(?string $ip): bool
|
||||
{
|
||||
if (! $this->captchaVerifier->isEnabled()) {
|
||||
if (! $this->turnstileVerifier->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! (bool) config('registration.enable_turnstile', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
return $this->shouldRequireCaptchaForIp($ip);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
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 = $this->tokenService->findValidRecord($token);
|
||||
|
||||
if (! $record) {
|
||||
return redirect(route('login', absolute: false))
|
||||
->withErrors(['email' => 'Verification link is invalid.']);
|
||||
}
|
||||
|
||||
$user = User::query()->find((int) $record->user_id);
|
||||
if (! $user) {
|
||||
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
|
||||
|
||||
return redirect(route('login', absolute: false))
|
||||
->withErrors(['email' => 'Verification link is invalid.']);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'email_verified_at' => $user->email_verified_at ?? now(),
|
||||
'onboarding_step' => 'verified',
|
||||
'is_active' => true,
|
||||
])->save();
|
||||
|
||||
DB::table('user_verification_tokens')
|
||||
->where('id', $record->id)
|
||||
->delete();
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect('/setup/password')->with('status', 'Email verified. Continue with password setup.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupPasswordController extends Controller
|
||||
{
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.setup-password', [
|
||||
'email' => (string) ($request->user()?->email ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'password' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:10',
|
||||
'regex:/\d/',
|
||||
'regex:/[^\w\s]/',
|
||||
'confirmed',
|
||||
],
|
||||
], [
|
||||
'password.min' => 'Your password must be at least 10 characters.',
|
||||
'password.regex' => 'Your password must include at least one number and one symbol.',
|
||||
'password.confirmed' => 'Password confirmation does not match.',
|
||||
]);
|
||||
|
||||
$request->user()->forceFill([
|
||||
'password' => Hash::make((string) $validated['password']),
|
||||
'onboarding_step' => 'password',
|
||||
'needs_password_reset' => false,
|
||||
])->save();
|
||||
|
||||
return redirect('/setup/username')->with('status', 'Password saved. Choose your public username to finish setup.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UsernameRequest;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SetupUsernameController extends Controller
|
||||
{
|
||||
public function __construct(private readonly UsernameApprovalService $usernameApprovalService)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.setup-username', [
|
||||
'username' => (string) ($request->user()?->username ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize((string) $request->input('username', ''));
|
||||
$request->merge(['username' => $normalized]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'username' => UsernameRequest::rulesFor((int) $request->user()->id),
|
||||
], [
|
||||
'username.required' => 'Please choose a username to continue.',
|
||||
'username.unique' => 'This username is already taken.',
|
||||
'username.regex' => 'Use only letters, numbers, and underscores.',
|
||||
'username.min' => 'Username must be at least 3 characters.',
|
||||
'username.max' => 'Username must be at most 20 characters.',
|
||||
]);
|
||||
|
||||
$candidate = (string) $validated['username'];
|
||||
$user = $request->user();
|
||||
|
||||
$similar = UsernamePolicy::similarReserved($candidate);
|
||||
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($candidate, (int) $user->id)) {
|
||||
$this->usernameApprovalService->submit($user, $candidate, 'onboarding_username', [
|
||||
'current_username' => (string) ($user->username ?? ''),
|
||||
]);
|
||||
|
||||
return back()
|
||||
->withInput()
|
||||
->with('status', 'Your request has been submitted for manual username review.')
|
||||
->withErrors([
|
||||
'username' => 'This username is too similar to a reserved name and requires manual approval.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $candidate): void {
|
||||
$oldUsername = (string) ($user->username ?? '');
|
||||
|
||||
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_history')) {
|
||||
DB::table('username_history')->insert([
|
||||
'user_id' => (int) $user->id,
|
||||
'old_username' => strtolower($oldUsername),
|
||||
'changed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_redirects')) {
|
||||
DB::table('username_redirects')->updateOrInsert(
|
||||
['old_username' => strtolower($oldUsername)],
|
||||
[
|
||||
'new_username' => strtolower($candidate),
|
||||
'user_id' => (int) $user->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'username' => strtolower($candidate),
|
||||
'onboarding_step' => 'complete',
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
])->save();
|
||||
});
|
||||
|
||||
return redirect('/@' . strtolower($candidate));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user