Support authenticating the provided key when loggin in

This commit is contained in:
Dane Everitt 2022-02-13 14:44:50 -05:00
parent 54c7207836
commit 09497c234a
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
9 changed files with 117 additions and 55 deletions

View file

@ -6,6 +6,7 @@ use Carbon\CarbonImmutable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\SecurityKey;
use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Factory\Psr17Factory;
use Illuminate\Contracts\Cache\Repository; use Illuminate\Contracts\Cache\Repository;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
@ -97,7 +98,7 @@ class SecurityKeyController extends ClientApiController
} }
$key = $this->storeSecurityKeyService $key = $this->storeSecurityKeyService
->setRequest($this->getServerRequest($request)) ->setRequest(SecurityKey::getPsrRequestFactory($request))
->setKeyName($request->input('name')) ->setKeyName($request->input('name'))
->handle($request->user(), $request->input('registration'), $credentials); ->handle($request->user(), $request->input('registration'), $credentials);
@ -115,13 +116,4 @@ class SecurityKeyController extends ClientApiController
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
} }
protected function getServerRequest(Request $request): ServerRequestInterface
{
$factory = new Psr17Factory();
$httpFactory = new PsrHttpFactory($factory, $factory, $factory, $factory);
return $httpFactory->createRequest($request);
}
} }

View file

@ -5,32 +5,44 @@ namespace Pterodactyl\Http\Controllers\Auth;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Illuminate\Http\Request;
use PragmaRX\Google2FA\Google2FA; use PragmaRX\Google2FA\Google2FA;
use Pterodactyl\Models\SecurityKey;
use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Encryption\Encrypter;
use Webauthn\PublicKeyCredentialRequestOptions;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest; use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
use Illuminate\Contracts\Validation\Factory as ValidationFactory; use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class LoginCheckpointController extends AbstractLoginController class LoginCheckpointController extends AbstractLoginController
{ {
public const TOKEN_EXPIRED_MESSAGE = 'The authentication token provided has expired, please refresh the page and try again.'; public const TOKEN_EXPIRED_MESSAGE = 'The authentication token provided has expired, please refresh the page and try again.';
private Encrypter $encrypter; protected Encrypter $encrypter;
private Google2FA $google2FA; protected Google2FA $google2FA;
private ValidationFactory $validation; protected WebauthnServerRepository $repository;
protected ValidationFactory $validation;
/** /**
* LoginCheckpointController constructor. * LoginCheckpointController constructor.
*/ */
public function __construct(Encrypter $encrypter, Google2FA $google2FA, ValidationFactory $validation) public function __construct(
{ Encrypter $encrypter,
Google2FA $google2FA,
ValidationFactory $validation,
WebauthnServerRepository $repository
) {
parent::__construct(); parent::__construct();
$this->encrypter = $encrypter; $this->encrypter = $encrypter;
$this->google2FA = $google2FA; $this->google2FA = $google2FA;
$this->validation = $validation; $this->validation = $validation;
$this->repository = $repository;
} }
/** /**
@ -46,35 +58,9 @@ class LoginCheckpointController extends AbstractLoginController
* @throws \Illuminate\Validation\ValidationException * @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException * @throws \Pterodactyl\Exceptions\DisplayException
*/ */
public function __invoke(LoginCheckpointRequest $request) public function token(LoginCheckpointRequest $request)
{ {
if ($this->hasTooManyLoginAttempts($request)) { $user = $this->extractUserFromRequest($request);
$this->sendLockoutResponse($request);
return;
}
$details = $request->session()->get('auth_confirmation_token');
if (!$this->hasValidSessionData($details)) {
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
return;
}
if (!hash_equals($request->input('confirmation_token') ?? '', $details['token_value'])) {
$this->sendFailedLoginResponse($request);
return;
}
try {
/** @var \Pterodactyl\Models\User $user */
$user = User::query()->findOrFail($details['user_id']);
} catch (ModelNotFoundException $exception) {
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
return;
}
// Recovery tokens go through a slightly different pathway for usage. // Recovery tokens go through a slightly different pathway for usage.
if (!is_null($recoveryToken = $request->input('recovery_token'))) { if (!is_null($recoveryToken = $request->input('recovery_token'))) {
@ -92,6 +78,65 @@ class LoginCheckpointController extends AbstractLoginController
$this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null); $this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
} }
/**
* Authenticates a login request using a security key for a user.
*
* @param \Illuminate\Http\Request $request
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException
*/
public function key(Request $request)
{
$key = $request->session()->get(SecurityKey::PK_SESSION_NAME);
if (!$key instanceof PublicKeyCredentialRequestOptions) {
throw new BadRequestHttpException('No security keys configured in session.');
}
$user = $this->extractUserFromRequest($request);
$source = $this->repository->getServer($user)->loadAndCheckAssertionResponse(
$request->input('data'),
$key,
$user->toPublicKeyCredentialEntity(),
SecurityKey::getPsrRequestFactory($request)
);
dd($source->getUserHandle());
}
/**
* @param \Illuminate\Http\Request $request
* @return \Pterodactyl\Models\User
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException
*/
protected function extractUserFromRequest(Request $request): User
{
if ($this->hasTooManyLoginAttempts($request)) {
$this->sendLockoutResponse($request);
}
$details = $request->session()->get('auth_confirmation_token');
if (!$this->hasValidSessionData($details)) {
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
}
if (!hash_equals($request->input('confirmation_token') ?? '', $details['token_value'])) {
$this->sendFailedLoginResponse($request);
}
try {
/** @var \Pterodactyl\Models\User $user */
$user = User::query()->findOrFail($details['user_id']);
} catch (ModelNotFoundException $exception) {
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
}
return $user;
}
/** /**
* Determines if a given recovery token is valid for the user account. If we find a matching token * 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. * it will be deleted from the database.

View file

@ -16,7 +16,6 @@ class LoginController extends AbstractLoginController
{ {
private const METHOD_TOTP = 'totp'; private const METHOD_TOTP = 'totp';
private const METHOD_WEBAUTHN = 'webauthn'; private const METHOD_WEBAUTHN = 'webauthn';
private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest';
protected GeneratePublicKeyCredentialsRequestService $service; protected GeneratePublicKeyCredentialsRequestService $service;
@ -98,7 +97,7 @@ class LoginController extends AbstractLoginController
if (!empty($user->securityKeys)) { if (!empty($user->securityKeys)) {
$key = $this->service->handle($user); $key = $this->service->handle($user);
$request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $key); $request->session()->put(SecurityKey::PK_SESSION_NAME, $key);
$response['webauthn'] = ['public_key' => $key]; $response['webauthn'] = ['public_key' => $key];
} }

View file

@ -3,19 +3,24 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Illuminate\Http\Request;
use Ramsey\Uuid\UuidInterface; use Ramsey\Uuid\UuidInterface;
use Webauthn\TrustPath\TrustPath; use Webauthn\TrustPath\TrustPath;
use Nyholm\Psr7\Factory\Psr17Factory;
use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialSource;
use Webauthn\TrustPath\TrustPathLoader; use Webauthn\TrustPath\TrustPathLoader;
use Webauthn\PublicKeyCredentialDescriptor; use Webauthn\PublicKeyCredentialDescriptor;
use Psr\Http\Message\ServerRequestInterface;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
class SecurityKey extends Model class SecurityKey extends Model
{ {
use HasFactory; use HasFactory;
public const RESOURCE_NAME = 'security_key'; public const RESOURCE_NAME = 'security_key';
public const PK_SESSION_NAME = 'security_key_pk_request';
protected $casts = [ protected $casts = [
'user_id' => 'int', 'user_id' => 'int',
@ -109,4 +114,19 @@ class SecurityKey extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/**
* Returns a PSR17 Request factory to be used by different Webauthn tooling.
*
* @param \Illuminate\Http\Request $request
* @return \Psr\Http\Message\ServerRequestInterface
*/
public static function getPsrRequestFactory(Request $request): ServerRequestInterface
{
$factory = new Psr17Factory();
$httpFactory = new PsrHttpFactory($factory, $factory, $factory, $factory);
return $httpFactory->createRequest($request);
}
} }

View file

@ -7,6 +7,7 @@ use Illuminate\Support\Collection;
use Illuminate\Auth\Authenticatable; use Illuminate\Auth\Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Webauthn\PublicKeyCredentialUserEntity;
use Pterodactyl\Models\Traits\HasAccessTokens; use Pterodactyl\Models\Traits\HasAccessTokens;
use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
@ -229,4 +230,12 @@ class User extends Model implements
}) })
->groupBy('servers.id'); ->groupBy('servers.id');
} }
/**
* @return \Webauthn\PublicKeyCredentialUserEntity
*/
public function toPublicKeyCredentialEntity(): PublicKeyCredentialUserEntity
{
return new PublicKeyCredentialUserEntity($this->username, $this->uuid, $this->email, null);
}
} }

