Add controllers and packages for security keys
This commit is contained in:
parent
f8ec8b4d5a
commit
06f692e649
29 changed files with 2398 additions and 383 deletions
105
app/Http/Controllers/Api/Client/SecurityKeyController.php
Normal file
105
app/Http/Controllers/Api/Client/SecurityKeyController.php
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Controllers\Api\Client;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
use Pterodactyl\Exceptions\DisplayException;
|
||||||
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
|
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||||
|
use Pterodactyl\Transformers\Api\Client\SecurityKeyTransformer;
|
||||||
|
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||||
|
use Pterodactyl\Services\Users\SecurityKeys\StoreSecurityKeyService;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\Account\RegisterSecurityKeyRequest;
|
||||||
|
use Pterodactyl\Services\Users\SecurityKeys\CreatePublicKeyCredentialService;
|
||||||
|
|
||||||
|
class SecurityKeyController extends ClientApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected CreatePublicKeyCredentialService $createPublicKeyCredentialService,
|
||||||
|
protected CacheRepository $cache,
|
||||||
|
protected WebauthnServerRepository $webauthnServerRepository,
|
||||||
|
protected StoreSecurityKeyService $storeSecurityKeyService
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the hardware security keys (WebAuthn) that exists for a user.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): array
|
||||||
|
{
|
||||||
|
return $this->fractal->collection($request->user()->securityKeys)
|
||||||
|
->transformWith(SecurityKeyTransformer::class)
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the data necessary for creating a new hardware security key for the
|
||||||
|
* user.
|
||||||
|
*
|
||||||
|
* @throws \Webauthn\Exception\InvalidDataException
|
||||||
|
*/
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$tokenId = Str::random(64);
|
||||||
|
$credentials = $this->createPublicKeyCredentialService->handle($request->user());
|
||||||
|
|
||||||
|
// TODO: session
|
||||||
|
$this->cache->put(
|
||||||
|
"register-security-key:$tokenId",
|
||||||
|
serialize($credentials),
|
||||||
|
CarbonImmutable::now()->addMinutes(10)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'token_id' => $tokenId,
|
||||||
|
'credentials' => $credentials->jsonSerialize(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a new key for a user account.
|
||||||
|
*
|
||||||
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function store(RegisterSecurityKeyRequest $request): array
|
||||||
|
{
|
||||||
|
$credentials = unserialize(
|
||||||
|
$this->cache->pull("register-security-key:{$request->input('token_id')}", serialize(null))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!is_object($credentials) ||
|
||||||
|
!$credentials instanceof PublicKeyCredentialCreationOptions ||
|
||||||
|
$credentials->getUser()->getId() !== $request->user()->uuid
|
||||||
|
) {
|
||||||
|
throw new DisplayException('Could not register security key: invalid data present in session, please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $this->storeSecurityKeyService
|
||||||
|
->setRequest(SecurityKey::getPsrRequestFactory($request))
|
||||||
|
->setKeyName($request->input('name'))
|
||||||
|
->handle($request->user(), $request->input('registration'), $credentials);
|
||||||
|
|
||||||
|
return $this->fractal->item($key)
|
||||||
|
->transformWith(SecurityKeyTransformer::class)
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a WebAuthn key from a user's account.
|
||||||
|
*/
|
||||||
|
public function delete(Request $request, string $securityKey): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()->securityKeys()->where('uuid', $securityKey)->delete();
|
||||||
|
|
||||||
|
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,6 @@ use Illuminate\Auth\AuthManager;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Auth\Events\Failed;
|
use Illuminate\Auth\Events\Failed;
|
||||||
use Illuminate\Container\Container;
|
use Illuminate\Container\Container;
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Pterodactyl\Events\Auth\DirectLogin;
|
use Pterodactyl\Events\Auth\DirectLogin;
|
||||||
use Pterodactyl\Exceptions\DisplayException;
|
use Pterodactyl\Exceptions\DisplayException;
|
||||||
use Pterodactyl\Http\Controllers\Controller;
|
use Pterodactyl\Http\Controllers\Controller;
|
||||||
|
@ -37,7 +36,7 @@ abstract class AbstractLoginController extends Controller
|
||||||
protected string $redirectTo = '/';
|
protected string $redirectTo = '/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoginController constructor.
|
* AbstractLoginController constructor.
|
||||||
*/
|
*/
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
@ -58,7 +57,7 @@ abstract class AbstractLoginController extends Controller
|
||||||
$this->getField($request->input('user')) => $request->input('user'),
|
$this->getField($request->input('user')) => $request->input('user'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($request->route()->named('auth.login-checkpoint')) {
|
if ($request->route()->named('auth.checkpoint') || $request->route()->named('auth.checkpoint.key')) {
|
||||||
throw new DisplayException($message ?? trans('auth.two_factor.checkpoint_failed'));
|
throw new DisplayException($message ?? trans('auth.two_factor.checkpoint_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,14 +76,13 @@ abstract class AbstractLoginController extends Controller
|
||||||
|
|
||||||
$this->auth->guard()->login($user, true);
|
$this->auth->guard()->login($user, true);
|
||||||
|
|
||||||
Event::dispatch(new DirectLogin($user, true));
|
event(new DirectLogin($user, true));
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'data' => [
|
|
||||||
'complete' => true,
|
'complete' => true,
|
||||||
|
'methods' => [],
|
||||||
'intended' => $this->redirectPath(),
|
'intended' => $this->redirectPath(),
|
||||||
'user' => $user->toVueObject(),
|
'user' => $user->toReactObject(),
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,6 +99,6 @@ abstract class AbstractLoginController extends Controller
|
||||||
*/
|
*/
|
||||||
protected function fireFailedLoginEvent(Authenticatable $user = null, array $credentials = [])
|
protected function fireFailedLoginEvent(Authenticatable $user = null, array $credentials = [])
|
||||||
{
|
{
|
||||||
Event::dispatch(new Failed('auth', $user, $credentials));
|
event(new Failed('auth', $user, $credentials));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,14 +5,17 @@ namespace Pterodactyl\Http\Controllers\Auth;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Pterodactyl\Models\User;
|
use Pterodactyl\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use PragmaRX\Google2FA\Google2FA;
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Pterodactyl\Models\SecurityKey;
|
||||||
use Illuminate\Contracts\Encryption\Encrypter;
|
use Illuminate\Contracts\Encryption\Encrypter;
|
||||||
|
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Pterodactyl\Events\Auth\ProvidedAuthenticationToken;
|
|
||||||
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
|
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
|
||||||
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
|
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
|
||||||
|
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
class LoginCheckpointController extends AbstractLoginController
|
class LoginCheckpointController extends AbstractLoginController
|
||||||
{
|
{
|
||||||
|
@ -24,6 +27,7 @@ class LoginCheckpointController extends AbstractLoginController
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Encrypter $encrypter,
|
private Encrypter $encrypter,
|
||||||
private Google2FA $google2FA,
|
private Google2FA $google2FA,
|
||||||
|
private WebauthnServerRepository $webauthnServerRepository,
|
||||||
private ValidationFactory $validation
|
private ValidationFactory $validation
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
@ -34,13 +38,80 @@ class LoginCheckpointController extends AbstractLoginController
|
||||||
* token. Once a user has reached this stage it is assumed that they have already
|
* token. Once a user has reached this stage it is assumed that they have already
|
||||||
* provided a valid username and password.
|
* provided a valid username and password.
|
||||||
*
|
*
|
||||||
|
* @return \Illuminate\Http\JsonResponse|void
|
||||||
|
*
|
||||||
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
|
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
|
||||||
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
|
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
|
||||||
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
|
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
|
||||||
* @throws \Exception
|
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
public function __invoke(LoginCheckpointRequest $request): JsonResponse
|
public function token(LoginCheckpointRequest $request)
|
||||||
|
{
|
||||||
|
$user = $this->extractUserFromRequest($request);
|
||||||
|
|
||||||
|
// Recovery tokens go through a slightly different pathway for usage.
|
||||||
|
if (!is_null($recoveryToken = $request->input('recovery_token'))) {
|
||||||
|
if ($this->isValidRecoveryToken($user, $recoveryToken)) {
|
||||||
|
return $this->sendLoginResponse($user, $request);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!$user->use_totp) {
|
||||||
|
$this->sendFailedLoginResponse($request, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decrypted = $this->encrypter->decrypt($user->totp_secret);
|
||||||
|
|
||||||
|
if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {
|
||||||
|
return $this->sendLoginResponse($user, $request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a login request using a security key for a user.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
|
*/
|
||||||
|
public function key(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$options = $request->session()->get(SecurityKey::PK_SESSION_NAME);
|
||||||
|
if (!$options instanceof PublicKeyCredentialRequestOptions) {
|
||||||
|
throw new BadRequestHttpException('No security keys configured in session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->extractUserFromRequest($request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$source = $this->webauthnServerRepository->loadAndCheckAssertionResponse(
|
||||||
|
$user,
|
||||||
|
// TODO: we may have to `json_encode` this so it will be decoded properly.
|
||||||
|
$request->input('data'),
|
||||||
|
$options,
|
||||||
|
SecurityKey::getPsrRequestFactory($request)
|
||||||
|
);
|
||||||
|
} catch (\Exception|\Throwable $e) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash_equals($user->uuid, $source->getUserHandle())) {
|
||||||
|
return $this->sendLoginResponse($user, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestHttpException('An unexpected error was encountered while validating that security key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the user from the session data using the provided confirmation token.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
|
*/
|
||||||
|
protected function extractUserFromRequest(Request $request): User
|
||||||
{
|
{
|
||||||
if ($this->hasTooManyLoginAttempts($request)) {
|
if ($this->hasTooManyLoginAttempts($request)) {
|
||||||
$this->sendLockoutResponse($request);
|
$this->sendLockoutResponse($request);
|
||||||
|
@ -62,24 +133,7 @@ class LoginCheckpointController extends AbstractLoginController
|
||||||
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
|
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovery tokens go through a slightly different pathway for usage.
|
return $user;
|
||||||
if (!is_null($recoveryToken = $request->input('recovery_token'))) {
|
|
||||||
if ($this->isValidRecoveryToken($user, $recoveryToken)) {
|
|
||||||
Event::dispatch(new ProvidedAuthenticationToken($user, true));
|
|
||||||
|
|
||||||
return $this->sendLoginResponse($user, $request);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$decrypted = $this->encrypter->decrypt($user->totp_secret);
|
|
||||||
|
|
||||||
if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {
|
|
||||||
Event::dispatch(new ProvidedAuthenticationToken($user));
|
|
||||||
|
|
||||||
return $this->sendLoginResponse($user, $request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -101,14 +155,19 @@ class LoginCheckpointController extends AbstractLoginController
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function hasValidSessionData(array $data): bool
|
||||||
|
{
|
||||||
|
return static::isValidSessionData($this->validation, $data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the data provided from the session is valid or not. This
|
* Determines if the data provided from the session is valid or not. This
|
||||||
* will return false if the data is invalid, or if more time has passed than
|
* will return false if the data is invalid, or if more time has passed than
|
||||||
* was configured when the session was written.
|
* was configured when the session was written.
|
||||||
*/
|
*/
|
||||||
protected function hasValidSessionData(array $data): bool
|
protected static function isValidSessionData(ValidationFactory $validation, array $data): bool
|
||||||
{
|
{
|
||||||
$validator = $this->validation->make($data, [
|
$validator = $validation->make($data, [
|
||||||
'user_id' => 'required|integer|min:1',
|
'user_id' => 'required|integer|min:1',
|
||||||
'token_value' => 'required|string',
|
'token_value' => 'required|string',
|
||||||
'expires_at' => 'required',
|
'expires_at' => 'required',
|
||||||
|
|
|
@ -9,15 +9,19 @@ use Pterodactyl\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Pterodactyl\Facades\Activity;
|
use Pterodactyl\Facades\Activity;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Contracts\View\Factory as ViewFactory;
|
use Pterodactyl\Models\SecurityKey;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||||
|
|
||||||
class LoginController extends AbstractLoginController
|
class LoginController extends AbstractLoginController
|
||||||
{
|
{
|
||||||
|
private const METHOD_TOTP = 'totp';
|
||||||
|
private const METHOD_WEBAUTHN = 'webauthn';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoginController constructor.
|
* LoginController constructor.
|
||||||
*/
|
*/
|
||||||
public function __construct(private ViewFactory $view)
|
public function __construct(protected WebauthnServerRepository $webauthnServerRepository)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
@ -29,7 +33,7 @@ class LoginController extends AbstractLoginController
|
||||||
*/
|
*/
|
||||||
public function index(): View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return $this->view->make('templates/auth.core');
|
return view('templates/auth.core');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -37,6 +41,7 @@ class LoginController extends AbstractLoginController
|
||||||
*
|
*
|
||||||
* @throws \Pterodactyl\Exceptions\DisplayException
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
* @throws \Webauthn\Exception\InvalidDataException
|
||||||
*/
|
*/
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
@ -62,7 +67,9 @@ class LoginController extends AbstractLoginController
|
||||||
$this->sendFailedLoginResponse($request, $user);
|
$this->sendFailedLoginResponse($request, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$user->use_totp) {
|
// Return early if the user does not have 2FA enabled, otherwise we will require them
|
||||||
|
// to complete a secondary challenge before they can log in.
|
||||||
|
if (!$user->has2FAEnabled()) {
|
||||||
return $this->sendLoginResponse($user, $request);
|
return $this->sendLoginResponse($user, $request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,11 +81,23 @@ class LoginController extends AbstractLoginController
|
||||||
'expires_at' => CarbonImmutable::now()->addMinutes(5),
|
'expires_at' => CarbonImmutable::now()->addMinutes(5),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new JsonResponse([
|
$response = [
|
||||||
'data' => [
|
|
||||||
'complete' => false,
|
'complete' => false,
|
||||||
'confirmation_token' => $token,
|
'methods' => array_values(array_filter([
|
||||||
],
|
$user->use_totp ? self::METHOD_TOTP : null,
|
||||||
]);
|
$user->securityKeys->isNotEmpty() ? self::METHOD_WEBAUTHN : null,
|
||||||
|
])),
|
||||||
|
'confirm_token' => $token,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($user->securityKeys->isNotEmpty()) {
|
||||||
|
$key = $this->webauthnServerRepository->generatePublicKeyCredentialRequestOptions($user);
|
||||||
|
|
||||||
|
$request->session()->put(SecurityKey::PK_SESSION_NAME, $key);
|
||||||
|
|
||||||
|
$request['webauthn'] = ['public_key' => $key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse($response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@ class RequireTwoFactorAuthentication
|
||||||
// send them right through, nothing else needs to be checked.
|
// send them right through, nothing else needs to be checked.
|
||||||
//
|
//
|
||||||
// If the level is set as admin and the user is not an admin, pass them through as well.
|
// If the level is set as admin and the user is not an admin, pass them through as well.
|
||||||
if ($level === self::LEVEL_NONE || $user->use_totp) {
|
if ($level === self::LEVEL_NONE || $user->has2FAEnabled()) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) {
|
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Requests\Api\Client\Account;
|
||||||
|
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||||
|
|
||||||
|
class RegisterSecurityKeyRequest extends ClientApiRequest
|
||||||
|
{
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['string', 'required'],
|
||||||
|
'token_id' => ['required', 'string'],
|
||||||
|
'registration' => ['required', 'array'],
|
||||||
|
'registration.id' => ['required', 'string'],
|
||||||
|
'registration.type' => ['required', 'in:public-key'],
|
||||||
|
'registration.response.attestationObject' => ['required', 'string'],
|
||||||
|
'registration.response.clientDataJSON' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ class ClientApiRequest extends ApplicationApiRequest
|
||||||
return $this->user()->can($this->permission(), $server);
|
return $this->user()->can($this->permission(), $server);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there is no server available on the reqest, trigger a failure since
|
// If there is no server available on the request, trigger a failure since
|
||||||
// we expect there to be one at this point.
|
// we expect there to be one at this point.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
125
app/Models/SecurityKey.php
Normal file
125
app/Models/SecurityKey.php
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
use Webauthn\TrustPath\TrustPath;
|
||||||
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
|
use Webauthn\PublicKeyCredentialSource;
|
||||||
|
use Webauthn\TrustPath\TrustPathLoader;
|
||||||
|
use Webauthn\PublicKeyCredentialDescriptor;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
|
||||||
|
|
||||||
|
class SecurityKey extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const RESOURCE_NAME = 'security_key';
|
||||||
|
public const PK_SESSION_NAME = 'security_key_pk_request';
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'user_id' => 'int',
|
||||||
|
'transports' => 'array',
|
||||||
|
'other_ui' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $guarded = [
|
||||||
|
'uuid',
|
||||||
|
'user_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getPublicKeyAttribute(string $value): string
|
||||||
|
{
|
||||||
|
return base64_decode($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPublicKeyAttribute(string $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['public_key'] = base64_encode($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPublicKeyIdAttribute(string $value): string
|
||||||
|
{
|
||||||
|
return base64_decode($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPublicKeyIdAttribute(string $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['public_key_id'] = base64_encode($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTrustPathAttribute(?string $value): ?TrustPath
|
||||||
|
{
|
||||||
|
if (is_null($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrustPathLoader::loadTrustPath(json_decode($value, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTrustPathAttribute(?TrustPath $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['trust_path'] = json_encode($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Ramsey\Uuid\UuidInterface|string|null $value
|
||||||
|
*/
|
||||||
|
public function setAaguidAttribute($value): void
|
||||||
|
{
|
||||||
|
$value = $value instanceof UuidInterface ? $value->__toString() : $value;
|
||||||
|
|
||||||
|
$this->attributes['aaguid'] = (is_null($value) || $value === Uuid::NIL) ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAaguidAttribute(?string $value): ?UuidInterface
|
||||||
|
{
|
||||||
|
if (!is_null($value) && Uuid::isValid($value)) {
|
||||||
|
return Uuid::fromString($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPublicKeyCredentialDescriptor(): PublicKeyCredentialDescriptor
|
||||||
|
{
|
||||||
|
return new PublicKeyCredentialDescriptor($this->type, $this->public_key_id, $this->transports);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPublicKeyCredentialSource(): PublicKeyCredentialSource
|
||||||
|
{
|
||||||
|
return new PublicKeyCredentialSource(
|
||||||
|
$this->public_key_id,
|
||||||
|
$this->type,
|
||||||
|
$this->transports,
|
||||||
|
$this->attestation_type,
|
||||||
|
$this->trust_path,
|
||||||
|
$this->aaguid ?? Uuid::fromString(Uuid::NIL),
|
||||||
|
$this->public_key,
|
||||||
|
$this->user_handle,
|
||||||
|
$this->counter
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a PSR17 Request factory to be used by different Webauthn tooling.
|
||||||
|
*/
|
||||||
|
public static function getPsrRequestFactory(Request $request): ServerRequestInterface
|
||||||
|
{
|
||||||
|
$factory = new Psr17Factory();
|
||||||
|
|
||||||
|
$httpFactory = new PsrHttpFactory($factory, $factory, $factory, $factory);
|
||||||
|
|
||||||
|
return $httpFactory->createRequest($request);
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ use Illuminate\Validation\Rules\In;
|
||||||
use Illuminate\Auth\Authenticatable;
|
use Illuminate\Auth\Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Webauthn\PublicKeyCredentialUserEntity;
|
||||||
use Pterodactyl\Models\Traits\HasAccessTokens;
|
use Pterodactyl\Models\Traits\HasAccessTokens;
|
||||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||||
use Pterodactyl\Traits\Helpers\AvailableLanguages;
|
use Pterodactyl\Traits\Helpers\AvailableLanguages;
|
||||||
|
@ -47,6 +48,8 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
|
||||||
* @property int|null $notifications_count
|
* @property int|null $notifications_count
|
||||||
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\RecoveryToken[] $recoveryTokens
|
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\RecoveryToken[] $recoveryTokens
|
||||||
* @property int|null $recovery_tokens_count
|
* @property int|null $recovery_tokens_count
|
||||||
|
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\SecurityKey[] $securityKeys
|
||||||
|
* @property int|null $security_keys_count
|
||||||
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers
|
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers
|
||||||
* @property int|null $servers_count
|
* @property int|null $servers_count
|
||||||
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\UserSSHKey[] $sshKeys
|
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\UserSSHKey[] $sshKeys
|
||||||
|
@ -186,9 +189,9 @@ class User extends Model implements
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the user model in a format that can be passed over to Vue templates.
|
* Return the user model in a format that can be passed over to React templates.
|
||||||
*/
|
*/
|
||||||
public function toVueObject(): array
|
public function toReactObject(): array
|
||||||
{
|
{
|
||||||
return Collection::make($this->toArray())->except(['id', 'external_id'])->toArray();
|
return Collection::make($this->toArray())->except(['id', 'external_id'])->toArray();
|
||||||
}
|
}
|
||||||
|
@ -248,6 +251,11 @@ class User extends Model implements
|
||||||
return $this->hasMany(UserSSHKey::class);
|
return $this->hasMany(UserSSHKey::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function securityKeys(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SecurityKey::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the activity logs where this user is the subject — not to
|
* Returns all the activity logs where this user is the subject — not to
|
||||||
* be confused by activity logs where this user is the _actor_.
|
* be confused by activity logs where this user is the _actor_.
|
||||||
|
@ -271,4 +279,17 @@ class User extends Model implements
|
||||||
})
|
})
|
||||||
->groupBy('servers.id');
|
->groupBy('servers.id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toPublicKeyCredentialEntity(): PublicKeyCredentialUserEntity
|
||||||
|
{
|
||||||
|
return PublicKeyCredentialUserEntity::create($this->username, $this->uuid, $this->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the user has two-factor authentication enabled.
|
||||||
|
*/
|
||||||
|
public function has2FAEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->use_totp || $this->securityKeys->isNotEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Repositories\SecurityKeys;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Illuminate\Container\Container;
|
||||||
|
use Webauthn\PublicKeyCredentialSource;
|
||||||
|
use Webauthn\PublicKeyCredentialUserEntity;
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
use Webauthn\PublicKeyCredentialSourceRepository as PublicKeyRepositoryInterface;
|
||||||
|
|
||||||
|
class PublicKeyCredentialSourceRepository implements PublicKeyRepositoryInterface
|
||||||
|
{
|
||||||
|
protected User $user;
|
||||||
|
|
||||||
|
public function __construct(User $user)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a single hardware security token for a user by using the credential ID.
|
||||||
|
*/
|
||||||
|
public function findOneByCredentialId(string $id): ?PublicKeyCredentialSource
|
||||||
|
{
|
||||||
|
/** @var \Pterodactyl\Models\SecurityKey $key */
|
||||||
|
$key = $this->user->securityKeys()
|
||||||
|
->where('public_key_id', base64_encode($id))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return optional($key)->getPublicKeyCredentialSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all the hardware tokens that exist for the user using the given
|
||||||
|
* entity handle.
|
||||||
|
*/
|
||||||
|
public function findAllForUserEntity(PublicKeyCredentialUserEntity $entity): array
|
||||||
|
{
|
||||||
|
$results = $this->user->securityKeys()
|
||||||
|
->where('user_handle', $entity->getId())
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $results->map(function (SecurityKey $key) {
|
||||||
|
return $key->getPublicKeyCredentialSource();
|
||||||
|
})->values()->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a credential to the database and link it with the user.
|
||||||
|
*
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function saveCredentialSource(PublicKeyCredentialSource $source): void
|
||||||
|
{
|
||||||
|
// no-op — we handle creation of the keys in StoreSecurityKeyService
|
||||||
|
//
|
||||||
|
// If you put logic in here it is triggered on each login.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new instance of the repository with the provided user attached.
|
||||||
|
*/
|
||||||
|
public static function factory(User $user): self
|
||||||
|
{
|
||||||
|
return Container::getInstance()->make(static::class, ['user' => $user]);
|
||||||
|
}
|
||||||
|
}
|
161
app/Repositories/SecurityKeys/WebauthnServerRepository.php
Normal file
161
app/Repositories/SecurityKeys/WebauthnServerRepository.php
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Repositories\SecurityKeys;
|
||||||
|
|
||||||
|
use Cose\Algorithms;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
use Webauthn\PublicKeyCredentialLoader;
|
||||||
|
use Webauthn\PublicKeyCredentialSource;
|
||||||
|
use Webauthn\PublicKeyCredentialRpEntity;
|
||||||
|
use Webauthn\PublicKeyCredentialParameters;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Webauthn\AuthenticatorAssertionResponse;
|
||||||
|
use Webauthn\AuthenticatorSelectionCriteria;
|
||||||
|
use Webauthn\AuthenticatorAttestationResponse;
|
||||||
|
use Cose\Algorithm\Manager as AlgorithmManager;
|
||||||
|
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||||
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
|
use Webauthn\AuthenticatorAssertionResponseValidator;
|
||||||
|
use Webauthn\AuthenticatorAttestationResponseValidator;
|
||||||
|
use Webauthn\AttestationStatement\AttestationObjectLoader;
|
||||||
|
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
|
||||||
|
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
||||||
|
|
||||||
|
final class WebauthnServerRepository
|
||||||
|
{
|
||||||
|
private PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository;
|
||||||
|
|
||||||
|
private PublicKeyCredentialRpEntity $rpEntity;
|
||||||
|
private PublicKeyCredentialLoader $credentialLoader;
|
||||||
|
private AuthenticatorAssertionResponseValidator $assertionValidator;
|
||||||
|
private AuthenticatorAttestationResponseValidator $attestationValidator;
|
||||||
|
|
||||||
|
public function __construct(PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository)
|
||||||
|
{
|
||||||
|
$url = str_replace(['http://', 'https://'], '', config('app.url'));
|
||||||
|
|
||||||
|
$this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository;
|
||||||
|
|
||||||
|
$this->rpEntity = new PublicKeyCredentialRpEntity(config('app.name'), trim($url, '/'));
|
||||||
|
$this->credentialLoader = new PublicKeyCredentialLoader(new AttestationObjectLoader(new AttestationStatementSupportManager()));
|
||||||
|
$this->assertionValidator = new AuthenticatorAssertionResponseValidator(
|
||||||
|
$this->publicKeyCredentialSourceRepository,
|
||||||
|
null,
|
||||||
|
ExtensionOutputCheckerHandler::create(),
|
||||||
|
AlgorithmManager::create(),
|
||||||
|
);
|
||||||
|
$this->attestationValidator = new AuthenticatorAttestationResponseValidator(
|
||||||
|
new AttestationStatementSupportManager(),
|
||||||
|
$this->publicKeyCredentialSourceRepository,
|
||||||
|
null,
|
||||||
|
new ExtensionOutputCheckerHandler(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Webauthn\Exception\InvalidDataException
|
||||||
|
*/
|
||||||
|
public function getPublicKeyCredentialCreationOptions(User $user): PublicKeyCredentialCreationOptions
|
||||||
|
{
|
||||||
|
$excluded = $user->securityKeys->map(function (SecurityKey $key) {
|
||||||
|
return $key->getPublicKeyCredentialDescriptor();
|
||||||
|
})->values()->toArray();
|
||||||
|
|
||||||
|
$challenge = Str::random(16);
|
||||||
|
|
||||||
|
return (new PublicKeyCredentialCreationOptions(
|
||||||
|
$this->rpEntity,
|
||||||
|
$user->toPublicKeyCredentialEntity(),
|
||||||
|
$challenge,
|
||||||
|
[
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256K),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES384),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES512),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS256),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS384),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS512),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS256),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS384),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS512),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ED256),
|
||||||
|
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ED512),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
->setTimeout(30_000)
|
||||||
|
->excludeCredentials(...$excluded)
|
||||||
|
->setAttestation(PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE)
|
||||||
|
->setAuthenticatorSelection(AuthenticatorSelectionCriteria::create());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Webauthn\Exception\InvalidDataException
|
||||||
|
*/
|
||||||
|
public function generatePublicKeyCredentialRequestOptions(User $user): PublicKeyCredentialRequestOptions
|
||||||
|
{
|
||||||
|
$allowedCredentials = $user->securityKeys->map(function (SecurityKey $key) {
|
||||||
|
return $key->getPublicKeyCredentialDescriptor();
|
||||||
|
})->values()->toArray();
|
||||||
|
|
||||||
|
return (new PublicKeyCredentialRequestOptions(Str::random(32)))
|
||||||
|
->allowCredentials(...$allowedCredentials)
|
||||||
|
->setUserVerification(PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Throwable
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function loadAndCheckAssertionResponse(
|
||||||
|
User $user,
|
||||||
|
array $data,
|
||||||
|
PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
|
||||||
|
ServerRequestInterface $request
|
||||||
|
): PublicKeyCredentialSource {
|
||||||
|
$credential = $this->credentialLoader->loadArray($data);
|
||||||
|
|
||||||
|
$authenticatorAssertionResponse = $credential->getResponse();
|
||||||
|
if (!$authenticatorAssertionResponse instanceof AuthenticatorAssertionResponse) {
|
||||||
|
// TODO
|
||||||
|
throw new \Exception('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->assertionValidator->check(
|
||||||
|
$credential->getRawId(),
|
||||||
|
$authenticatorAssertionResponse,
|
||||||
|
$publicKeyCredentialRequestOptions,
|
||||||
|
$request,
|
||||||
|
null, // TODO: use handle?
|
||||||
|
// $user->toPublicKeyCredentialEntity()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new security key for a user.
|
||||||
|
*
|
||||||
|
* @throws \Throwable
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function loadAndCheckAttestationResponse(
|
||||||
|
User $user,
|
||||||
|
array $data,
|
||||||
|
PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
|
||||||
|
ServerRequestInterface $request
|
||||||
|
): PublicKeyCredentialSource {
|
||||||
|
$credential = $this->credentialLoader->loadArray($data);
|
||||||
|
|
||||||
|
$authenticatorAttestationResponse = $credential->getResponse();
|
||||||
|
if (!$authenticatorAttestationResponse instanceof AuthenticatorAttestationResponse) {
|
||||||
|
// TODO
|
||||||
|
throw new \Exception('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->attestationValidator->check(
|
||||||
|
$authenticatorAttestationResponse,
|
||||||
|
$publicKeyCredentialCreationOptions,
|
||||||
|
$request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Services\Users\SecurityKeys;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
|
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||||
|
|
||||||
|
class CreatePublicKeyCredentialService
|
||||||
|
{
|
||||||
|
protected WebauthnServerRepository $webauthnServerRepository;
|
||||||
|
|
||||||
|
public function __construct(WebauthnServerRepository $webauthnServerRepository)
|
||||||
|
{
|
||||||
|
$this->webauthnServerRepository = $webauthnServerRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Webauthn\Exception\InvalidDataException
|
||||||
|
*/
|
||||||
|
public function handle(User $user): PublicKeyCredentialCreationOptions
|
||||||
|
{
|
||||||
|
return $this->webauthnServerRepository->getPublicKeyCredentialCreationOptions($user);
|
||||||
|
}
|
||||||
|
}
|
79
app/Services/Users/SecurityKeys/StoreSecurityKeyService.php
Normal file
79
app/Services/Users/SecurityKeys/StoreSecurityKeyService.php
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Services\Users\SecurityKeys;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class StoreSecurityKeyService
|
||||||
|
{
|
||||||
|
protected ?ServerRequestInterface $request = null;
|
||||||
|
|
||||||
|
protected ?string $keyName = null;
|
||||||
|
|
||||||
|
public function __construct(protected WebauthnServerRepository $webauthnServerRepository)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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->webauthnServerRepository->loadAndCheckAttestationResponse($user, $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.
|
||||||
|
/** @var \Pterodactyl\Models\SecurityKey $key */
|
||||||
|
$key = $user->securityKeys()->make()->forceFill([
|
||||||
|
'uuid' => Uuid::uuid4(),
|
||||||
|
'name' => $this->keyName ?? 'Security Key (' . Str::random() . ')',
|
||||||
|
'public_key_id' => $source->getPublicKeyCredentialId(),
|
||||||
|
'public_key' => $source->getCredentialPublicKey(),
|
||||||
|
'aaguid' => $source->getAaguid(),
|
||||||
|
'type' => $source->getType(),
|
||||||
|
'transports' => $source->getTransports(),
|
||||||
|
'attestation_type' => $source->getAttestationType(),
|
||||||
|
'trust_path' => $source->getTrustPath(),
|
||||||
|
'user_handle' => $user->uuid,
|
||||||
|
'counter' => $source->getCounter(),
|
||||||
|
'other_ui' => $source->getOtherUI(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$key->saveOrFail();
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
25
app/Transformers/Api/Client/SecurityKeyTransformer.php
Normal file
25
app/Transformers/Api/Client/SecurityKeyTransformer.php
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Transformers\Api\Client;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
|
||||||
|
class SecurityKeyTransformer extends BaseClientTransformer
|
||||||
|
{
|
||||||
|
public function getResourceName(): string
|
||||||
|
{
|
||||||
|
return SecurityKey::RESOURCE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transform(SecurityKey $model): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $model->uuid,
|
||||||
|
'name' => $model->name,
|
||||||
|
'type' => $model->type,
|
||||||
|
'public_key_id' => base64_encode($model->public_key_id),
|
||||||
|
'created_at' => self::formatTimestamp($model->created_at),
|
||||||
|
'updated_at' => self::formatTimestamp($model->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,6 +38,7 @@
|
||||||
"league/flysystem-aws-s3-v3": "~3.5.0",
|
"league/flysystem-aws-s3-v3": "~3.5.0",
|
||||||
"league/flysystem-memory": "~3.3.0",
|
"league/flysystem-memory": "~3.3.0",
|
||||||
"matriphe/iso-639": "~1.2",
|
"matriphe/iso-639": "~1.2",
|
||||||
|
"nyholm/psr7": "~1.5",
|
||||||
"phpseclib/phpseclib": "~3.0",
|
"phpseclib/phpseclib": "~3.0",
|
||||||
"pragmarx/google2fa": "~5.0.0",
|
"pragmarx/google2fa": "~5.0.0",
|
||||||
"predis/predis": "~2.0.2",
|
"predis/predis": "~2.0.2",
|
||||||
|
@ -50,7 +51,9 @@
|
||||||
"symfony/http-client": "~6.0",
|
"symfony/http-client": "~6.0",
|
||||||
"symfony/mailgun-mailer": "~6.0",
|
"symfony/mailgun-mailer": "~6.0",
|
||||||
"symfony/postmark-mailer": "~6.0",
|
"symfony/postmark-mailer": "~6.0",
|
||||||
|
"symfony/psr-http-message-bridge": "~2.1",
|
||||||
"symfony/yaml": "~5.4",
|
"symfony/yaml": "~5.4",
|
||||||
|
"web-auth/webauthn-lib": "~4.3",
|
||||||
"webmozart/assert": "~1.11"
|
"webmozart/assert": "~1.11"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
@ -100,7 +103,7 @@
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "8.0.2"
|
"php": "8.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1225
composer.lock
generated
1225
composer.lock
generated
File diff suppressed because it is too large
Load diff
43
database/Factories/SecurityKeyFactory.php
Normal file
43
database/Factories/SecurityKeyFactory.php
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
use Webauthn\TrustPath\EmptyTrustPath;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
class SecurityKeyFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name of the factory's corresponding model.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $model = SecurityKey::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => Uuid::uuid4()->toString(),
|
||||||
|
'name' => $this->faker->word,
|
||||||
|
'type' => 'public-key',
|
||||||
|
'transports' => [],
|
||||||
|
'attestation_type' => 'none',
|
||||||
|
'trust_path' => new EmptyTrustPath(),
|
||||||
|
'counter' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withUser(User $user): self
|
||||||
|
{
|
||||||
|
return $this->state([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_handle' => $user->uuid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class CreateSecurityKeysTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('security_keys', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->char('uuid', 36)->unique();
|
||||||
|
$table->unsignedInteger('user_id');
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('public_key_id');
|
||||||
|
$table->text('public_key');
|
||||||
|
$table->char('aaguid', 36)->nullable();
|
||||||
|
$table->string('type');
|
||||||
|
$table->json('transports');
|
||||||
|
$table->string('attestation_type');
|
||||||
|
$table->json('trust_path');
|
||||||
|
$table->text('user_handle');
|
||||||
|
$table->unsignedInteger('counter');
|
||||||
|
$table->json('other_ui')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('security_keys');
|
||||||
|
}
|
||||||
|
}
|
85
resources/scripts/api/account/security-keys.ts
Normal file
85
resources/scripts/api/account/security-keys.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import useSWR, { ConfigInterface } from 'swr';
|
||||||
|
import { useStoreState } from '@/state/hooks';
|
||||||
|
import http, { FractalResponseList } from '@/api/http';
|
||||||
|
import { SecurityKey, Transformers } from '@definitions/user';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { decodeBase64 } from '@/lib/base64';
|
||||||
|
import { decodeBuffer, encodeBuffer } from '@/lib/buffer';
|
||||||
|
import { LoginResponse } from '@/api/auth/login';
|
||||||
|
import { useUserSWRKey } from '@/plugins/useSWRKey';
|
||||||
|
|
||||||
|
function decodeSecurityKeyCredentials(credentials: PublicKeyCredentialDescriptor[]) {
|
||||||
|
return credentials.map(c => ({
|
||||||
|
id: decodeBuffer(decodeBase64(c.id.toString())),
|
||||||
|
type: c.type,
|
||||||
|
transports: c.transports,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSecurityKeys(config?: ConfigInterface<SecurityKey[], AxiosError>) {
|
||||||
|
const uuid = useStoreState(state => state.user.data!.uuid);
|
||||||
|
const key = useUserSWRKey(['account', 'security-keys']);
|
||||||
|
|
||||||
|
return useSWR<SecurityKey[], AxiosError>(
|
||||||
|
key,
|
||||||
|
async (): Promise<SecurityKey[]> => {
|
||||||
|
const { data } = await http.get('/api/client/account/security-keys');
|
||||||
|
|
||||||
|
return (data as FractalResponseList).data.map((datum) => Transformers.toSecurityKey(datum.attributes));
|
||||||
|
},
|
||||||
|
{ revalidateOnMount: false, ...(config || {}) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSecurityKey(uuid: string): Promise<void> {
|
||||||
|
await http.delete(`/api/client/account/security-keys/${uuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerCredentialForAccount(name: string, tokenId: string, credential: PublicKeyCredential): Promise<SecurityKey> {
|
||||||
|
const { data } = await http.post('/api/client/account/security-keys/register', {
|
||||||
|
name,
|
||||||
|
token_id: tokenId,
|
||||||
|
registration: {
|
||||||
|
id: credential.id,
|
||||||
|
type: credential.type,
|
||||||
|
rawId: encodeBuffer(credential.rawId),
|
||||||
|
response: {
|
||||||
|
attestationObject: encodeBuffer((credential.response as AuthenticatorAttestationResponse).attestationObject),
|
||||||
|
clientDataJSON: encodeBuffer(credential.response.clientDataJSON),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Transformers.toSecurityKey(data.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerSecurityKey(name: string): Promise<SecurityKey> {
|
||||||
|
const { data } = await http.get('/api/client/account/security-keys/register');
|
||||||
|
|
||||||
|
const publicKey = data.data.credentials;
|
||||||
|
publicKey.challenge = decodeBuffer(decodeBase64(publicKey.challenge));
|
||||||
|
publicKey.user.id = decodeBuffer(publicKey.user.id);
|
||||||
|
|
||||||
|
if (publicKey.excludeCredentials) {
|
||||||
|
publicKey.excludeCredentials = decodeSecurityKeyCredentials(publicKey.excludeCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await navigator.credentials.create({ publicKey });
|
||||||
|
if (!credentials || credentials.type !== 'public-key') {
|
||||||
|
throw new Error(`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await registerCredentialForAccount(name, data.data.token_id, credentials as PublicKeyCredential);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
async function authenticateSecurityKey(data: { confirmation_token: string; data: string }): Promise<LoginResponse> {
|
||||||
|
const response = await http.post('/auth/login/checkpoint/key', data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
complete: response.data.complete,
|
||||||
|
intended: response.data.data?.intended || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useSecurityKeys, deleteSecurityKey, registerSecurityKey, authenticateSecurityKey };
|
|
@ -1,24 +1,6 @@
|
||||||
import { Model, UUID } from '@/api/definitions';
|
import { Model, UUID } from '@/api/definitions';
|
||||||
import { SubuserPermission } from '@/state/server/subusers';
|
import { SubuserPermission } from '@/state/server/subusers';
|
||||||
|
|
||||||
interface User extends Model {
|
|
||||||
uuid: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
image: string;
|
|
||||||
twoFactorEnabled: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
permissions: SubuserPermission[];
|
|
||||||
can(permission: SubuserPermission): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHKey extends Model {
|
|
||||||
name: string;
|
|
||||||
publicKey: string;
|
|
||||||
fingerprint: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActivityLog extends Model<'actor'> {
|
interface ActivityLog extends Model<'actor'> {
|
||||||
id: string;
|
id: string;
|
||||||
batch: UUID | null;
|
batch: UUID | null;
|
||||||
|
@ -33,3 +15,30 @@ interface ActivityLog extends Model<'actor'> {
|
||||||
actor: User | null;
|
actor: User | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface User extends Model {
|
||||||
|
uuid: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
image: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
permissions: SubuserPermission[];
|
||||||
|
can(permission: SubuserPermission): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityKey extends Model {
|
||||||
|
uuid: UUID;
|
||||||
|
name: string;
|
||||||
|
type: 'public-key';
|
||||||
|
publicKeyId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHKey extends Model {
|
||||||
|
name: string;
|
||||||
|
publicKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,36 @@ import { FractalResponseData } from '@/api/http';
|
||||||
import { transform } from '@definitions/helpers';
|
import { transform } from '@definitions/helpers';
|
||||||
|
|
||||||
export default class Transformers {
|
export default class Transformers {
|
||||||
|
static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => {
|
||||||
|
const { actor } = attributes.relationships || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: attributes.id,
|
||||||
|
batch: attributes.batch,
|
||||||
|
event: attributes.event,
|
||||||
|
ip: attributes.ip,
|
||||||
|
isApi: attributes.is_api,
|
||||||
|
description: attributes.description,
|
||||||
|
properties: attributes.properties,
|
||||||
|
hasAdditionalMetadata: attributes.has_additional_metadata ?? false,
|
||||||
|
timestamp: new Date(attributes.timestamp),
|
||||||
|
relationships: {
|
||||||
|
actor: transform(actor as FractalResponseData, this.toUser, null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
static toSecurityKey (data: Record<string, any>): Models.SecurityKey {
|
||||||
|
return {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static toSSHKey = (data: Record<any, any>): Models.SSHKey => {
|
static toSSHKey = (data: Record<any, any>): Models.SSHKey => {
|
||||||
return {
|
return {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
@ -26,25 +56,6 @@ export default class Transformers {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => {
|
|
||||||
const { actor } = attributes.relationships || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: attributes.id,
|
|
||||||
batch: attributes.batch,
|
|
||||||
event: attributes.event,
|
|
||||||
ip: attributes.ip,
|
|
||||||
isApi: attributes.is_api,
|
|
||||||
description: attributes.description,
|
|
||||||
properties: attributes.properties,
|
|
||||||
hasAdditionalMetadata: attributes.has_additional_metadata ?? false,
|
|
||||||
timestamp: new Date(attributes.timestamp),
|
|
||||||
relationships: {
|
|
||||||
actor: transform(actor as FractalResponseData, this.toUser, null),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MetaTransformers {}
|
export class MetaTransformers {}
|
||||||
|
|
12
resources/scripts/lib/base64.spec.ts
Normal file
12
resources/scripts/lib/base64.spec.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { decodeBase64 } from '@/lib/base64';
|
||||||
|
|
||||||
|
describe('@/lib/base64.ts', function () {
|
||||||
|
describe('decodeBase64()', function () {
|
||||||
|
it.each([
|
||||||
|
['', ''],
|
||||||
|
['', ''],
|
||||||
|
])('should decode "%s" to "%s"', function (input, output) {
|
||||||
|
expect(decodeBase64(input)).toBe(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
16
resources/scripts/lib/base64.ts
Normal file
16
resources/scripts/lib/base64.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
function decodeBase64 (input: string): string {
|
||||||
|
input = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
|
||||||
|
const pad = input.length % 4;
|
||||||
|
if (pad) {
|
||||||
|
if (pad === 1) {
|
||||||
|
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
|
||||||
|
}
|
||||||
|
|
||||||
|
input += new Array(5 - pad).join('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { decodeBase64 }
|
21
resources/scripts/lib/buffer.spec.ts
Normal file
21
resources/scripts/lib/buffer.spec.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { decodeBuffer, encodeBuffer } from '@/lib/buffer';
|
||||||
|
|
||||||
|
describe('@/lib/buffer.ts', function () {
|
||||||
|
describe('decodeBuffer()', function () {
|
||||||
|
it.each([
|
||||||
|
['', ''],
|
||||||
|
['', ''],
|
||||||
|
])('should decode "%s" to "%s"', function (input, output) {
|
||||||
|
expect(decodeBuffer(input)).toBe(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeBuffer()', function () {
|
||||||
|
it.each([
|
||||||
|
[new Uint8Array(0), ''],
|
||||||
|
[new Uint8Array(0), ''],
|
||||||
|
])('should encode "%s" to "%s"', function (input, output) {
|
||||||
|
expect(encodeBuffer(input)).toBe(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
9
resources/scripts/lib/buffer.ts
Normal file
9
resources/scripts/lib/buffer.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
function decodeBuffer(value: string): ArrayBuffer {
|
||||||
|
return Uint8Array.from(window.atob(value), c => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBuffer(value: ArrayBuffer): string {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { decodeBuffer, encodeBuffer };
|
|
@ -36,6 +36,11 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function ()
|
||||||
Route::post('/api-keys', [Client\ApiKeyController::class, 'store']);
|
Route::post('/api-keys', [Client\ApiKeyController::class, 'store']);
|
||||||
Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']);
|
Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']);
|
||||||
|
|
||||||
|
Route::get('/security-keys', [Client\SecurityKeyController::class, 'index'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
|
||||||
|
Route::get('/security-keys/register', [Client\SecurityKeyController::class, 'create'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
|
||||||
|
Route::post('/security-keys/register', [Client\SecurityKeyController::class, 'store'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
|
||||||
|
Route::delete('/security-keys/{securityKey}', [Client\SecurityKeyController::class, 'delete'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
|
||||||
|
|
||||||
Route::prefix('/ssh-keys')->group(function () {
|
Route::prefix('/ssh-keys')->group(function () {
|
||||||
Route::get('/', [Client\SSHKeyController::class, 'index']);
|
Route::get('/', [Client\SSHKeyController::class, 'index']);
|
||||||
Route::post('/', [Client\SSHKeyController::class, 'store']);
|
Route::post('/', [Client\SSHKeyController::class, 'store']);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Pterodactyl\Http\Controllers\Auth;
|
use Pterodactyl\Http\Controllers\Auth;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -11,7 +12,7 @@ use Pterodactyl\Http\Controllers\Auth;
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// These routes are defined so that we can continue to reference them programatically.
|
// These routes are defined so that we can continue to reference them programmatically.
|
||||||
// They all route to the same controller function which passes off to React.
|
// They all route to the same controller function which passes off to React.
|
||||||
Route::get('/login', [Auth\LoginController::class, 'index'])->name('auth.login');
|
Route::get('/login', [Auth\LoginController::class, 'index'])->name('auth.login');
|
||||||
Route::get('/password', [Auth\LoginController::class, 'index'])->name('auth.forgot-password');
|
Route::get('/password', [Auth\LoginController::class, 'index'])->name('auth.forgot-password');
|
||||||
|
@ -24,7 +25,8 @@ Route::get('/password/reset/{token}', [Auth\LoginController::class, 'index'])->n
|
||||||
Route::middleware(['throttle:authentication'])->group(function () {
|
Route::middleware(['throttle:authentication'])->group(function () {
|
||||||
// Login endpoints.
|
// Login endpoints.
|
||||||
Route::post('/login', [Auth\LoginController::class, 'login'])->middleware('recaptcha');
|
Route::post('/login', [Auth\LoginController::class, 'login'])->middleware('recaptcha');
|
||||||
Route::post('/login/checkpoint', Auth\LoginCheckpointController::class)->name('auth.login-checkpoint');
|
Route::post('/login/checkpoint', [Auth\LoginCheckpointController::class, 'token'])->name('auth.checkpoint');
|
||||||
|
Route::post('/login/checkpoint/key', [Auth\LoginCheckpointController::class, 'key'])->name('auth.checkpoint.key');
|
||||||
|
|
||||||
// Forgot password route. A post to this endpoint will trigger an
|
// Forgot password route. A post to this endpoint will trigger an
|
||||||
// email to be sent containing a reset token.
|
// email to be sent containing a reset token.
|
||||||
|
@ -38,12 +40,12 @@ Route::middleware(['throttle:authentication'])->group(function () {
|
||||||
// is created).
|
// is created).
|
||||||
Route::post('/password/reset', Auth\ResetPasswordController::class)->name('auth.reset-password');
|
Route::post('/password/reset', Auth\ResetPasswordController::class)->name('auth.reset-password');
|
||||||
|
|
||||||
// Remove the guest middleware and apply the authenticated middleware to this endpoint
|
// Remove the guest middleware and apply the authenticated middleware to this endpoint,
|
||||||
// so it cannot be used unless you're already logged in.
|
// so it cannot be used unless you're already logged in.
|
||||||
Route::post('/logout', [Auth\LoginController::class, 'logout'])
|
Route::post('/logout', [Auth\LoginController::class, 'logout'])
|
||||||
->withoutMiddleware('guest')
|
->withoutMiddleware('guest')
|
||||||
->middleware('auth')
|
->middleware('auth')
|
||||||
->name('auth.logout');
|
->name('auth.logout');
|
||||||
|
|
||||||
// Catch any other combinations of routes and pass them off to the Vuejs component.
|
// Catch any other combinations of routes and pass them off to the React frontend.
|
||||||
Route::fallback([Auth\LoginController::class, 'index']);
|
Route::fallback([Auth\LoginController::class, 'index']);
|
||||||
|
|
|
@ -0,0 +1,328 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Tests\Unit\Http\Middleware;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
|
use Pterodactyl\Exceptions\Http\TwoFactorAuthRequiredException;
|
||||||
|
use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication;
|
||||||
|
|
||||||
|
class RequireTwoFactorAuthenticationTest extends MiddlewareTestCase
|
||||||
|
{
|
||||||
|
public function testNoRequirementUserWithout2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => false]);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRequirementUserWithTotp2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => true]);
|
||||||
|
|
||||||
|
$this->assertTrue($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRequirementUserWithWebauthn2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Models\User $user */
|
||||||
|
$user = User::factory()
|
||||||
|
->has(SecurityKey::factory()->count(1))
|
||||||
|
->create(['use_totp' => false]);
|
||||||
|
$this->setRequestUserModel($user);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertNotEmpty($user->securityKeys);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRequirementGuestUser()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE);
|
||||||
|
|
||||||
|
$this->setRequestUserModel();
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/auth/login');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn('auth.login');
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllRequirementUserWithout2fa()
|
||||||
|
{
|
||||||
|
$this->expectException(TwoFactorAuthRequiredException::class);
|
||||||
|
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => false]);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertEmpty($user->securityKeys);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllRequirementUserWithTotp2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => true]);
|
||||||
|
|
||||||
|
$this->assertTrue($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllRequirementRuserWithWebauthn2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Models\User $user */
|
||||||
|
$user = User::factory()
|
||||||
|
->has(SecurityKey::factory()->count(1))
|
||||||
|
->create(['use_totp' => false]);
|
||||||
|
$this->setRequestUserModel($user);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertNotEmpty($user->securityKeys);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllRequirementGuestUser()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL);
|
||||||
|
|
||||||
|
$this->setRequestUserModel();
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/auth/login');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn('auth.login');
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminRequirementUserWithout2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => false]);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertFalse($user->root_admin);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminRequirementAdminUserWithout2fa()
|
||||||
|
{
|
||||||
|
$this->expectException(TwoFactorAuthRequiredException::class);
|
||||||
|
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => false, 'root_admin' => true]);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertTrue($user->root_admin);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminRequirementUserWithTotp2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => true]);
|
||||||
|
|
||||||
|
$this->assertTrue($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertFalse($user->root_admin);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminRequirementAdminUserWithTotp2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
|
||||||
|
|
||||||
|
$user = $this->generateRequestUserModel(['use_totp' => true, 'root_admin' => true]);
|
||||||
|
|
||||||
|
$this->assertTrue($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertTrue($user->root_admin);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminRequirementUserWithWebauthn2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Models\User $user */
|
||||||
|
$user = User::factory()->has(SecurityKey::factory()->count(1))->create(['use_totp' => false]);
|
||||||
|
$this->setRequestUserModel($user);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertFalse($user->root_admin);
|
||||||
|
$this->assertNotEmpty($user->securityKeys);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminRequirementAdminUserWithWebauthn2fa()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Models\User $user */
|
||||||
|
$user = User::factory()
|
||||||
|
->has(SecurityKey::factory()->count(1))
|
||||||
|
->create(['use_totp' => false, 'root_admin' => true]);
|
||||||
|
$this->setRequestUserModel($user);
|
||||||
|
|
||||||
|
$this->assertFalse($user->use_totp);
|
||||||
|
$this->assertEmpty($user->totp_secret);
|
||||||
|
$this->assertEmpty($user->totp_authenticated_at);
|
||||||
|
$this->assertTrue($user->root_admin);
|
||||||
|
$this->assertNotEmpty($user->securityKeys);
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null);
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminRequirementGuestUser()
|
||||||
|
{
|
||||||
|
// Disable the 2FA requirement
|
||||||
|
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
|
||||||
|
|
||||||
|
$this->setRequestUserModel();
|
||||||
|
|
||||||
|
$this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/auth/login');
|
||||||
|
$this->request->shouldReceive('route->getName')->withNoArgs()->andReturn('auth.login');
|
||||||
|
$this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true);
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication $controller */
|
||||||
|
$middleware = $this->app->make(RequireTwoFactorAuthentication::class);
|
||||||
|
$middleware->handle($this->request, $this->getClosureAssertions());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue