diff --git a/app/Http/Controllers/Api/Client/HardwareTokenController.php b/app/Http/Controllers/Api/Client/HardwareTokenController.php deleted file mode 100644 index 09ead408b..000000000 --- a/app/Http/Controllers/Api/Client/HardwareTokenController.php +++ /dev/null @@ -1,74 +0,0 @@ -cache = $cache; - $this->createPublicKeyCredentials = $createPublicKeyCredentials; - } - - /** - * Returns all of the hardware security keys (WebAuthn) that exists for a user. - */ - public function index(Request $request): array - { - return []; - } - - /** - * Returns the data necessary for creating a new hardware security key for the - * user. - */ - public function create(Request $request): JsonResponse - { - $tokenId = Str::random(64); - $credentials = $this->createPublicKeyCredentials->handle($request->user()); - - $this->cache->put("webauthn:$tokenId", [ - 'credentials' => $credentials->jsonSerialize(), - 'user_entity' => $credentials->getUser()->jsonSerialize(), - ], CarbonImmutable::now()->addMinutes(10)); - - return new JsonResponse([ - 'data' => [ - 'token_id' => $tokenId, - 'credentials' => $credentials->jsonSerialize(), - ], - ]); - } - - /** - * Stores a new key for a user account. - */ - public function store(RegisterWebauthnTokenRequest $request): JsonResponse - { - return new JsonResponse([]); - } - - /** - * Removes a WebAuthn key from a user's account. - */ - public function delete(Request $request, int $webauthnKeyId): JsonResponse - { - return new JsonResponse([]); - } -} diff --git a/app/Http/Controllers/Api/Client/SecurityKeyController.php b/app/Http/Controllers/Api/Client/SecurityKeyController.php new file mode 100644 index 000000000..125b09c76 --- /dev/null +++ b/app/Http/Controllers/Api/Client/SecurityKeyController.php @@ -0,0 +1,131 @@ +cache = $cache; + $this->webauthnServerRepository = $webauthnServerRepository; + $this->createPublicKeyCredentials = $createPublicKeyCredentials; + } + + /** + * Returns all of the hardware security keys (WebAuthn) that exists for a user. + */ + public function index(Request $request): array + { + return []; + } + + /** + * Returns the data necessary for creating a new hardware security key for the + * user. + */ + public function create(Request $request): JsonResponse + { + $tokenId = Str::random(64); + $credentials = $this->createPublicKeyCredentials->handle($request->user(), $request->get('display_name')); + + $this->cache->put( + "register-security-key:$tokenId", + serialize($credentials), + CarbonImmutable::now()->addMinutes(10) + ); + + return new JsonResponse([ + 'data' => [ + 'token_id' => $tokenId, + 'credentials' => $credentials->jsonSerialize(), + ], + ]); + } + + /** + * Stores a new key for a user account. + * + * @throws \Exception + * @throws \Pterodactyl\Exceptions\DisplayException + */ + public function store(RegisterWebauthnTokenRequest $request): JsonResponse + { + $stored = $this->cache->pull("register-security-key:{$request->input('token_id')}"); + + if (!$stored) { + throw new DisplayException('Could not register security key: no data present in session, please try your request again.'); + } + + $credentials = unserialize($stored); + if (!$credentials instanceof PublicKeyCredentialCreationOptions) { + throw new Exception(sprintf('Unexpected security key data pulled from cache: expected "%s" but got "%s".', PublicKeyCredentialCreationOptions::class, get_class($credentials))); + } + + $server = $this->webauthnServerRepository->getServer($request->user()); + + $source = $server->loadAndCheckAttestationResponse( + json_encode($request->input('registration')), + $credentials, + $this->getServerRequest($request), + ); + + // Unfortunately this repository interface doesn't define a response — it is explicitly + // void — so we need to just query the database immediately after this to pull the information + // we just stored to return to the caller. + PublicKeyCredentialSourceRepository::factory($request->user())->saveCredentialSource($source); + + $created = $request->user()->securityKeys() + ->where('public_key_id', base64_encode($source->getPublicKeyCredentialId())) + ->first(); + + $created->update(['name' => $request->input('name')]); + + return new JsonResponse([ + 'data' => $created->toArray(), + ]); + } + + /** + * Removes a WebAuthn key from a user's account. + */ + public function delete(Request $request, int $webauthnKeyId): JsonResponse + { + return new JsonResponse([]); + } + + 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/Requests/Api/Client/Account/RegisterWebauthnTokenRequest.php b/app/Http/Requests/Api/Client/Account/RegisterWebauthnTokenRequest.php index ad67341bb..42255ab8c 100644 --- a/app/Http/Requests/Api/Client/Account/RegisterWebauthnTokenRequest.php +++ b/app/Http/Requests/Api/Client/Account/RegisterWebauthnTokenRequest.php @@ -10,8 +10,12 @@ class RegisterWebauthnTokenRequest extends AccountApiRequest { return [ 'name' => ['string', 'required'], - 'register' => ['string', 'required'], - 'public_key' => ['string', 'required'], + 'token_id' => ['required', 'string'], + 'registration' => ['required', 'array'], + 'registration.id' => ['required', 'string'], + 'registration.type' => ['required', 'in:public-key'], + 'registration.response.attestationObject' => ['required', 'string'], + 'registration.response.clientDataJSON' => ['required', 'string'], ]; } } diff --git a/app/Models/HardwareSecurityKey.php b/app/Models/SecurityKey.php similarity index 87% rename from app/Models/HardwareSecurityKey.php rename to app/Models/SecurityKey.php index 4f2797b14..947491460 100644 --- a/app/Models/HardwareSecurityKey.php +++ b/app/Models/SecurityKey.php @@ -6,19 +6,24 @@ use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialDescriptor; use Illuminate\Database\Eloquent\Factories\HasFactory; -class HardwareSecurityKey extends Model +class SecurityKey extends Model { use HasFactory; - public const RESOURCE_NAME = 'hardware_security_key'; + public const RESOURCE_NAME = 'security_key'; - protected $attributes = [ + protected $casts = [ 'user_id' => 'int', 'transports' => 'array', 'trust_path' => 'array', 'other_ui' => 'array', ]; + protected $guarded = [ + 'uuid', + 'user_id', + ]; + public function user() { return $this->belongsTo(User::class); diff --git a/app/Models/User.php b/app/Models/User.php index 01966d8e2..60fa80504 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -210,9 +210,9 @@ class User extends Model implements return $this->hasMany(RecoveryToken::class); } - public function hardwareSecurityKeys(): HasMany + public function securityKeys(): HasMany { - return $this->hasMany(HardwareSecurityKey::class); + return $this->hasMany(SecurityKey::class); } /** diff --git a/app/Repositories/Webauthn/PublicKeyCredentialSourceRepository.php b/app/Repositories/SecurityKeys/PublicKeyCredentialSourceRepository.php similarity index 58% rename from app/Repositories/Webauthn/PublicKeyCredentialSourceRepository.php rename to app/Repositories/SecurityKeys/PublicKeyCredentialSourceRepository.php index 0367cba04..ed1971fce 100644 --- a/app/Repositories/Webauthn/PublicKeyCredentialSourceRepository.php +++ b/app/Repositories/SecurityKeys/PublicKeyCredentialSourceRepository.php @@ -1,12 +1,13 @@ user->hardwareSecurityKeys() + /** @var \Pterodactyl\Models\SecurityKey $key */ + $key = $this->user->securityKeys() ->where('public_key_id', $id) ->first(); @@ -37,21 +38,36 @@ class PublicKeyCredentialSourceRepository implements PublicKeyRepositoryInterfac */ public function findAllForUserEntity(PublicKeyCredentialUserEntity $entity): array { - $results = $this->user->hardwareSecurityKeys() + $results = $this->user->securityKeys() ->where('user_handle', $entity->getId()) ->get(); - return $results->map(function (HardwareSecurityKey $key) { + return $results->map(function (SecurityKey $key) { return $key->toCredentialSource(); })->values()->toArray(); } /** * Save a credential to the database and link it with the user. + * + * @throws \Throwable */ public function saveCredentialSource(PublicKeyCredentialSource $source): void { - // todo: implement + $this->user->securityKeys()->forceCreate([ + 'uuid' => Uuid::uuid4()->toString(), + 'user_id' => $this->user->id, + 'public_key_id' => base64_encode($source->getPublicKeyCredentialId()), + 'public_key' => base64_encode($source->getCredentialPublicKey()), + 'aaguid' => $source->getAaguid()->toString(), + 'type' => $source->getType(), + 'transports' => $source->getTransports(), + 'attestation_type' => $source->getAttestationType(), + 'trust_path' => $source->getTrustPath()->jsonSerialize(), + 'user_handle' => $source->getUserHandle(), + 'counter' => $source->getCounter(), + 'other_ui' => $source->getOtherUI(), + ]); } /** diff --git a/app/Repositories/SecurityKeys/WebauthnServerRepository.php b/app/Repositories/SecurityKeys/WebauthnServerRepository.php new file mode 100644 index 000000000..9d371f5e8 --- /dev/null +++ b/app/Repositories/SecurityKeys/WebauthnServerRepository.php @@ -0,0 +1,28 @@ +rpEntity = new PublicKeyCredentialRpEntity(config('app.name'), trim($url, '/')); + } + + public function getServer(User $user) + { + return new Server( + $this->rpEntity, + PublicKeyCredentialSourceRepository::factory($user) + ); + } +} diff --git a/app/Services/Users/HardwareSecurityKeys/CreatePublicKeyCredentialsService.php b/app/Services/Users/HardwareSecurityKeys/CreatePublicKeyCredentialsService.php deleted file mode 100644 index 20bd62152..000000000 --- a/app/Services/Users/HardwareSecurityKeys/CreatePublicKeyCredentialsService.php +++ /dev/null @@ -1,49 +0,0 @@ -rpEntity = new PublicKeyCredentialRpEntity(config('app.name'), trim($url, '/')); - } - - public function handle(User $user): PublicKeyCredentialCreationOptions - { - $id = Uuid::uuid4()->toString(); - - $entity = new PublicKeyCredentialUserEntity($user->uuid, $id, $user->email); - - $excluded = $user->hardwareSecurityKeys->map(function (HardwareSecurityKey $key) { - return $key->toCredentialsDescriptor(); - })->values()->toArray(); - - return $this->getServerInstance($user)->generatePublicKeyCredentialCreationOptions( - $entity, - PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, - $excluded - ); - } - - protected function getServerInstance(User $user) - { - return new Server( - $this->rpEntity, - PublicKeyCredentialSourceRepository::factory($user) - ); - } -} diff --git a/app/Services/Users/SecurityKeys/CreatePublicKeyCredentialsService.php b/app/Services/Users/SecurityKeys/CreatePublicKeyCredentialsService.php new file mode 100644 index 000000000..5c8a0daca --- /dev/null +++ b/app/Services/Users/SecurityKeys/CreatePublicKeyCredentialsService.php @@ -0,0 +1,39 @@ +webauthnServerRepository = $webauthnServerRepository; + } + + public function handle(User $user, ?string $displayName): PublicKeyCredentialCreationOptions + { + $id = Uuid::uuid4()->toString(); + + $entity = new PublicKeyCredentialUserEntity($user->uuid, $id, $name ?? $user->email); + + $excluded = $user->securityKeys->map(function (SecurityKey $key) { + return $key->toCredentialsDescriptor(); + })->values()->toArray(); + + $server = $this->webauthnServerRepository->getServer($user); + + return $server->generatePublicKeyCredentialCreationOptions( + $entity, + PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, + $excluded + ); + } +} diff --git a/app/Transformers/Api/Client/WebauthnKeyTransformer.php b/app/Transformers/Api/Client/WebauthnKeyTransformer.php index 3f7b695ca..b19e79976 100644 --- a/app/Transformers/Api/Client/WebauthnKeyTransformer.php +++ b/app/Transformers/Api/Client/WebauthnKeyTransformer.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Transformers\Api\Client; -use Pterodactyl\Models\HardwareSecurityKey; +use Pterodactyl\Models\SecurityKey; use Pterodactyl\Transformers\Api\Transformer; class WebauthnKeyTransformer extends Transformer @@ -12,13 +12,13 @@ class WebauthnKeyTransformer extends Transformer */ public function getResourceName(): string { - return HardwareSecurityKey::RESOURCE_NAME; + return SecurityKey::RESOURCE_NAME; } /** * Return basic information about the currently logged in user. */ - public function transform(HardwareSecurityKey $model): array + public function transform(SecurityKey $model): array { return [ 'id' => $model->id, diff --git a/composer.json b/composer.json index ea5caa291..03d821a1e 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "league/flysystem-aws-s3-v3": "^1.0", "league/flysystem-memory": "^1.0", "matriphe/iso-639": "^1.2", + "nyholm/psr7": "^1.4", "pragmarx/google2fa": "^8.0", "predis/predis": "^1.1", "prologue/alerts": "^0.4", @@ -40,6 +41,7 @@ "spatie/laravel-fractal": "^5.8", "spatie/laravel-query-builder": "^3.5", "staudenmeir/belongs-to-through": "^2.11", + "symfony/psr-http-message-bridge": "^2.1", "symfony/yaml": "^5.3", "web-auth/webauthn-lib": "^3.3", "webmozart/assert": "^1.10" diff --git a/composer.lock b/composer.lock index 2628b6bc2..9f6f58fb8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f6da942b0b0b32416bca002dff11f403", + "content-hash": "576d3b9784111373d9df4790023538d8", "packages": [ { "name": "aws/aws-sdk-php", @@ -3314,6 +3314,83 @@ }, "time": "2021-07-21T10:44:31+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "2212385b47153ea71b1c1b1374f8cb5e4f7892ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/2212385b47153ea71b1c1b1374f8cb5e4f7892ec", + "reference": "2212385b47153ea71b1c1b1374f8cb5e4f7892ec", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "php-http/message-factory": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || 8.5 || 9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.4.1" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2021-07-02T08:32:20+00:00" + }, { "name": "opis/closure", "version": "3.6.2", @@ -3446,6 +3523,60 @@ }, "time": "2020-12-06T15:14:20+00:00" }, + { + "name": "php-http/message-factory", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/message-factory.git", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Factory interfaces for PSR-7 HTTP Message", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "stream", + "uri" + ], + "support": { + "issues": "https://github.com/php-http/message-factory/issues", + "source": "https://github.com/php-http/message-factory/tree/master" + }, + "time": "2015-12-19T14:08:53+00:00" + }, { "name": "phpoption/phpoption", "version": "1.7.5", @@ -6587,6 +6718,94 @@ ], "time": "2021-07-23T15:54:19+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "c9012994c4b4fb23e7c57dd86b763a417a04feba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/c9012994c4b4fb23e7c57dd86b763a417a04feba", + "reference": "c9012994c4b4fb23e7c57dd86b763a417a04feba", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0", + "symfony/http-foundation": "^4.4 || ^5.0" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "psr/log": "^1.1 || ^2 || ^3", + "symfony/browser-kit": "^4.4 || ^5.0", + "symfony/config": "^4.4 || ^5.0", + "symfony/event-dispatcher": "^4.4 || ^5.0", + "symfony/framework-bundle": "^4.4 || ^5.0", + "symfony/http-kernel": "^4.4 || ^5.0", + "symfony/phpunit-bridge": "^4.4.19 || ^5.2" + }, + "suggest": { + "nyholm/psr7": "For a super lightweight PSR-7/17 implementation" + }, + "type": "symfony-bridge", + "extra": { + "branch-alias": { + "dev-main": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "http://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/symfony/psr-http-message-bridge/issues", + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v2.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-07-27T17:25:39+00:00" + }, { "name": "symfony/routing", "version": "v5.3.4", diff --git a/database/Factories/WebauthnKeyFactory.php b/database/Factories/WebauthnKeyFactory.php index b398b5b5c..de447e23d 100644 --- a/database/Factories/WebauthnKeyFactory.php +++ b/database/Factories/WebauthnKeyFactory.php @@ -2,7 +2,7 @@ namespace Database\Factories; -use Pterodactyl\Models\HardwareSecurityKey; +use Pterodactyl\Models\SecurityKey; use Illuminate\Database\Eloquent\Factories\Factory; class WebauthnKeyFactory extends Factory @@ -10,7 +10,7 @@ class WebauthnKeyFactory extends Factory /** * The name of the factory's corresponding model. */ - protected $model = HardwareSecurityKey::class; + protected $model = SecurityKey::class; /** * Define the model's default state. diff --git a/database/migrations/2021_08_07_170141_create_hardware_security_keys_table.php b/database/migrations/2021_08_07_170141_create_security_keys_table.php similarity index 64% rename from database/migrations/2021_08_07_170141_create_hardware_security_keys_table.php rename to database/migrations/2021_08_07_170141_create_security_keys_table.php index 7342adb06..a2efaddb4 100644 --- a/database/migrations/2021_08_07_170141_create_hardware_security_keys_table.php +++ b/database/migrations/2021_08_07_170141_create_security_keys_table.php @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class CreateHardwareSecurityKeysTable extends Migration +class CreateSecurityKeysTable extends Migration { /** * Run the migrations. @@ -13,10 +13,11 @@ class CreateHardwareSecurityKeysTable extends Migration */ public function up() { - Schema::create('hardware_security_keys', function (Blueprint $table) { + Schema::create('security_keys', function (Blueprint $table) { $table->id(); - $table->char('uuid', 36); - $table->unsignedInteger('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->char('uuid', 36)->unique(); + $table->unsignedInteger('user_id'); + $table->string('name'); $table->text('public_key_id'); $table->text('public_key'); $table->char('aaguid', 36); @@ -26,8 +27,10 @@ class CreateHardwareSecurityKeysTable extends Migration $table->json('trust_path'); $table->text('user_handle'); $table->unsignedInteger('counter'); - $table->json('other_ui'); + $table->json('other_ui')->nullable(); $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); }); } @@ -38,6 +41,6 @@ class CreateHardwareSecurityKeysTable extends Migration */ public function down() { - Schema::dropIfExists('hardware_security_keys'); + Schema::dropIfExists('security_keys'); } } diff --git a/resources/scripts/api/account/webauthn/getWebauthnKeys.ts b/resources/scripts/api/account/webauthn/getWebauthnKeys.ts index c2e305de7..6e790b60b 100644 --- a/resources/scripts/api/account/webauthn/getWebauthnKeys.ts +++ b/resources/scripts/api/account/webauthn/getWebauthnKeys.ts @@ -16,7 +16,7 @@ export const rawDataToWebauthnKey = (data: any): WebauthnKey => ({ export default (): Promise => { return new Promise((resolve, reject) => { - http.get('/api/client/account/webauthn') + http.get('/api/client/account/security-keys') .then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToWebauthnKey(d.attributes)))) .catch(reject); }); diff --git a/resources/scripts/api/account/webauthn/registerWebauthnKey.ts b/resources/scripts/api/account/webauthn/registerWebauthnKey.ts index 3de54a611..4532a513c 100644 --- a/resources/scripts/api/account/webauthn/registerWebauthnKey.ts +++ b/resources/scripts/api/account/webauthn/registerWebauthnKey.ts @@ -1,5 +1,4 @@ import http from '@/api/http'; -import { rawDataToWebauthnKey, WebauthnKey } from '@/api/account/webauthn/getWebauthnKeys'; export const base64Decode = (input: string): string => { input = input.replace(/-/g, '+').replace(/_/g, '/'); @@ -13,14 +12,10 @@ export const base64Decode = (input: string): string => { return input; }; -export const bufferDecode = (value: string): ArrayBuffer => { - return Uint8Array.from(window.atob(value), c => c.charCodeAt(0)); -}; +export const bufferDecode = (value: string): ArrayBuffer => Uint8Array.from(window.atob(value), c => c.charCodeAt(0)); -export const bufferEncode = (value: ArrayBuffer): string => { - // @ts-ignore - return window.btoa(String.fromCharCode.apply(null, new Uint8Array(value))); -}; +// @ts-ignore +export const bufferEncode = (value: ArrayBuffer): string => window.btoa(String.fromCharCode.apply(null, new Uint8Array(value))); export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[]) => { return credentials.map(c => { @@ -32,42 +27,44 @@ export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[]) }); }; -export default (name: string): Promise => { - return new Promise((resolve, reject) => { - http.get('/api/client/account/webauthn/register').then((res) => { - const publicKey = res.data.public_key; - const publicKeyCredential = Object.assign({}, publicKey); - - publicKeyCredential.user.id = bufferDecode(publicKey.user.id); - publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge)); - if (publicKey.excludeCredentials) { - publicKeyCredential.excludeCredentials = decodeCredentials(publicKey.excludeCredentials); - } - - return navigator.credentials.create({ - publicKey: publicKeyCredential, - }); - }).then((c) => { - if (c === null) { - return; - } - const credential = c as PublicKeyCredential; - const response = credential.response as AuthenticatorAttestationResponse; - - http.post('/api/client/account/webauthn/register', { - name: name, - - register: JSON.stringify({ - id: credential.id, - type: credential.type, - rawId: bufferEncode(credential.rawId), - - response: { - attestationObject: bufferEncode(response.attestationObject), - clientDataJSON: bufferEncode(response.clientDataJSON), - }, - }), - }).then(({ data }) => resolve(rawDataToWebauthnKey(data.attributes))).catch(reject); - }).catch(reject); +const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential) => { + const { data } = await http.post('/api/client/account/security-keys/register', { + name, + token_id: tokenId, + registration: { + id: credential.id, + type: credential.type, + rawId: bufferEncode(credential.rawId), + response: { + attestationObject: bufferEncode((credential.response as AuthenticatorAttestationResponse).attestationObject), + clientDataJSON: bufferEncode(credential.response.clientDataJSON), + }, + }, }); + + console.log(data.data); +}; + +export const register = async (name: string): Promise => { + const { data } = await http.get('/api/client/account/security-keys/register', { + params: { + display_name: name, + }, + }); + + const publicKey = data.data.credentials; + publicKey.challenge = bufferDecode(base64Decode(publicKey.challenge)); + publicKey.user.id = bufferDecode(publicKey.user.id); + + if (publicKey.excludeCredentials) { + publicKey.excludeCredentials = decodeCredentials(publicKey.excludeCredentials); + } + + const credentials = await navigator.credentials.create({ publicKey }); + + if (!credentials || credentials.type !== 'public-key') { + throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`); + } + + await registerCredentialForAccount(name, data.data.token_id, credentials); }; diff --git a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx index f4092efec..f3a237a40 100644 --- a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx +++ b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx @@ -7,7 +7,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import deleteWebauthnKey from '@/api/account/webauthn/deleteWebauthnKey'; import getWebauthnKeys, { WebauthnKey } from '@/api/account/webauthn/getWebauthnKeys'; -import registerWebauthnKey from '@/api/account/webauthn/registerWebauthnKey'; +import { register } from '@/api/account/webauthn/registerWebauthnKey'; import FlashMessageRender from '@/components/FlashMessageRender'; import Button from '@/components/elements/Button'; import ContentBox from '@/components/elements/ContentBox'; @@ -28,14 +28,13 @@ const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) => const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers) => { clearFlashes('security_keys'); - registerWebauthnKey(name) - .then(key => { + register(name) + .then(() => { resetForm(); - onKeyAdded(key); }) - .catch(error => { - console.error(error); - clearAndAddHttpError({ key: 'security_keys', error }); + .catch(err => { + console.error(err); + clearAndAddHttpError({ key: 'security_keys', error: err }); }) .then(() => setSubmitting(false)); }; diff --git a/routes/api-client.php b/routes/api-client.php index a06d9104b..fe173b846 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -30,10 +30,10 @@ Route::group(['prefix' => '/account'], function () { Route::post('/api-keys', [Client\ApiKeyController::class, 'store']); Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']); - Route::get('/webauthn', [Client\HardwareTokenController::class, 'index'])->withoutMiddleware(RequireTwoFactorAuthentication::class); - Route::get('/webauthn/register', [Client\HardwareTokenController::class, 'create'])->withoutMiddleware(RequireTwoFactorAuthentication::class); - Route::post('/webauthn/register', [Client\HardwareTokenController::class, 'store'])->withoutMiddleware(RequireTwoFactorAuthentication::class); - Route::delete('/webauthn/{id}', [Client\HardwareTokenController::class, 'delete'])->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::get('/security-keys', [Client\SecurityKeyController::class, 'index'])->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::get('/security-keys/register', [Client\SecurityKeyController::class, 'create'])->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::post('/security-keys/register', [Client\SecurityKeyController::class, 'store'])->withoutMiddleware(RequireTwoFactorAuthentication::class); + Route::delete('/security-keys/{securityKey}', [Client\SecurityKeyController::class, 'delete'])->withoutMiddleware(RequireTwoFactorAuthentication::class); Route::get('/ssh', 'SSHKeyController@index'); Route::post('/ssh', 'SSHKeyController@store'); diff --git a/tests/Unit/Http/Middleware/RequireTwoFactorAuthenticationTest.php b/tests/Unit/Http/Middleware/RequireTwoFactorAuthenticationTest.php index 941d6cdfa..bee221c7f 100644 --- a/tests/Unit/Http/Middleware/RequireTwoFactorAuthenticationTest.php +++ b/tests/Unit/Http/Middleware/RequireTwoFactorAuthenticationTest.php @@ -4,7 +4,7 @@ namespace Pterodactyl\Tests\Unit\Http\Middleware; use Mockery as m; use Pterodactyl\Models\User; -use Pterodactyl\Models\HardwareSecurityKey; +use Pterodactyl\Models\SecurityKey; use Prologue\Alerts\AlertsMessageBag; use Pterodactyl\Exceptions\Http\TwoFactorAuthRequiredException; use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication; @@ -66,7 +66,7 @@ class RequireTwoFactorAuthenticationTest extends MiddlewareTestCase /** @var \Pterodactyl\Models\User $user */ $user = User::factory() - ->has(HardwareSecurityKey::factory()->count(1)) + ->has(SecurityKey::factory()->count(1)) ->create(['use_totp' => false]); $this->setRequestUserModel($user); @@ -141,7 +141,7 @@ class RequireTwoFactorAuthenticationTest extends MiddlewareTestCase /** @var \Pterodactyl\Models\User $user */ $user = User::factory() - ->has(HardwareSecurityKey::factory()->count(1)) + ->has(SecurityKey::factory()->count(1)) ->create(['use_totp' => false]); $this->setRequestUserModel($user); @@ -255,7 +255,7 @@ class RequireTwoFactorAuthenticationTest extends MiddlewareTestCase config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); /** @var \Pterodactyl\Models\User $user */ - $user = User::factory()->has(HardwareSecurityKey::factory()->count(1))->create(['use_totp' => false]); + $user = User::factory()->has(SecurityKey::factory()->count(1))->create(['use_totp' => false]); $this->setRequestUserModel($user); $this->assertFalse($user->use_totp); @@ -278,7 +278,7 @@ class RequireTwoFactorAuthenticationTest extends MiddlewareTestCase /** @var \Pterodactyl\Models\User $user */ $user = User::factory() - ->has(HardwareSecurityKey::factory()->count(1)) + ->has(SecurityKey::factory()->count(1)) ->create(['use_totp' => false, 'root_admin' => true]); $this->setRequestUserModel($user);