diff --git a/app/Contracts/Repository/ApiKeyRepositoryInterface.php b/app/Contracts/Repository/ApiKeyRepositoryInterface.php index 5b8f638ef..2fce09cd2 100644 --- a/app/Contracts/Repository/ApiKeyRepositoryInterface.php +++ b/app/Contracts/Repository/ApiKeyRepositoryInterface.php @@ -9,6 +9,16 @@ namespace Pterodactyl\Contracts\Repository; +use Pterodactyl\Models\APIKey; + interface ApiKeyRepositoryInterface extends RepositoryInterface { + /** + * Load permissions for a key onto the model. + * + * @param \Pterodactyl\Models\APIKey $model + * @param bool $refresh + * @return \Pterodactyl\Models\APIKey + */ + public function loadPermissions(APIKey $model, bool $refresh = false): APIKey; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 573a88120..13a7b29c0 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -24,6 +24,7 @@ use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth; use Pterodactyl\Http\Middleware\API\AuthenticateIPAccess; use Pterodactyl\Http\Middleware\Daemon\DaemonAuthenticate; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Pterodactyl\Http\Middleware\API\HasPermissionToResource; use Pterodactyl\Http\Middleware\Server\AuthenticateAsSubuser; use Pterodactyl\Http\Middleware\Server\SubuserBelongsToServer; use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication; @@ -95,6 +96,9 @@ class Kernel extends HttpKernel 'bindings' => SubstituteBindings::class, 'recaptcha' => VerifyReCaptcha::class, + // API specific middleware. + 'api..user_level' => HasPermissionToResource::class, + // Server specific middleware (used for authenticating access to resources) // // These are only used for individual server authentication, and not gloabl diff --git a/app/Http/Middleware/API/HasPermissionToResource.php b/app/Http/Middleware/API/HasPermissionToResource.php new file mode 100644 index 000000000..1d99ffbf7 --- /dev/null +++ b/app/Http/Middleware/API/HasPermissionToResource.php @@ -0,0 +1,58 @@ +repository = $repository; + } + + /** + * Determine if an API key has permission to access the given route. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param string $role + * @return mixed + */ + public function handle(Request $request, Closure $next, string $role = 'admin') + { + /** @var \Pterodactyl\Models\APIKey $model */ + $model = $request->attributes->get('api_key'); + + if ($role === 'admin' && ! $request->user()->root_admin) { + throw new NotFoundHttpException; + } + + $this->repository->loadPermissions($model); + $routeKey = str_replace(['api.', 'admin.'], '', $request->route()->getName()); + + $count = $model->getRelation('permissions')->filter(function ($permission) use ($routeKey) { + return $routeKey === str_replace('-', '.', $permission->permission); + })->count(); + + if ($count === 1) { + return $next($request); + } + + throw new AccessDeniedHttpException('Cannot access resource without required `' . $routeKey . '` permission.'); + } +} diff --git a/app/Http/Middleware/HMACAuthorization.php b/app/Http/Middleware/HMACAuthorization.php deleted file mode 100644 index fa048b59f..000000000 --- a/app/Http/Middleware/HMACAuthorization.php +++ /dev/null @@ -1,209 +0,0 @@ -. - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ - -namespace Pterodactyl\Http\Middleware; - -use Auth; -use Crypt; -use Config; -use Closure; -use Debugbar; -use IPTools\IP; -use IPTools\Range; -use Illuminate\Http\Request; -use Pterodactyl\Models\APIKey; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; // 400 -use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; // 403 - -class HMACAuthorization -{ - /** - * The algorithm to use for handling HMAC encryption. - * - * @var string - */ - const HMAC_ALGORITHM = 'sha256'; - - /** - * Stored values from the Authorization header. - * - * @var array - */ - protected $token = []; - - /** - * The eloquent model for the API key. - * - * @var \Pterodactyl\Models\APIKey - */ - protected $key; - - /** - * The illuminate request object. - * - * @var \Illuminate\Http\Request - */ - private $request; - - /** - * Construct class instance. - */ - public function __construct() - { - Debugbar::disable(); - Config::set('session.driver', 'array'); - } - - /** - * Handle an incoming request for the API. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed - */ - public function handle(Request $request, Closure $next) - { - $this->request = $request; - - $this->checkBearer(); - $this->validateRequest(); - $this->validateIPAccess(); - $this->validateContents(); - - Auth::loginUsingId($this->key()->user_id); - - return $next($request); - } - - /** - * Checks that the Bearer token is provided and in a valid format. - * - * - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - */ - protected function checkBearer() - { - if (empty($this->request()->bearerToken())) { - throw new BadRequestHttpException('Request was missing required Authorization header.'); - } - - $token = explode('.', $this->request()->bearerToken()); - if (count($token) !== 2) { - throw new BadRequestHttpException('The Authorization header passed was not in a validate public/private key format.'); - } - - $this->token = [ - 'public' => $token[0], - 'hash' => $token[1], - ]; - } - - /** - * Determine if the request contains a valid public API key - * as well as permissions for the resource. - * - * - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - */ - protected function validateRequest() - { - $this->key = APIKey::where('public', $this->public())->first(); - if (! $this->key) { - throw new AccessDeniedHttpException('Unable to identify requester. Authorization token is invalid.'); - } - } - - /** - * Determine if the requesting IP address is allowed to use this API key. - * - * @return bool - * - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - */ - protected function validateIPAccess() - { - if (! is_null($this->key()->allowed_ips)) { - foreach (json_decode($this->key()->allowed_ips) as $ip) { - if (Range::parse($ip)->contains(new IP($this->request()->ip()))) { - return true; - } - } - - throw new AccessDeniedHttpException('This IP address does not have permission to access the API using these credentials.'); - } - - return true; - } - - /** - * Determine if the HMAC sent in the request matches the one generated - * on the panel side. - * - * - * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException - */ - protected function validateContents() - { - if (! hash_equals(base64_decode($this->hash()), $this->generateSignature())) { - throw new BadRequestHttpException('The HMAC for the request was invalid.'); - } - } - - /** - * Generate a HMAC from the request and known API secret key. - * - * @return string - */ - protected function generateSignature() - { - $content = urldecode($this->request()->fullUrl()) . $this->request()->getContent(); - - return hash_hmac(self::HMAC_ALGORITHM, $content, Crypt::decrypt($this->key()->secret), true); - } - - /** - * Return the public key passed in the Authorization header. - * - * @return string - */ - protected function public() - { - return $this->token['public']; - } - - /** - * Return the base64'd HMAC sent in the Authorization header. - * - * @return string - */ - protected function hash() - { - return $this->token['hash']; - } - - /** - * Return the API Key model. - * - * @return \Pterodactyl\Models\APIKey - */ - protected function key() - { - return $this->key; - } - - /** - * Return the Illuminate Request object. - * - * @return \Illuminate\Http\Request - */ - private function request() - { - return $this->request; - } -} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 4d8f9b2b6..a0f902859 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -49,7 +49,7 @@ class RouteServiceProvider extends ServiceProvider ->namespace($this->namespace . '\Server') ->group(base_path('routes/server.php')); - Route::middleware(['api'])->prefix('/api/admin') + Route::middleware(['api', 'api..user_level:admin'])->prefix('/api/admin') ->namespace($this->namespace . '\API\Admin') ->group(base_path('routes/api-admin.php')); diff --git a/app/Repositories/Eloquent/ApiKeyRepository.php b/app/Repositories/Eloquent/ApiKeyRepository.php index facac39c8..107e0b6c9 100644 --- a/app/Repositories/Eloquent/ApiKeyRepository.php +++ b/app/Repositories/Eloquent/ApiKeyRepository.php @@ -21,4 +21,20 @@ class ApiKeyRepository extends EloquentRepository implements ApiKeyRepositoryInt { return APIKey::class; } + + /** + * Load permissions for a key onto the model. + * + * @param \Pterodactyl\Models\APIKey $model + * @param bool $refresh + * @return \Pterodactyl\Models\APIKey + */ + public function loadPermissions(APIKey $model, bool $refresh = false): APIKey + { + if (! $model->relationLoaded('permissions') || $refresh) { + $model->load('permissions'); + } + + return $model; + } } diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 16c8d08bf..e117b59bb 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -232,3 +232,11 @@ $factory->define(Pterodactyl\Models\APIKey::class, function (Faker\Generator $fa 'updated_at' => \Carbon\Carbon::now()->toDateTimeString(), ]; }); + +$factory->define(Pterodactyl\Models\APIPermission::class, function (Faker\Generator $faker) { + return [ + 'id' => $faker->unique()->randomNumber(), + 'key_id' => $faker->randomNumber(), + 'permission' => mb_strtolower($faker->word), + ]; +}); diff --git a/tests/Unit/Http/Middleware/API/HasPermissionToResourceTest.php b/tests/Unit/Http/Middleware/API/HasPermissionToResourceTest.php new file mode 100644 index 000000000..7ef6c3830 --- /dev/null +++ b/tests/Unit/Http/Middleware/API/HasPermissionToResourceTest.php @@ -0,0 +1,109 @@ +repository = m::mock(ApiKeyRepositoryInterface::class); + } + + /** + * Test that a non-admin user cannot access admin level routes. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + public function testNonAdminAccessingAdminLevel() + { + $model = factory(APIKey::class)->make(); + $this->setRequestAttribute('api_key', $model); + $this->setRequestUser(factory(User::class)->make(['root_admin' => false])); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + /** + * Test non-admin accessing non-admin route. + */ + public function testAccessingAllowedRoute() + { + $model = factory(APIKey::class)->make(); + $model->setRelation('permissions', collect([ + factory(APIPermission::class)->make(['permission' => 'user.test-route']), + ])); + $this->setRequestAttribute('api_key', $model); + $this->setRequestUser(factory(User::class)->make(['root_admin' => false])); + + $this->request->shouldReceive('route->getName')->withNoArgs()->once()->andReturn('api.user.test.route'); + $this->repository->shouldReceive('loadPermissions')->with($model)->once()->andReturn($model); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions(), 'user'); + } + + /** + * Test admin accessing administrative route. + */ + public function testAccessingAllowedAdminRoute() + { + $model = factory(APIKey::class)->make(); + $model->setRelation('permissions', collect([ + factory(APIPermission::class)->make(['permission' => 'test-route']), + ])); + $this->setRequestAttribute('api_key', $model); + $this->setRequestUser(factory(User::class)->make(['root_admin' => true])); + + $this->request->shouldReceive('route->getName')->withNoArgs()->once()->andReturn('api.admin.test.route'); + $this->repository->shouldReceive('loadPermissions')->with($model)->once()->andReturn($model); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + /** + * Test a user accessing a disallowed route. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + */ + public function testAccessingDisallowedRoute() + { + $model = factory(APIKey::class)->make(); + $model->setRelation('permissions', collect([ + factory(APIPermission::class)->make(['permission' => 'user.other-route']), + ])); + $this->setRequestAttribute('api_key', $model); + $this->setRequestUser(factory(User::class)->make(['root_admin' => false])); + + $this->request->shouldReceive('route->getName')->withNoArgs()->once()->andReturn('api.user.test.route'); + $this->repository->shouldReceive('loadPermissions')->with($model)->once()->andReturn($model); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions(), 'user'); + } + + /** + * Return an instance of the middleware with mocked dependencies for testing. + * + * @return \Pterodactyl\Http\Middleware\API\HasPermissionToResource + */ + private function getMiddleware(): HasPermissionToResource + { + return new HasPermissionToResource($this->repository); + } +}