Compare commits

...

7 commits

Author SHA1 Message Date
Matthew Penner
ba7ff571e5
cleanup, switch to attributes 2023-01-17 16:09:28 -07:00
Matthew Penner
f631ac1946
Merge branch 'develop' into matthewpi/security-keys-backport 2023-01-17 15:33:53 -07:00
Matthew Penner
aa380d4c0d
fix SecurityKeyFactory 2022-10-31 13:42:49 -06:00
Matthew Penner
940c899eab
run php-cs-fixer 2022-10-31 13:22:00 -06:00
Matthew Penner
ce7c913e18
tests(unit): fix RequireTwoFactorAuthenticationTest 2022-10-31 13:20:06 -06:00
Matthew Penner
d7d5da6beb
ci(tests): only run on PHP 8.1 2022-10-31 12:27:36 -06:00
Matthew Penner
06f692e649
Add controllers and packages for security keys 2022-10-31 12:18:25 -06:00
29 changed files with 2792 additions and 706 deletions

View 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);
}
}

View file

@ -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()
{ {
@ -60,7 +59,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'));
} }
@ -79,14 +78,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->toReactObject(), 'user' => $user->toReactObject(),
],
]); ]);
} }
@ -103,6 +101,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));
} }
} }

View file

@ -4,15 +4,18 @@ namespace Pterodactyl\Http\Controllers\Auth;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Http\Request;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
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, $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, $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',

View file

@ -9,10 +9,23 @@ 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 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.
*/
public function __construct(protected WebauthnServerRepository $webauthnServerRepository)
{
parent::__construct();
}
/** /**
* Handle all incoming requests for the authentication routes and render the * Handle all incoming requests for the authentication routes and render the
* base authentication view component. React will take over at this point and * base authentication view component. React will take over at this point and
@ -28,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
{ {
@ -53,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);
} }
@ -65,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, 'methods' => array_values(array_filter([
'confirmation_token' => $token, $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);
} }
} }

View file

@ -47,9 +47,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() || ($level === self::LEVEL_ADMIN && !$user->root_admin)) {
return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) {
return $next($request); return $next($request);
} }

View file

@ -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'],
];
}
}

View file

@ -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
View file

@ -0,0 +1,125 @@
<?php
namespace Pterodactyl\Models;
use Illuminate\Http\Request;
use Symfony\Component\Uid\Uuid;
use Webauthn\TrustPath\TrustPath;
use Symfony\Component\Uid\NilUuid;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Component\Uid\AbstractUid;
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;
use Illuminate\Database\Eloquent\Casts\Attribute;
/**
* @property int $id
* @property string $uuid
* @property int $user_id
* @property string $name
* @property string $public_key_id
* @property string $public_key
* @property AbstractUid $aaguid
* @property string $type
* @property string[] $transports
* @property string $attestation_type
* @property \Webauthn\TrustPath\TrustPath $trust_path
* @property string $user_handle
* @property int $counter
* @property array<string, mixed>|null $other_ui
*
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
*/
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 publicKey(): Attribute
{
return new Attribute(
get: fn (string $value) => base64_decode($value),
set: fn (string $value) => base64_encode($value),
);
}
public function publicKeyId(): Attribute
{
return new Attribute(
get: fn (string $value) => base64_decode($value),
set: fn (string $value) => base64_encode($value),
);
}
public function aaguid(): Attribute
{
return Attribute::make(
get: fn (string|null $value): AbstractUid => is_null($value) ? new NilUuid() : Uuid::fromString($value),
set: fn (AbstractUid|null $value): string|null => (is_null($value) || $value instanceof NilUuid) ? null : $value->__toString(),
);
}
public function trustPath(): Attribute
{
return new Attribute(
get: fn (mixed $value) => is_null($value) ? null : TrustPathLoader::loadTrustPath(json_decode($value, true)),
set: fn (TrustPath|null $value) => json_encode($value),
);
}
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,
$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);
}
}

View file

@ -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 Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -51,6 +52,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
@ -276,6 +279,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 servers that a user can access by way of being the owner of the * Returns all the servers that a user can access by way of being the owner of the
* server, or because they are assigned as a subuser for that server. * server, or because they are assigned as a subuser for that server.
@ -290,4 +298,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();
}
} }

View file

@ -0,0 +1,68 @@
<?php
namespace Pterodactyl\Repositories\SecurityKeys;
use Pterodactyl\Models\User;
use Illuminate\Container\Container;
use Pterodactyl\Models\SecurityKey;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
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]);
}
}

View 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,
);
}
}

View file

@ -38,7 +38,9 @@ class DeployServerDatabaseService
throw new NoSuitableDatabaseHostException(); throw new NoSuitableDatabaseHostException();
} }
$databaseHostId = $hosts->random()->id; /** @var \Pterodactyl\Models\DatabaseHost $databaseHost */
$databaseHost = $hosts->random();
$databaseHostId = $databaseHost->id;
} }
return $this->managementService->create($server, [ return $this->managementService->create($server, [

View file

@ -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);
}
}

View 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;
}
}

View file

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

