diff --git a/app/Http/Controllers/Api/Client/SSHKeyController.php b/app/Http/Controllers/Api/Client/SSHKeyController.php new file mode 100644 index 000000000..5b25a221c --- /dev/null +++ b/app/Http/Controllers/Api/Client/SSHKeyController.php @@ -0,0 +1,48 @@ +fractal->collection($request->user()->sshKeys) + ->transformWith($this->getTransformer(SSHKeyTransformer::class)) + ->toArray(); + } + + /** + * Stores a new SSH key for the authenticated user's account. + */ + public function store(StoreSSHKeyRequest $request): array + { + $model = $request->user()->sshKeys()->create([ + 'name' => $request->input('name'), + 'public_key' => $request->input('public_key'), + 'fingerprint' => $request->getKeyFingerprint(), + ]); + + return $this->fractal->item($model) + ->transformWith($this->getTransformer(SSHKeyTransformer::class)) + ->toArray(); + } + + /** + * Deletes an SSH key from the user's account. + */ + public function delete(ClientApiRequest $request, string $identifier): JsonResponse + { + $request->user()->sshKeys()->where('fingerprint', $identifier)->delete(); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Requests/Api/Application/ApplicationApiRequest.php b/app/Http/Requests/Api/Application/ApplicationApiRequest.php index 11deab45b..064a36853 100644 --- a/app/Http/Requests/Api/Application/ApplicationApiRequest.php +++ b/app/Http/Requests/Api/Application/ApplicationApiRequest.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Http\Requests\Api\Application; use Pterodactyl\Models\ApiKey; +use Illuminate\Validation\Validator; use Pterodactyl\Services\Acl\Api\AdminAcl; use Illuminate\Foundation\Http\FormRequest; use Pterodactyl\Exceptions\PterodactylException; @@ -96,6 +97,16 @@ abstract class ApplicationApiRequest extends FormRequest return $this->route()->parameter($parameterKey); } + /** + * Helper method allowing a developer to easily hook into this logic without having + * to remember what the method name is called or where to use it. By default this is + * a no-op. + */ + public function withValidator(Validator $validator): void + { + // do nothing + } + /** * Validate that the resource exists and can be accessed prior to booting * the validator and attempting to use the data. diff --git a/app/Http/Requests/Api/Client/Account/StoreSSHKeyRequest.php b/app/Http/Requests/Api/Client/Account/StoreSSHKeyRequest.php new file mode 100644 index 000000000..5705056c4 --- /dev/null +++ b/app/Http/Requests/Api/Client/Account/StoreSSHKeyRequest.php @@ -0,0 +1,71 @@ + UserSSHKey::getRulesForField('name'), + 'public_key' => UserSSHKey::getRulesForField('public_key'), + ]; + } + + /** + * Check to see if this SSH key has already been added to the user's account + * and if so return an error. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function () { + try { + $this->key = PublicKeyLoader::loadPublicKey($this->input('public_key')); + } catch (NoKeyLoadedException $exception) { + $this->validator->errors()->add('public_key', 'The public key provided is not valid.'); + + return; + } + + if ($this->key instanceof DSA) { + $this->validator->errors()->add('public_key', 'DSA public keys are not supported.'); + } + + if ($this->key instanceof RSA && $this->key->getLength() < 2048) { + $this->validator->errors()->add('public_key', 'RSA keys must be at 2048 bytes.'); + } + + $fingerprint = $this->key->getFingerprint('sha256'); + if ($this->user()->sshKeys()->where('fingerprint', $fingerprint)->exists()) { + $this->validator->errors()->add('public_key', 'The public key provided already exists on your account.'); + } + }); + } + + /** + * Returns the SHA256 fingerprint of the key provided. + */ + public function getKeyFingerprint(): string + { + if (!$this->key) { + throw new Exception('The public key was not properly loaded for this request.'); + } + + return $this->key->getFingerprint('sha256'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6cdc414f2..42ddc774e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Auth\Passwords\CanResetPassword; use Pterodactyl\Traits\Helpers\AvailableLanguages; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\Access\Authorizable; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; @@ -17,6 +18,8 @@ use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; /** + * \Pterodactyl\Models\User. + * * @property int $id * @property string|null $external_id * @property string $uuid @@ -38,6 +41,37 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @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 string|null $remember_token + * @property int|null $api_keys_count + * @property \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications + * @property int|null $notifications_count + * @property int|null $recovery_tokens_count + * @property int|null $servers_count + * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\UserSSHKey[] $sshKeys + * @property int|null $ssh_keys_count + * + * @method static \Database\Factories\UserFactory factory(...$parameters) + * @method static Builder|User newModelQuery() + * @method static Builder|User newQuery() + * @method static Builder|User query() + * @method static Builder|User whereCreatedAt($value) + * @method static Builder|User whereEmail($value) + * @method static Builder|User whereExternalId($value) + * @method static Builder|User whereGravatar($value) + * @method static Builder|User whereId($value) + * @method static Builder|User whereLanguage($value) + * @method static Builder|User whereNameFirst($value) + * @method static Builder|User whereNameLast($value) + * @method static Builder|User wherePassword($value) + * @method static Builder|User whereRememberToken($value) + * @method static Builder|User whereRootAdmin($value) + * @method static Builder|User whereTotpAuthenticatedAt($value) + * @method static Builder|User whereTotpSecret($value) + * @method static Builder|User whereUpdatedAt($value) + * @method static Builder|User whereUseTotp($value) + * @method static Builder|User whereUsername($value) + * @method static Builder|User whereUuid($value) + * @mixin \Eloquent */ class User extends Model implements AuthenticatableContract, @@ -225,6 +259,11 @@ class User extends Model implements return $this->hasMany(RecoveryToken::class); } + public function sshKeys(): HasMany + { + return $this->hasMany(UserSSHKey::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. diff --git a/app/Models/UserSSHKey.php b/app/Models/UserSSHKey.php new file mode 100644 index 000000000..848ba32c8 --- /dev/null +++ b/app/Models/UserSSHKey.php @@ -0,0 +1,61 @@ + ['required', 'string'], + 'fingerprint' => ['required', 'string'], + 'public_key' => ['required', 'string'], + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Transformers/Api/Client/SSHKeyTransformer.php b/app/Transformers/Api/Client/SSHKeyTransformer.php new file mode 100644 index 000000000..1f9b96c29 --- /dev/null +++ b/app/Transformers/Api/Client/SSHKeyTransformer.php @@ -0,0 +1,26 @@ + $model->name, + 'fingerprint' => $model->fingerprint, + 'public_key' => $model->public_key, + 'created_at' => $model->created_at->toIso8601String(), + ]; + } +} diff --git a/composer.json b/composer.json index fa5db6eb0..8279aee77 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "league/flysystem-aws-s3-v3": "~1.0.29", "league/flysystem-memory": "~1.0.2", "matriphe/iso-639": "~1.2.0", + "phpseclib/phpseclib": "~3.0", "pragmarx/google2fa": "~5.0.0", "predis/predis": "~1.1.10", "prologue/alerts": "~0.4.8", diff --git a/composer.lock b/composer.lock index 8fecedfe9..db2b52ca1 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": "966e12710f76fb744c32e90103b9f823", + "content-hash": "59024efe671be95afe14319b19606566", "packages": [ { "name": "aws/aws-crt-php", @@ -3266,6 +3266,115 @@ ], "time": "2021-12-04T23:24:31+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.14", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2f0b7af658cbea265cbb4a791d6c29a6613f98ef", + "reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.14" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2022-04-04T05:15:45+00:00" + }, { "name": "pragmarx/google2fa", "version": "v5.0.0", diff --git a/database/migrations/2021_07_17_211512_create_user_ssh_keys_table.php b/database/migrations/2021_07_17_211512_create_user_ssh_keys_table.php new file mode 100644 index 000000000..d5b8a13c6 --- /dev/null +++ b/database/migrations/2021_07_17_211512_create_user_ssh_keys_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->unsignedInteger('user_id'); + $table->string('name'); + $table->string('fingerprint'); + $table->text('public_key'); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down() + { + Schema::dropIfExists('user_ssh_keys'); + } +} diff --git a/resources/scripts/api/account/ssh-keys.ts b/resources/scripts/api/account/ssh-keys.ts new file mode 100644 index 000000000..baf3b609c --- /dev/null +++ b/resources/scripts/api/account/ssh-keys.ts @@ -0,0 +1,28 @@ +import useSWR, { ConfigInterface } from 'swr'; +import useUserSWRContentKey from '@/plugins/useUserSWRContentKey'; +import http, { FractalResponseList } from '@/api/http'; +import { SSHKey, Transformers } from '@definitions/user'; +import { AxiosError } from 'axios'; + +const useSSHKeys = (config?: ConfigInterface) => { + const key = useUserSWRContentKey([ 'account', 'ssh-keys' ]); + + return useSWR(key, async () => { + const { data } = await http.get('/api/client/account/ssh-keys'); + + return (data as FractalResponseList).data.map((datum: any) => { + return Transformers.toSSHKey(datum.attributes); + }); + }, { revalidateOnMount: false, ...(config || {}) }); +}; + +const createSSHKey = async (name: string, publicKey: string): Promise => { + const { data } = await http.post('/api/client/account/ssh-keys', { name, public_key: publicKey }); + + return Transformers.toSSHKey(data.attributes); +}; + +const deleteSSHKey = async (fingerprint: string): Promise => + await http.delete(`/api/client/account/ssh-keys/${fingerprint}`); + +export { useSSHKeys, createSSHKey, deleteSSHKey }; diff --git a/resources/scripts/api/definitions/user/models.d.ts b/resources/scripts/api/definitions/user/models.d.ts index d462cadf8..51bea475c 100644 --- a/resources/scripts/api/definitions/user/models.d.ts +++ b/resources/scripts/api/definitions/user/models.d.ts @@ -1,2 +1,8 @@ -// empty export -export type _T = string; +import { Model } from '@/api/definitions'; + +interface SSHKey extends Model { + name: string; + publicKey: string; + fingerprint: string; + createdAt: Date; +} diff --git a/resources/scripts/api/definitions/user/transformers.ts b/resources/scripts/api/definitions/user/transformers.ts index a69ad708d..89adbad75 100644 --- a/resources/scripts/api/definitions/user/transformers.ts +++ b/resources/scripts/api/definitions/user/transformers.ts @@ -1,4 +1,14 @@ +import { SSHKey } from '@definitions/user/models'; + export default class Transformers { + static toSSHKey (data: Record): SSHKey { + return { + name: data.name, + publicKey: data.public_key, + fingerprint: data.fingerprint, + createdAt: new Date(data.created_at), + }; + } } export class MetaTransformers { diff --git a/resources/scripts/components/dashboard/ssh/AccountSSHContainer.tsx b/resources/scripts/components/dashboard/ssh/AccountSSHContainer.tsx new file mode 100644 index 000000000..59539b3ce --- /dev/null +++ b/resources/scripts/components/dashboard/ssh/AccountSSHContainer.tsx @@ -0,0 +1,66 @@ +import React, { useEffect } from 'react'; +import ContentBox from '@/components/elements/ContentBox'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import tw from 'twin.macro'; +import GreyRowBox from '@/components/elements/GreyRowBox'; +import { useSSHKeys } from '@/api/account/ssh-keys'; +import { useFlashKey } from '@/plugins/useFlash'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faKey } from '@fortawesome/free-solid-svg-icons'; +import { format } from 'date-fns'; +import CreateSSHKeyForm from '@/components/dashboard/ssh/CreateSSHKeyForm'; +import DeleteSSHKeyButton from '@/components/dashboard/ssh/DeleteSSHKeyButton'; + +export default () => { + const { clearAndAddHttpError } = useFlashKey('account'); + const { data, isValidating, error } = useSSHKeys({ + revalidateOnMount: true, + revalidateOnFocus: false, + }); + + useEffect(() => { + clearAndAddHttpError(error); + }, [ error ]); + + return ( + + +
+ + + + + + { + !data || !data.length ? +

+ {!data ? 'Loading...' : 'No SSH Keys exist for this account.'} +

+ : + data.map((key, index) => ( + 0 && tw`mt-2` ]} + > + +
+

{key.name}

+

+ SHA256:{key.fingerprint} +

+

+ Added on:  + {format(key.createdAt, 'MMM do, yyyy HH:mm')} +

+
+ +
+ )) + } +
+
+
+ ); +}; diff --git a/resources/scripts/components/dashboard/ssh/CreateSSHKeyForm.tsx b/resources/scripts/components/dashboard/ssh/CreateSSHKeyForm.tsx new file mode 100644 index 000000000..0443901b8 --- /dev/null +++ b/resources/scripts/components/dashboard/ssh/CreateSSHKeyForm.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Field, Form, Formik, FormikHelpers } from 'formik'; +import { object, string } from 'yup'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import Input, { Textarea } from '@/components/elements/Input'; +import styled from 'styled-components/macro'; +import { useFlashKey } from '@/plugins/useFlash'; +import { createSSHKey, useSSHKeys } from '@/api/account/ssh-keys'; + +interface Values { + name: string; + publicKey: string; +} + +const CustomTextarea = styled(Textarea)`${tw`h-32`}`; + +export default () => { + const { clearAndAddHttpError } = useFlashKey('account'); + const { mutate } = useSSHKeys(); + + const submit = (values: Values, { setSubmitting, resetForm }: FormikHelpers) => { + clearAndAddHttpError(); + + createSSHKey(values.name, values.publicKey) + .then((key) => { + resetForm(); + mutate((data) => (data || []).concat(key)); + }) + .catch((error) => clearAndAddHttpError(error)) + .then(() => setSubmitting(false)); + }; + + return ( + <> + + {({ isSubmitting }) => ( +
+ + + + + + + +
+ +
+ + )} +
+ + ); +}; diff --git a/resources/scripts/components/dashboard/ssh/DeleteSSHKeyButton.tsx b/resources/scripts/components/dashboard/ssh/DeleteSSHKeyButton.tsx new file mode 100644 index 000000000..f5c31e322 --- /dev/null +++ b/resources/scripts/components/dashboard/ssh/DeleteSSHKeyButton.tsx @@ -0,0 +1,46 @@ +import tw from 'twin.macro'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import React, { useState } from 'react'; +import { useFlashKey } from '@/plugins/useFlash'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import { deleteSSHKey, useSSHKeys } from '@/api/account/ssh-keys'; + +export default ({ fingerprint }: { fingerprint: string }) => { + const { clearAndAddHttpError } = useFlashKey('account'); + const [ visible, setVisible ] = useState(false); + const { mutate } = useSSHKeys(); + + const onClick = () => { + clearAndAddHttpError(); + + Promise.all([ + mutate((data) => data?.filter((value) => value.fingerprint !== fingerprint), false), + deleteSSHKey(fingerprint), + ]) + .catch((error) => { + mutate(undefined, true); + clearAndAddHttpError(error); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you wish to delete this SSH key? + + + + ); +}; diff --git a/resources/scripts/plugins/useFlash.ts b/resources/scripts/plugins/useFlash.ts index a55b87312..830be9251 100644 --- a/resources/scripts/plugins/useFlash.ts +++ b/resources/scripts/plugins/useFlash.ts @@ -2,8 +2,23 @@ import { Actions, useStoreActions } from 'easy-peasy'; import { FlashStore } from '@/state/flashes'; import { ApplicationStore } from '@/state'; +interface KeyedFlashStore { + clearFlashes: () => void; + clearAndAddHttpError: (error?: Error | string | null) => void; +} + const useFlash = (): Actions => { return useStoreActions((actions: Actions) => actions.flashes); }; +const useFlashKey = (key: string): KeyedFlashStore => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + return { + clearFlashes: () => clearFlashes(key), + clearAndAddHttpError: (error) => clearAndAddHttpError({ key, error }), + }; +}; + +export { useFlashKey }; export default useFlash; diff --git a/resources/scripts/plugins/useUserSWRContentKey.ts b/resources/scripts/plugins/useUserSWRContentKey.ts new file mode 100644 index 000000000..23ea2eca2 --- /dev/null +++ b/resources/scripts/plugins/useUserSWRContentKey.ts @@ -0,0 +1,12 @@ +import { useStoreState } from '@/state/hooks'; + +export default (context: string | string[]) => { + const key = Array.isArray(context) ? context.join(':') : context; + const uuid = useStoreState(state => state.user.data?.uuid); + + if (!key.trim().length) { + throw new Error('Must provide a valid context key to "useUserSWRContextKey".'); + } + + return `swr::${uuid || 'unknown'}:${key.trim()}`; +}; diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 513cc3fa8..36bc5b40e 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -7,6 +7,7 @@ import AccountApiContainer from '@/components/dashboard/AccountApiContainer'; import { NotFound } from '@/components/elements/ScreenBlock'; import TransitionRouter from '@/TransitionRouter'; import SubNavigation from '@/components/elements/SubNavigation'; +import AccountSSHContainer from '@/components/dashboard/ssh/AccountSSHContainer'; export default ({ location }: RouteComponentProps) => ( <> @@ -16,6 +17,7 @@ export default ({ location }: RouteComponentProps) => (
Settings API Credentials + SSH Keys
} @@ -30,6 +32,9 @@ export default ({ location }: RouteComponentProps) => ( + + + diff --git a/resources/scripts/state/flashes.ts b/resources/scripts/state/flashes.ts index fb89a0a8d..31ab75a36 100644 --- a/resources/scripts/state/flashes.ts +++ b/resources/scripts/state/flashes.ts @@ -6,7 +6,7 @@ export interface FlashStore { items: FlashMessage[]; addFlash: Action; addError: Action; - clearAndAddHttpError: Action; + clearAndAddHttpError: Action; clearFlashes: Action; } @@ -29,8 +29,19 @@ const flashes: FlashStore = { state.items.push({ type: 'error', title: 'Error', ...payload }); }), - clearAndAddHttpError: action((state, { key, error }) => { - state.items = [ { type: 'error', title: 'Error', key, message: httpErrorToHuman(error) } ]; + clearAndAddHttpError: action((state, payload) => { + if (!payload.error) { + state.items = []; + } else { + console.error(payload.error); + + state.items = [ { + type: 'error', + title: 'Error', + key: payload.key, + message: httpErrorToHuman(payload.error), + } ]; + } }), clearFlashes: action((state, payload) => { diff --git a/routes/api-client.php b/routes/api-client.php index 572f4505b..5c2bfa9b1 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -29,6 +29,12 @@ Route::group(['prefix' => '/account'], function () { Route::get('/api-keys', [Client\ApiKeyController::class, 'index']); Route::post('/api-keys', [Client\ApiKeyController::class, 'store']); Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']); + + Route::prefix('/ssh-keys')->group(function () { + Route::get('/', [Client\SSHKeyController::class, 'index']); + Route::post('/', [Client\SSHKeyController::class, 'store']); + Route::delete('/{identifier}', [Client\SSHKeyController::class, 'delete']); + }); }); /*