Support using recovery tokens during the login process to bypass 2fa; closes #479
This commit is contained in:
parent
795e045950
commit
7b75e7a648
7 changed files with 84 additions and 30 deletions
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'));
|
||||||
|
}),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue