From 7b75e7a648455ceebec3459d3144fb8cd8bf8ed7 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 2 Jul 2020 23:01:02 -0700 Subject: [PATCH] Support using recovery tokens during the login process to bypass 2fa; closes #479 --- .../Auth/AbstractLoginController.php | 9 ++-- .../Auth/LoginCheckpointController.php | 35 +++++++++++++--- app/Http/Controllers/Auth/LoginController.php | 2 +- .../Requests/Auth/LoginCheckpointRequest.php | 16 +++++++- app/Models/User.php | 4 +- resources/scripts/api/auth/loginCheckpoint.ts | 7 ++-- .../auth/LoginCheckpointContainer.tsx | 41 ++++++++++++------- 7 files changed, 84 insertions(+), 30 deletions(-) diff --git a/app/Http/Controllers/Auth/AbstractLoginController.php b/app/Http/Controllers/Auth/AbstractLoginController.php index 0810d7e93..b24a1a62a 100644 --- a/app/Http/Controllers/Auth/AbstractLoginController.php +++ b/app/Http/Controllers/Auth/AbstractLoginController.php @@ -68,10 +68,11 @@ abstract class AbstractLoginController extends Controller * * @param \Illuminate\Http\Request $request * @param \Illuminate\Contracts\Auth\Authenticatable|null $user + * @param string|null $message * * @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->fireFailedLoginEvent($user, [ @@ -79,7 +80,9 @@ abstract class AbstractLoginController extends Controller ]); 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')); @@ -116,7 +119,7 @@ abstract class AbstractLoginController extends Controller */ protected function getField(string $input = null): string { - return str_contains($input, '@') ? 'email' : 'username'; + return ($input && str_contains($input, '@')) ? 'email' : 'username'; } /** diff --git a/app/Http/Controllers/Auth/LoginCheckpointController.php b/app/Http/Controllers/Auth/LoginCheckpointController.php index 1cc2fe1af..c44f18a81 100644 --- a/app/Http/Controllers/Auth/LoginCheckpointController.php +++ b/app/Http/Controllers/Auth/LoginCheckpointController.php @@ -11,6 +11,7 @@ use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Pterodactyl\Contracts\Repository\UserRepositoryInterface; use Pterodactyl\Exceptions\Repository\RecordNotFoundException; +use Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository; class LoginCheckpointController extends AbstractLoginController { @@ -34,6 +35,11 @@ class LoginCheckpointController extends AbstractLoginController */ private $encrypter; + /** + * @var \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository + */ + private $recoveryTokenRepository; + /** * LoginCheckpointController constructor. * @@ -42,6 +48,7 @@ class LoginCheckpointController extends AbstractLoginController * @param \PragmaRX\Google2FA\Google2FA $google2FA * @param \Illuminate\Contracts\Config\Repository $config * @param \Illuminate\Contracts\Cache\Repository $cache + * @param \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository $recoveryTokenRepository * @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository */ public function __construct( @@ -50,6 +57,7 @@ class LoginCheckpointController extends AbstractLoginController Google2FA $google2FA, Repository $config, CacheRepository $cache, + RecoveryTokenRepository $recoveryTokenRepository, UserRepositoryInterface $repository ) { parent::__construct($auth, $config); @@ -58,6 +66,7 @@ class LoginCheckpointController extends AbstractLoginController $this->cache = $cache; $this->repository = $repository; $this->encrypter = $encrypter; + $this->recoveryTokenRepository = $recoveryTokenRepository; } /** @@ -76,21 +85,35 @@ class LoginCheckpointController extends AbstractLoginController public function __invoke(LoginCheckpointRequest $request): JsonResponse { $token = $request->input('confirmation_token'); + $recoveryToken = $request->input('recovery_token'); try { + /** @var \Pterodactyl\Models\User $user */ $user = $this->repository->find($this->cache->get($token, 0)); } 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'))) { - $this->cache->delete($token); + return $this->sendLoginResponse($user, $request); + } + } + } 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); } } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 0d7f21978..593189db1 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -103,7 +103,7 @@ class LoginController extends AbstractLoginController $token = Str::random(64); $this->cache->put($token, $user->id, Chronos::now()->addMinutes(5)); - return JsonResponse::create([ + return new JsonResponse([ 'data' => [ 'complete' => false, 'confirmation_token' => $token, diff --git a/app/Http/Requests/Auth/LoginCheckpointRequest.php b/app/Http/Requests/Auth/LoginCheckpointRequest.php index 158f5c465..87d84ce97 100644 --- a/app/Http/Requests/Auth/LoginCheckpointRequest.php +++ b/app/Http/Requests/Auth/LoginCheckpointRequest.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Http\Requests\Auth; +use Illuminate\Validation\Rule; use Illuminate\Foundation\Http\FormRequest; class LoginCheckpointRequest extends FormRequest @@ -25,7 +26,20 @@ class LoginCheckpointRequest extends FormRequest { return [ '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')); + }), + ], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 47334ccee..408ceead3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -39,7 +39,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @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 AuthenticatableContract, @@ -256,7 +256,7 @@ class User extends Model implements /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ - public function recoveryCodes() + public function recoveryTokens() { return $this->hasMany(RecoveryToken::class); } diff --git a/resources/scripts/api/auth/loginCheckpoint.ts b/resources/scripts/api/auth/loginCheckpoint.ts index 244d27c81..25bb715a4 100644 --- a/resources/scripts/api/auth/loginCheckpoint.ts +++ b/resources/scripts/api/auth/loginCheckpoint.ts @@ -1,13 +1,14 @@ import http from '@/api/http'; import { LoginResponse } from '@/api/auth/login'; -export default (token: string, code: string): Promise => { +export default (token: string, code: string, recoveryToken?: string): Promise => { return new Promise((resolve, reject) => { http.post('/auth/login/checkpoint', { - // eslint-disable-next-line @typescript-eslint/camelcase + /* eslint-disable @typescript-eslint/camelcase */ confirmation_token: token, - // eslint-disable-next-line @typescript-eslint/camelcase authentication_code: code, + recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined, + /* eslint-enable @typescript-eslint/camelcase */ }) .then(response => resolve({ complete: response.data.data.complete, diff --git a/resources/scripts/components/auth/LoginCheckpointContainer.tsx b/resources/scripts/components/auth/LoginCheckpointContainer.tsx index 5534dd0f7..dddcd0c1c 100644 --- a/resources/scripts/components/auth/LoginCheckpointContainer.tsx +++ b/resources/scripts/components/auth/LoginCheckpointContainer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Link, RouteComponentProps } from 'react-router-dom'; import loginCheckpoint from '@/api/auth/loginCheckpoint'; import { httpErrorToHuman } from '@/api/http'; @@ -14,6 +14,7 @@ import Field from '@/components/elements/Field'; interface Values { code: string; + recoveryCode: '', } type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }> @@ -24,7 +25,8 @@ type Props = OwnProps & { } const LoginCheckpointContainer = () => { - const { isSubmitting } = useFormikContext(); + const { isSubmitting, setFieldValue } = useFormikContext(); + const [ isMissingDevice, setIsMissingDevice ] = useState(false); return ( {
@@ -54,6 +60,18 @@ const LoginCheckpointContainer = () => { } +
+ { + 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'} + +
{ }; const EnhancedForm = withFormik({ - handleSubmit: ({ code }, { setSubmitting, props: { addError, clearFlashes, location } }) => { + handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { addError, clearFlashes, location } }) => { clearFlashes(); - console.log(location.state.token, code); - loginCheckpoint(location.state?.token || '', code) + loginCheckpoint(location.state?.token || '', code, recoveryCode) .then(response => { if (response.complete) { // @ts-ignore @@ -89,11 +106,7 @@ const EnhancedForm = withFormik({ mapPropsToValues: () => ({ code: '', - }), - - validationSchema: object().shape({ - code: string().required('An authentication code must be provided.') - .length(6, 'Authentication code must be 6 digits in length.'), + recoveryCode: '', }), })(LoginCheckpointContainer);