Update interface to begin change to seperate account API keys and application keys

Main difference is permissions, cleaner UI for normal users, and account keys use permissions assigned to servers and subusers while application keys use R/W ACLs stored in the key table.
This commit is contained in:
Dane Everitt 2018-01-14 13:30:55 -06:00
parent 28ebd18f57
commit f9fc3f4370
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
18 changed files with 312 additions and 298 deletions

View file

@ -0,0 +1,58 @@
<?php
namespace Pterodactyl\Console\Commands\Migration;
use Pterodactyl\Models\ApiKey;
use Illuminate\Console\Command;
use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
class CleanOrphanedApiKeysCommand extends Command
{
/**
* @var \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface
*/
private $repository;
/**
* @var string
*/
protected $signature = 'p:migration:clean-orphaned-keys';
/**
* @var string
*/
protected $description = 'Cleans API keys from the database that are not assigned a specific role.';
/**
* CleanOrphanedApiKeysCommand constructor.
*
* @param \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface $repository
*/
public function __construct(ApiKeyRepositoryInterface $repository)
{
parent::__construct();
$this->repository = $repository;
}
/**
* Delete all orphaned API keys from the database when upgrading from 0.6 to 0.7.
*
* @return null|void
*/
public function handle()
{
$count = $this->repository->findCountWhere([['key_type', '=', ApiKey::TYPE_NONE]]);
$continue = $this->confirm(
'This action will remove ' . $count . ' keys from the database. Are you sure you wish to continue?', false
);
if (! $continue) {
return null;
}
$this->info('Deleting keys...');
$this->repository->deleteWhere([['key_type', '=', ApiKey::TYPE_NONE]]);
$this->info('Keys were successfully deleted.');
}
}

View file

@ -1,25 +1,26 @@
<?php <?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Contracts\Repository; namespace Pterodactyl\Contracts\Repository;
use Pterodactyl\Models\ApiKey; use Pterodactyl\Models\User;
use Illuminate\Support\Collection;
interface ApiKeyRepositoryInterface extends RepositoryInterface interface ApiKeyRepositoryInterface extends RepositoryInterface
{ {
/** /**
* Load permissions for a key onto the model. * Get all of the account API keys that exist for a specific user.
* *
* @param \Pterodactyl\Models\ApiKey $model * @param \Pterodactyl\Models\User $user
* @param bool $refresh * @return \Illuminate\Support\Collection
* @deprecated
* @return \Pterodactyl\Models\ApiKey
*/ */
public function loadPermissions(ApiKey $model, bool $refresh = false): ApiKey; public function getAccountKeys(User $user): Collection;
/**
* Delete an account API key from the panel for a specific user.
*
* @param \Pterodactyl\Models\User $user
* @param string $identifier
* @return int
*/
public function deleteAccountKey(User $user, string $identifier): int;
} }

View file

@ -2,14 +2,17 @@
namespace Pterodactyl\Http\Controllers\Base; namespace Pterodactyl\Http\Controllers\Base;
use Illuminate\View\View;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Models\ApiKey;
use Prologue\Alerts\AlertsMessageBag; use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Api\KeyCreationService; use Pterodactyl\Services\Api\KeyCreationService;
use Pterodactyl\Http\Requests\Base\ApiKeyFormRequest; use Pterodactyl\Http\Requests\Base\StoreAccountKeyRequest;
use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface; use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
class APIController extends Controller class AccountKeyController extends Controller
{ {
/** /**
* @var \Prologue\Alerts\AlertsMessageBag * @var \Prologue\Alerts\AlertsMessageBag
@ -44,49 +47,44 @@ class APIController extends Controller
} }
/** /**
* Display base API index page. * Display a listing of all account API keys.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View * @return \Illuminate\View\View
*/ */
public function index(Request $request) public function index(Request $request): View
{ {
return view('base.api.index', [ return view('base.api.index', [
'keys' => $this->repository->findWhere([['user_id', '=', $request->user()->id]]), 'keys' => $this->repository->getAccountKeys($request->user()),
]); ]);
} }
/** /**
* Display API key creation page. * Display account API key creation page.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View * @return \Illuminate\View\View
*/ */
public function create(Request $request) public function create(Request $request): View
{ {
return view('base.api.new');
} }
/** /**
* Handle saving new API key. * Handle saving new account API key.
* *
* @param \Pterodactyl\Http\Requests\Base\ApiKeyFormRequest $request * @param \Pterodactyl\Http\Requests\Base\StoreAccountKeyRequest $request
* @return \Illuminate\Http\RedirectResponse * @return \Illuminate\Http\RedirectResponse
* *
* @throws \Exception
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/ */
public function store(ApiKeyFormRequest $request) public function store(StoreAccountKeyRequest $request)
{ {
$adminPermissions = []; $this->keyService->setKeyType(ApiKey::TYPE_ACCOUNT)->handle([
if ($request->user()->root_admin) {
$adminPermissions = $request->input('admin_permissions', []);
}
$secret = $this->keyService->handle([
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'allowed_ips' => $request->input('allowed_ips'), 'allowed_ips' => $request->input('allowed_ips'),
'memo' => $request->input('memo'), 'memo' => $request->input('memo'),
], $request->input('permissions', []), $adminPermissions); ]);
$this->alert->success(trans('base.api.index.keypair_created'))->flash(); $this->alert->success(trans('base.api.index.keypair_created'))->flash();
@ -94,18 +92,15 @@ class APIController extends Controller
} }
/** /**
* @param \Illuminate\Http\Request $request * Delete an account API key from the Panel via an AJAX request.
* @param string $key
* @return \Illuminate\Http\Response
* *
* @throws \Exception * @param \Illuminate\Http\Request $request
* @param string $identifier
* @return \Illuminate\Http\Response
*/ */
public function revoke(Request $request, $key) public function revoke(Request $request, string $identifier): Response
{ {
$this->repository->deleteWhere([ $this->repository->deleteAccountKey($request->user(), $identifier);
['user_id', '=', $request->user()->id],
['token', '=', $key],
]);
return response('', 204); return response('', 204);
} }

View file

@ -3,6 +3,7 @@
namespace Pterodactyl\Http\Middleware\Api\Admin; namespace Pterodactyl\Http\Middleware\Api\Admin;
use Closure; use Closure;
use Cake\Chronos\Chronos;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Pterodactyl\Models\ApiKey; use Pterodactyl\Models\ApiKey;
use Illuminate\Auth\AuthManager; use Illuminate\Auth\AuthManager;
@ -51,8 +52,8 @@ class AuthenticateKey
* @param \Closure $next * @param \Closure $next
* @return mixed * @return mixed
* *
* @throws \Symfony\Component\HttpKernel\Exception\HttpException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function handle(Request $request, Closure $next) public function handle(Request $request, Closure $next)
{ {
@ -65,7 +66,10 @@ class AuthenticateKey
$token = substr($raw, ApiKey::IDENTIFIER_LENGTH); $token = substr($raw, ApiKey::IDENTIFIER_LENGTH);
try { try {
$model = $this->repository->findFirstWhere([['identifier', '=', $identifier]]); $model = $this->repository->findFirstWhere([
['identifier', '=', $identifier],
['key_type', '=', ApiKey::TYPE_APPLICATION],
]);
} catch (RecordNotFoundException $exception) { } catch (RecordNotFoundException $exception) {
throw new AccessDeniedHttpException; throw new AccessDeniedHttpException;
} }
@ -76,6 +80,7 @@ class AuthenticateKey
$this->auth->guard()->loginUsingId($model->user_id); $this->auth->guard()->loginUsingId($model->user_id);
$request->attributes->set('api_key', $model); $request->attributes->set('api_key', $model);
$this->repository->withoutFreshModel()->update($model->id, ['last_used_at' => Chronos::now()]);
return $next($request); return $next($request);
} }

View file

@ -0,0 +1,23 @@
<?php
namespace Pterodactyl\Http\Requests\Base;
use Pterodactyl\Http\Requests\FrontendUserFormRequest;
class StoreAccountKeyRequest extends FrontendUserFormRequest
{
/**
* Rules to validate the request input aganist before storing
* an account API key.
*
* @return array
*/
public function rules()
{
return [
'memo' => 'required|nullable|string|max:500',
'allowed_ips' => 'present',
'allowed_ips.*' => 'sometimes|string',
];
}
}

View file

@ -6,7 +6,6 @@ use Sofa\Eloquence\Eloquence;
use Sofa\Eloquence\Validable; use Sofa\Eloquence\Validable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Pterodactyl\Services\Acl\Api\AdminAcl; use Pterodactyl\Services\Acl\Api\AdminAcl;
use Illuminate\Contracts\Encryption\Encrypter;
use Sofa\Eloquence\Contracts\CleansAttributes; use Sofa\Eloquence\Contracts\CleansAttributes;
use Sofa\Eloquence\Contracts\Validable as ValidableContract; use Sofa\Eloquence\Contracts\Validable as ValidableContract;
@ -18,7 +17,7 @@ class ApiKey extends Model implements CleansAttributes, ValidableContract
* Different API keys that can exist on the system. * Different API keys that can exist on the system.
*/ */
const TYPE_NONE = 0; const TYPE_NONE = 0;
const TYPE_USER = 1; const TYPE_ACCOUNT = 1;
const TYPE_APPLICATION = 2; const TYPE_APPLICATION = 2;
const TYPE_DAEMON_USER = 3; const TYPE_DAEMON_USER = 3;
const TYPE_DAEMON_APPLICATION = 4; const TYPE_DAEMON_APPLICATION = 4;
@ -70,6 +69,7 @@ class ApiKey extends Model implements CleansAttributes, ValidableContract
'token', 'token',
'allowed_ips', 'allowed_ips',
'memo', 'memo',
'last_used_at',
]; ];
/** /**
@ -90,6 +90,7 @@ class ApiKey extends Model implements CleansAttributes, ValidableContract
'memo' => 'required', 'memo' => 'required',
'user_id' => 'required', 'user_id' => 'required',
'token' => 'required', 'token' => 'required',
'key_type' => 'present',
]; ];
/** /**
@ -99,6 +100,7 @@ class ApiKey extends Model implements CleansAttributes, ValidableContract
*/ */
protected static $dataIntegrityRules = [ protected static $dataIntegrityRules = [
'user_id' => 'exists:users,id', 'user_id' => 'exists:users,id',
'key_type' => 'integer|min:0|max:4',
'identifier' => 'string|size:16|unique:api_keys,identifier', 'identifier' => 'string|size:16|unique:api_keys,identifier',
'token' => 'string', 'token' => 'string',
'memo' => 'nullable|string|max:500', 'memo' => 'nullable|string|max:500',
@ -123,14 +125,4 @@ class ApiKey extends Model implements CleansAttributes, ValidableContract
self::UPDATED_AT, self::UPDATED_AT,
'last_used_at', 'last_used_at',
]; ];
/**
* Return a decrypted version of the token.
*
* @return string
*/
public function getDecryptedTokenAttribute()
{
return app()->make(Encrypter::class)->decrypt($this->token);
}
} }

