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\Auth\Events\Failed;
|
||||
use Illuminate\Container\Container;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Pterodactyl\Events\Auth\DirectLogin;
|
||||
use Pterodactyl\Exceptions\DisplayException;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
|
@ -37,7 +36,7 @@ abstract class AbstractLoginController extends Controller
|
|||
protected string $redirectTo = '/';
|
||||
|
||||
/**
|
||||
* LoginController constructor.
|
||||
* AbstractLoginController constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
|
@ -58,7 +57,7 @@ abstract class AbstractLoginController extends Controller
|
|||
$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'));
|
||||
}
|
||||
|
||||
|
@ -77,14 +76,13 @@ abstract class AbstractLoginController extends Controller
|
|||
|
||||
$this->auth->guard()->login($user, true);
|
||||
|
||||
Event::dispatch(new DirectLogin($user, true));
|
||||
event(new DirectLogin($user, true));
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'complete' => true,
|
||||
'intended' => $this->redirectPath(),
|
||||
'user' => $user->toVueObject(),
|
||||
],
|
||||
'complete' => true,
|
||||
'methods' => [],
|
||||
'intended' => $this->redirectPath(),
|
||||
'user' => $user->toReactObject(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -101,6 +99,6 @@ abstract class AbstractLoginController extends Controller
|
|||
*/
|
||||
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\CarbonInterface;
|
||||
use Pterodactyl\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Pterodactyl\Models\SecurityKey;
|
||||
use Illuminate\Contracts\Encryption\Encrypter;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Pterodactyl\Events\Auth\ProvidedAuthenticationToken;
|
||||
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
|
||||
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
|
||||
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class LoginCheckpointController extends AbstractLoginController
|
||||
{
|
||||
|
@ -24,6 +27,7 @@ class LoginCheckpointController extends AbstractLoginController
|
|||
public function __construct(
|
||||
private Encrypter $encrypter,
|
||||
private Google2FA $google2FA,
|
||||
private WebauthnServerRepository $webauthnServerRepository,
|
||||
private ValidationFactory $validation
|
||||
) {
|
||||
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
|
||||
* provided a valid username and password.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse|void
|
||||
*
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
|
||||
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
|
||||
* @throws \Exception
|
||||
* @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)) {
|
||||
$this->sendLockoutResponse($request);
|
||||
|
@ -62,24 +133,7 @@ class LoginCheckpointController extends AbstractLoginController
|
|||
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
|
||||
}
|
||||
|
||||
// Recovery tokens go through a slightly different pathway for usage.
|
||||
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);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -101,14 +155,19 @@ class LoginCheckpointController extends AbstractLoginController
|
|||
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
|
||||
* will return false if the data is invalid, or if more time has passed than
|
||||
* 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',
|
||||
'token_value' => 'required|string',
|
||||
'expires_at' => 'required',
|
||||
|
|
|
@ -9,15 +9,19 @@ use Pterodactyl\Models\User;
|
|||
use Illuminate\Http\JsonResponse;
|
||||
use Pterodactyl\Facades\Activity;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Contracts\View\Factory as ViewFactory;
|
||||
use Pterodactyl\Models\SecurityKey;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||
|
||||
class LoginController extends AbstractLoginController
|
||||
{
|
||||
private const METHOD_TOTP = 'totp';
|
||||
private const METHOD_WEBAUTHN = 'webauthn';
|
||||
|
||||
/**
|
||||
* LoginController constructor.
|
||||
*/
|
||||
public function __construct(private ViewFactory $view)
|
||||
public function __construct(protected WebauthnServerRepository $webauthnServerRepository)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
@ -29,7 +33,7 @@ class LoginController extends AbstractLoginController
|
|||
*/
|
||||
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 \Illuminate\Validation\ValidationException
|
||||
* @throws \Webauthn\Exception\InvalidDataException
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
|
@ -62,7 +67,9 @@ class LoginController extends AbstractLoginController
|
|||
$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);
|
||||
}
|
||||
|
||||
|
@ -74,11 +81,23 @@ class LoginController extends AbstractLoginController
|
|||
'expires_at' => CarbonImmutable::now()->addMinutes(5),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'complete' => false,
|
||||
'confirmation_token' => $token,
|
||||
],
|
||||
]);
|
||||
$response = [
|
||||
'complete' => false,
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue