diff --git a/app/Http/Controllers/Api/Client/SecurityKeyController.php b/app/Http/Controllers/Api/Client/SecurityKeyController.php index 5f9ff713..96c493bd 100644 --- a/app/Http/Controllers/Api/Client/SecurityKeyController.php +++ b/app/Http/Controllers/Api/Client/SecurityKeyController.php @@ -12,9 +12,10 @@ use Psr\Http\Message\ServerRequestInterface; use Pterodactyl\Exceptions\DisplayException; use Webauthn\PublicKeyCredentialCreationOptions; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Pterodactyl\Transformers\Api\Client\SecurityKeyTransformer; use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository; +use Pterodactyl\Services\Users\SecurityKeys\StoreSecurityKeyService; use Pterodactyl\Http\Requests\Api\Client\Account\RegisterWebauthnTokenRequest; -use Pterodactyl\Repositories\SecurityKeys\PublicKeyCredentialSourceRepository; use Pterodactyl\Services\Users\SecurityKeys\CreatePublicKeyCredentialsService; class SecurityKeyController extends ClientApiController @@ -25,9 +26,12 @@ class SecurityKeyController extends ClientApiController protected WebauthnServerRepository $webauthnServerRepository; + protected StoreSecurityKeyService $storeSecurityKeyService; + public function __construct( Repository $cache, WebauthnServerRepository $webauthnServerRepository, + StoreSecurityKeyService $storeSecurityKeyService, CreatePublicKeyCredentialsService $createPublicKeyCredentials ) { parent::__construct(); @@ -35,6 +39,7 @@ class SecurityKeyController extends ClientApiController $this->cache = $cache; $this->webauthnServerRepository = $webauthnServerRepository; $this->createPublicKeyCredentials = $createPublicKeyCredentials; + $this->storeSecurityKeyService = $storeSecurityKeyService; } /** @@ -42,7 +47,9 @@ class SecurityKeyController extends ClientApiController */ public function index(Request $request): array { - return []; + return $this->fractal->collection($request->user()->securityKeys) + ->transformWith(SecurityKeyTransformer::class) + ->toArray(); } /** @@ -74,7 +81,7 @@ class SecurityKeyController extends ClientApiController * @throws \Pterodactyl\Exceptions\DisplayException * @throws \Throwable */ - public function store(RegisterWebauthnTokenRequest $request): JsonResponse + public function store(RegisterWebauthnTokenRequest $request): array { $credentials = unserialize( $this->cache->pull("register-security-key:{$request->input('token_id')}", serialize(null)) @@ -88,35 +95,24 @@ class SecurityKeyController extends ClientApiController throw new DisplayException('Could not register security key: invalid data present in session, please try again.'); } - $source = $this->webauthnServerRepository->getServer($request->user()) - ->loadAndCheckAttestationResponse( - json_encode($request->input('registration')), - $credentials, - $this->getServerRequest($request), - ); + $key = $this->storeSecurityKeyService + ->setRequest($this->getServerRequest($request)) + ->setKeyName($request->input('name')) + ->handle($request->user(), $request->input('registration'), $credentials); - // 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' => [], - ]); + return $this->fractal->item($key) + ->transformWith(SecurityKeyTransformer::class) + ->toArray(); } /** * Removes a WebAuthn key from a user's account. */ - public function delete(Request $request, int $webauthnKeyId): JsonResponse + public function delete(Request $request, string $securityKey): JsonResponse { - return new JsonResponse([]); + $request->user()->securityKeys()->where('uuid', $securityKey)->delete(); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } protected function getServerRequest(Request $request): ServerRequestInterface diff --git a/app/Services/Users/SecurityKeys/StoreSecurityKeyService.php b/app/Services/Users/SecurityKeys/StoreSecurityKeyService.php new file mode 100644 index 00000000..5e7f6518 --- /dev/null +++ b/app/Services/Users/SecurityKeys/StoreSecurityKeyService.php @@ -0,0 +1,74 @@ +serverRepository = $serverRepository; + } + + /** + * Sets the server request interface on the service, this is needed by the attestation + * checking service on the Webauthn server. + */ + public function setRequest(ServerRequestInterface $request): self + { + $this->request = $request; + + return $this; + } + + /** + * Sets the security key's name. If not provided a random string will be used. + */ + public function setKeyName(?string $name): self + { + $this->keyName = $name; + + return $this; + } + + /** + * Validates and stores a new hardware security key on a user's account. + * + * @throws \Throwable + */ + public function handle(User $user, array $registration, PublicKeyCredentialCreationOptions $options): SecurityKey + { + Assert::notNull($this->request, 'A request interface must be set on the service before it can be called.'); + + $source = $this->serverRepository->getServer($user) + ->loadAndCheckAttestationResponse(json_encode($registration), $options, $this->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($user)->saveCredentialSource($source); + + /** @var \Pterodactyl\Models\SecurityKey $created */ + $created = $user->securityKeys() + ->where('public_key_id', base64_encode($source->getPublicKeyCredentialId())) + ->first(); + + $created->update(['name' => $this->keyName ?? 'Security Key (' . Str::random() . ')']); + + return $created; + } +} diff --git a/app/Transformers/Api/Client/SecurityKeyTransformer.php b/app/Transformers/Api/Client/SecurityKeyTransformer.php new file mode 100644 index 00000000..3df19c37 --- /dev/null +++ b/app/Transformers/Api/Client/SecurityKeyTransformer.php @@ -0,0 +1,26 @@ + $key->uuid, + 'name' => $key->name, + 'type' => $key->type, + 'public_key_id' => base64_encode($key->public_key_id), + 'created_at' => self::formatTimestamp($key->created_at), + 'updated_at' => self::formatTimestamp($key->updated_at), + ]; + } +} diff --git a/resources/scripts/api/account/webauthn/deleteSecurityKey.ts b/resources/scripts/api/account/webauthn/deleteSecurityKey.ts new file mode 100644 index 00000000..3a234244 --- /dev/null +++ b/resources/scripts/api/account/webauthn/deleteSecurityKey.ts @@ -0,0 +1,5 @@ +import http from '@/api/http'; + +export default async (uuid: string): Promise => { + await http.delete(`/api/client/account/security-keys/${uuid}`); +}; diff --git a/resources/scripts/api/account/webauthn/deleteWebauthnKey.ts b/resources/scripts/api/account/webauthn/deleteWebauthnKey.ts deleted file mode 100644 index b7abb162..00000000 --- a/resources/scripts/api/account/webauthn/deleteWebauthnKey.ts +++ /dev/null @@ -1,9 +0,0 @@ -import http from '@/api/http'; - -export default (id: number): Promise => { - return new Promise((resolve, reject) => { - http.delete(`/api/client/account/webauthn/${id}`) - .then(() => resolve()) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/account/webauthn/getSecurityKeys.ts b/resources/scripts/api/account/webauthn/getSecurityKeys.ts new file mode 100644 index 00000000..4d6f9449 --- /dev/null +++ b/resources/scripts/api/account/webauthn/getSecurityKeys.ts @@ -0,0 +1,25 @@ +import http from '@/api/http'; + +export interface SecurityKey { + uuid: string; + name: string; + type: 'public-key'; + publicKeyId: string; + createdAt: Date; + updatedAt: Date; +} + +export const rawDataToSecurityKey = (data: any): SecurityKey => ({ + uuid: data.uuid, + name: data.name, + type: data.type, + publicKeyId: data.public_key_id, + createdAt: new Date(data.created_at), + updatedAt: new Date(data.updated_at), +}); + +export default async (): Promise => { + const { data } = await http.get('/api/client/account/security-keys'); + + return (data.data || []).map((datum: any) => rawDataToSecurityKey(datum.attributes)); +}; diff --git a/resources/scripts/api/account/webauthn/getWebauthnKeys.ts b/resources/scripts/api/account/webauthn/getWebauthnKeys.ts deleted file mode 100644 index 6e790b60..00000000 --- a/resources/scripts/api/account/webauthn/getWebauthnKeys.ts +++ /dev/null @@ -1,23 +0,0 @@ -import http from '@/api/http'; - -export interface WebauthnKey { - id: number; - name: string; - createdAt: Date; - lastUsedAt: Date | undefined; -} - -export const rawDataToWebauthnKey = (data: any): WebauthnKey => ({ - id: data.id, - name: data.name, - createdAt: new Date(data.created_at), - lastUsedAt: data.last_used_at ? new Date(data.last_used_at) : undefined, -}); - -export default (): Promise => { - return new Promise((resolve, reject) => { - 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/registerSecurityKey.ts similarity index 86% rename from resources/scripts/api/account/webauthn/registerWebauthnKey.ts rename to resources/scripts/api/account/webauthn/registerSecurityKey.ts index 4d62f7aa..85c7c3a5 100644 --- a/resources/scripts/api/account/webauthn/registerWebauthnKey.ts +++ b/resources/scripts/api/account/webauthn/registerSecurityKey.ts @@ -1,4 +1,5 @@ import http from '@/api/http'; +import { rawDataToSecurityKey, SecurityKey } from '@/api/account/webauthn/getSecurityKeys'; export const base64Decode = (input: string): string => { input = input.replace(/-/g, '+').replace(/_/g, '/'); @@ -27,7 +28,7 @@ export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[]) }); }; -const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential) => { +const registerCredentialForAccount = async (name: string, tokenId: string, credential: PublicKeyCredential): Promise => { const { data } = await http.post('/api/client/account/security-keys/register', { name, token_id: tokenId, @@ -42,10 +43,10 @@ const registerCredentialForAccount = async (name: string, tokenId: string, crede }, }); - console.log(data.data); + return rawDataToSecurityKey(data.attributes); }; -export const register = async (name: string): Promise => { +export default async (name: string): Promise => { const { data } = await http.get('/api/client/account/security-keys/register'); const publicKey = data.data.credentials; @@ -62,5 +63,5 @@ export const register = async (name: string): Promise => { throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`); } - await registerCredentialForAccount(name, data.data.token_id, credentials); + return await registerCredentialForAccount(name, data.data.token_id, credentials); }; diff --git a/resources/scripts/api/account/webauthn/webauthnChallenge.ts b/resources/scripts/api/account/webauthn/webauthnChallenge.ts index 9e7ff294..cd0344d6 100644 --- a/resources/scripts/api/account/webauthn/webauthnChallenge.ts +++ b/resources/scripts/api/account/webauthn/webauthnChallenge.ts @@ -1,6 +1,6 @@ import http from '@/api/http'; import { LoginResponse } from '@/api/auth/login'; -import { base64Decode, bufferDecode, bufferEncode, decodeCredentials } from '@/api/account/webauthn/registerWebauthnKey'; +import { base64Decode, bufferDecode, bufferEncode, decodeCredentials } from '@/api/account/webauthn/registerSecurityKey'; export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Promise => { return new Promise((resolve, reject) => { diff --git a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx index f3a237a4..381554b1 100644 --- a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx +++ b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx @@ -4,10 +4,10 @@ import { Form, Formik, FormikHelpers } from 'formik'; import tw from 'twin.macro'; import { object, string } from 'yup'; 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 { register } from '@/api/account/webauthn/registerWebauthnKey'; +import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import deleteWebauthnKey from '@/api/account/webauthn/deleteSecurityKey'; +import getWebauthnKeys, { SecurityKey } from '@/api/account/webauthn/getSecurityKeys'; +import registerSecurityKey from '@/api/account/webauthn/registerSecurityKey'; import FlashMessageRender from '@/components/FlashMessageRender'; import Button from '@/components/elements/Button'; import ContentBox from '@/components/elements/ContentBox'; @@ -22,15 +22,16 @@ interface Values { name: string; } -const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) => void }) => { +const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => void }) => { const { clearFlashes, clearAndAddHttpError } = useFlash(); const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers) => { clearFlashes('security_keys'); - register(name) - .then(() => { + registerSecurityKey(name) + .then(key => { resetForm(); + onKeyAdded(key); }) .catch(err => { console.error(err); @@ -69,20 +70,20 @@ const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) => export default () => { const { clearFlashes, clearAndAddHttpError } = useFlash(); - const [ keys, setKeys ] = useState([]); + const [ keys, setKeys ] = useState([]); const [ loading, setLoading ] = useState(true); - const [ deleteId, setDeleteId ] = useState(null); + const [ deleteId, setDeleteId ] = useState(null); - const doDeletion = (id: number | null) => { - if (id === null) { + const doDeletion = (uuid: string | null) => { + if (uuid === null) { return; } clearFlashes('security_keys'); - deleteWebauthnKey(id) + deleteWebauthnKey(uuid) .then(() => setKeys(s => ([ - ...(s || []).filter(key => key.id !== id), + ...(s || []).filter(key => key.uuid !== uuid), ]))) .catch(error => { console.error(error); @@ -132,16 +133,19 @@ export default () => { : null : keys.map((key, index) => ( - 0 && tw`mt-2` ]}> - + 0 && tw`mt-2` ]} + > +

{key.name}

- Last used:  - {key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'} + Created at:  + {key.createdAt ? format(key.createdAt, 'MMM do, yyyy HH:mm') : 'Never'}

-