feat(ssh-keys): add ssh key endpoints and ui components

This commit is contained in:
Matthew Penner 2021-07-17 15:45:46 -06:00
parent 9d64c6751b
commit f9114e2de0
17 changed files with 375 additions and 7 deletions

View file

@ -3,11 +3,9 @@
namespace Pterodactyl\Http\Controllers\Api\Application\Roles;
use Illuminate\Http\Response;
use Pterodactyl\Models\Location;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\AdminRole;
use Spatie\QueryBuilder\QueryBuilder;
use Pterodactyl\Transformers\Api\Application\LocationTransformer;
use Pterodactyl\Transformers\Api\Application\AdminRoleTransformer;
use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException;
use Pterodactyl\Http\Requests\Api\Application\Roles\GetRoleRequest;

View file

@ -0,0 +1,58 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\UserSSHKey;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Transformers\Api\Client\UserSSHKeyTransformer;
use Pterodactyl\Http\Requests\Api\Client\Account\StoreSSHKeyRequest;
class SSHKeyController extends ClientApiController
{
/**
* ?
*
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function index(Request $request): \Pterodactyl\Extensions\Spatie\Fractalistic\Fractal
{
return $this->fractal->collection(UserSSHKey::query()->where('user_id', '=', $request->user()->id)->get())
->transformWith($this->getTransformer(UserSSHKeyTransformer::class));
}
/**
* ?
*
* @return JsonResponse
* @throws \Illuminate\Contracts\Container\BindingResolutionException
* @throws \Pterodactyl\Exceptions\DisplayException
*/
public function store(StoreSSHKeyRequest $request): JsonResponse
{
if ($request->user()->sshKeys->count() >= 5) {
throw new DisplayException('You have reached the account limit for number of SSH keys.');
}
$data = array_merge($request->validated(), [
'user_id' => $request->user()->id,
]);
$key = UserSSHKey::query()->create($data);
return $this->fractal->item($key)
->transformWith($this->getTransformer(UserSSHKeyTransformer::class))
->respond(JsonResponse::HTTP_CREATED);
}
/**
* ?
*/
public function delete(Request $request, UserSSHKey $sshKey): Response
{
$sshKey->delete();
return new Response('', Response::HTTP_NO_CONTENT);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Account;
use Pterodactyl\Models\UserSSHKey;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class StoreSSHKeyRequest extends ClientApiRequest
{
public function rules(): array
{
return UserSSHKey::getRules();
}
}

View file

@ -45,7 +45,7 @@ class AuditLog extends Model
/**
* @var string[]
*/
public static $validationRules = [
public static array $validationRules = [
'uuid' => 'required|uuid',
'action' => 'required|string|max:191',
'subaction' => 'nullable|string|max:191',

View file

@ -64,7 +64,7 @@ class DatabaseHost extends Model
*
* @var array
*/
public static $validationRules = [
public static array $validationRules = [
'name' => 'required|string|max:191',
'host' => 'required|string',
'port' => 'required|numeric|between:1,65535',

View file

@ -70,7 +70,7 @@ class EggVariable extends Model
/**
* @var array
*/
public static $validationRules = [
public static array $validationRules = [
'egg_id' => 'exists:eggs,id',
'name' => 'required|string|between:1,191',
'description' => 'string',

View file

@ -40,6 +40,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* @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\UserSSHKey|\Illuminate\Database\Eloquent\Collection $sshKeys
* @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens
* @property \LaravelWebauthn\Models\WebauthnKey[]|\Illuminate\Database\Eloquent\Collection $webauthnKeys
*/
@ -247,6 +248,11 @@ class User extends Model implements
return $this->hasMany(Server::class, 'owner_id');
}
public function sshKeys(): HasMany
{
return $this->hasMany(UserSSHKey::class);
}
public function recoveryTokens(): HasMany
{
return $this->hasMany(RecoveryToken::class);

View file

@ -2,15 +2,20 @@
namespace Pterodactyl\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $user_id
* @property string $name
* @property string $public_key
* @property \Carbon\CarbonImmutable $created_at
* @property \Pterodactyl\Models\User $user
*/
class UserSSHKey extends Model
{
const UPDATED_AT = null;
protected $table = 'user_ssh_keys';
protected bool $immutableDates = true;
@ -23,4 +28,9 @@ class UserSSHKey extends Model
'name' => 'required|string',
'public_key' => 'required|string',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\UserSSHKey;
class UserSSHKeyTransformer extends BaseClientTransformer
{
/**
* Return the resource name for the JSONAPI output.
*/
public function getResourceName(): string
{
return 'user_ssh_key';
}
/**
* Return basic information about the currently logged in user.
*/
public function transform(UserSSHKey $model): array
{
return [
'id' => $model->id,
'name' => $model->name,
'public_key' => $model->public_key,
'created_at' => $model->created_at->toIso8601String(),
];
}
}

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateUserSshKeysTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_ssh_keys', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('user_id');
$table->string('name');
$table->text('public_key');
$table->timestamp('created_at')->nullable();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_ssh_keys');
}
}

View file

@ -0,0 +1,10 @@
import http from '@/api/http';
import { SSHKey, rawDataToSSHKey } from '@/api/account/ssh/getSSHKeys';
export default (name: string, publicKey: string): Promise<SSHKey> => {
return new Promise((resolve, reject) => {
http.post('/api/client/account/ssh', { name, public_key: publicKey })
.then(({ data }) => resolve(rawDataToSSHKey(data.attributes)))
.catch(reject);
});
};

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (id: number): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/account/ssh/${id}`)
.then(() => resolve())
.catch(reject);
});
};

View file

@ -0,0 +1,23 @@
import http from '@/api/http';
export interface SSHKey {
id: number;
name: string;
publicKey: string;
createdAt: Date;
}
export const rawDataToSSHKey = (data: any): SSHKey => ({
id: data.id,
name: data.name,
publicKey: data.public_key,
createdAt: new Date(data.created_at),
});
export default (): Promise<SSHKey[]> => {
return new Promise((resolve, reject) => {
http.get('/api/client/account/ssh')
.then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToSSHKey(d.attributes))))
.catch(reject);
});
};

View file

@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import { Field as FormikField, 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 createSSHKey from '@/api/account/ssh/createSSHKey';
import deleteSSHKey from '@/api/account/ssh/deleteSSHKey';
import getSSHKeys, { SSHKey } from '@/api/account/ssh/getSSHKeys';
import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button';
import ContentBox from '@/components/elements/ContentBox';
import Field from '@/components/elements/Field';
import GreyRowBox from '@/components/elements/GreyRowBox';
import PageContentBlock from '@/components/elements/PageContentBlock';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import { Textarea } from '@/components/elements/Input';
interface Values {
name: string;
publicKey: string;
}
const AddSSHKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SSHKey) => void }) => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const submit = ({ name, publicKey }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes('ssh_keys');
createSSHKey(name, publicKey)
.then(key => {
resetForm();
onKeyAdded(key);
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'ssh_keys', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{ name: '', publicKey: '' }}
validationSchema={object().shape({
name: string().required(),
publicKey: string().required(),
})}
>
{({ isSubmitting }) => (
<Form>
<SpinnerOverlay visible={isSubmitting}/>
<Field
type={'text'}
id={'name'}
name={'name'}
label={'Name'}
description={'A descriptive name for this SSH key.'}
/>
<div css={tw`mt-6`}>
<FormikFieldWrapper
name={'publicKey'}
label={'Public Key'}
description={'SSH Public Key starting with ssh-*'}
>
<FormikField as={Textarea} name={'publicKey'} rows={6}/>
</FormikFieldWrapper>
</div>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>
</div>
</Form>
)}
</Formik>
);
};
export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ keys, setKeys ] = useState<SSHKey[]>([]);
const [ loading, setLoading ] = useState(true);
const [ deleteId, setDeleteId ] = useState<number | null>(null);
const doDeletion = (id: number | null) => {
if (id === null) {
return;
}
clearFlashes('ssh_keys');
deleteSSHKey(id)
.then(() => setKeys(s => ([
...(s || []).filter(key => key.id !== id),
])))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'ssh_keys', error });
});
};
useEffect(() => {
clearFlashes('ssh_keys');
getSSHKeys()
.then(keys => setKeys(keys))
.then(() => setLoading(false))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'ssh_keys', error });
});
}, []);
return (
<PageContentBlock title={'SSH Keys'}>
<FlashMessageRender byKey={'ssh_keys'}/>
<div css={tw`md:flex flex-nowrap my-10`}>
<ContentBox title={'SSH Keys'} css={tw`flex-1 md:mr-8`}>
<SpinnerOverlay visible={loading}/>
<ConfirmationModal
visible={!!deleteId}
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
onConfirmed={() => {
doDeletion(deleteId);
setDeleteId(null);
}}
onModalDismissed={() => setDeleteId(null)}
>
Are you sure you wish to delete this SSH key?
</ConfirmationModal>
{keys.length === 0 ?
!loading ?
<p css={tw`text-center text-sm`}>
No SSH keys have been configured for this account.
</p>
: null
:
keys.map((key, index) => (
<GreyRowBox key={index} css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}>
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
<div css={tw`ml-4 flex-1 overflow-hidden`}>
<p css={tw`text-sm break-words`}>{key.name}</p>
</div>
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.id)}>
<FontAwesomeIcon
icon={faTrashAlt}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
/>
</button>
</GreyRowBox>
))
}
</ContentBox>
<ContentBox title={'Add SSH Key'} css={tw`flex-none w-full mt-8 md:mt-0 md:w-1/2`}>
<AddSSHKeyForm onKeyAdded={key => setKeys(s => ([ ...s!, key ]))}/>
</ContentBox>
</div>
</PageContentBlock>
);
};

View file

@ -119,8 +119,8 @@ export default () => {
}}
onModalDismissed={() => setDeleteId(null)}
>
Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail.
Are you sure you wish to delete this security key?
You will no longer be able to authenticate using this key.
</ConfirmationModal>
{keys.length === 0 ?
!loading ?

View file

@ -6,6 +6,7 @@ import DashboardContainer from '@/components/dashboard/DashboardContainer';
import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer';
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
import SecurityKeyContainer from '@/components/dashboard/SecurityKeyContainer';
import SSHKeyContainer from '@/components/dashboard/SSHKeyContainer';
import { NotFound } from '@/components/elements/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation';
@ -18,6 +19,7 @@ export default ({ location }: RouteComponentProps) => (
<NavLink to={'/account'} exact>Settings</NavLink>
<NavLink to={'/account/api'}>API Credentials</NavLink>
<NavLink to={'/account/keys/security'}>Security Keys</NavLink>
<NavLink to={'/account/keys/ssh'}>SSH Keys</NavLink>
</div>
</SubNavigation>
}
@ -35,6 +37,9 @@ export default ({ location }: RouteComponentProps) => (
<Route path={'/account/keys/security'} exact>
<SecurityKeyContainer/>
</Route>
<Route path={'/account/keys/ssh'} exact>
<SSHKeyContainer/>
</Route>
<Route path={'*'}>
<NotFound/>
</Route>

View file

@ -33,6 +33,10 @@ Route::group(['prefix' => '/account'], function () {
Route::get('/webauthn/register', 'WebauthnController@register')->withoutMiddleware(RequireTwoFactorAuthentication::class);
Route::post('/webauthn/register', 'WebauthnController@create')->withoutMiddleware(RequireTwoFactorAuthentication::class);
Route::delete('/webauthn/{id}', 'WebauthnController@deleteKey')->withoutMiddleware(RequireTwoFactorAuthentication::class);
Route::get('/ssh', 'SSHKeyController@index');
Route::post('/ssh', 'SSHKeyController@store');
Route::delete('/ssh/{ssh_key}', 'SSHKeyController@delete');
});
/*