Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 2FA Progress Sync #920

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
# Ignore public assets
/public/hot
/public/storage
/storage/credit_deduction_log
/storage/debugbar
/storage/app/public/logo.png

# Ignore environment files and configuration
Expand All @@ -31,4 +33,4 @@ Homestead.yaml
# Ignore installation logs and locks
public/install/logs.txt
install.lock
public/install/logs/installer.log
/public/install/logs/*.log
29 changes: 29 additions & 0 deletions app/Console/Commands/Overrides/KeyGenerateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Console\Commands\Overrides;

use Illuminate\Foundation\Console\KeyGenerateCommand as BaseKeyGenerateCommand;

class KeyGenerateCommand extends BaseKeyGenerateCommand
{
/**
* Override the default Laravel key generation command to throw a warning to the user
* if it appears that they have already generated an application encryption key.
* Credits: Pterodactyl Panel
*/
public function handle()
{
if (!empty(config('app.key')) && $this->input->isInteractive()) {
$this->output->warning('It appears you have already configured an application encryption key. Continuing with this process with overwrite that key and cause data corruption for any existing encrypted data. DO NOT CONTINUE UNLESS YOU KNOW WHAT YOU ARE DOING.');
if (!$this->confirm('I understand the consequences of performing this command and accept all responsibility for the loss of encrypted data.')) {
return;
}

if (!$this->confirm('Are you sure you wish to continue? Changing the application encryption key WILL CAUSE DATA LOSS. WE CANNOT HELP YOU RECOVER YOUR DATA IF YOU PROCEED.')) {
return;
}
}

parent::handle();
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/Admin/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public function show(User $user, LocaleSettings $locale_settings, GeneralSetting
/**
* Get a JSON response of users.
*
* @return \Illuminate\Support\Collection|\App\models\User
* @return \Illuminate\Support\Collection|\App\Models\User
*/
public function json(Request $request)
{
Expand Down
80 changes: 80 additions & 0 deletions app/Http/Controllers/Auth/Login2FAController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;

class Login2FAController extends Controller
{
private $secret;


/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}

/**
* @throws ValidationException
*/
public function authenticate(Request $request, User $user)
{
$google2fa = app(Google2FA::class);

$valid = $google2fa->verifyKey('6TZBNEJT5I4VKTHN', $request->input('one_time_password'));
logger()->info('2FA Valid: ' . $valid);
if ($valid) {
// Authentication passed...
logger()->info('2FA Passed: ' . $request->input('one_time_password'));
return redirect()->route('home');
}
else {
//throw error
logger()->info('2FA Failed: ' . $request->input('one_time_password'));
return redirect()->back()->withErrors([
'one_time_password' => 'The one time password is invalid.'
]);
}
}

// Create a new secret and display the QR code
public function Attempt2FA()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attempt2Fa()

Use one function naming style bcs lower is for example isValidRecoveryKey

{
$google2fa = app(Google2FA::class);
$this->secret = $google2fa->generateSecretKey();

logger()->info('2FA Secret: ' . $this->secret);
$g2faUrl = $google2fa->getQRCodeUrl(
'pragmarx',
'[email protected]',
'6TZBNEJT5I4VKTHN'
);

$writer = new Writer(
new ImageRenderer(
new RendererStyle(400),
new ImagickImageBackEnd()
)
);

$qrcode_image = base64_encode($writer->writeString($g2faUrl));

return view('auth.2fa-secret')->with([
'qrcode_image' => $qrcode_image,
'secret' => '6TZBNEJT5I4VKTHN'
]);
}
}
130 changes: 130 additions & 0 deletions app/Http/Controllers/Auth/LoginCheckpointController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

namespace Pterodactyl\Http\Controllers\Auth;

use App\Models\User;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use PragmaRX\Google2FA\Google2FA;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;

class LoginCheckpointController extends LoginController
{
use AuthenticatesUsers;

private const TOKEN_EXPIRED_MESSAGE = 'The authentication token provided has expired, please refresh the page and try again.';

/**
* LoginCheckpointController constructor.
*/
public function __construct(
private Encrypter $encrypter,
private Google2FA $google2FA,
private ValidationFactory $validation
) {
parent::__construct();
}

/**
* Handle a login where the user is required to provide a TOTP authentication
* token. Once a user has reached this stage it is assumed that they have already
* provided a valid username and password.
*
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
* @throws \Exception
* @throws \Illuminate\Validation\ValidationException
*/
public function __invoke(Request $request): JsonResponse
{
if ($this->hasTooManyLoginAttempts($request)) {
$this->sendLockoutResponse($request);
}

$details = $request->session()->get('auth_confirmation_token');
if (!$this->hasValidSessionData($details)) {
$this->sendFailedLoginResponse($request); // token expired
}

if (!hash_equals($request->input('confirmation_token') ?? '', $details['token_value'])) {
$this->sendFailedLoginResponse($request); // token invalid
}

try {
/** @var User $user */
$user = User::query()->findOrFail($details['user_id']);
} catch (ModelNotFoundException) {
$this->sendFailedLoginResponse($request); // user not found
}

// Recovery tokens go through a slightly different pathway for usage.
if (!is_null($recoveryToken = $request->input('recovery_token'))) {
if ($this->isValidRecoveryToken($user, $recoveryToken)) {
// If the recovery token is valid, send the login response
return $this->sendLoginResponse($request);
}
} else {
$decrypted = $this->encrypter->decrypt($user->totp_secret);

if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {

return $this->sendLoginResponse($request);
}
}

$this->sendFailedLoginResponse($request); // recovery token invalid
}

/**
* Determines if a given recovery token is valid for the user account. If we find a matching token
* it will be deleted from the database.
*
* @throws \Exception
*/
protected function isValidRecoveryToken(User $user, string $value): bool
{
foreach ($user->recoveryTokens as $token) {
if (password_verify($value, $token->token)) {
$token->delete();

return true;
}
}

return false;
}

/**
* Determines if the data provided from the session is valid or not. This
* will return false if the data is invalid, or if more time has passed than
* was configured when the session was written.
*/
protected function hasValidSessionData(array $data): bool
{
$validator = $this->validation->make($data, [
'user_id' => 'required|integer|min:1',
'token_value' => 'required|string',
'expires_at' => 'required',
]);

if ($validator->fails()) {
return false;
}

if (!$data['expires_at'] instanceof CarbonInterface) {
return false;
}

if ($data['expires_at']->isBefore(CarbonImmutable::now())) {
return false;
}

return true;
}
}
1 change: 1 addition & 0 deletions app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class Kernel extends HttpKernel
'role' => \Spatie\Permission\Middlewares\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middlewares\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middlewares\RoleOrPermissionMiddleware::class,
'2fa' => \PragmaRX\Google2FALaravel\Middleware::class,
];

}
34 changes: 34 additions & 0 deletions app/Models/RecoveryToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\Models;

