webauthn: add controllers and transformers

This commit is contained in:
Matthew Penner 2021-07-17 11:47:07 -06:00
parent cdd07fa275
commit 28146f5bb6
7 changed files with 329 additions and 90 deletions

View file

@ -0,0 +1,125 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use LaravelWebauthn\Facades\Webauthn;
use LaravelWebauthn\Models\WebauthnKey;
use Webauthn\PublicKeyCredentialCreationOptions;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Pterodactyl\Transformers\Api\Client\WebauthnKeyTransformer;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class WebauthnController extends ClientApiController
{
private const SESSION_PUBLICKEY_CREATION = 'webauthn.publicKeyCreation';
/**
* ?
*
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function index(Request $request): array
{
return $this->fractal->collection(WebauthnKey::query()->where('user_id', '=', $request->user()->id)->get())
->transformWith($this->getTransformer(WebauthnKeyTransformer::class))
->toArray();
}
/**
* ?
*/
public function register(Request $request): JsonResponse
{
if (!Webauthn::canRegister($request->user())) {
return new JsonResponse([
'error' => [
'message' => trans('webauthn::errors.cannot_register_new_key'),
],
], JsonResponse::HTTP_FORBIDDEN);
}
$publicKey = Webauthn::getRegisterData($request->user());
$request->session()->put(self::SESSION_PUBLICKEY_CREATION, $publicKey);
$request->session()->save();
return new JsonResponse([
'public_key' => $publicKey,
]);
}
/**
* ?
*
* @return array|JsonResponse
*/
public function create(Request $request)
{
if (!Webauthn::canRegister($request->user())) {
return new JsonResponse([
'error' => [
'message' => trans('webauthn::errors.cannot_register_new_key'),
],
], JsonResponse::HTTP_FORBIDDEN);
}
if ($request->input('register') === null) {
throw new BadRequestHttpException('Missing register data in request body.');
}
if ($request->input('name') === null) {
throw new BadRequestHttpException('Missing name in request body.');
}
try {
$publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_CREATION);
if (!$publicKey instanceof PublicKeyCredentialCreationOptions) {
throw new ModelNotFoundException(trans('webauthn::errors.create_data_not_found'));
}
$webauthnKey = Webauthn::doRegister(
$request->user(),
$publicKey,
$request->input('register'),
$request->input('name'),
);
return $this->fractal->item($webauthnKey)
->transformWith($this->getTransformer(WebauthnKeyTransformer::class))
->toArray();
} catch (Exception $e) {
return new JsonResponse([
'error' => [
'message' => $e->getMessage(),
],
], JsonResponse::HTTP_FORBIDDEN);
}
}
/**
* ?
*/
public function deleteKey(Request $request, int $webauthnKeyId): JsonResponse
{
try {
WebauthnKey::query()
->where('user_id', $request->user()->getAuthIdentifier())
->findOrFail($webauthnKeyId)
->delete();
return new JsonResponse([
'deleted' => true,
'id' => $webauthnKeyId,
]);
} catch (ModelNotFoundException $e) {
return new JsonResponse([
'error' => [
'message' => trans('webauthn::errors.object_not_found'),
],
], JsonResponse::HTTP_NOT_FOUND);
}
}
}

View file

