From 09497c234a262898a04ab6f80c0c63cf236caa5d Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 13 Feb 2022 14:44:50 -0500 Subject: [PATCH] Support authenticating the provided key when loggin in --- .../Api/Client/SecurityKeyController.php | 12 +- .../Auth/LoginCheckpointController.php | 111 ++++++++++++------ app/Http/Controllers/Auth/LoginController.php | 3 +- app/Models/SecurityKey.php | 20 ++++ app/Models/User.php | 9 ++ .../SecurityKeys/WebauthnServerRepository.php | 1 - .../CreatePublicKeyCredentialsService.php | 5 +- .../api/account/webauthn/webauthnChallenge.ts | 2 - routes/auth.php | 9 +- 9 files changed, 117 insertions(+), 55 deletions(-) diff --git a/app/Http/Controllers/Api/Client/SecurityKeyController.php b/app/Http/Controllers/Api/Client/SecurityKeyController.php index 3354f12d6..132e0b862 100644 --- a/app/Http/Controllers/Api/Client/SecurityKeyController.php +++ b/app/Http/Controllers/Api/Client/SecurityKeyController.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Illuminate\Support\Str; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Pterodactyl\Models\SecurityKey; use Nyholm\Psr7\Factory\Psr17Factory; use Illuminate\Contracts\Cache\Repository; use Psr\Http\Message\ServerRequestInterface; @@ -97,7 +98,7 @@ class SecurityKeyController extends ClientApiController } $key = $this->storeSecurityKeyService - ->setRequest($this->getServerRequest($request)) + ->setRequest(SecurityKey::getPsrRequestFactory($request)) ->setKeyName($request->input('name')) ->handle($request->user(), $request->input('registration'), $credentials); @@ -115,13 +116,4 @@ class SecurityKeyController extends ClientApiController 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); - } } diff --git a/app/Http/Controllers/Auth/LoginCheckpointController.php b/app/Http/Controllers/Auth/LoginCheckpointController.php index 865377b7b..d033dfd36 100644 --- a/app/Http/Controllers/Auth/LoginCheckpointController.php +++ b/app/Http/Controllers/Auth/LoginCheckpointController.php @@ -5,32 +5,44 @@ namespace Pterodactyl\Http\Controllers\Auth; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Pterodactyl\Models\User; +use Illuminate\Http\Request; use PragmaRX\Google2FA\Google2FA; +use Pterodactyl\Models\SecurityKey; use Illuminate\Contracts\Encryption\Encrypter; +use Webauthn\PublicKeyCredentialRequestOptions; use Illuminate\Database\Eloquent\ModelNotFoundException; use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest; use Illuminate\Contracts\Validation\Factory as ValidationFactory; +use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class LoginCheckpointController extends AbstractLoginController { 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. */ - public function __construct(Encrypter $encrypter, Google2FA $google2FA, ValidationFactory $validation) - { + public function __construct( + Encrypter $encrypter, + Google2FA $google2FA, + ValidationFactory $validation, + WebauthnServerRepository $repository + ) { parent::__construct(); $this->encrypter = $encrypter; $this->google2FA = $google2FA; $this->validation = $validation; + $this->repository = $repository; } /** @@ -46,35 +58,9 @@ class LoginCheckpointController extends AbstractLoginController * @throws \Illuminate\Validation\ValidationException * @throws \Pterodactyl\Exceptions\DisplayException */ - public function __invoke(LoginCheckpointRequest $request) + public function token(LoginCheckpointRequest $request) { - if ($this->hasTooManyLoginAttempts($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; - } + $user = $this->extractUserFromRequest($request); // Recovery tokens go through a slightly different pathway for usage. 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); } + /** + * 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 * it will be deleted from the database. diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 2b5979dfe..54c2a44b6 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -16,7 +16,6 @@ class LoginController extends AbstractLoginController { private const METHOD_TOTP = 'totp'; private const METHOD_WEBAUTHN = 'webauthn'; - private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest'; protected GeneratePublicKeyCredentialsRequestService $service; @@ -98,7 +97,7 @@ class LoginController extends AbstractLoginController if (!empty($user->securityKeys)) { $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]; } diff --git a/app/Models/SecurityKey.php b/app/Models/SecurityKey.php index 552967e73..93b3a928c 100644 --- a/app/Models/SecurityKey.php +++ b/app/Models/SecurityKey.php @@ -3,19 +3,24 @@ namespace Pterodactyl\Models; use Ramsey\Uuid\Uuid; +use Illuminate\Http\Request; use Ramsey\Uuid\UuidInterface; use Webauthn\TrustPath\TrustPath; +use Nyholm\Psr7\Factory\Psr17Factory; use Webauthn\PublicKeyCredentialSource; use Webauthn\TrustPath\TrustPathLoader; use Webauthn\PublicKeyCredentialDescriptor; +use Psr\Http\Message\ServerRequestInterface; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; class SecurityKey extends Model { use HasFactory; public const RESOURCE_NAME = 'security_key'; + public const PK_SESSION_NAME = 'security_key_pk_request'; protected $casts = [ 'user_id' => 'int', @@ -109,4 +114,19 @@ class SecurityKey extends Model { 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); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 60fa80504..2819273e4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Illuminate\Auth\Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\Builder; +use Webauthn\PublicKeyCredentialUserEntity; use Pterodactyl\Models\Traits\HasAccessTokens; use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Database\Eloquent\Relations\HasOne; @@ -229,4 +230,12 @@ class User extends Model implements }) ->groupBy('servers.id'); } + + /** + * @return \Webauthn\PublicKeyCredentialUserEntity + */ + public function toPublicKeyCredentialEntity(): PublicKeyCredentialUserEntity + { + return new PublicKeyCredentialUserEntity($this->username, $this->uuid, $this->email, null); + } } diff --git a/app/Repositories/SecurityKeys/WebauthnServerRepository.php b/app/Repositories/SecurityKeys/WebauthnServerRepository.php index 9d371f5e8..57dc2c46a 100644 --- a/app/Repositories/SecurityKeys/WebauthnServerRepository.php +++ b/app/Repositories/SecurityKeys/WebauthnServerRepository.php @@ -4,7 +4,6 @@ namespace Pterodactyl\Repositories\SecurityKeys; use Webauthn\Server; use Pterodactyl\Models\User; -use Illuminate\Container\Container; use Webauthn\PublicKeyCredentialRpEntity; final class WebauthnServerRepository diff --git a/app/Services/Users/SecurityKeys/CreatePublicKeyCredentialsService.php b/app/Services/Users/SecurityKeys/CreatePublicKeyCredentialsService.php index d2bedccea..b17ca654c 100644 --- a/app/Services/Users/SecurityKeys/CreatePublicKeyCredentialsService.php +++ b/app/Services/Users/SecurityKeys/CreatePublicKeyCredentialsService.php @@ -4,7 +4,6 @@ namespace Pterodactyl\Services\Users\SecurityKeys; use Pterodactyl\Models\User; use Pterodactyl\Models\SecurityKey; -use Webauthn\PublicKeyCredentialUserEntity; use Webauthn\PublicKeyCredentialCreationOptions; use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository; @@ -19,8 +18,6 @@ class CreatePublicKeyCredentialsService public function handle(User $user): PublicKeyCredentialCreationOptions { - $entity = new PublicKeyCredentialUserEntity($user->username, $user->uuid, $user->email, null); - $excluded = $user->securityKeys->map(function (SecurityKey $key) { return $key->getPublicKeyCredentialDescriptor(); })->values()->toArray(); @@ -28,7 +25,7 @@ class CreatePublicKeyCredentialsService $server = $this->webauthnServerRepository->getServer($user); return $server->generatePublicKeyCredentialCreationOptions( - $entity, + $user->toPublicKeyCredentialEntity(), PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, $excluded ); diff --git a/resources/scripts/api/account/webauthn/webauthnChallenge.ts b/resources/scripts/api/account/webauthn/webauthnChallenge.ts index cd0344d6f..f5374a44c 100644 --- a/resources/scripts/api/account/webauthn/webauthnChallenge.ts +++ b/resources/scripts/api/account/webauthn/webauthnChallenge.ts @@ -22,12 +22,10 @@ export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Pr const data = { confirmation_token: token, - data: JSON.stringify({ id: credential.id, type: credential.type, rawId: bufferEncode(credential.rawId), - response: { authenticatorData: bufferEncode(response.authenticatorData), clientDataJSON: bufferEncode(response.clientDataJSON), diff --git a/routes/auth.php b/routes/auth.php index 5e561603c..f2686a3b0 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -8,6 +8,9 @@ | Endpoint: /auth | */ + +use Pterodactyl\Http\Controllers\Auth; + Route::group(['middleware' => 'guest'], function () { // 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. @@ -21,9 +24,9 @@ Route::group(['middleware' => 'guest'], function () { // @see \Pterodactyl\Providers\RouteServiceProvider Route::middleware(['throttle:authentication'])->group(function () { // Login endpoints. - Route::post('/login', 'LoginController@login')->middleware('recaptcha'); - Route::post('/login/checkpoint', 'LoginCheckpointController')->name('auth.login-checkpoint'); - Route::post('/login/checkpoint/key', 'WebauthnController@auth')->name('auth.login-checkpoint-key'); + Route::post('/login', [Auth\LoginController::class, 'login'])->middleware('recaptcha'); + Route::post('/login/checkpoint', [Auth\LoginCheckpointController::class, 'token'])->name('auth.login-checkpoint'); + 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 // email to be sent containing a reset token.