use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
* @property int $id
* @property int $user_id
* @property string $token
* @property CarbonImmutable $created_at
* @property User $user
*/
class RecoveryToken extends Model
{
/**
* There are no updates to this model, only inserts and deletes.
*/
public const UPDATED_AT = null;

public $timestamps = true;

protected bool $immutableDates = true;

public static array $validationRules = [
'token' => 'required|string',
];

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
30 changes: 29 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Classes\PterodactylClient;
use App\Settings\PterodactylSettings;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
Expand Down Expand Up @@ -67,6 +68,9 @@ class User extends Authenticatable implements MustVerifyEmail
'suspended',
'referral_code',
'email_verified_reward',
'use_totp',
'totp_secret',
'totp_authenticated_at',
];

/**
Expand All @@ -77,6 +81,8 @@ class User extends Authenticatable implements MustVerifyEmail
protected $hidden = [
'password',
'remember_token',
'totp_secret',
'totp_authenticated_at'
];

/**
Expand All @@ -89,7 +95,9 @@ class User extends Authenticatable implements MustVerifyEmail
'last_seen' => 'datetime',
'credits' => 'float',
'server_limit' => 'float',
'email_verified_reward' => 'boolean'
'email_verified_reward' => 'boolean',
'use_totp' => 'boolean',
'totp_secret' => 'nullable|string'
];

public function __construct()
Expand Down Expand Up @@ -312,4 +320,24 @@ public function getActivitylogOptions(): LogOptions
->logOnlyDirty()
->dontSubmitEmptyLogs();
}

public function recoveryTokens(): HasMany
{
return $this->hasMany(RecoveryToken::class);
}

/**
* Interact with the 2fa secret attribute.
*
* @param string $value
* @return \Illuminate\Database\Eloquent\Casts\Attribute
*/
protected function google2faSecret(): Attribute
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

U can use encrypted cast without this

{
return new Attribute(
get: fn ($value) => decrypt($value),
set: fn ($value) => encrypt($value),
);
}

}
Empty file modified bootstrap/cache/.gitignore
100755 → 100644
Empty file.
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"require": {
"php": "^8.1",
"ext-intl": "*",
"bacon/bacon-qr-code": "^2.0",
"biscolab/laravel-recaptcha": "^5.4",
"doctrine/dbal": "^3.5.3",
"guzzlehttp/guzzle": "^7.5",
Expand All @@ -22,6 +23,7 @@
"league/flysystem-aws-s3-v3": "^3.12.2",
"paypal/paypal-checkout-sdk": "^1.0.2",
"paypal/rest-api-sdk-php": "^1.14.0",
"pragmarx/google2fa-laravel": "^2.1",
"predis/predis": "*",
"qirolab/laravel-themer": "^2.0.2",
"socialiteproviders/discord": "^4.1.2",
Expand Down
Loading