UI tweaking and transformer for the stored keys
This commit is contained in:
parent
81a6a8653f
commit
5a4d1a668f
10 changed files with 179 additions and 80 deletions
|
@ -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
|
||||
|
|
74
app/Services/Users/SecurityKeys/StoreSecurityKeyService.php
Normal file
74
app/Services/Users/SecurityKeys/StoreSecurityKeyService.php
Normal 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;
|
||||
}
|
||||
}
|
26
app/Transformers/Api/Client/SecurityKeyTransformer.php
Normal file
26
app/Transformers/Api/Client/SecurityKeyTransformer.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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}`);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
25
resources/scripts/api/account/webauthn/getSecurityKeys.ts
Normal file
25
resources/scripts/api/account/webauthn/getSecurityKeys.ts
Normal 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));
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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<SecurityKey> => {
|
||||
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<void> => {
|
||||
export default async (name: string): Promise<SecurityKey> => {
|
||||
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<void> => {
|
|||
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);
|
||||
};
|
|
@ -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<LoginResponse> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
|
@ -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<Values>) => {
|
||||
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<WebauthnKey[]>([]);
|
||||
const [ keys, setKeys ] = useState<SecurityKey[]>([]);
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ deleteId, setDeleteId ] = useState<number | null>(null);
|
||||
const [ deleteId, setDeleteId ] = useState<string | null>(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) => (
|
||||
<GreyRowBox key={index} css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}>
|
||||
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
|
||||
<GreyRowBox
|
||||
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`}>
|
||||
<p css={tw`text-sm break-words`}>{key.name}</p>
|
||||
<p css={tw`text-2xs text-neutral-300 uppercase`}>
|
||||
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'}
|
||||
</p>
|
||||
</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
|
||||
icon={faTrashAlt}
|
||||
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
|
||||
|
|
Loading…
Reference in a new issue