Support authenticating the provided key when loggin in
This commit is contained in:
parent
54c7207836
commit
09497c234a
9 changed files with 117 additions and 55 deletions
|
@ -6,6 +6,7 @@ use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Pterodactyl\Models\SecurityKey;
|
||||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
use Illuminate\Contracts\Cache\Repository;
|
use Illuminate\Contracts\Cache\Repository;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
@ -97,7 +98,7 @@ class SecurityKeyController extends ClientApiController
|
||||||
}
|
}
|
||||||
|
|
||||||
$key = $this->storeSecurityKeyService
|
$key = $this->storeSecurityKeyService
|
||||||
->setRequest($this->getServerRequest($request))
|
->setRequest(SecurityKey::getPsrRequestFactory($request))
|
||||||
->setKeyName($request->input('name'))
|
->setKeyName($request->input('name'))
|
||||||
->handle($request->user(), $request->input('registration'), $credentials);
|
->handle($request->user(), $request->input('registration'), $credentials);
|
||||||
|
|
||||||
|
@ -115,13 +116,4 @@ class SecurityKeyController extends ClientApiController
|
||||||
|
|
||||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getServerRequest(Request $request): ServerRequestInterface
|
|
||||||
{
|
|
||||||
$factory = new Psr17Factory();
|
|
||||||
|
|
||||||
$httpFactory = new PsrHttpFactory($factory, $factory, $factory, $factory);
|
|
||||||
|
|
||||||
return $httpFactory->createRequest($request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,32 +5,44 @@ namespace Pterodactyl\Http\Controllers\Auth;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Carbon\CarbonInterface;
|
use Carbon\CarbonInterface;
|
||||||
use Pterodactyl\Models\User;
|
use Pterodactyl\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use PragmaRX\Google2FA\Google2FA;
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
|
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\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
|
||||||
{
|
{
|
||||||
public const TOKEN_EXPIRED_MESSAGE = 'The authentication token provided has expired, please refresh the page and try again.';
|
public const TOKEN_EXPIRED_MESSAGE = 'The authentication token provided has expired, please refresh the page and try again.';
|
||||||
|
|
||||||
private Encrypter $encrypter;
|
protected Encrypter $encrypter;
|
||||||
|
|
||||||
private Google2FA $google2FA;
|
protected Google2FA $google2FA;
|
||||||
|
|
||||||
private ValidationFactory $validation;
|
protected WebauthnServerRepository $repository;
|
||||||
|
|
||||||
|
protected ValidationFactory $validation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoginCheckpointController constructor.
|
* LoginCheckpointController constructor.
|
||||||
*/
|
*/
|
||||||
public function __construct(Encrypter $encrypter, Google2FA $google2FA, ValidationFactory $validation)
|
public function __construct(
|
||||||
{
|
Encrypter $encrypter,
|
||||||
|
Google2FA $google2FA,
|
||||||
|
ValidationFactory $validation,
|
||||||
|
WebauthnServerRepository $repository
|
||||||
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
|
|
||||||
$this->encrypter = $encrypter;
|
$this->encrypter = $encrypter;
|
||||||
$this->google2FA = $google2FA;
|
$this->google2FA = $google2FA;
|
||||||
$this->validation = $validation;
|
$this->validation = $validation;
|
||||||
|
$this->repository = $repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -46,35 +58,9 @@ class LoginCheckpointController extends AbstractLoginController
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
* @throws \Pterodactyl\Exceptions\DisplayException
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
*/
|
*/
|
||||||
public function __invoke(LoginCheckpointRequest $request)
|
public function token(LoginCheckpointRequest $request)
|
||||||
{
|
{
|
||||||
if ($this->hasTooManyLoginAttempts($request)) {
|
$user = $this->extractUserFromRequest($request);
|
||||||
$this->sendLockoutResponse($request);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$details = $request->session()->get('auth_confirmation_token');
|
|
||||||
if (!$this->hasValidSessionData($details)) {
|
|
||||||
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hash_equals($request->input('confirmation_token') ?? '', $details['token_value'])) {
|
|
||||||
$this->sendFailedLoginResponse($request);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
/** @var \Pterodactyl\Models\User $user */
|
|
||||||
$user = User::query()->findOrFail($details['user_id']);
|
|
||||||
} catch (ModelNotFoundException $exception) {
|
|
||||||
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recovery tokens go through a slightly different pathway for usage.
|
// Recovery tokens go through a slightly different pathway for usage.
|
||||||
if (!is_null($recoveryToken = $request->input('recovery_token'))) {
|
if (!is_null($recoveryToken = $request->input('recovery_token'))) {
|
||||||
|
@ -92,6 +78,65 @@ class LoginCheckpointController extends AbstractLoginController
|
||||||
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a login request using a security key for a user.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
|
*/
|
||||||
|
public function key(Request $request)
|
||||||
|
{
|
||||||
|
$key = $request->session()->get(SecurityKey::PK_SESSION_NAME);
|
||||||
|
if (!$key instanceof PublicKeyCredentialRequestOptions) {
|
||||||
|
throw new BadRequestHttpException('No security keys configured in session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->extractUserFromRequest($request);
|
||||||
|
|
||||||
|
$source = $this->repository->getServer($user)->loadAndCheckAssertionResponse(
|
||||||
|
$request->input('data'),
|
||||||
|
$key,
|
||||||
|
$user->toPublicKeyCredentialEntity(),
|
||||||
|
SecurityKey::getPsrRequestFactory($request)
|
||||||
|
);
|
||||||
|
|
||||||
|
dd($source->getUserHandle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @return \Pterodactyl\Models\User
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
* @throws \Pterodactyl\Exceptions\DisplayException
|
||||||
|
*/
|
||||||
|
protected function extractUserFromRequest(Request $request): User
|
||||||
|
{
|
||||||
|
if ($this->hasTooManyLoginAttempts($request)) {
|
||||||
|
$this->sendLockoutResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = $request->session()->get('auth_confirmation_token');
|
||||||
|
if (!$this->hasValidSessionData($details)) {
|
||||||
|
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hash_equals($request->input('confirmation_token') ?? '', $details['token_value'])) {
|
||||||
|
$this->sendFailedLoginResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var \Pterodactyl\Models\User $user */
|
||||||
|
$user = User::query()->findOrFail($details['user_id']);
|
||||||
|
} catch (ModelNotFoundException $exception) {
|
||||||
|
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a given recovery token is valid for the user account. If we find a matching token
|
* Determines if a given recovery token is valid for the user account. If we find a matching token
|
||||||
* it will be deleted from the database.
|
* it will be deleted from the database.
|
||||||
|
|
|
@ -16,7 +16,6 @@ class LoginController extends AbstractLoginController
|
||||||
{
|
{
|
||||||
private const METHOD_TOTP = 'totp';
|
private const METHOD_TOTP = 'totp';
|
||||||
private const METHOD_WEBAUTHN = 'webauthn';
|
private const METHOD_WEBAUTHN = 'webauthn';
|
||||||
private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest';
|
|
||||||
|
|
||||||
protected GeneratePublicKeyCredentialsRequestService $service;
|
protected GeneratePublicKeyCredentialsRequestService $service;
|
||||||
|
|
||||||
|
@ -98,7 +97,7 @@ class LoginController extends AbstractLoginController
|
||||||
if (!empty($user->securityKeys)) {
|
if (!empty($user->securityKeys)) {
|
||||||
$key = $this->service->handle($user);
|
$key = $this->service->handle($user);
|
||||||
|
|
||||||
$request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $key);
|
$request->session()->put(SecurityKey::PK_SESSION_NAME, $key);
|
||||||
|
|
||||||
$response['webauthn'] = ['public_key' => $key];
|
$response['webauthn'] = ['public_key' => $key];
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,19 +3,24 @@
|
||||||
namespace Pterodactyl\Models;
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
use Ramsey\Uuid\Uuid;
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Ramsey\Uuid\UuidInterface;
|
use Ramsey\Uuid\UuidInterface;
|
||||||
use Webauthn\TrustPath\TrustPath;
|
use Webauthn\TrustPath\TrustPath;
|
||||||
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
use Webauthn\PublicKeyCredentialSource;
|
use Webauthn\PublicKeyCredentialSource;
|
||||||
use Webauthn\TrustPath\TrustPathLoader;
|
use Webauthn\TrustPath\TrustPathLoader;
|
||||||
use Webauthn\PublicKeyCredentialDescriptor;
|
use Webauthn\PublicKeyCredentialDescriptor;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
|
||||||
|
|
||||||
class SecurityKey extends Model
|
class SecurityKey extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
public const RESOURCE_NAME = 'security_key';
|
public const RESOURCE_NAME = 'security_key';
|
||||||
|
public const PK_SESSION_NAME = 'security_key_pk_request';
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'user_id' => 'int',
|
'user_id' => 'int',
|
||||||
|
@ -109,4 +114,19 @@ class SecurityKey extends Model
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a PSR17 Request factory to be used by different Webauthn tooling.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @return \Psr\Http\Message\ServerRequestInterface
|
||||||
|
*/
|
||||||
|
public static function getPsrRequestFactory(Request $request): ServerRequestInterface
|
||||||
|
{
|
||||||
|
$factory = new Psr17Factory();
|
||||||
|
|
||||||
|
$httpFactory = new PsrHttpFactory($factory, $factory, $factory, $factory);
|
||||||
|
|
||||||
|
return $httpFactory->createRequest($request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ use Illuminate\Support\Collection;
|
||||||
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\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
@ -229,4 +230,12 @@ class User extends Model implements
|
||||||
})
|
})
|
||||||
->groupBy('servers.id');
|
->groupBy('servers.id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Webauthn\PublicKeyCredentialUserEntity
|
||||||
|
*/
|
||||||
|
public function toPublicKeyCredentialEntity(): PublicKeyCredentialUserEntity
|
||||||
|
{
|
||||||
|
return new PublicKeyCredentialUserEntity($this->username, $this->uuid, $this->email, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ namespace Pterodactyl\Repositories\SecurityKeys;
|
||||||
|
|
||||||
use Webauthn\Server;
|
use Webauthn\Server;
|
||||||
use Pterodactyl\Models\User;
|
use Pterodactyl\Models\User;
|
||||||
use Illuminate\Container\Container;
|
|
||||||
use Webauthn\PublicKeyCredentialRpEntity;
|
use Webauthn\PublicKeyCredentialRpEntity;
|
||||||
|
|
||||||
final class WebauthnServerRepository
|
final class WebauthnServerRepository
|
||||||
|
|
|
@ -4,7 +4,6 @@ namespace Pterodactyl\Services\Users\SecurityKeys;
|
||||||
|
|
||||||
use Pterodactyl\Models\User;
|
use Pterodactyl\Models\User;
|
||||||
use Pterodactyl\Models\SecurityKey;
|
use Pterodactyl\Models\SecurityKey;
|
||||||
use Webauthn\PublicKeyCredentialUserEntity;
|
|
||||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
|
||||||
|
|
||||||
|
@ -19,8 +18,6 @@ class CreatePublicKeyCredentialsService
|
||||||
|
|
||||||
public function handle(User $user): PublicKeyCredentialCreationOptions
|
public function handle(User $user): PublicKeyCredentialCreationOptions
|
||||||
{
|
{
|
||||||
$entity = new PublicKeyCredentialUserEntity($user->username, $user->uuid, $user->email, null);
|
|
||||||
|
|
||||||
$excluded = $user->securityKeys->map(function (SecurityKey $key) {
|
$excluded = $user->securityKeys->map(function (SecurityKey $key) {
|
||||||
return $key->getPublicKeyCredentialDescriptor();
|
return $key->getPublicKeyCredentialDescriptor();
|
||||||
})->values()->toArray();
|
})->values()->toArray();
|
||||||
|
@ -28,7 +25,7 @@ class CreatePublicKeyCredentialsService
|
||||||
$server = $this->webauthnServerRepository->getServer($user);
|
$server = $this->webauthnServerRepository->getServer($user);
|
||||||
|
|
||||||
return $server->generatePublicKeyCredentialCreationOptions(
|
return $server->generatePublicKeyCredentialCreationOptions(
|
||||||
$entity,
|
$user->toPublicKeyCredentialEntity(),
|
||||||
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
|
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
|
||||||
$excluded
|
$excluded
|
||||||
);
|
);
|
||||||
|
|
|
@ -22,12 +22,10 @@ export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Pr
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
confirmation_token: token,
|
confirmation_token: token,
|
||||||
|
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
type: credential.type,
|
type: credential.type,
|
||||||
rawId: bufferEncode(credential.rawId),
|
rawId: bufferEncode(credential.rawId),
|
||||||
|
|
||||||
response: {
|
response: {
|
||||||
authenticatorData: bufferEncode(response.authenticatorData),
|
authenticatorData: bufferEncode(response.authenticatorData),
|
||||||
clientDataJSON: bufferEncode(response.clientDataJSON),
|
clientDataJSON: bufferEncode(response.clientDataJSON),
|
||||||
|
|
|
@ -8,6 +8,9 @@
|
||||||
| Endpoint: /auth
|
| Endpoint: /auth
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use Pterodactyl\Http\Controllers\Auth;
|
||||||
|
|
||||||
Route::group(['middleware' => 'guest'], function () {
|
Route::group(['middleware' => 'guest'], function () {
|
||||||
// These routes are defined so that we can continue to reference them programmatically.
|
// These routes are defined so that we can continue to reference them programmatically.
|
||||||
// They all route to the same controller function which passes off to React.
|
// They all route to the same controller function which passes off to React.
|
||||||
|
@ -21,9 +24,9 @@ Route::group(['middleware' => 'guest'], function () {
|
||||||
// @see \Pterodactyl\Providers\RouteServiceProvider
|
// @see \Pterodactyl\Providers\RouteServiceProvider
|
||||||
Route::middleware(['throttle:authentication'])->group(function () {
|
Route::middleware(['throttle:authentication'])->group(function () {
|
||||||
// Login endpoints.
|
// Login endpoints.
|
||||||
Route::post('/login', 'LoginController@login')->middleware('recaptcha');
|
Route::post('/login', [Auth\LoginController::class, 'login'])->middleware('recaptcha');
|
||||||
Route::post('/login/checkpoint', 'LoginCheckpointController')->name('auth.login-checkpoint');
|
Route::post('/login/checkpoint', [Auth\LoginCheckpointController::class, 'token'])->name('auth.login-checkpoint');
|
||||||
Route::post('/login/checkpoint/key', 'WebauthnController@auth')->name('auth.login-checkpoint-key');
|
Route::post('/login/checkpoint/key', [Auth\LoginCheckpointController::class, 'key'])->name('auth.login-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.
|
||||||
|
|
Loading…
Reference in a new issue