Skip to content

Commit 7b75e7a

Browse files
committed
Support using recovery tokens during the login process to bypass 2fa; closes pterodactyl#479
1 parent 795e045 commit 7b75e7a

File tree

7 files changed

+84
-30
lines changed

7 files changed

+84
-30
lines changed

app/Http/Controllers/Auth/AbstractLoginController.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,21 @@ public function __construct(AuthManager $auth, Repository $config)
6868
*
6969
* @param \Illuminate\Http\Request $request
7070
* @param \Illuminate\Contracts\Auth\Authenticatable|null $user
71+
* @param string|null $message
7172
*
7273
* @throws \Pterodactyl\Exceptions\DisplayException
7374
*/
74-
protected function sendFailedLoginResponse(Request $request, Authenticatable $user = null)
75+
protected function sendFailedLoginResponse(Request $request, Authenticatable $user = null, string $message = null)
7576
{
7677
$this->incrementLoginAttempts($request);
7778
$this->fireFailedLoginEvent($user, [
7879
$this->getField($request->input('user')) => $request->input('user'),
7980
]);
8081

8182
if ($request->route()->named('auth.login-checkpoint')) {
82-
throw new DisplayException(trans('auth.two_factor.checkpoint_failed'));
83+
throw new DisplayException(
84+
$message ?? trans('auth.two_factor.checkpoint_failed')
85+
);
8386
}
8487

8588
throw new DisplayException(trans('auth.failed'));
@@ -116,7 +119,7 @@ protected function sendLoginResponse(User $user, Request $request): JsonResponse
116119
*/
117120
protected function getField(string $input = null): string
118121
{
119-
return str_contains($input, '@') ? 'email' : 'username';
122+
return ($input && str_contains($input, '@')) ? 'email' : 'username';
120123
}
121124

122125
/**

app/Http/Controllers/Auth/LoginCheckpointController.php

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Illuminate\Contracts\Cache\Repository as CacheRepository;
1212
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
1313
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
14+
use Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository;
1415

1516
class LoginCheckpointController extends AbstractLoginController
1617
{
@@ -34,6 +35,11 @@ class LoginCheckpointController extends AbstractLoginController
3435
*/
3536
private $encrypter;
3637

38+
/**
39+
* @var \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository
40+
*/
41+
private $recoveryTokenRepository;
42+
3743
/**
3844
* LoginCheckpointController constructor.
3945
*
@@ -42,6 +48,7 @@ class LoginCheckpointController extends AbstractLoginController
4248
* @param \PragmaRX\Google2FA\Google2FA $google2FA
4349
* @param \Illuminate\Contracts\Config\Repository $config
4450
* @param \Illuminate\Contracts\Cache\Repository $cache
51+
* @param \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository $recoveryTokenRepository
4552
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
4653
*/
4754
public function __construct(
@@ -50,6 +57,7 @@ public function __construct(
5057
Google2FA $google2FA,
5158
Repository $config,
5259
CacheRepository $cache,
60+
RecoveryTokenRepository $recoveryTokenRepository,
5361
UserRepositoryInterface $repository
5462
) {
5563
parent::__construct($auth, $config);
@@ -58,6 +66,7 @@ public function __construct(
5866
$this->cache = $cache;
5967
$this->repository = $repository;
6068
$this->encrypter = $encrypter;
69+
$this->recoveryTokenRepository = $recoveryTokenRepository;
6170
}
6271

6372
/**
@@ -76,21 +85,35 @@ public function __construct(
7685
public function __invoke(LoginCheckpointRequest $request): JsonResponse
7786
{
7887
$token = $request->input('confirmation_token');
88+
$recoveryToken = $request->input('recovery_token');
7989

8090
try {
91+
/** @var \Pterodactyl\Models\User $user */
8192
$user = $this->repository->find($this->cache->get($token, 0));
8293
} catch (RecordNotFoundException $exception) {
83-
return $this->sendFailedLoginResponse($request);
94+
return $this->sendFailedLoginResponse($request, null, 'The authentication token provided has expired, please refresh the page and try again.');
8495
}
8596

86-
$decrypted = $this->encrypter->decrypt($user->totp_secret);
97+
// If we got a recovery token try to find one that matches for the user and then continue
98+
// through the process (and delete the token).
99+
if (! is_null($recoveryToken)) {
100+
foreach ($user->recoveryTokens as $token) {
101+
if (password_verify($recoveryToken, $token->token)) {
102+
$this->recoveryTokenRepository->delete($token->id);
103+
104+
return $this->sendLoginResponse($user, $request);
105+
}
106+
}
107+
} else {
108+
$decrypted = $this->encrypter->decrypt($user->totp_secret);
87109

88-
if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {
89-
$this->cache->delete($token);
110+
if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {
111+
$this->cache->delete($token);
90112

91-
return $this->sendLoginResponse($user, $request);
113+
return $this->sendLoginResponse($user, $request);
114+
}
92115
}
93116

94-
return $this->sendFailedLoginResponse($request, $user);
117+
return $this->sendFailedLoginResponse($request, $user, ! empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
95118
}
96119
}

app/Http/Controllers/Auth/LoginController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public function login(Request $request): JsonResponse
103103
$token = Str::random(64);
104104
$this->cache->put($token, $user->id, Chronos::now()->addMinutes(5));
105105

106-
return JsonResponse::create([
106+
return new JsonResponse([
107107
'data' => [
108108
'complete' => false,
109109
'confirmation_token' => $token,

app/Http/Requests/Auth/LoginCheckpointRequest.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Pterodactyl\Http\Requests\Auth;
44

5+
use Illuminate\Validation\Rule;
56
use Illuminate\Foundation\Http\FormRequest;
67

78
class LoginCheckpointRequest extends FormRequest
@@ -25,7 +26,20 @@ public function rules(): array
2526
{
2627
return [
2728
'confirmation_token' => 'required|string',
28-
'authentication_code' => 'required|numeric',
29+
'authentication_code' => [
30+
'nullable',
31+
'numeric',
32+
Rule::requiredIf(function () {
33+
return empty($this->input('recovery_token'));
34+
}),
35+
],
36+
'recovery_token' => [
37+
'nullable',
38+
'string',
39+
Rule::requiredIf(function () {
40+
return empty($this->input('authentication_code'));
41+
}),
42+
],
2943
];
3044
}
3145
}

app/Models/User.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
* @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys
4040
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
4141
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
42-
* @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryCodes
42+
* @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens
4343
*/
4444
class User extends Model implements
4545
AuthenticatableContract,
@@ -256,7 +256,7 @@ public function apiKeys()
256256
/**
257257
* @return \Illuminate\Database\Eloquent\Relations\HasMany
258258
*/
259-
public function recoveryCodes()
259+
public function recoveryTokens()
260260
{
261261
return $this->hasMany(RecoveryToken::class);
262262
}

resources/scripts/api/auth/loginCheckpoint.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import http from '@/api/http';
22
import { LoginResponse } from '@/api/auth/login';
33

4-
export default (token: string, code: string): Promise<LoginResponse> => {
4+
export default (token: string, code: string, recoveryToken?: string): Promise<LoginResponse> => {
55
return new Promise((resolve, reject) => {
66
http.post('/auth/login/checkpoint', {
7-
// eslint-disable-next-line @typescript-eslint/camelcase
7+
/* eslint-disable @typescript-eslint/camelcase */
88
confirmation_token: token,
9-
// eslint-disable-next-line @typescript-eslint/camelcase
109
authentication_code: code,
10+
recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined,
11+
/* eslint-enable @typescript-eslint/camelcase */
1112
})
1213
.then(response => resolve({
1314
complete: response.data.data.complete,

resources/scripts/components/auth/LoginCheckpointContainer.tsx

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import { Link, RouteComponentProps } from 'react-router-dom';
33
import loginCheckpoint from '@/api/auth/loginCheckpoint';
44
import { httpErrorToHuman } from '@/api/http';
@@ -14,6 +14,7 @@ import Field from '@/components/elements/Field';
1414

1515
interface Values {
1616
code: string;
17+
recoveryCode: '',
1718
}
1819

1920
type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }>
@@ -24,7 +25,8 @@ type Props = OwnProps & {
2425
}
2526

2627
const LoginCheckpointContainer = () => {
27-
const { isSubmitting } = useFormikContext<Values>();
28+
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
29+
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
2830

2931
return (
3032
<LoginFormContainer
@@ -34,10 +36,14 @@ const LoginCheckpointContainer = () => {
3436
<div className={'mt-6'}>
3537
<Field
3638
light={true}
37-
name={'code'}
38-
title={'Authentication Code'}
39-
description={'Enter the two-factor token generated by your device.'}
40-
type={'number'}
39+
name={isMissingDevice ? 'recoveryCode' : 'code'}
40+
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
41+
description={
42+
isMissingDevice
43+
? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.'
44+
: 'Enter the two-factor token generated by your device.'
45+
}
46+
type={isMissingDevice ? 'text' : 'number'}
4147
autoFocus={true}
4248
/>
4349
</div>
@@ -54,6 +60,18 @@ const LoginCheckpointContainer = () => {
5460
}
5561
</button>
5662
</div>
63+
<div className={'mt-6 text-center'}>
64+
<span
65+
onClick={() => {
66+
setFieldValue('code', '');
67+
setFieldValue('recoveryCode', '');
68+
setIsMissingDevice(s => !s);
69+
}}
70+
className={'cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
71+
>
72+
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
73+
</span>
74+
</div>
5775
<div className={'mt-6 text-center'}>
5876
<Link
5977
to={'/auth/login'}
@@ -67,10 +85,9 @@ const LoginCheckpointContainer = () => {
6785
};
6886

6987
const EnhancedForm = withFormik<Props, Values>({
70-
handleSubmit: ({ code }, { setSubmitting, props: { addError, clearFlashes, location } }) => {
88+
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { addError, clearFlashes, location } }) => {
7189
clearFlashes();
72-
console.log(location.state.token, code);
73-
loginCheckpoint(location.state?.token || '', code)
90+
loginCheckpoint(location.state?.token || '', code, recoveryCode)
7491
.then(response => {
7592
if (response.complete) {
7693
// @ts-ignore
@@ -89,11 +106,7 @@ const EnhancedForm = withFormik<Props, Values>({
89106

90107
mapPropsToValues: () => ({
91108
code: '',
92-
}),
93-
94-
validationSchema: object().shape({
95-
code: string().required('An authentication code must be provided.')
96-
.length(6, 'Authentication code must be 6 digits in length.'),
109+
recoveryCode: '',
97110
}),
98111
})(LoginCheckpointContainer);
99112

0 commit comments

Comments
 (0)