View file

@ -17,7 +17,7 @@
} }
], ],
"require": { "require": {
"php": "^8.0.2 || ^8.1 || ^8.2", "php": "^8.1 || ^8.2",
"ext-json": "*", "ext-json": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-pdo": "*", "ext-pdo": "*",
@ -38,6 +38,7 @@
"league/flysystem-aws-s3-v3": "~3.10", "league/flysystem-aws-s3-v3": "~3.10",
"league/flysystem-memory": "~3.10", "league/flysystem-memory": "~3.10",
"matriphe/iso-639": "~1.2", "matriphe/iso-639": "~1.2",
"nyholm/psr7": "~1.5",
"phpseclib/phpseclib": "~3.0", "phpseclib/phpseclib": "~3.0",
"pragmarx/google2fa": "~8.0", "pragmarx/google2fa": "~8.0",
"predis/predis": "~2.0", "predis/predis": "~2.0",
@ -49,7 +50,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": "~6.0", "symfony/yaml": "~6.0",
"web-auth/webauthn-lib": "~4.3",
"webmozart/assert": "~1.11" "webmozart/assert": "~1.11"
}, },
"require-dev": { "require-dev": {
@ -101,7 +104,7 @@
"preferred-install": "dist", "preferred-install": "dist",
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "8.0.2" "php": "8.1.0"
} }
} }
} }

2008
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use Ramsey\Uuid\Uuid;
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,
];
}
}

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
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');
}
}

View file

@ -0,0 +1,95 @@
import type { AxiosError } from 'axios';
import type { SWRConfiguration } from 'swr';
import useSWR from 'swr';
import type { SecurityKey } from '@definitions/user';
import { Transformers } from '@definitions/user';
import { LoginResponse } from '@/api/auth/login';
import type { FractalResponseList } from '@/api/http';
import http from '@/api/http';
import { decodeBase64 } from '@/lib/base64';
import { decodeBuffer, encodeBuffer } from '@/lib/buffer';
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?: SWRConfiguration<SecurityKey[], AxiosError>) {
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 };

View file

@ -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;
}

View file

@ -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 {}

View file

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
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);
});
});
});

View 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 };

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
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);
});
});
});

View 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 };

View file

@ -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']);

View file

@ -25,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.
@ -46,5 +47,5 @@ Route::post('/logout', [Auth\LoginController::class, 'logout'])
->middleware('auth') ->middleware('auth')
->name('auth.logout'); ->name('auth.logout');
// Catch any other combinations of routes and pass them off to the React 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']);

View file

@ -0,0 +1,338 @@
<?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->assertTrue($user->securityKeys->isEmpty());
$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->assertTrue($user->securityKeys->isEmpty());
$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 testNoRequirementUserWithSecurityKey2fa()
{
// Disable the 2FA requirement
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE);
/** @var \Pterodactyl\Models\User $user */
$user = User::factory()
->make(['use_totp' => false])
->setRelation('securityKeys', SecurityKey::factory()->count(1)->make());
$this->setRequestUserModel($user);
$this->assertFalse($user->use_totp);
$this->assertEmpty($user->totp_secret);
$this->assertEmpty($user->totp_authenticated_at);
$this->assertTrue($user->securityKeys->isNotEmpty());
$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->assertTrue($user->securityKeys->isEmpty());
$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->assertTrue($user->securityKeys->isEmpty());
$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 testAllRequirementUserWithSecurityKey2fa()
{
// Disable the 2FA requirement
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL);
/** @var \Pterodactyl\Models\User $user */
$user = User::factory()
->make(['use_totp' => false])
->setRelation('securityKeys', SecurityKey::factory()->count(1)->make());
$this->setRequestUserModel($user);
$this->assertFalse($user->use_totp);
$this->assertEmpty($user->totp_secret);
$this->assertEmpty($user->totp_authenticated_at);
$this->assertTrue($user->securityKeys->isNotEmpty());
$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->assertTrue($user->securityKeys->isEmpty());
$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->securityKeys->isEmpty());
$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->assertTrue($user->securityKeys->isEmpty());
$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->securityKeys->isEmpty());
$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 testAdminRequirementUserWithSecurityKey2fa()
{
// Disable the 2FA requirement
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
/** @var \Pterodactyl\Models\User $user */
$user = User::factory()
->make(['use_totp' => false])
->setRelation('securityKeys', SecurityKey::factory()->count(1)->make());
$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->assertTrue($user->securityKeys->isNotEmpty());
$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 testAdminRequirementAdminUserWithSecurityKey2fa()
{
// Disable the 2FA requirement
config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN);
/** @var \Pterodactyl\Models\User $user */
$user = User::factory()
->make(['use_totp' => false, 'root_admin' => true])
->setRelation('securityKeys', SecurityKey::factory()->count(1)->make());
$this->setRequestUserModel($user);
$this->assertFalse($user->use_totp);
$this->assertEmpty($user->totp_secret);
$this->assertEmpty($user->totp_authenticated_at);
$this->assertTrue($user->securityKeys->isNotEmpty());
$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 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());
}
}