View file

@ -2,7 +2,9 @@
namespace Pterodactyl\Repositories\Eloquent; namespace Pterodactyl\Repositories\Eloquent;
use Pterodactyl\Models\User;
use Pterodactyl\Models\ApiKey; use Pterodactyl\Models\ApiKey;
use Illuminate\Support\Collection;
use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface; use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
class ApiKeyRepository extends EloquentRepository implements ApiKeyRepositoryInterface class ApiKeyRepository extends EloquentRepository implements ApiKeyRepositoryInterface
@ -18,19 +20,30 @@ class ApiKeyRepository extends EloquentRepository implements ApiKeyRepositoryInt
} }
/** /**
* Load permissions for a key onto the model. * Get all of the account API keys that exist for a specific user.
* *
* @param \Pterodactyl\Models\ApiKey $model * @param \Pterodactyl\Models\User $user
* @param bool $refresh * @return \Illuminate\Support\Collection
* @deprecated
* @return \Pterodactyl\Models\ApiKey
*/ */
public function loadPermissions(ApiKey $model, bool $refresh = false): ApiKey public function getAccountKeys(User $user): Collection
{ {
if (! $model->relationLoaded('permissions') || $refresh) { return $this->getBuilder()->where('user_id', $user->id)
$model->load('permissions'); ->where('key_type', ApiKey::TYPE_ACCOUNT)
->get($this->getColumns());
} }
return $model; /**
* Delete an account API key from the panel for a specific user.
*
* @param \Pterodactyl\Models\User $user
* @param string $identifier
* @return int
*/
public function deleteAccountKey(User $user, string $identifier): int
{
return $this->getBuilder()->where('user_id', $user->id)
->where('key_type', ApiKey::TYPE_ACCOUNT)
->where('identifier', $identifier)
->delete();
} }
} }

View file

