middleware('guest')->except(['logout', 'confirmEmail']); } public function showLoginForm(Request $request): View|RedirectResponse { $this->bounce($request); $step = $request->query('step'); $pendingStep = PendingLoginService::step($request); if (! in_array($step, [PendingLoginService::STEP_2FA, PendingLoginService::STEP_VERIFY], true)) { return view('auth.login', [ 'step' => 'credentials', 'pendingUser' => null, 'returnTo' => $this->intendedClientName($request), ]); } if (! $pendingStep) { return redirect()->route('login'); } if ($pendingStep !== $step) { return redirect()->route('login', ['step' => $pendingStep]); } $user = PendingLoginService::user($request); if (! $user) { return redirect()->route('login'); } return view('auth.login', [ 'step' => $step, 'pendingUser' => $user, 'returnTo' => $this->intendedClientName($request), 'mode' => $request->query('mode') === 'backup' ? 'backup' : 'totp', 'changeEmail' => $request->boolean('change'), ]); } /** * Handle a login request. Overrides the trait so credentials are validated * without logging the user in. */ public function login(Request $request) { $this->validateLogin($request); if ($this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); return $this->sendLockoutResponse($request); } $credentials = $this->credentials($request); if (! $this->guard()->validate($credentials)) { $this->incrementLoginAttempts($request); $this->sendFailedLoginResponse($request); } $user = $this->guard()->getLastAttempted(); if (! $user || $this->loginBlocked($user)) { $this->incrementLoginAttempts($request); $this->sendFailedLoginResponse($request); } $provider = $this->guard()->getProvider(); if (method_exists($provider, 'rehashPasswordIfRequired')) { $provider->rehashPasswordIfRequired($user, $credentials); } $remember = $request->boolean('remember'); if ((bool) $user->{'2fa_enabled'}) { PendingLoginService::start( $request, $user, $remember, PendingLoginService::STEP_2FA ); return redirect()->route('login', [ 'step' => PendingLoginService::STEP_2FA, ]); } return $this->completeLogin($request, $user, $remember); } public function verifyTwoFactor(Request $request): Response { $this->bounce($request); $request->validate([ 'code' => 'required|string|max:32', ]); $user = $this->pendingUser($request, PendingLoginService::STEP_2FA); if (! $user) { return $this->redirectToPendingStep($request); } if (PendingLoginService::verifyCode($user, (string) $request->input('code'))) { return $this->completeLogin( $request, $user, PendingLoginService::remember($request) ); } $this->log( $request, $user, 'auth.2fa.failed', '2FA verification failed' ); if (PendingLoginService::recordFailure($request)) { return redirect()->route('login')->withErrors([ 'login' => __('Too many invalid codes. Sign in again to retry.'), ]); } $remaining = PendingLoginService::attemptsRemaining($request); $params = [ 'step' => PendingLoginService::STEP_2FA, ]; if ($request->input('mode') === 'backup') { $params['mode'] = 'backup'; } return redirect()->route('login', $params)->withErrors([ 'code' => __('Invalid code.').' '.trans_choice( '{1} :count attempt left|[2,*] :count attempts left', $remaining, ['count' => $remaining] ), ]); } public function cancelPendingLogin(Request $request): RedirectResponse { PendingLoginService::clear($request); return redirect()->route('login'); } public function continueAfterVerification(Request $request): Response { $user = $this->pendingUser($request, PendingLoginService::STEP_VERIFY); if (! $user) { return $this->redirectToPendingStep($request); } if ($this->requiresEmailVerification($user)) { return redirect() ->route('login', [ 'step' => PendingLoginService::STEP_VERIFY, ]) ->withErrors([ 'verify' => __('Your email is not verified yet. Open the link we sent, or resend it below.'), ]); } return $this->completeLogin( $request, $user, PendingLoginService::remember($request) ); } public function resendVerification(Request $request): Response { $user = $this->pendingUser($request, PendingLoginService::STEP_VERIFY); if (! $user) { return $this->redirectToPendingStep($request); } if (! $this->requiresEmailVerification($user)) { return $this->completeLogin( $request, $user, PendingLoginService::remember($request) ); } if (! EmailVerificationService::send($user)) { return redirect() ->route('login', [ 'step' => PendingLoginService::STEP_VERIFY, ]) ->withErrors([ 'verify' => __('A verification email was sent a moment ago. Check your inbox, then try again in a minute.'), ]); } return redirect() ->route('login', [ 'step' => PendingLoginService::STEP_VERIFY, ]) ->with( 'status', __('Verification email sent to').' '.$user->email ); } public function updatePendingEmail(Request $request): Response { $user = $this->pendingUser($request, PendingLoginService::STEP_VERIFY); if (! $user) { return $this->redirectToPendingStep($request); } if (! $this->requiresEmailVerification($user)) { return $this->completeLogin( $request, $user, PendingLoginService::remember($request) ); } $request->validate([ 'email' => 'required|string|email|max:255|unique:users,email', ]); $user->email = $request->input('email'); $user->email_verified_at = null; $user->save(); PendingLoginService::start( $request, $user, PendingLoginService::remember($request), PendingLoginService::STEP_VERIFY ); $this->log( $request, $user, 'account.email.changed', 'Email address changed before verification' ); EmailVerificationService::send($user); return redirect() ->route('login', [ 'step' => PendingLoginService::STEP_VERIFY, ]) ->with( 'status', __('Verification email sent to').' '.$user->email ); } /** * Token-only confirmation, safe for guests. Completes the pending login * when the confirming browser is the one waiting on this address. */ public function confirmEmail( Request $request, string $userToken, string $randomToken ): Response { $user = EmailVerificationService::confirm( $userToken, $randomToken ); if (! $user) { if ($request->user() !== null) { return redirect($this->redirectPath()); } return redirect() ->route('login') ->withErrors([ 'login' => __('That verification link is invalid or has expired. Sign in to request a new one.'), ]); } $this->log( $request, $user, 'account.email.verified', 'Email address verified' ); if ($request->user() !== null) { return redirect($this->redirectPath()); } $pending = PendingLoginService::user($request); if ($pending && (int) $pending->id === (int) $user->id) { if (PendingLoginService::step($request) === PendingLoginService::STEP_VERIFY) { return $this->completeLogin( $request, $user, PendingLoginService::remember($request) ); } return redirect() ->route('login', [ 'step' => PendingLoginService::step($request), ]) ->with('status', __('Email verified.')); } return redirect() ->route('login') ->with('status', __('Email verified. Sign in to continue.')); } /** * Final gate. Either parks the user on the verify step or creates the * session and hands off to sendLoginResponse (which consumes url.intended). */ protected function completeLogin( Request $request, User $user, bool $remember ): Response { /* * Defense-in-depth: completeLogin() can be reached from multiple * authentication flows, including 2FA and email verification. * * Never allow an account that has become blocked during a pending * login to receive an authenticated session. */ if ($this->loginBlocked($user)) { PendingLoginService::clear($request); throw ValidationException::withMessages([ $this->username() => [trans('auth.failed')], ]); } if ($this->requiresEmailVerification($user)) { PendingLoginService::start( $request, $user, $remember, PendingLoginService::STEP_VERIFY ); EmailVerificationService::send( $user, EmailVerificationService::AUTO_SEND_COOLDOWN_SECONDS ); return redirect()->route('login', [ 'step' => PendingLoginService::STEP_VERIFY, ]); } $this->guard()->login($user, $remember); $response = $this->sendLoginResponse($request); PendingLoginService::clear($request); return $response; } /** * Determine whether the account is prohibited from authenticating. */ protected function loginBlocked(User $user): bool { return in_array( $user->status, [ 'delete', 'deleted', 'suspend', 'suspended', ], true ); } protected function requiresEmailVerification(User $user): bool { return (bool) config('pixelfed.enforce_email_verification') && is_null($user->email_verified_at); } protected function pendingUser(Request $request, string $step): ?User { if (PendingLoginService::step($request) !== $step) { return null; } return PendingLoginService::user($request); } protected function redirectToPendingStep( Request $request ): RedirectResponse { if ($step = PendingLoginService::step($request)) { return redirect()->route('login', [ 'step' => $step, ]); } return redirect() ->route('login') ->withErrors([ 'login' => __('Your sign-in session expired. Please sign in again.'), ]); } /** * Name of the OAuth client the user will be returned to, when the login * was triggered by /oauth/authorize. */ protected function intendedClientName(Request $request): ?string { $intended = $request->session()->get('url.intended'); if ( ! is_string($intended) || parse_url($intended, PHP_URL_PATH) !== '/oauth/authorize' ) { return null; } parse_str( (string) parse_url($intended, PHP_URL_QUERY), $query ); if ( empty($query['client_id']) || ! is_string($query['client_id']) ) { return null; } try { $name = Passport::client() ->newQuery() ->whereKey($query['client_id']) ->value('name'); } catch (\Throwable) { return null; } return is_string($name) && $name !== '' ? $name : null; } protected function bounce(Request $request): void { if (config('pixelfed.bouncer.cloud_ips.ban_logins')) { abort_if( BouncerService::checkIp($request->ip()), 404 ); } } /** * The 2FA and verify routes carry no email input, so key the throttle on * the pending login's email instead. */ protected function throttleKey(Request $request): string { $email = $request->input($this->username()) ?: PendingLoginService::email($request); return Str::transliterate( Str::lower((string) $email).'|'.$request->ip() ); } /** * Validate the user login request. * * @param Request $request */ public function validateLogin($request): void { $this->bounce($request); $rules = [ $this->username() => 'required|email', 'password' => 'required|string|min:6', ]; $messages = []; if (app('captcha.manager')->activeOnLogin()) { $field = app('captcha.manager')->active()->responseField(); $rules[$field] = 'required|filled|captcha_verify'; $messages[$field.'.required'] = 'The captcha must be filled'; } $request->validate($rules, $messages); } /** * The user has been authenticated. * * @param Request $request * @param mixed $user */ protected function authenticated($request, $user): void { $this->log( $request, $user, 'auth.login', 'Account Login' ); } /** * Get the failed login response instance. * * @throws ValidationException */ protected function sendFailedLoginResponse( Request $request ): void { if (config('captcha.triggers.login.enabled')) { if ($request->session()->has('login_attempts')) { $ct = $request->session()->get('login_attempts'); $request->session()->put( 'login_attempts', $ct + 1 ); } else { $request->session()->put( 'login_attempts', 1 ); } } throw ValidationException::withMessages([ $this->username() => [trans('auth.failed')], ]); } protected function log( Request $request, User $user, string $action, string $message ): void { $log = new AccountLog; $log->user_id = $user->id; $log->item_id = $user->id; $log->item_type = User::class; $log->action = $action; $log->message = $message; $log->link = null; $log->ip_address = $request->ip(); $log->user_agent = $request->userAgent(); $log->save(); } }