@ -11,55 +11,29 @@ use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
use Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository;
class LoginCheckpointController extends AbstractLoginController
{
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
private CacheRepository $cache;
private Encrypter $encrypter;
private Google2FA $google2FA;
/**
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
*/
private $repository;
/**
* @var \PragmaRX\Google2FA\Google2FA
*/
private $google2FA;
/**
* @var \Illuminate\Contracts\Encryption\Encrypter
*/
private $encrypter;
/**
* @var \Pterodactyl\Repositories\Eloquent\RecoveryTokenRepository
*/
private $recoveryTokenRepository;
/**
* LoginCheckpointController constructor.
*/
public function __construct(
AuthManager $auth,
Encrypter $encrypter,
Google2FA $google2FA,
Repository $config,
CacheRepository $cache,
RecoveryTokenRepository $recoveryTokenRepository,
UserRepositoryInterface $repository
Encrypter $encrypter,
Google2FA $google2FA
) {
parent::__construct($auth, $config);
$this->google2FA = $google2FA;
$this->cache = $cache;
$this->repository = $repository;
$this->encrypter = $encrypter;
$this->recoveryTokenRepository = $recoveryTokenRepository;
$this->google2FA = $google2FA;
}
/**
@ -72,13 +46,13 @@ class LoginCheckpointController extends AbstractLoginController
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
* @throws \Exception
* @throws \Illuminate\Validation\ValidationException
*/
public function __invoke(LoginCheckpointRequest $request): JsonResponse
{
if ($this->hasTooManyLoginAttempts($request)) {
$this->sendLockoutResponse($request);
return;
}
$token = $request->input('confirmation_token');
@ -88,11 +62,12 @@ class LoginCheckpointController extends AbstractLoginController
} catch (ModelNotFoundException $exception) {
$this->incrementLoginAttempts($request);
return $this->sendFailedLoginResponse(
$this->sendFailedLoginResponse(
$request,
null,
'The authentication token provided has expired, please refresh the page and try again.'
);
return;
}
// Recovery tokens go through a slightly different pathway for usage.
@ -111,8 +86,7 @@ class LoginCheckpointController extends AbstractLoginController
}
$this->incrementLoginAttempts($request);
return $this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
$this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
}
/**

View file

@ -8,6 +8,7 @@ use Illuminate\Http\Request;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Contracts\View\View;
use LaravelWebauthn\Facades\Webauthn;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
@ -17,19 +18,13 @@ use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
class LoginController extends AbstractLoginController
{
/**
* @var \Illuminate\Contracts\View\Factory
* @var string
*/
private $view;
private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest';
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
*/
private $repository;
private CacheRepository $cache;
private UserRepositoryInterface $repository;
private ViewFactory $view;
/**
* LoginController constructor.
@ -43,14 +38,14 @@ class LoginController extends AbstractLoginController
) {
parent::__construct($auth, $config);
$this->view = $view;
$this->cache = $cache;
$this->repository = $repository;
$this->view = $view;
}
/**
* Handle all incoming requests for the authentication routes and render the
* base authentication view component. Vuejs will take over at this point and
* base authentication view component. React will take over at this point and
* turn the login area into a SPA.
*/
public function index(): View
@ -74,31 +69,57 @@ class LoginController extends AbstractLoginController
if ($this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
$this->sendLockoutResponse($request);
return;
}
try {
/** @var \Pterodactyl\Models\User $user */
$user = $this->repository->findFirstWhere([[$useColumn, '=', $username]]);
} catch (RecordNotFoundException $exception) {
return $this->sendFailedLoginResponse($request);
$this->sendFailedLoginResponse($request);
return;
}
// Ensure that the account is using a valid username and password before trying to
// continue. Previously this was handled in the 2FA checkpoint, however that has
// a flaw in which you can discover if an account exists simply by seeing if you
// can proceede to the next step in the login process.
// can proceed to the next step in the login process.
if (!password_verify($request->input('password'), $user->password)) {
return $this->sendFailedLoginResponse($request, $user);
$this->sendFailedLoginResponse($request, $user);
return;
}
if ($user->use_totp) {
$webauthnKeys = $user->webauthnKeys()->get();
if (sizeof($webauthnKeys) > 0) {
$token = Str::random(64);
$this->cache->put($token, $user->id, CarbonImmutable::now()->addMinutes(5));
$publicKey = Webauthn::getAuthenticateData($user);
$request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey);
$request->session()->save();
$methods = ['webauthn'];
if ($user->use_totp) {
$methods[] = 'totp';
}
return new JsonResponse([
'complete' => false,
'methods' => $methods,
'confirmation_token' => $token,
'webauthn' => [
'public_key' => $publicKey,
],
]);
} else if ($user->use_totp) {
$token = Str::random(64);
$this->cache->put($token, $user->id, CarbonImmutable::now()->addMinutes(5));
return new JsonResponse([
'data' => [
'complete' => false,
'confirmation_token' => $token,
],
'complete' => false,
'methods' => ['totp'],
'confirmation_token' => $token,
]);
}

View file

