You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/app/Http/Controllers/Auth/LoginController.php

628 lines
18 KiB
PHTML

<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\AccountLog;
use App\Models\User;
9 months ago
use App\Services\BouncerService;
use App\Services\EmailVerificationService;
use App\Services\PendingLoginService;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Passport\Passport;
4 weeks ago
use Symfony\Component\HttpFoundation\Response;
class LoginController extends Controller
{
/*
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| Password is checked first, without creating a session. Users with 2FA
| enabled, or an unverified email when enforcement is on, are held in a
| pending state (see PendingLoginService) and only get a real session once
| every step passes. Because nothing consumes url.intended until the final
| sendLoginResponse, OAuth authorize URLs (and any other guarded deep link)
| survive the whole flow.
|
*/
use AuthenticatesUsers;
/**
* Where to redirect users after login.
*
* @var string
*/
protected $redirectTo = '/i/web';
protected $maxAttempts = 5;
9 months ago
protected $decayMinutes = 60;
public function __construct()
{
$this->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
{
9 months ago
if (config('pixelfed.bouncer.cloud_ips.ban_logins')) {
abort_if(
BouncerService::checkIp($request->ip()),
404
);
9 months ago
}
}
/**
* 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',
9 months ago
'password' => 'required|string|min:6',
];
$messages = [];
1 week ago
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';
6 years ago
}
$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.
*
4 weeks ago
* @throws ValidationException
*/
protected function sendFailedLoginResponse(
Request $request
): void {
9 months ago
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
);
9 months ago
} else {
$request->session()->put(
'login_attempts',
1
);
9 months ago
}
}
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();
}
}