UI tweaking and transformer for the stored keys

This commit is contained in:
Dane Everitt 2021-08-08 11:52:05 -07:00
parent 81a6a8653f
commit 5a4d1a668f
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
10 changed files with 179 additions and 80 deletions

View file

@ -12,9 +12,10 @@ use Psr\Http\Message\ServerRequestInterface;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialCreationOptions;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Pterodactyl\Transformers\Api\Client\SecurityKeyTransformer;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository; use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
use Pterodactyl\Services\Users\SecurityKeys\StoreSecurityKeyService;
use Pterodactyl\Http\Requests\Api\Client\Account\RegisterWebauthnTokenRequest; use Pterodactyl\Http\Requests\Api\Client\Account\RegisterWebauthnTokenRequest;
use Pterodactyl\Repositories\SecurityKeys\PublicKeyCredentialSourceRepository;
use Pterodactyl\Services\Users\SecurityKeys\CreatePublicKeyCredentialsService; use Pterodactyl\Services\Users\SecurityKeys\CreatePublicKeyCredentialsService;
class SecurityKeyController extends ClientApiController class SecurityKeyController extends ClientApiController
@ -25,9 +26,12 @@ class SecurityKeyController extends ClientApiController
protected WebauthnServerRepository $webauthnServerRepository; protected WebauthnServerRepository $webauthnServerRepository;
protected StoreSecurityKeyService $storeSecurityKeyService;
public function __construct( public function __construct(
Repository $cache, Repository $cache,
WebauthnServerRepository $webauthnServerRepository, WebauthnServerRepository $webauthnServerRepository,
StoreSecurityKeyService $storeSecurityKeyService,
CreatePublicKeyCredentialsService $createPublicKeyCredentials CreatePublicKeyCredentialsService $createPublicKeyCredentials
) { ) {
parent::__construct(); parent::__construct();
@ -35,6 +39,7 @@ class SecurityKeyController extends ClientApiController
$this->cache = $cache; $this->cache = $cache;
$this->webauthnServerRepository = $webauthnServerRepository; $this->webauthnServerRepository = $webauthnServerRepository;
$this->createPublicKeyCredentials = $createPublicKeyCredentials; $this->createPublicKeyCredentials = $createPublicKeyCredentials;
$this->storeSecurityKeyService = $storeSecurityKeyService;
} }
/** /**
@ -42,7 +47,9 @@ class SecurityKeyController extends ClientApiController
*/ */
public function index(Request $request): array 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 \Pterodactyl\Exceptions\DisplayException
* @throws \Throwable * @throws \Throwable
*/ */
public function store(RegisterWebauthnTokenRequest $request): JsonResponse public function store(RegisterWebauthnTokenRequest $request): array
{ {
$credentials = unserialize( $credentials = unserialize(
$this->cache->pull("register-security-key:{$request->input('token_id')}", serialize(null)) $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.'); throw new DisplayException('Could not register security key: invalid data present in session, please try again.');
} }
$source = $this->webauthnServerRepository->getServer($request->user()) $key = $this->storeSecurityKeyService
->loadAndCheckAttestationResponse( ->setRequest($this->getServerRequest($request))
json_encode($request->input('registration')), ->setKeyName($request->input('name'))
$credentials, ->handle($request->user(), $request->input('registration'), $credentials);
$this->getServerRequest($request),
);
// Unfortunately this repository interface doesn't define a response — it is explicitly return $this->fractal->item($key)
// void — so we need to just query the database immediately after this to pull the information ->transformWith(SecurityKeyTransformer::class)
// we just stored to return to the caller. ->toArray();
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' => [],
]);
} }
/** /**
* Removes a WebAuthn key from a user's account. * 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 protected function getServerRequest(Request $request): ServerRequestInterface

View file

@ -0,0 +1,74 @@
<?php
namespace Pterodactyl\Services\Users\SecurityKeys;
use Illuminate\Support\Str;
use Pterodactyl\Models\User;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\SecurityKey;
use Psr\Http\Message\ServerRequestInterface;
use Webauthn\PublicKeyCredentialCreationOptions;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
use Pterodactyl\Repositories\SecurityKeys\PublicKeyCredentialSourceRepository;
class StoreSecurityKeyService
{
protected WebauthnServerRepository $serverRepository;
protected ?ServerRequestInterface $request = null;
protected ?string $keyName = null;
public function __construct(WebauthnServerRepository $serverRepository)
{
$this->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;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\SecurityKey;
use Pterodactyl\Transformers\Api\Transformer;
class SecurityKeyTransformer extends Transformer
{
public function getResourceName(): string
{
return SecurityKey::RESOURCE_NAME;
}
public function transform(SecurityKey $key): array
{
return [
'uuid' => $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),
];
}
}

View file

@ -0,0 +1,5 @@
import http from '@/api/http';
export default async (uuid: string): Promise<void> => {
await http.delete(`/api/client/account/security-keys/${uuid}`);
};

View file

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

View file

@ -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<SecurityKey[]> => {
const { data } = await http.get('/api/client/account/security-keys');
return (data.data || []).map((datum: any) => rawDataToSecurityKey(datum.attributes));
};

View file

@ -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<WebauthnKey[]> => {
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);
});
};

View file

@ -1,4 +1,5 @@
import http from '@/api/http'; import http from '@/api/http';
import { rawDataToSecurityKey, SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
export const base64Decode = (input: string): string => { export const base64Decode = (input: string): string => {
input = input.replace(/-/g, '+').replace(/_/g, '/'); 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<SecurityKey> => {
const { data } = await http.post('/api/client/account/security-keys/register', { const { data } = await http.post('/api/client/account/security-keys/register', {
name, name,
token_id: tokenId, 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<void> => { export default async (name: string): Promise<SecurityKey> => {
const { data } = await http.get('/api/client/account/security-keys/register'); const { data } = await http.get('/api/client/account/security-keys/register');
const publicKey = data.data.credentials; const publicKey = data.data.credentials;
@ -62,5 +63,5 @@ export const register = async (name: string): Promise<void> => {
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`); 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);
}; };

View file

@ -1,6 +1,6 @@
import http from '@/api/http'; import http from '@/api/http';
import { LoginResponse } from '@/api/auth/login'; 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<LoginResponse> => { export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Promise<LoginResponse> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -4,10 +4,10 @@ import { Form, Formik, FormikHelpers } from 'formik';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { object, string } from 'yup'; import { object, string } from 'yup';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faFingerprint, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import deleteWebauthnKey from '@/api/account/webauthn/deleteWebauthnKey'; import deleteWebauthnKey from '@/api/account/webauthn/deleteSecurityKey';
import getWebauthnKeys, { WebauthnKey } from '@/api/account/webauthn/getWebauthnKeys'; import getWebauthnKeys, { SecurityKey } from '@/api/account/webauthn/getSecurityKeys';
import { register } from '@/api/account/webauthn/registerWebauthnKey'; import registerSecurityKey from '@/api/account/webauthn/registerSecurityKey';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import ContentBox from '@/components/elements/ContentBox'; import ContentBox from '@/components/elements/ContentBox';
@ -22,15 +22,16 @@ interface Values {
name: string; name: string;
} }
const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) => void }) => { const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SecurityKey) => void }) => {
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => { const submit = ({ name }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes('security_keys'); clearFlashes('security_keys');
register(name) registerSecurityKey(name)
.then(() => { .then(key => {
resetForm(); resetForm();
onKeyAdded(key);
}) })
.catch(err => { .catch(err => {
console.error(err); console.error(err);
@ -69,20 +70,20 @@ const AddSecurityKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: WebauthnKey) =>
export default () => { export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ keys, setKeys ] = useState<WebauthnKey[]>([]); const [ keys, setKeys ] = useState<SecurityKey[]>([]);
const [ loading, setLoading ] = useState(true); const [ loading, setLoading ] = useState(true);
const [ deleteId, setDeleteId ] = useState<number | null>(null); const [ deleteId, setDeleteId ] = useState<string | null>(null);
const doDeletion = (id: number | null) => { const doDeletion = (uuid: string | null) => {
if (id === null) { if (uuid === null) {
return; return;
} }
clearFlashes('security_keys'); clearFlashes('security_keys');
deleteWebauthnKey(id) deleteWebauthnKey(uuid)
.then(() => setKeys(s => ([ .then(() => setKeys(s => ([
...(s || []).filter(key => key.id !== id), ...(s || []).filter(key => key.uuid !== uuid),
]))) ])))
.catch(error => { .catch(error => {
console.error(error); console.error(error);
@ -132,16 +133,19 @@ export default () => {
: null : null
: :
keys.map((key, index) => ( keys.map((key, index) => (
<GreyRowBox key={index} css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}> <GreyRowBox
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/> key={index}
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
>
<FontAwesomeIcon icon={faFingerprint} css={tw`text-neutral-300`}/>
<div css={tw`ml-4 flex-1 overflow-hidden`}> <div css={tw`ml-4 flex-1 overflow-hidden`}>
<p css={tw`text-sm break-words`}>{key.name}</p> <p css={tw`text-sm break-words`}>{key.name}</p>
<p css={tw`text-2xs text-neutral-300 uppercase`}> <p css={tw`text-2xs text-neutral-300 uppercase`}>
Last used:&nbsp; Created at:&nbsp;
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'} {key.createdAt ? format(key.createdAt, 'MMM do, yyyy HH:mm') : 'Never'}
</p> </p>
</div> </div>
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.id)}> <button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.uuid)}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashAlt} icon={faTrashAlt}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`} css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}