View file

@ -4,7 +4,6 @@ namespace Pterodactyl\Repositories\SecurityKeys;
use Webauthn\Server; use Webauthn\Server;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Illuminate\Container\Container;
use Webauthn\PublicKeyCredentialRpEntity; use Webauthn\PublicKeyCredentialRpEntity;
final class WebauthnServerRepository final class WebauthnServerRepository

View file

@ -4,7 +4,6 @@ namespace Pterodactyl\Services\Users\SecurityKeys;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Pterodactyl\Models\SecurityKey; use Pterodactyl\Models\SecurityKey;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialCreationOptions;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository; use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
@ -19,8 +18,6 @@ class CreatePublicKeyCredentialsService
public function handle(User $user): PublicKeyCredentialCreationOptions public function handle(User $user): PublicKeyCredentialCreationOptions
{ {
$entity = new PublicKeyCredentialUserEntity($user->username, $user->uuid, $user->email, null);
$excluded = $user->securityKeys->map(function (SecurityKey $key) { $excluded = $user->securityKeys->map(function (SecurityKey $key) {
return $key->getPublicKeyCredentialDescriptor(); return $key->getPublicKeyCredentialDescriptor();
})->values()->toArray(); })->values()->toArray();
@ -28,7 +25,7 @@ class CreatePublicKeyCredentialsService
$server = $this->webauthnServerRepository->getServer($user); $server = $this->webauthnServerRepository->getServer($user);
return $server->generatePublicKeyCredentialCreationOptions( return $server->generatePublicKeyCredentialCreationOptions(
$entity, $user->toPublicKeyCredentialEntity(),
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
$excluded $excluded
); );

View file

@ -22,12 +22,10 @@ export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Pr
const data = { const data = {
confirmation_token: token, confirmation_token: token,
data: JSON.stringify({ data: JSON.stringify({
id: credential.id, id: credential.id,
type: credential.type, type: credential.type,
rawId: bufferEncode(credential.rawId), rawId: bufferEncode(credential.rawId),
response: { response: {
authenticatorData: bufferEncode(response.authenticatorData), authenticatorData: bufferEncode(response.authenticatorData),
clientDataJSON: bufferEncode(response.clientDataJSON), clientDataJSON: bufferEncode(response.clientDataJSON),

View file

@ -8,6 +8,9 @@
| Endpoint: /auth | Endpoint: /auth
| |
*/ */
use Pterodactyl\Http\Controllers\Auth;
Route::group(['middleware' => 'guest'], function () { Route::group(['middleware' => 'guest'], function () {
// These routes are defined so that we can continue to reference them programmatically. // These routes are defined so that we can continue to reference them programmatically.
// They all route to the same controller function which passes off to React. // They all route to the same controller function which passes off to React.
@ -21,9 +24,9 @@ Route::group(['middleware' => 'guest'], function () {
// @see \Pterodactyl\Providers\RouteServiceProvider // @see \Pterodactyl\Providers\RouteServiceProvider
Route::middleware(['throttle:authentication'])->group(function () { Route::middleware(['throttle:authentication'])->group(function () {
// Login endpoints. // Login endpoints.
Route::post('/login', 'LoginController@login')->middleware('recaptcha'); Route::post('/login', [Auth\LoginController::class, 'login'])->middleware('recaptcha');
Route::post('/login/checkpoint', 'LoginCheckpointController')->name('auth.login-checkpoint'); Route::post('/login/checkpoint', [Auth\LoginCheckpointController::class, 'token'])->name('auth.login-checkpoint');
Route::post('/login/checkpoint/key', 'WebauthnController@auth')->name('auth.login-checkpoint-key'); Route::post('/login/checkpoint/key', [Auth\LoginCheckpointController::class, 'key'])->name('auth.login-checkpoint-key');
// Forgot password route. A post to this endpoint will trigger an // Forgot password route. A post to this endpoint will trigger an
// email to be sent containing a reset token. // email to be sent containing a reset token.