From 28146f5bb66e89c8eeccaf6a12f1d49d73497378 Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Sat, 17 Jul 2021 11:47:07 -0600 Subject: [PATCH] webauthn: add controllers and transformers --- .../Api/Client/WebauthnController.php | 125 ++++++++++++++++++ .../Auth/LoginCheckpointController.php | 46 ++----- app/Http/Controllers/Auth/LoginController.php | 63 ++++++--- .../Controllers/Auth/WebauthnController.php | 99 ++++++++++++++ app/Models/User.php | 47 +++---- .../Api/Client/WebauthnKeyTransformer.php | 29 ++++ .../2019_03_29_163611_add_webauthn.php | 10 +- 7 files changed, 329 insertions(+), 90 deletions(-) create mode 100644 app/Http/Controllers/Api/Client/WebauthnController.php create mode 100644 app/Http/Controllers/Auth/WebauthnController.php create mode 100644 app/Transformers/Api/Client/WebauthnKeyTransformer.php diff --git a/app/Http/Controllers/Api/Client/WebauthnController.php b/app/Http/Controllers/Api/Client/WebauthnController.php new file mode 100644 index 000000000..f988c7a16 --- /dev/null +++ b/app/Http/Controllers/Api/Client/WebauthnController.php @@ -0,0 +1,125 @@ +fractal->collection(WebauthnKey::query()->where('user_id', '=', $request->user()->id)->get()) + ->transformWith($this->getTransformer(WebauthnKeyTransformer::class)) + ->toArray(); + } + + /** + * ? + */ + public function register(Request $request): JsonResponse + { + if (!Webauthn::canRegister($request->user())) { + return new JsonResponse([ + 'error' => [ + 'message' => trans('webauthn::errors.cannot_register_new_key'), + ], + ], JsonResponse::HTTP_FORBIDDEN); + } + + $publicKey = Webauthn::getRegisterData($request->user()); + + $request->session()->put(self::SESSION_PUBLICKEY_CREATION, $publicKey); + $request->session()->save(); + + return new JsonResponse([ + 'public_key' => $publicKey, + ]); + } + + /** + * ? + * + * @return array|JsonResponse + */ + public function create(Request $request) + { + if (!Webauthn::canRegister($request->user())) { + return new JsonResponse([ + 'error' => [ + 'message' => trans('webauthn::errors.cannot_register_new_key'), + ], + ], JsonResponse::HTTP_FORBIDDEN); + } + + if ($request->input('register') === null) { + throw new BadRequestHttpException('Missing register data in request body.'); + } + + if ($request->input('name') === null) { + throw new BadRequestHttpException('Missing name in request body.'); + } + + try { + $publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_CREATION); + if (!$publicKey instanceof PublicKeyCredentialCreationOptions) { + throw new ModelNotFoundException(trans('webauthn::errors.create_data_not_found')); + } + + $webauthnKey = Webauthn::doRegister( + $request->user(), + $publicKey, + $request->input('register'), + $request->input('name'), + ); + + return $this->fractal->item($webauthnKey) + ->transformWith($this->getTransformer(WebauthnKeyTransformer::class)) + ->toArray(); + } catch (Exception $e) { + return new JsonResponse([ + 'error' => [ + 'message' => $e->getMessage(), + ], + ], JsonResponse::HTTP_FORBIDDEN); + } + } + + /** + * ? + */ + public function deleteKey(Request $request, int $webauthnKeyId): JsonResponse + { + try { + WebauthnKey::query() + ->where('user_id', $request->user()->getAuthIdentifier()) + ->findOrFail($webauthnKeyId) + ->delete(); + + return new JsonResponse([ + 'deleted' => true, + 'id' => $webauthnKeyId, + ]); + } catch (ModelNotFoundException $e) { + return new JsonResponse([ + 'error' => [ + 'message' => trans('webauthn::errors.object_not_found'), + ], + ], JsonResponse::HTTP_NOT_FOUND); + } + } +} diff --git a/app/Http/Controllers/Auth/LoginCheckpointController.php b/app/Http/Controllers/Auth/LoginCheckpointController.php index 3710da3a2..25c5c5ebf 100644 --- a/app/Http/Controllers/Auth/LoginCheckpointController.php +++ b/app/Http/Controllers/Auth/LoginCheckpointController.php @@ -11,55 +11,29 @@ use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Database\Eloquent\ModelNotFoundException; use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest; use Illuminate\Contracts\Cache\Repository as CacheRepository; -use Pterodactyl\Contracts\Repository\UserRepositoryInterface; -use Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository; class LoginCheckpointController extends AbstractLoginController { - /** - * @var \Illuminate\Contracts\Cache\Repository - */ - private $cache; + private CacheRepository $cache; + private Encrypter $encrypter; + private Google2FA $google2FA; - /** - * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface - */ - private $repository; - - /** - * @var \PragmaRX\Google2FA\Google2FA - */ - private $google2FA; - - /** - * @var \Illuminate\Contracts\Encryption\Encrypter - */ - private $encrypter; - - /** - * @var \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository - */ - private $recoveryTokenRepository; /** * LoginCheckpointController constructor. */ public function __construct( AuthManager $auth, - Encrypter $encrypter, - Google2FA $google2FA, Repository $config, CacheRepository $cache, - RecoveryTokenRepository $recoveryTokenRepository, - UserRepositoryInterface $repository + Encrypter $encrypter, + Google2FA $google2FA ) { parent::__construct($auth, $config); - $this->google2FA = $google2FA; $this->cache = $cache; - $this->repository = $repository; $this->encrypter = $encrypter; - $this->recoveryTokenRepository = $recoveryTokenRepository; + $this->google2FA = $google2FA; } /** @@ -72,13 +46,13 @@ class LoginCheckpointController extends AbstractLoginController * @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException * @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException * @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException - * @throws \Exception * @throws \Illuminate\Validation\ValidationException */ public function __invoke(LoginCheckpointRequest $request): JsonResponse { if ($this->hasTooManyLoginAttempts($request)) { $this->sendLockoutResponse($request); + return; } $token = $request->input('confirmation_token'); @@ -88,11 +62,12 @@ class LoginCheckpointController extends AbstractLoginController } catch (ModelNotFoundException $exception) { $this->incrementLoginAttempts($request); - return $this->sendFailedLoginResponse( + $this->sendFailedLoginResponse( $request, null, 'The authentication token provided has expired, please refresh the page and try again.' ); + return; } // Recovery tokens go through a slightly different pathway for usage. @@ -111,8 +86,7 @@ class LoginCheckpointController extends AbstractLoginController } $this->incrementLoginAttempts($request); - - return $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); } /** diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index db74ab125..7942a27b1 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Illuminate\Auth\AuthManager; use Illuminate\Http\JsonResponse; use Illuminate\Contracts\View\View; +use LaravelWebauthn\Facades\Webauthn; use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Contracts\Cache\Repository as CacheRepository; @@ -17,19 +18,13 @@ use Pterodactyl\Exceptions\Repository\RecordNotFoundException; class LoginController extends AbstractLoginController { /** - * @var \Illuminate\Contracts\View\Factory + * @var string */ - private $view; + private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest'; - /** - * @var \Illuminate\Contracts\Cache\Repository - */ - private $cache; - - /** - * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface - */ - private $repository; + private CacheRepository $cache; + private UserRepositoryInterface $repository; + private ViewFactory $view; /** * LoginController constructor. @@ -43,14 +38,14 @@ class LoginController extends AbstractLoginController ) { parent::__construct($auth, $config); - $this->view = $view; $this->cache = $cache; $this->repository = $repository; + $this->view = $view; } /** * Handle all incoming requests for the authentication routes and render the - * base authentication view component. Vuejs will take over at this point and + * base authentication view component. React will take over at this point and * turn the login area into a SPA. */ public function index(): View @@ -74,31 +69,57 @@ class LoginController extends AbstractLoginController if ($this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); $this->sendLockoutResponse($request); + return; } try { + /** @var \Pterodactyl\Models\User $user */ $user = $this->repository->findFirstWhere([[$useColumn, '=', $username]]); } catch (RecordNotFoundException $exception) { - return $this->sendFailedLoginResponse($request); + $this->sendFailedLoginResponse($request); + return; } // Ensure that the account is using a valid username and password before trying to // continue. Previously this was handled in the 2FA checkpoint, however that has // a flaw in which you can discover if an account exists simply by seeing if you - // can proceede to the next step in the login process. + // can proceed to the next step in the login process. if (!password_verify($request->input('password'), $user->password)) { - return $this->sendFailedLoginResponse($request, $user); + $this->sendFailedLoginResponse($request, $user); + return; } - if ($user->use_totp) { + $webauthnKeys = $user->webauthnKeys()->get(); + + if (sizeof($webauthnKeys) > 0) { + $token = Str::random(64); + $this->cache->put($token, $user->id, CarbonImmutable::now()->addMinutes(5)); + + $publicKey = Webauthn::getAuthenticateData($user); + $request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey); + $request->session()->save(); + + $methods = ['webauthn']; + if ($user->use_totp) { + $methods[] = 'totp'; + } + + return new JsonResponse([ + 'complete' => false, + 'methods' => $methods, + 'confirmation_token' => $token, + 'webauthn' => [ + 'public_key' => $publicKey, + ], + ]); + } else if ($user->use_totp) { $token = Str::random(64); $this->cache->put($token, $user->id, CarbonImmutable::now()->addMinutes(5)); return new JsonResponse([ - 'data' => [ - 'complete' => false, - 'confirmation_token' => $token, - ], + 'complete' => false, + 'methods' => ['totp'], + 'confirmation_token' => $token, ]); } diff --git a/app/Http/Controllers/Auth/WebauthnController.php b/app/Http/Controllers/Auth/WebauthnController.php new file mode 100644 index 000000000..b39e70109 --- /dev/null +++ b/app/Http/Controllers/Auth/WebauthnController.php @@ -0,0 +1,99 @@ +cache = $cache; + } + + /** + * @return JsonResponse|void + * + * @throws \Illuminate\Validation\ValidationException + * @throws \Pterodactyl\Exceptions\DisplayException + */ + public function auth(Request $request): JsonResponse + { + if ($this->hasTooManyLoginAttempts($request)) { + $this->sendLockoutResponse($request); + return; + } + + $token = $request->input('confirmation_token'); + try { + /** @var \Pterodactyl\Models\User $user */ + $user = User::query()->findOrFail($this->cache->get($token, 0)); + } catch (ModelNotFoundException $exception) { + $this->incrementLoginAttempts($request); + + $this->sendFailedLoginResponse( + $request, + null, + 'The authentication token provided has expired, please refresh the page and try again.' + ); + return; + } + $this->auth->guard()->onceUsingId($user->id); + + try { + $publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_REQUEST); + if (!$publicKey instanceof PublicKeyCredentialRequestOptions) { + throw new ModelNotFoundException(trans('webauthn::errors.auth_data_not_found')); + } + + $result = Webauthn::doAuthenticate( + $user, + $publicKey, + $this->input($request, 'data'), + ); + + if (!$result) { + return new JsonResponse([ + 'error' => [ + 'message' => 'Nice attempt, you didn\'t pass the challenge.', + ], + ], JsonResponse::HTTP_I_AM_A_TEAPOT); + } + + $this->cache->delete($token); + + return $this->sendLoginResponse($user, $request); + } catch (Exception $e) { + return new JsonResponse([ + 'error' => [ + 'message' => $e->getMessage(), + ], + ], JsonResponse::HTTP_FORBIDDEN); + } + } + + /** + * Retrieve the input with a string result. + */ + private function input(Request $request, string $name, string $default = ''): string + { + $result = $request->input($name); + + return is_string($result) ? $result : $default; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 65eac44d5..1378ee43a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,13 +4,15 @@ namespace Pterodactyl\Models; use Pterodactyl\Rules\Username; use Illuminate\Support\Collection; -use Illuminate\Validation\Rules\In; use Illuminate\Auth\Authenticatable; +use LaravelWebauthn\Models\WebauthnKey; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Auth\Passwords\CanResetPassword; use Pterodactyl\Traits\Helpers\AvailableLanguages; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\Access\Authorizable; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; @@ -35,9 +37,11 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property string $name + * @property \Pterodactyl\Models\AdminRole $adminRole * @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens + * @property \LaravelWebauthn\Models\WebauthnKey[]|\Illuminate\Database\Eloquent\Collection $webauthnKeys */ class User extends Model implements AuthenticatableContract, @@ -227,50 +231,37 @@ class User extends Model implements return $role->name; } - /** - * Gets the admin role associated with a user. - * - * @return \Illuminate\Database\Eloquent\Relations\HasOne - */ - public function adminRole() + public function adminRole(): HasOne { return $this->hasOne(AdminRole::class, 'id', 'admin_role_id'); } - /** - * Returns all servers that a user owns. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function servers() - { - return $this->hasMany(Server::class, 'owner_id'); - } - - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function apiKeys() + public function apiKeys(): HasMany { return $this->hasMany(ApiKey::class) ->where('key_type', ApiKey::TYPE_ACCOUNT); } - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function recoveryTokens() + public function servers(): HasMany + { + return $this->hasMany(Server::class, 'owner_id'); + } + + public function recoveryTokens(): HasMany { return $this->hasMany(RecoveryToken::class); } + public function webauthnKeys(): HasMany + { + return $this->hasMany(WebauthnKey::class); + } + /** * Returns all of the servers that a user can access by way of being the owner of the * server, or because they are assigned as a subuser for that server. - * - * @return \Illuminate\Database\Eloquent\Builder */ - public function accessibleServers() + public function accessibleServers(): Builder { return Server::query() ->select('servers.*') diff --git a/app/Transformers/Api/Client/WebauthnKeyTransformer.php b/app/Transformers/Api/Client/WebauthnKeyTransformer.php new file mode 100644 index 000000000..b76b3a2da --- /dev/null +++ b/app/Transformers/Api/Client/WebauthnKeyTransformer.php @@ -0,0 +1,29 @@ + $model->id, + 'name' => $model->name, + 'created_at' => $model->created_at->toIso8601String(), + 'last_used_at' => now()->toIso8601String(), + ]; + } +} diff --git a/database/migrations/2019_03_29_163611_add_webauthn.php b/database/migrations/2019_03_29_163611_add_webauthn.php index f4187460d..5e9718a4d 100644 --- a/database/migrations/2019_03_29_163611_add_webauthn.php +++ b/database/migrations/2019_03_29_163611_add_webauthn.php @@ -18,18 +18,18 @@ class AddWebauthn extends Migration $table->unsignedInteger('user_id'); $table->string('name')->default('key'); - $table->string('credential_id', 255); + $table->string('credentialId', 255); $table->string('type', 255); $table->text('transports'); - $table->string('attestation_type', 255); - $table->text('trust_path'); + $table->string('attestationType', 255); + $table->text('trustPath'); $table->text('aaguid'); - $table->text('credential_public_key'); + $table->text('credentialPublicKey'); $table->integer('counter'); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); - $table->index('credential_id'); + $table->index('credentialId'); }); }