Skip to content

Commit a998b46

Browse files
committed
Generate recovery tokens when enabling 2FA on an account
1 parent 7ee509d commit a998b46

File tree

6 files changed

+145
-8
lines changed

6 files changed

+145
-8
lines changed

app/Http/Controllers/Api/Client/TwoFactorController.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,14 @@ public function store(Request $request)
9696
throw new ValidationException($validator);
9797
}
9898

99-
$this->toggleTwoFactorService->handle($request->user(), $request->input('code'), true);
99+
$tokens = $this->toggleTwoFactorService->handle($request->user(), $request->input('code'), true);
100100

101-
return new JsonResponse([], Response::HTTP_NO_CONTENT);
101+
return new JsonResponse([
102+
'object' => 'recovery_tokens',
103+
'attributes' => [
104+
'tokens' => $tokens,
105+
],
106+
]);
102107
}
103108

104109
/**

app/Models/RecoveryToken.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace Pterodactyl\Models;
4+
5+
/**
6+
* @property int $id
7+
* @property int $user_id
8+
* @property string $token
9+
* @property \Carbon\CarbonImmutable $created_at
10+
*
11+
* @property \Pterodactyl\Models\User $user
12+
*/
13+
class RecoveryToken extends Model
14+
{
15+
/**
16+
* There are no updates to this model, only inserts and deletes.
17+
*/
18+
const UPDATED_AT = null;
19+
20+
/**
21+
* @var bool
22+
*/
23+
protected $immutableDates = true;
24+
25+
/**
26+
* @var string[]
27+
*/
28+
public static $validationRules = [
29+
'token' => 'required|string',
30+
];
31+
32+
/**
33+
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
34+
*/
35+
public function user()
36+
{
37+
return $this->belongsTo(User::class);
38+
}
39+
}

app/Models/User.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +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
4243
*/
4344
class User extends Model implements
4445
AuthenticatableContract,
@@ -251,4 +252,12 @@ public function apiKeys()
251252
return $this->hasMany(ApiKey::class)
252253
->where('key_type', ApiKey::TYPE_ACCOUNT);
253254
}
255+
256+
/**
257+
* @return \Illuminate\Database\Eloquent\Relations\HasMany
258+
*/
259+
public function recoveryCodes()
260+
{
261+
return $this->hasMany(RecoveryToken::class);
262+
}
254263
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Pterodactyl\Repositories\Eloquent;
4+
5+
use Pterodactyl\Models\RecoveryToken;
6+
7+
class RecoveryTokenRepository extends EloquentRepository
8+
{
9+
/**
10+
* @return string
11+
*/
12+
public function model()
13+
{
14+
return RecoveryToken::class;
15+
}
16+
}

app/Services/Users/ToggleTwoFactorService.php

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
namespace Pterodactyl\Services\Users;
44

55
use Carbon\Carbon;
6+
use Illuminate\Support\Str;
67
use Pterodactyl\Models\User;
78
use PragmaRX\Google2FA\Google2FA;
89
use Illuminate\Contracts\Encryption\Encrypter;
910
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
11+
use Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository;
1012
use Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid;
1113

1214
class ToggleTwoFactorService
@@ -26,21 +28,29 @@ class ToggleTwoFactorService
2628
*/
2729
private $repository;
2830

31+
/**
32+
* @var \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository
33+
*/
34+
private $recoveryTokenRepository;
35+
2936
/**
3037
* ToggleTwoFactorService constructor.
3138
*
3239
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
3340
* @param \PragmaRX\Google2FA\Google2FA $google2FA
41+
* @param \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository $recoveryTokenRepository
3442
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository
3543
*/
3644
public function __construct(
3745
Encrypter $encrypter,
3846
Google2FA $google2FA,
47+
RecoveryTokenRepository $recoveryTokenRepository,
3948
UserRepositoryInterface $repository
4049
) {
4150
$this->encrypter = $encrypter;
4251
$this->google2FA = $google2FA;
4352
$this->repository = $repository;
53+
$this->recoveryTokenRepository = $recoveryTokenRepository;
4454
}
4555

4656
/**
@@ -49,7 +59,7 @@ public function __construct(
4959
* @param \Pterodactyl\Models\User $user
5060
* @param string $token
5161
* @param bool|null $toggleState
52-
* @return bool
62+
* @return string[]
5363
*
5464
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
5565
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
@@ -58,23 +68,50 @@ public function __construct(
5868
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
5969
* @throws \Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid
6070
*/
61-
public function handle(User $user, string $token, bool $toggleState = null): bool
71+
public function handle(User $user, string $token, bool $toggleState = null): array
6272
{
6373
$secret = $this->encrypter->decrypt($user->totp_secret);
6474

6575
$isValidToken = $this->google2FA->verifyKey($secret, $token, config()->get('pterodactyl.auth.2fa.window'));
6676

6777
if (! $isValidToken) {
68-
throw new TwoFactorAuthenticationTokenInvalid(
69-
'The token provided is not valid.'
70-
);
78+
throw new TwoFactorAuthenticationTokenInvalid('The token provided is not valid.');
79+
}
80+
81+
// Now that we're enabling 2FA on the account, generate 10 recovery tokens for the account
82+
// and store them hashed in the database. We'll return them to the caller so that the user
83+
// can see and save them.
84+
//
85+
// If a user is unable to login with a 2FA token they can provide one of these backup codes
86+
// which will then be marked as deleted from the database and will also bypass 2FA protections
87+
// on their account.
88+
$tokens = [];
89+
if ((! $toggleState && ! $user->use_totp) || $toggleState) {
90+
$inserts = [];
91+
for ($i = 0; $i < 10; $i++) {
92+
$token = Str::random(10);
93+
94+
$inserts[] = [
95+
'user_id' => $user->id,
96+
'token' => password_hash($token, PASSWORD_DEFAULT),
97+
];
98+
99+
$tokens[] = $token;
100+
}
101+
102+
// Bulk insert the hashed tokens.
103+
$this->recoveryTokenRepository->insert($inserts);
104+
} elseif ($toggleState === false || $user->use_totp) {
105+
// If we are disabling 2FA on this account we will delete all of the recovery codes
106+
// that exist in the database for this account.
107+
$this->recoveryTokenRepository->deleteWhere(['user_id' => $user->id]);
71108
}
72109

73110
$this->repository->withoutFreshModel()->update($user->id, [
74111
'totp_authenticated_at' => Carbon::now(),
75112
'use_totp' => (is_null($toggleState) ? ! $user->use_totp : $toggleState),
76113
]);
77114

78-
return true;
115+
return $tokens;
79116
}
80117
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
class CreateUserRecoveryTokensTable extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*
12+
* @return void
13+
*/
14+
public function up()
15+
{
16+
Schema::create('recovery_tokens', function (Blueprint $table) {
17+
$table->id();
18+
$table->timestamps();
19+
});
20+
}
21+
22+
/**
23+
* Reverse the migrations.
24+
*
25+
* @return void
26+
*/
27+
public function down()
28+
{
29+
Schema::dropIfExists('recovery_tokens');
30+
}
31+
}

0 commit comments

Comments
 (0)