Support using recovery tokens during the login process to bypass 2fa; closes #479

This commit is contained in:
Dane Everitt 2020-07-02 23:01:02 -07:00
parent 795e045950
commit 7b75e7a648
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
7 changed files with 84 additions and 30 deletions

View File

@ -68,10 +68,11 @@ abstract class AbstractLoginController extends Controller
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \Illuminate\Contracts\Auth\Authenticatable|null $user * @param \Illuminate\Contracts\Auth\Authenticatable|null $user
* @param string|null $message
* *
* @throws \Pterodactyl\Exceptions\DisplayException * @throws \Pterodactyl\Exceptions\DisplayException
*/ */
protected function sendFailedLoginResponse(Request $request, Authenticatable $user = null) protected function sendFailedLoginResponse(Request $request, Authenticatable $user = null, string $message = null)
{ {
$this->incrementLoginAttempts($request); $this->incrementLoginAttempts($request);
$this->fireFailedLoginEvent($user, [ $this->fireFailedLoginEvent($user, [
@ -79,7 +80,9 @@ abstract class AbstractLoginController extends Controller
]); ]);
if ($request->route()->named('auth.login-checkpoint')) { if ($request->route()->named('auth.login-checkpoint')) {
throw new DisplayException(trans('auth.two_factor.checkpoint_failed')); throw new DisplayException(
$message ?? trans('auth.two_factor.checkpoint_failed')
);
} }
throw new DisplayException(trans('auth.failed')); throw new DisplayException(trans('auth.failed'));
@ -116,7 +119,7 @@ abstract class AbstractLoginController extends Controller
*/ */
protected function getField(string $input = null): string protected function getField(string $input = null): string
{ {
return str_contains($input, '@') ? 'email' : 'username'; return ($input && str_contains($input, '@')) ? 'email' : 'username';
} }
/** /**

View File

@ -11,6 +11,7 @@ use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Pterodactyl\Contracts\Repository\UserRepositoryInterface; use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository;
class LoginCheckpointController extends AbstractLoginController class LoginCheckpointController extends AbstractLoginController
{ {
@ -34,6 +35,11 @@ class LoginCheckpointController extends AbstractLoginController
*/ */
private $encrypter; private $encrypter;
/**
* @var \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository
*/
private $recoveryTokenRepository;
/** /**
* LoginCheckpointController constructor. * LoginCheckpointController constructor.
* *
@ -42,6 +48,7 @@ class LoginCheckpointController extends AbstractLoginController
* @param \PragmaRX\Google2FA\Google2FA $google2FA * @param \PragmaRX\Google2FA\Google2FA $google2FA
* @param \Illuminate\Contracts\Config\Repository $config * @param \Illuminate\Contracts\Config\Repository $config
* @param \Illuminate\Contracts\Cache\Repository $cache * @param \Illuminate\Contracts\Cache\Repository $cache
* @param \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository $recoveryTokenRepository
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository * @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
*/ */
public function __construct( public function __construct(
@ -50,6 +57,7 @@ class LoginCheckpointController extends AbstractLoginController
Google2FA $google2FA, Google2FA $google2FA,
Repository $config, Repository $config,
CacheRepository $cache, CacheRepository $cache,
RecoveryTokenRepository $recoveryTokenRepository,
UserRepositoryInterface $repository UserRepositoryInterface $repository
) { ) {
parent::__construct($auth, $config); parent::__construct($auth, $config);
@ -58,6 +66,7 @@ class LoginCheckpointController extends AbstractLoginController
$this->cache = $cache; $this->cache = $cache;
$this->repository = $repository; $this->repository = $repository;
$this->encrypter = $encrypter; $this->encrypter = $encrypter;
$this->recoveryTokenRepository = $recoveryTokenRepository;
} }
/** /**
@ -76,21 +85,35 @@ class LoginCheckpointController extends AbstractLoginController
public function __invoke(LoginCheckpointRequest $request): JsonResponse public function __invoke(LoginCheckpointRequest $request): JsonResponse
{ {
$token = $request->input('confirmation_token'); $token = $request->input('confirmation_token');
$recoveryToken = $request->input('recovery_token');
try { try {
/** @var \Pterodactyl\Models\User $user */
$user = $this->repository->find($this->cache->get($token, 0)); $user = $this->repository->find($this->cache->get($token, 0));
} catch (RecordNotFoundException $exception) { } catch (RecordNotFoundException $exception) {
return $this->sendFailedLoginResponse($request); return $this->sendFailedLoginResponse($request, null, 'The authentication token provided has expired, please refresh the page and try again.');
} }
$decrypted = $this->encrypter->decrypt($user->totp_secret); // If we got a recovery token try to find one that matches for the user and then continue
// through the process (and delete the token).
if (! is_null($recoveryToken)) {
foreach ($user->recoveryTokens as $token) {
if (password_verify($recoveryToken, $token->token)) {
$this->recoveryTokenRepository->delete($token->id);
if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) { return $this->sendLoginResponse($user, $request);
$this->cache->delete($token); }
}
} else {
$decrypted = $this->encrypter->decrypt($user->totp_secret);
return $this->sendLoginResponse($user, $request); if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {
$this->cache->delete($token);
return $this->sendLoginResponse($user, $request);
}
} }
return $this->sendFailedLoginResponse($request, $user); return $this->sendFailedLoginResponse($request, $user, ! empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
} }
} }

View File

