mirror of https://github.com/pixelfed/pixelfed
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.
612 lines
17 KiB
PHP
612 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AccountLog;
|
|
use App\Models\User;
|
|
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;
|
|
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;
|
|
|
|
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 instanceof \App\Models\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 instanceof \App\Models\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 instanceof \App\Models\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 instanceof \App\Models\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 instanceof \App\Models\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 instanceof \App\Models\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')->activeOn('login')) {
|
|
$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 {
|
|
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();
|
|
}
|
|
}
|