@ -0,0 +1,99 @@
<?php
namespace Pterodactyl\Http\Controllers\Auth;
use Exception;
use Pterodactyl\Models\User;
use Illuminate\Http\Request;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use LaravelWebauthn\Facades\Webauthn;
use Webauthn\PublicKeyCredentialRequestOptions;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
class WebauthnController extends AbstractLoginController
{
private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest';
private CacheRepository $cache;
public function __construct(AuthManager $auth, ConfigRepository $config, CacheRepository $cache)
{
parent::__construct($auth, $config);
$this->cache = $cache;
}
/**
* @return JsonResponse|void
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException
*/
public function auth(Request $request): JsonResponse
{
if ($this->hasTooManyLoginAttempts($request)) {
$this->sendLockoutResponse($request);
return;
}
$token = $request->input('confirmation_token');
try {
/** @var \Pterodactyl\Models\User $user */
$user = User::query()->findOrFail($this->cache->get($token, 0));
} catch (ModelNotFoundException $exception) {
$this->incrementLoginAttempts($request);
$this->sendFailedLoginResponse(
$request,
null,
'The authentication token provided has expired, please refresh the page and try again.'
);
return;
}
$this->auth->guard()->onceUsingId($user->id);
try {
$publicKey = $request->session()->pull(self::SESSION_PUBLICKEY_REQUEST);
if (!$publicKey instanceof PublicKeyCredentialRequestOptions) {
throw new ModelNotFoundException(trans('webauthn::errors.auth_data_not_found'));
}
$result = Webauthn::doAuthenticate(
$user,
$publicKey,
$this->input($request, 'data'),
);
if (!$result) {
return new JsonResponse([
'error' => [
'message' => 'Nice attempt, you didn\'t pass the challenge.',
],
], JsonResponse::HTTP_I_AM_A_TEAPOT);
}
$this->cache->delete($token);
return $this->sendLoginResponse($user, $request);
} catch (Exception $e) {
return new JsonResponse([
'error' => [
'message' => $e->getMessage(),
],
], JsonResponse::HTTP_FORBIDDEN);
}
}
/**
* Retrieve the input with a string result.
*/
private function input(Request $request, string $name, string $default = ''): string
{
$result = $request->input($name);
return is_string($result) ? $result : $default;
}
}

View file

@ -4,13 +4,15 @@ namespace Pterodactyl\Models;
use Pterodactyl\Rules\Username;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rules\In;
use Illuminate\Auth\Authenticatable;
use LaravelWebauthn\Models\WebauthnKey;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Auth\Passwords\CanResetPassword;
use Pterodactyl\Traits\Helpers\AvailableLanguages;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
@ -35,9 +37,11 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property string $name
* @property \Pterodactyl\Models\AdminRole $adminRole
* @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens
* @property \LaravelWebauthn\Models\WebauthnKey[]|\Illuminate\Database\Eloquent\Collection $webauthnKeys
*/
class User extends Model implements
AuthenticatableContract,
@ -227,50 +231,37 @@ class User extends Model implements
return $role->name;
}
/**
* Gets the admin role associated with a user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function adminRole()
public function adminRole(): HasOne
{
return $this->hasOne(AdminRole::class, 'id', 'admin_role_id');
}
/**
* Returns all servers that a user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function servers()
{
return $this->hasMany(Server::class, 'owner_id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function apiKeys()
public function apiKeys(): HasMany
{
return $this->hasMany(ApiKey::class)
->where('key_type', ApiKey::TYPE_ACCOUNT);
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function recoveryTokens()
public function servers(): HasMany
{
return $this->hasMany(Server::class, 'owner_id');
}
public function recoveryTokens(): HasMany
{
return $this->hasMany(RecoveryToken::class);
}
public function webauthnKeys(): HasMany
{
return $this->hasMany(WebauthnKey::class);
}
/**
* Returns all of 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.
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function accessibleServers()
public function accessibleServers(): Builder
{
return Server::query()
->select('servers.*')

View file

@ -0,0 +1,29 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use LaravelWebauthn\Models\WebauthnKey;
class WebauthnKeyTransformer extends BaseClientTransformer
{
/**
* Return the resource name for the JSONAPI output.
*/
public function getResourceName(): string
{
return 'webauthn_key';
}
/**
* Return basic information about the currently logged in user.
*/
public function transform(WebauthnKey $model): array
{
return [
'id' => $model->id,
'name' => $model->name,
'created_at' => $model->created_at->toIso8601String(),
'last_used_at' => now()->toIso8601String(),
];
}
}

View file

@ -18,18 +18,18 @@ class AddWebauthn extends Migration
$table->unsignedInteger('user_id');
$table->string('name')->default('key');
$table->string('credential_id', 255);
$table->string('credentialId', 255);
$table->string('type', 255);
$table->text('transports');
$table->string('attestation_type', 255);
$table->text('trust_path');
$table->string('attestationType', 255);
$table->text('trustPath');
$table->text('aaguid');
$table->text('credential_public_key');
$table->text('credentialPublicKey');
$table->integer('counter');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->index('credential_id');
$table->index('credentialId');
});
}