@ -103,7 +103,7 @@ class LoginController extends AbstractLoginController
$token = Str::random(64); $token = Str::random(64);
$this->cache->put($token, $user->id, Chronos::now()->addMinutes(5)); $this->cache->put($token, $user->id, Chronos::now()->addMinutes(5));
return JsonResponse::create([ return new JsonResponse([
'data' => [ 'data' => [
'complete' => false, 'complete' => false,
'confirmation_token' => $token, 'confirmation_token' => $token,

View File

@ -2,6 +2,7 @@
namespace Pterodactyl\Http\Requests\Auth; namespace Pterodactyl\Http\Requests\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class LoginCheckpointRequest extends FormRequest class LoginCheckpointRequest extends FormRequest
@ -25,7 +26,20 @@ class LoginCheckpointRequest extends FormRequest
{ {
return [ return [
'confirmation_token' => 'required|string', 'confirmation_token' => 'required|string',
'authentication_code' => 'required|numeric', 'authentication_code' => [
'nullable',
'numeric',
Rule::requiredIf(function () {
return empty($this->input('recovery_token'));
}),
],
'recovery_token' => [
'nullable',
'string',
Rule::requiredIf(function () {
return empty($this->input('authentication_code'));
}),
],
]; ];
} }
} }

View File

@ -39,7 +39,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys * @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
* @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryCodes * @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens
*/ */
class User extends Model implements class User extends Model implements
AuthenticatableContract, AuthenticatableContract,
@ -256,7 +256,7 @@ class User extends Model implements
/** /**
* @return \Illuminate\Database\Eloquent\Relations\HasMany * @return \Illuminate\Database\Eloquent\Relations\HasMany
*/ */
public function recoveryCodes() public function recoveryTokens()
{ {
return $this->hasMany(RecoveryToken::class); return $this->hasMany(RecoveryToken::class);
} }

View File

@ -1,13 +1,14 @@
import http from '@/api/http'; import http from '@/api/http';
import { LoginResponse } from '@/api/auth/login'; import { LoginResponse } from '@/api/auth/login';
export default (token: string, code: string): Promise<LoginResponse> => { export default (token: string, code: string, recoveryToken?: string): Promise<LoginResponse> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.post('/auth/login/checkpoint', { http.post('/auth/login/checkpoint', {
// eslint-disable-next-line @typescript-eslint/camelcase /* eslint-disable @typescript-eslint/camelcase */
confirmation_token: token, confirmation_token: token,
// eslint-disable-next-line @typescript-eslint/camelcase
authentication_code: code, authentication_code: code,
recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined,
/* eslint-enable @typescript-eslint/camelcase */
}) })
.then(response => resolve({ .then(response => resolve({
complete: response.data.data.complete, complete: response.data.data.complete,

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom'; import { Link, RouteComponentProps } from 'react-router-dom';
import loginCheckpoint from '@/api/auth/loginCheckpoint'; import loginCheckpoint from '@/api/auth/loginCheckpoint';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
@ -14,6 +14,7 @@ import Field from '@/components/elements/Field';
interface Values { interface Values {
code: string; code: string;
recoveryCode: '',
} }
type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }> type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }>
@ -24,7 +25,8 @@ type Props = OwnProps & {
} }
const LoginCheckpointContainer = () => { const LoginCheckpointContainer = () => {
const { isSubmitting } = useFormikContext<Values>(); const { isSubmitting, setFieldValue } = useFormikContext<Values>();
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
return ( return (
<LoginFormContainer <LoginFormContainer
@ -34,10 +36,14 @@ const LoginCheckpointContainer = () => {
<div className={'mt-6'}> <div className={'mt-6'}>
<Field <Field
light={true} light={true}
name={'code'} name={isMissingDevice ? 'recoveryCode' : 'code'}
title={'Authentication Code'} title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
description={'Enter the two-factor token generated by your device.'} description={
type={'number'} isMissingDevice
? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.'
: 'Enter the two-factor token generated by your device.'
}
type={isMissingDevice ? 'text' : 'number'}
autoFocus={true} autoFocus={true}
/> />
</div> </div>
@ -54,6 +60,18 @@ const LoginCheckpointContainer = () => {
} }
</button> </button>
</div> </div>
<div className={'mt-6 text-center'}>
<span
onClick={() => {
setFieldValue('code', '');
setFieldValue('recoveryCode', '');
setIsMissingDevice(s => !s);
}}
className={'cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
>
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
</span>
</div>
<div className={'mt-6 text-center'}> <div className={'mt-6 text-center'}>
<Link <Link
to={'/auth/login'} to={'/auth/login'}
@ -67,10 +85,9 @@ const LoginCheckpointContainer = () => {
}; };
const EnhancedForm = withFormik<Props, Values>({ const EnhancedForm = withFormik<Props, Values>({
handleSubmit: ({ code }, { setSubmitting, props: { addError, clearFlashes, location } }) => { handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { addError, clearFlashes, location } }) => {
clearFlashes(); clearFlashes();
console.log(location.state.token, code); loginCheckpoint(location.state?.token || '', code, recoveryCode)
loginCheckpoint(location.state?.token || '', code)
.then(response => { .then(response => {
if (response.complete) { if (response.complete) {
// @ts-ignore // @ts-ignore
@ -89,11 +106,7 @@ const EnhancedForm = withFormik<Props, Values>({
mapPropsToValues: () => ({ mapPropsToValues: () => ({
code: '', code: '',
}), recoveryCode: '',
validationSchema: object().shape({
code: string().required('An authentication code must be provided.')
.length(6, 'Authentication code must be 6 digits in length.'),
}), }),
})(LoginCheckpointContainer); })(LoginCheckpointContainer);