@ -13,6 +13,11 @@ class KeyCreationService
*/ */
private $encrypter; private $encrypter;
/**
* @var int
*/
private $keyType = ApiKey::TYPE_NONE;
/** /**
* @var \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface * @var \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface
*/ */
@ -30,23 +35,43 @@ class KeyCreationService
$this->repository = $repository; $this->repository = $repository;
} }
/**
* Set the type of key that should be created. By default an orphaned key will be
* created. These keys cannot be used for anything, and will not render in the UI.
*
* @param int $type
* @return \Pterodactyl\Services\Api\KeyCreationService
*/
public function setKeyType(int $type)
{
$this->keyType = $type;
return $this;
}
/** /**
* Create a new API key for the Panel using the permissions passed in the data request. * Create a new API key for the Panel using the permissions passed in the data request.
* This will automatically generate an identifer and an encrypted token that are * This will automatically generate an identifer and an encrypted token that are
* stored in the database. * stored in the database.
* *
* @param array $data * @param array $data
* @param array $permissions
* @return \Pterodactyl\Models\ApiKey * @return \Pterodactyl\Models\ApiKey
* *
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/ */
public function handle(array $data): ApiKey public function handle(array $data, array $permissions = []): ApiKey
{ {
$data = array_merge($data, [ $data = array_merge($data, [
'key_type' => $this->keyType,
'identifier' => str_random(ApiKey::IDENTIFIER_LENGTH), 'identifier' => str_random(ApiKey::IDENTIFIER_LENGTH),
'token' => $this->encrypter->encrypt(str_random(ApiKey::KEY_LENGTH)), 'token' => $this->encrypter->encrypt(str_random(ApiKey::KEY_LENGTH)),
]); ]);
if ($this->keyType === ApiKey::TYPE_APPLICATION) {
$data = array_merge($data, $permissions);
}
$instance = $this->repository->create($data, true, true); $instance = $this->repository->create($data, true, true);
return $instance; return $instance;

File diff suppressed because one or more lines are too long

View file

@ -29,183 +29,25 @@ return [
], ],
'api' => [ 'api' => [
'index' => [ 'index' => [
'header' => 'API Access', 'list' => 'Your Keys',
'header_sub' => 'Manage your API access keys.', 'header' => 'Accout API',
'list' => 'API Keys', 'header_sub' => 'Manage access keys that allow you to perform actions aganist the panel.',
'create_new' => 'Create New API key', 'create_new' => 'Create New API key',
'keypair_created' => 'An API key has been successfully generated and is listed below.', 'keypair_created' => 'An API key has been successfully generated and is listed below.',
], ],
'new' => [ 'new' => [
'header' => 'New API Key', 'header' => 'New API Key',
'header_sub' => 'Create a new API access key', 'header_sub' => 'Create a new account access key.',
'form_title' => 'Details', 'form_title' => 'Details',
'descriptive_memo' => [ 'descriptive_memo' => [
'title' => 'Descriptive Memo', 'title' => 'Description',
'description' => 'Enter a brief description of what this API key will be used for.', 'description' => 'Enter a brief description of this key that will be useful for reference.',
], ],
'allowed_ips' => [ 'allowed_ips' => [
'title' => 'Allowed IPs', 'title' => 'Allowed IPs',
'description' => 'Enter a line delimitated list of IPs that are allowed to access the API using this key. CIDR notation is allowed. Leave blank to allow any IP.', 'description' => 'Enter a line delimitated list of IPs that are allowed to access the API using this key. CIDR notation is allowed. Leave blank to allow any IP.',
], ],
], ],
'permissions' => [
'user' => [
'server_header' => 'User Server Permissions',
'server' => [
'list' => [
'title' => 'List Servers',
'desc' => 'Allows listing of all servers a user owns or has access to as a subuser.',
],
'view' => [
'title' => 'View Server',
'desc' => 'Allows viewing of specific server user can access.',
],
'power' => [
'title' => 'Toggle Power',
'desc' => 'Allow toggling of power status for a server.',
],
'command' => [
'title' => 'Send Command',
'desc' => 'Allow sending of a command to a running server.',
],
],
],
'admin' => [
'server_header' => 'Server Control',
'server' => [
'list' => [
'title' => 'List Servers',
'desc' => 'Allows listing of all servers currently on the system.',
],
'view' => [
'title' => 'View Server',
'desc' => 'Allows view of single server including service and details.',
],
'delete' => [
'title' => 'Delete Server',
'desc' => 'Allows deletion of a server from the system.',
],
'create' => [
'title' => 'Create Server',
'desc' => 'Allows creation of a new server on the system.',
],
'edit-details' => [
'title' => 'Edit Server Details',
'desc' => 'Allows editing of server details such as name, owner, description, and secret key.',
],
'edit-container' => [
'title' => 'Edit Server Container',
'desc' => 'Allows for modification of the docker container the server runs in.',
],
'suspend' => [
'title' => 'Suspend Server',
'desc' => 'Allows for the suspension and unsuspension of a given server.',
],
'install' => [
'title' => 'Toggle Install Status',
'desc' => '',
],
'rebuild' => [
'title' => 'Rebuild Server',
'desc' => '',
],
'edit-build' => [
'title' => 'Edit Server Build',
'desc' => 'Allows editing of server build setting such as CPU and memory allocations.',
],
'edit-startup' => [
'title' => 'Edit Server Startup',
'desc' => 'Allows modification of server startup commands and parameters.',
],
],
'location_header' => 'Location Control',
'location' => [
'list' => [
'title' => 'List Locations',
'desc' => 'Allows listing all locations and thier associated nodes.',
],
],
'node_header' => 'Node Control',
'node' => [
'list' => [
'title' => 'List Nodes',
'desc' => 'Allows listing of all nodes currently on the system.',
],
'view' => [
'title' => 'View Node',
'desc' => 'Allows viewing details about a specific node including active services.',
],
'view-config' => [
'title' => 'View Node Configuration',
'desc' => 'Danger. This allows the viewing of the node configuration file used by the daemon, and exposes secret daemon tokens.',
],
'create' => [
'title' => 'Create Node',
'desc' => 'Allows creating a new node on the system.',
],
'delete' => [
'title' => 'Delete Node',
'desc' => 'Allows deletion of a node from the system.',
],
],
'user_header' => 'User Control',
'user' => [
'list' => [
'title' => 'List Users',
'desc' => 'Allows listing of all users currently on the system.',
],
'view' => [
'title' => 'View User',
'desc' => 'Allows viewing details about a specific user including active services.',
],
'create' => [
'title' => 'Create User',
'desc' => 'Allows creating a new user on the system.',
],
'edit' => [
'title' => 'Update User',
'desc' => 'Allows modification of user details.',
],
'delete' => [
'title' => 'Delete User',
'desc' => 'Allows deleting a user.',
],
],
'service_header' => 'Service Control',
'service' => [
'list' => [
'title' => 'List Service',
'desc' => 'Allows listing of all services configured on the system.',
],
'view' => [
'title' => 'View Service',
'desc' => 'Allows listing details about each service on the system including service options and variables.',
],
],
'option_header' => 'Option Control',
'option' => [
'list' => [
'title' => 'List Options',
'desc' => '',
],
'view' => [
'title' => 'View Option',
'desc' => '',
],
],
'pack_header' => 'Pack Control',
'pack' => [
'list' => [
'title' => 'List Packs',
'desc' => '',
],
'view' => [
'title' => 'View Pack',
'desc' => '',
],
],
],
],
], ],
'account' => [ 'account' => [
'details_updated' => 'Your account details have been successfully updated.', 'details_updated' => 'Your account details have been successfully updated.',

View file

@ -6,7 +6,7 @@ return [
'header' => 'ACCOUNT MANAGEMENT', 'header' => 'ACCOUNT MANAGEMENT',
'my_account' => 'My Account', 'my_account' => 'My Account',
'security_controls' => 'Security Controls', 'security_controls' => 'Security Controls',
'api_access' => 'API Access', 'api_access' => 'Account API',
'my_servers' => 'My Servers', 'my_servers' => 'My Servers',
], ],
'server' => [ 'server' => [

View file

@ -52,7 +52,7 @@
@datetimeHuman($key->created_at) @datetimeHuman($key->created_at)
</td> </td>
<td class="text-center"> <td class="text-center">
<a href="#delete" class="text-danger" data-action="delete" data-attr="{{ $key->token }}"><i class="fa fa-trash"></i></a> <a href="#delete" class="text-danger" data-action="delete" data-attr="{{ $key->identifier }}"><i class="fa fa-trash"></i></a>
</td> </td>
</tr> </tr>
@endforeach @endforeach
@ -84,7 +84,7 @@
}, function () { }, function () {
$.ajax({ $.ajax({
method: 'DELETE', method: 'DELETE',
url: Router.route('account.api.revoke', { key: self.data('attr') }), url: Router.route('account.api.revoke', { identifier: self.data('attr') }),
headers: { headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}' 'X-CSRF-TOKEN': '{{ csrf_token() }}'
} }

View file

@ -49,13 +49,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 col-xs-8"> <div class="col-xs-12">
<div class="btn-group">
<a id="selectAllCheckboxes" class="btn btn-default">@lang('strings.select_all')</a>
<a id="unselectAllCheckboxes" class="btn btn-default">@lang('strings.select_none')</a>
</div>
</div>
<div class="col-md-6 col-xs-4">
{!! csrf_field() !!} {!! csrf_field() !!}
<button class="btn btn-success pull-right">@lang('strings.create') &rarr;</button> <button class="btn btn-success pull-right">@lang('strings.create') &rarr;</button>
</div> </div>

View file

@ -32,12 +32,12 @@ Route::group(['prefix' => 'account'], function () {
| |
*/ */
Route::group(['prefix' => 'account/api'], function () { Route::group(['prefix' => 'account/api'], function () {
Route::get('/', 'APIController@index')->name('account.api'); Route::get('/', 'AccountKeyController@index')->name('account.api');
Route::get('/new', 'APIController@create')->name('account.api.new'); Route::get('/new', 'AccountKeyController@create')->name('account.api.new');
Route::post('/new', 'APIController@store'); Route::post('/new', 'AccountKeyController@store');
Route::delete('/revoke/{key}', 'APIController@revoke')->name('account.api.revoke'); Route::delete('/revoke/{identifier}', 'AccountKeyController@revoke')->name('account.api.revoke');
}); });
/* /*

View file

@ -2,6 +2,7 @@
namespace Tests; namespace Tests;
use Cake\Chronos\Chronos;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
@ -18,6 +19,16 @@ abstract class TestCase extends BaseTestCase
$this->setKnownUuidFactory(); $this->setKnownUuidFactory();
} }
/**
* Tear down tests.
*/
protected function tearDown()
{
parent::tearDown();
Chronos::setTestNow();
}
/** /**
* Handles the known UUID handling in certain unit tests. Use the "KnownUuid" trait * Handles the known UUID handling in certain unit tests. Use the "KnownUuid" trait
* in order to enable this ability. * in order to enable this ability.

View file

@ -3,16 +3,15 @@
namespace Tests\Unit\Http\Controllers\Base; namespace Tests\Unit\Http\Controllers\Base;
use Mockery as m; use Mockery as m;
use Pterodactyl\Models\User;
use Pterodactyl\Models\ApiKey; use Pterodactyl\Models\ApiKey;
use Prologue\Alerts\AlertsMessageBag; use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Services\Api\KeyCreationService; use Pterodactyl\Services\Api\KeyCreationService;
use Tests\Unit\Http\Controllers\ControllerTestCase; use Tests\Unit\Http\Controllers\ControllerTestCase;
use Pterodactyl\Http\Controllers\Base\APIController; use Pterodactyl\Http\Requests\Base\StoreAccountKeyRequest;
use Pterodactyl\Http\Requests\Base\ApiKeyFormRequest; use Pterodactyl\Http\Controllers\Base\AccountKeyController;
use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface; use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
class APIControllerTest extends ControllerTestCase class AccountKeyControllerTest extends ControllerTestCase
{ {
/** /**
* @var \Prologue\Alerts\AlertsMessageBag|\Mockery\Mock * @var \Prologue\Alerts\AlertsMessageBag|\Mockery\Mock
@ -48,7 +47,7 @@ class APIControllerTest extends ControllerTestCase
{ {
$model = $this->generateRequestUserModel(); $model = $this->generateRequestUserModel();
$this->repository->shouldReceive('findWhere')->with([['user_id', '=', $model->id]])->once()->andReturn(collect(['testkeys'])); $this->repository->shouldReceive('getAccountKeys')->with($model)->once()->andReturn(collect(['testkeys']));
$response = $this->getController()->index($this->request); $response = $this->getController()->index($this->request);
$this->assertIsViewResponse($response); $this->assertIsViewResponse($response);
@ -59,51 +58,34 @@ class APIControllerTest extends ControllerTestCase
/** /**
* Test the create API view controller. * Test the create API view controller.
*
* @dataProvider rootAdminDataProvider
*/ */
public function testCreateController($admin) public function testCreateController()
{ {
$this->generateRequestUserModel(['root_admin' => $admin]); $this->generateRequestUserModel();
$response = $this->getController()->create($this->request); $response = $this->getController()->create($this->request);
$this->assertIsViewResponse($response); $this->assertIsViewResponse($response);
$this->assertViewNameEquals('base.api.new', $response);
$this->assertViewHasKey('permissions.user', $response);
$this->assertViewHasKey('permissions.admin', $response);
if ($admin) {
$this->assertViewKeyNotEquals('permissions.admin', null, $response);
} else {
$this->assertViewKeyEquals('permissions.admin', null, $response);
}
} }
/** /**
* Test the store functionality for a non-admin user. * Test the store functionality for a user.
*
* @dataProvider rootAdminDataProvider
*/ */
public function testStoreController($admin) public function testStoreController()
{ {
$this->setRequestMockClass(ApiKeyFormRequest::class); $this->setRequestMockClass(StoreAccountKeyRequest::class);
$model = $this->generateRequestUserModel(['root_admin' => $admin]); $model = $this->generateRequestUserModel();
$keyModel = factory(ApiKey::class)->make(); $keyModel = factory(ApiKey::class)->make();
if ($admin) {
$this->request->shouldReceive('input')->with('admin_permissions', [])->once()->andReturn(['admin.permission']);
}
$this->request->shouldReceive('user')->withNoArgs()->andReturn($model); $this->request->shouldReceive('user')->withNoArgs()->andReturn($model);
$this->request->shouldReceive('input')->with('allowed_ips')->once()->andReturnNull(); $this->request->shouldReceive('input')->with('allowed_ips')->once()->andReturnNull();
$this->request->shouldReceive('input')->with('memo')->once()->andReturnNull(); $this->request->shouldReceive('input')->with('memo')->once()->andReturnNull();
$this->request->shouldReceive('input')->with('permissions', [])->once()->andReturn(['test.permission']);
$this->keyService->shouldReceive('setKeyType')->with(ApiKey::TYPE_ACCOUNT)->once()->andReturnSelf();
$this->keyService->shouldReceive('handle')->with([ $this->keyService->shouldReceive('handle')->with([
'user_id' => $model->id, 'user_id' => $model->id,
'allowed_ips' => null, 'allowed_ips' => null,
'memo' => null, 'memo' => null,
], ['test.permission'], ($admin) ? ['admin.permission'] : [])->once()->andReturn($keyModel); ])->once()->andReturn($keyModel);
$this->alert->shouldReceive('success')->with(trans('base.api.index.keypair_created'))->once()->andReturnSelf(); $this->alert->shouldReceive('success')->with(trans('base.api.index.keypair_created'))->once()->andReturnSelf();
$this->alert->shouldReceive('flash')->withNoArgs()->once()->andReturnNull(); $this->alert->shouldReceive('flash')->withNoArgs()->once()->andReturnNull();
@ -120,34 +102,21 @@ class APIControllerTest extends ControllerTestCase
{ {
$model = $this->generateRequestUserModel(); $model = $this->generateRequestUserModel();
$this->repository->shouldReceive('deleteWhere')->with([ $this->repository->shouldReceive('deleteAccountKey')->with($model, 'testIdentifier')->once()->andReturn(1);
['user_id', '=', $model->id],
['token', '=', 'testKey123'],
])->once()->andReturn(1);
$response = $this->getController()->revoke($this->request, 'testKey123'); $response = $this->getController()->revoke($this->request, 'testIdentifier');
$this->assertIsResponse($response); $this->assertIsResponse($response);
$this->assertEmpty($response->getContent()); $this->assertEmpty($response->getContent());
$this->assertResponseCodeEquals(204, $response); $this->assertResponseCodeEquals(204, $response);
} }
/**
* Data provider to determine if a user is a root admin.
*
* @return array
*/
public function rootAdminDataProvider()
{
return [[0], [1]];
}
/** /**
* Return an instance of the controller with mocked dependencies for testing. * Return an instance of the controller with mocked dependencies for testing.
* *
* @return \Pterodactyl\Http\Controllers\Base\APIController * @return \Pterodactyl\Http\Controllers\Base\AccountKeyController
*/ */
private function getController(): APIController private function getController(): AccountKeyController
{ {
return new APIController($this->alert, $this->repository, $this->keyService); return new AccountKeyController($this->alert, $this->repository, $this->keyService);
} }
} }

View file

@ -1,8 +1,9 @@
<?php <?php
namespace Tests\Unit\Http\Middleware\Api; namespace Tests\Unit\Http\Middleware\Api\Admin;
use Mockery as m; use Mockery as m;
use Cake\Chronos\Chronos;
use Pterodactyl\Models\ApiKey; use Pterodactyl\Models\ApiKey;
use Illuminate\Auth\AuthManager; use Illuminate\Auth\AuthManager;
use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Encryption\Encrypter;
@ -35,6 +36,7 @@ class AuthenticateKeyTest extends MiddlewareTestCase
public function setUp() public function setUp()
{ {
parent::setUp(); parent::setUp();
Chronos::setTestNow(Chronos::now());
$this->auth = m::mock(AuthManager::class); $this->auth = m::mock(AuthManager::class);
$this->encrypter = m::mock(Encrypter::class); $this->encrypter = m::mock(Encrypter::class);
@ -77,10 +79,17 @@ class AuthenticateKeyTest extends MiddlewareTestCase
$model = factory(ApiKey::class)->make(); $model = factory(ApiKey::class)->make();
$this->request->shouldReceive('bearerToken')->withNoArgs()->twice()->andReturn($model->identifier . 'decrypted'); $this->request->shouldReceive('bearerToken')->withNoArgs()->twice()->andReturn($model->identifier . 'decrypted');
$this->repository->shouldReceive('findFirstWhere')->with([['identifier', '=', $model->identifier]])->once()->andReturn($model); $this->repository->shouldReceive('findFirstWhere')->with([
['identifier', '=', $model->identifier],
['key_type', '=', ApiKey::TYPE_APPLICATION],
])->once()->andReturn($model);
$this->encrypter->shouldReceive('decrypt')->with($model->token)->once()->andReturn('decrypted'); $this->encrypter->shouldReceive('decrypt')->with($model->token)->once()->andReturn('decrypted');
$this->auth->shouldReceive('guard->loginUsingId')->with($model->user_id)->once()->andReturnNull(); $this->auth->shouldReceive('guard->loginUsingId')->with($model->user_id)->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel->update')->with($model->id, [
'last_used_at' => Chronos::now(),
])->once()->andReturnNull();
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->assertEquals($model, $this->request->attributes->get('api_key')); $this->assertEquals($model, $this->request->attributes->get('api_key'));
} }
@ -96,7 +105,10 @@ class AuthenticateKeyTest extends MiddlewareTestCase
$model = factory(ApiKey::class)->make(); $model = factory(ApiKey::class)->make();
$this->request->shouldReceive('bearerToken')->withNoArgs()->twice()->andReturn($model->identifier . 'asdf'); $this->request->shouldReceive('bearerToken')->withNoArgs()->twice()->andReturn($model->identifier . 'asdf');
$this->repository->shouldReceive('findFirstWhere')->with([['identifier', '=', $model->identifier]])->once()->andReturn($model); $this->repository->shouldReceive('findFirstWhere')->with([
['identifier', '=', $model->identifier],
['key_type', '=', ApiKey::TYPE_APPLICATION],
])->once()->andReturn($model);
$this->encrypter->shouldReceive('decrypt')->with($model->token)->once()->andReturn('decrypted'); $this->encrypter->shouldReceive('decrypt')->with($model->token)->once()->andReturn('decrypted');
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions());

View file

@ -51,6 +51,7 @@ class KeyCreationServiceTest extends TestCase
$this->repository->shouldReceive('create')->with([ $this->repository->shouldReceive('create')->with([
'test-data' => 'test', 'test-data' => 'test',
'key_type' => ApiKey::TYPE_NONE,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH, 'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token, 'token' => $model->token,
], true, true)->once()->andReturn($model); ], true, true)->once()->andReturn($model);
@ -62,6 +63,9 @@ class KeyCreationServiceTest extends TestCase
$this->assertSame($model, $response); $this->assertSame($model, $response);
} }
/**
* Test that an identifier is only set by the function.
*/
public function testIdentifierAndTokenAreOnlySetByFunction() public function testIdentifierAndTokenAreOnlySetByFunction()
{ {
$model = factory(ApiKey::class)->make(); $model = factory(ApiKey::class)->make();
@ -74,6 +78,7 @@ class KeyCreationServiceTest extends TestCase
$this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token); $this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token);
$this->repository->shouldReceive('create')->with([ $this->repository->shouldReceive('create')->with([
'key_type' => ApiKey::TYPE_NONE,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH, 'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token, 'token' => $model->token,
], true, true)->once()->andReturn($model); ], true, true)->once()->andReturn($model);
@ -85,6 +90,75 @@ class KeyCreationServiceTest extends TestCase
$this->assertSame($model, $response); $this->assertSame($model, $response);
} }
/**
* Test that permissions passed in are loaded onto the key data.
*/
public function testPermissionsAreRetrievedForApplicationKeys()
{
$model = factory(ApiKey::class)->make();
$this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'str_random')
->expects($this->exactly(2))->willReturnCallback(function ($length) {
return 'str_' . $length;
});
$this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token);
$this->repository->shouldReceive('create')->with([
'key_type' => ApiKey::TYPE_APPLICATION,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token,
'permission-key' => 'exists',
], true, true)->once()->andReturn($model);
$response = $this->getService()->setKeyType(ApiKey::TYPE_APPLICATION)->handle([], ['permission-key' => 'exists']);
$this->assertNotEmpty($response);
$this->assertInstanceOf(ApiKey::class, $response);
$this->assertSame($model, $response);
}
/**
* Test that permissions are not retrieved for any key that is not an application key.
*
* @dataProvider keyTypeDataProvider
*/
public function testPermissionsAreNotRetrievedForNonApplicationKeys($keyType)
{
$model = factory(ApiKey::class)->make();
$this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'str_random')
->expects($this->exactly(2))->willReturnCallback(function ($length) {
return 'str_' . $length;
});
$this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token);
$this->repository->shouldReceive('create')->with([
'key_type' => $keyType,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token,
], true, true)->once()->andReturn($model);
$response = $this->getService()->setKeyType($keyType)->handle([], ['fake-permission' => 'should-not-exist']);
$this->assertNotEmpty($response);
$this->assertInstanceOf(ApiKey::class, $response);
$this->assertSame($model, $response);
}
/**
* Provide key types that are not an application specific key.
*
* @return array
*/
public function keyTypeDataProvider(): array
{
return [
[ApiKey::TYPE_NONE], [ApiKey::TYPE_ACCOUNT], [ApiKey::TYPE_DAEMON_USER], [ApiKey::TYPE_DAEMON_APPLICATION],
];
}
/** /**
* Return an instance of the service with mocked dependencies for testing. * Return an instance of the service with mocked dependencies for testing.
* *