From e045ef443a1d9bb04ebdd1db9fa6aa9ae4e312e8 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 30 Aug 2017 21:11:14 -0500 Subject: [PATCH] Should wrap up the base landing page stuff for accounts, next step is server rendering --- .php_cs | 4 + .../Daemon/ServerRepositoryInterface.php | 7 + .../Repository/RepositoryInterface.php | 3 +- .../Repository/ServerRepositoryInterface.php | 29 ++++ .../SessionRepositoryInterface.php} | 50 ++----- .../Base/InvalidPasswordProvidedException.php | 31 ++++ .../TwoFactorAuthenticationTokenInvalid.php | 29 ++++ app/Http/Controllers/Base/APIController.php | 59 ++++---- .../Controllers/Base/AccountController.php | 82 +++++------ app/Http/Controllers/Base/IndexController.php | 95 +++++++------ .../Controllers/Base/SecurityController.php | 109 ++++++++++----- .../Requests/Base/AccountDataFormRequest.php | 85 +++++++++++ .../ApiKeyFormRequest.php} | 10 +- ...equest.php => FrontendUserFormRequest.php} | 4 +- app/Models/User.php | 39 ++---- app/Providers/RepositoryServiceProvider.php | 9 ++ app/Repositories/Daemon/ServerRepository.php | 8 ++ .../Eloquent/ServerRepository.php | 70 ++++++++++ .../Eloquent/SessionRepository.php | 55 ++++++++ ...{KeyService.php => KeyCreationService.php} | 27 ++-- .../Servers/ServerAccessHelperService.php | 71 ++++++++++ app/Services/Users/ToggleTwoFactorService.php | 84 +++++++++++ app/Services/Users/TwoFactorSetupService.php | 91 ++++++++++++ app/Services/Users/UserUpdateService.php | 1 + resources/lang/en/base.php | 7 +- .../pterodactyl/base/security.blade.php | 50 ++++--- routes/base.php | 4 - .../Assertions/ControllerAssertionsTrait.php | 9 ++ .../Base/AccountControllerTest.php | 132 ++++++++++++++++++ ...iceTest.php => KeyCreationServiceTest.php} | 46 +++--- .../Users/ToggleTwoFactorServiceTest.php | 132 ++++++++++++++++++ .../Users/TwoFactorSetupServiceTest.php | 108 ++++++++++++++ 32 files changed, 1223 insertions(+), 317 deletions(-) rename app/{Http/Controllers/Base/LanguageController.php => Contracts/Repository/SessionRepositoryInterface.php} (51%) create mode 100644 app/Exceptions/Http/Base/InvalidPasswordProvidedException.php create mode 100644 app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php create mode 100644 app/Http/Requests/Base/AccountDataFormRequest.php rename app/Http/Requests/{ApiKeyRequest.php => Base/ApiKeyFormRequest.php} (91%) rename app/Http/Requests/{BaseFormRequest.php => FrontendUserFormRequest.php} (94%) create mode 100644 app/Repositories/Eloquent/SessionRepository.php rename app/Services/Api/{KeyService.php => KeyCreationService.php} (89%) create mode 100644 app/Services/Servers/ServerAccessHelperService.php create mode 100644 app/Services/Users/ToggleTwoFactorService.php create mode 100644 app/Services/Users/TwoFactorSetupService.php create mode 100644 tests/Unit/Http/Controllers/Base/AccountControllerTest.php rename tests/Unit/Services/Api/{KeyServiceTest.php => KeyCreationServiceTest.php} (72%) create mode 100644 tests/Unit/Services/Users/ToggleTwoFactorServiceTest.php create mode 100644 tests/Unit/Services/Users/TwoFactorSetupServiceTest.php diff --git a/.php_cs b/.php_cs index aca934c80..fe3dfb56a 100644 --- a/.php_cs +++ b/.php_cs @@ -25,6 +25,10 @@ return PhpCsFixer\Config::create() 'declare_equal_normalize' => ['space' => 'single'], 'heredoc_to_nowdoc' => true, 'linebreak_after_opening_tag' => true, + 'method_argument_space' => [ + 'ensure_fully_multiline' => false, + 'keep_multiple_spaces_after_comma' => false, + ], 'new_with_braces' => false, 'no_alias_functions' => true, 'no_multiline_whitespace_before_semicolons' => true, diff --git a/app/Contracts/Repository/Daemon/ServerRepositoryInterface.php b/app/Contracts/Repository/Daemon/ServerRepositoryInterface.php index 42bfb975f..703736547 100644 --- a/app/Contracts/Repository/Daemon/ServerRepositoryInterface.php +++ b/app/Contracts/Repository/Daemon/ServerRepositoryInterface.php @@ -88,4 +88,11 @@ interface ServerRepositoryInterface extends BaseRepositoryInterface * @return \Psr\Http\Message\ResponseInterface */ public function delete(); + + /** + * Return detials on a specific server. + * + * @return \Psr\Http\Message\ResponseInterface + */ + public function details(); } diff --git a/app/Contracts/Repository/RepositoryInterface.php b/app/Contracts/Repository/RepositoryInterface.php index 15c3a4165..44450ea41 100644 --- a/app/Contracts/Repository/RepositoryInterface.php +++ b/app/Contracts/Repository/RepositoryInterface.php @@ -74,11 +74,12 @@ interface RepositoryInterface * * @param array $fields * @param bool $validate + * @param bool $force * @return mixed * * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - public function create(array $fields, $validate = true); + public function create(array $fields, $validate = true, $force = false); /** * Delete a given record from the database. diff --git a/app/Contracts/Repository/ServerRepositoryInterface.php b/app/Contracts/Repository/ServerRepositoryInterface.php index d71a349a5..9413b160f 100644 --- a/app/Contracts/Repository/ServerRepositoryInterface.php +++ b/app/Contracts/Repository/ServerRepositoryInterface.php @@ -87,4 +87,33 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function getDaemonServiceData($id); + + /** + * Return an array of server IDs that a given user can access based on owner and subuser permissions. + * + * @param int $user + * @return array + */ + public function getUserAccessServers($user); + + /** + * Return a paginated list of servers that a user can access at a given level. + * + * @param int $user + * @param string $level + * @param bool $admin + * @param array $relations + * @return \Illuminate\Pagination\LengthAwarePaginator + */ + public function filterUserAccessServers($user, $admin = false, $level = 'all', array $relations = []); + + /** + * Return a server by UUID. + * + * @param string $uuid + * @return \Illuminate\Database\Eloquent\Collection + * + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function getByUuid($uuid); } diff --git a/app/Http/Controllers/Base/LanguageController.php b/app/Contracts/Repository/SessionRepositoryInterface.php similarity index 51% rename from app/Http/Controllers/Base/LanguageController.php rename to app/Contracts/Repository/SessionRepositoryInterface.php index 0addd2185..a4f0f3e9b 100644 --- a/app/Http/Controllers/Base/LanguageController.php +++ b/app/Contracts/Repository/SessionRepositoryInterface.php @@ -1,5 +1,5 @@ . * @@ -22,50 +22,24 @@ * SOFTWARE. */ -namespace Pterodactyl\Http\Controllers\Base; +namespace Pterodactyl\Contracts\Repository; -use Auth; -use Session; -use Illuminate\Http\Request; -use Pterodactyl\Models\User; -use Pterodactyl\Http\Controllers\Controller; - -class LanguageController extends Controller +interface SessionRepositoryInterface extends RepositoryInterface { /** - * A list of supported languages on the panel. + * Delete a session for a given user. * - * @var array + * @param int $user + * @param int $session + * @return null|int */ - protected $languages = [ - 'de' => 'German', - 'en' => 'English', - 'et' => 'Estonian', - 'nb' => 'Norwegian', - 'nl' => 'Dutch', - 'pt' => 'Portuguese', - 'ro' => 'Romanian', - 'ru' => 'Russian', - ]; + public function deleteUserSession($user, $session); /** - * Sets the language for a user. + * Return all of the active sessions for a user. * - * @param \Illuminate\Http\Request $request - * @param string $language - * @return \Illuminate\Http\RedirectResponse + * @param int $user + * @return \Illuminate\Support\Collection */ - public function setLanguage(Request $request, $language) - { - if (array_key_exists($language, $this->languages)) { - if (Auth::check()) { - $user = User::findOrFail(Auth::user()->id); - $user->language = $language; - $user->save(); - } - Session::put('applocale', $language); - } - - return redirect()->back(); - } + public function getUserSessions($user); } diff --git a/app/Exceptions/Http/Base/InvalidPasswordProvidedException.php b/app/Exceptions/Http/Base/InvalidPasswordProvidedException.php new file mode 100644 index 000000000..3b4fce107 --- /dev/null +++ b/app/Exceptions/Http/Base/InvalidPasswordProvidedException.php @@ -0,0 +1,31 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Exceptions\Http\Base; + +use Pterodactyl\Exceptions\DisplayException; + +class InvalidPasswordProvidedException extends DisplayException +{ +} diff --git a/app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php b/app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php new file mode 100644 index 000000000..1e7c6483b --- /dev/null +++ b/app/Exceptions/Service/User/TwoFactorAuthenticationTokenInvalid.php @@ -0,0 +1,29 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Exceptions\Service\User; + +class TwoFactorAuthenticationTokenInvalid extends \Exception +{ +} diff --git a/app/Http/Controllers/Base/APIController.php b/app/Http/Controllers/Base/APIController.php index 7d7e39521..72a4e7b60 100644 --- a/app/Http/Controllers/Base/APIController.php +++ b/app/Http/Controllers/Base/APIController.php @@ -27,12 +27,11 @@ namespace Pterodactyl\Http\Controllers\Base; use Illuminate\Http\Request; use Prologue\Alerts\AlertsMessageBag; +use Pterodactyl\Http\Requests\Base\ApiKeyFormRequest; use Pterodactyl\Models\APIPermission; -use Pterodactyl\Services\ApiKeyService; use Pterodactyl\Http\Controllers\Controller; -use Pterodactyl\Http\Requests\ApiKeyRequest; -use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface; +use Pterodactyl\Services\Api\KeyCreationService; class APIController extends Controller { @@ -41,31 +40,31 @@ class APIController extends Controller */ protected $alert; + /** + * @var \Pterodactyl\Services\Api\KeyCreationService + */ + protected $keyService; + /** * @var \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface */ protected $repository; - /** - * @var \Pterodactyl\Services\ApiKeyService - */ - protected $service; - /** * APIController constructor. * * @param \Prologue\Alerts\AlertsMessageBag $alert * @param \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface $repository - * @param \Pterodactyl\Services\ApiKeyService $service + * @param \Pterodactyl\Services\Api\KeyCreationService $keyService */ public function __construct( AlertsMessageBag $alert, ApiKeyRepositoryInterface $repository, - ApiKeyService $service + KeyCreationService $keyService ) { $this->alert = $alert; + $this->keyService = $keyService; $this->repository = $repository; - $this->service = $service; } /** @@ -73,6 +72,8 @@ class APIController extends Controller * * @param \Illuminate\Http\Request $request * @return \Illuminate\View\View + * + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function index(Request $request) { @@ -84,14 +85,15 @@ class APIController extends Controller /** * Display API key creation page. * + * @param \Illuminate\Http\Request $request * @return \Illuminate\View\View */ - public function create() + public function create(Request $request) { return view('base.api.new', [ 'permissions' => [ 'user' => collect(APIPermission::CONST_PERMISSIONS)->pull('_user'), - 'admin' => collect(APIPermission::CONST_PERMISSIONS)->except('_user')->toArray(), + 'admin' => ! $request->user()->root_admin ?: collect(APIPermission::CONST_PERMISSIONS)->except('_user')->toArray(), ], ]); } @@ -99,30 +101,25 @@ class APIController extends Controller /** * Handle saving new API key. * - * @param \Pterodactyl\Http\Requests\ApiKeyRequest $request + * @param \Pterodactyl\Http\Requests\Base\ApiKeyFormRequest $request * @return \Illuminate\Http\RedirectResponse - * * @throws \Exception * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - public function store(ApiKeyRequest $request) + public function store(ApiKeyFormRequest $request) { $adminPermissions = []; - if ($request->user()->isRootAdmin()) { + if ($request->user()->root_admin) { $adminPermissions = $request->input('admin_permissions') ?? []; } - $secret = $this->service->create([ + $secret = $this->keyService->handle([ 'user_id' => $request->user()->id, 'allowed_ips' => $request->input('allowed_ips'), 'memo' => $request->input('memo'), - ], $request->input('permissions') ?? [], $adminPermissions); + ], $request->input('permissions', []), $adminPermissions); - $this->alert->success( - "An API Key-Pair has successfully been generated. The API secret - for this public key is shown below and will not be shown again. -

{$secret}" - )->flash(); + $this->alert->success(trans('base.api.index.keypair_created', ['token' => $secret]))->flash(); return redirect()->route('account.api'); } @@ -136,16 +133,10 @@ class APIController extends Controller */ public function revoke(Request $request, $key) { - try { - $key = $this->repository->withColumns('id')->findFirstWhere([ - ['user_id', '=', $request->user()->id], - ['public', $key], - ]); - - $this->service->revoke($key->id); - } catch (RecordNotFoundException $ex) { - return abort(404); - } + $this->repository->deleteWhere([ + ['user_id', '=', $request->user()->id], + ['public', '=', $key], + ]); return response('', 204); } diff --git a/app/Http/Controllers/Base/AccountController.php b/app/Http/Controllers/Base/AccountController.php index 0c00b92c1..102850ed5 100644 --- a/app/Http/Controllers/Base/AccountController.php +++ b/app/Http/Controllers/Base/AccountController.php @@ -25,83 +25,69 @@ namespace Pterodactyl\Http\Controllers\Base; -use Log; -use Alert; -use Illuminate\Http\Request; -use Pterodactyl\Models\User; +use Prologue\Alerts\AlertsMessageBag; use Pterodactyl\Http\Controllers\Controller; -use Pterodactyl\Exceptions\DisplayValidationException; +use Pterodactyl\Http\Requests\Base\AccountDataFormRequest; +use Pterodactyl\Services\Users\UserUpdateService; class AccountController extends Controller { - public function __construct() - { + /** + * @var \Prologue\Alerts\AlertsMessageBag + */ + protected $alert; + + /** + * @var \Pterodactyl\Services\Users\UserUpdateService + */ + protected $updateService; + + /** + * AccountController constructor. + * + * @param \Prologue\Alerts\AlertsMessageBag $alert + * @param \Pterodactyl\Services\Users\UserUpdateService $updateService + */ + public function __construct( + AlertsMessageBag $alert, + UserUpdateService $updateService + ) { + $this->alert = $alert; + $this->updateService = $updateService; } /** * Display base account information page. * - * @param \Illuminate\Http\Request $request * @return \Illuminate\View\View */ - public function index(Request $request) + public function index() { return view('base.account'); } /** - * Update details for a users account. + * Update details for a user's account. * - * @param \Illuminate\Http\Request $request + * @param \Pterodactyl\Http\Requests\Base\AccountDataFormRequest $request * @return \Illuminate\Http\RedirectResponse - * @throws \Symfony\Component\HttpKernel\Exception\HttpException + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ - public function update(Request $request) + public function update(AccountDataFormRequest $request) { $data = []; - - // Request to update account Password if ($request->input('do_action') === 'password') { - $this->validate($request, [ - 'current_password' => 'required', - 'new_password' => 'required|confirmed|' . User::PASSWORD_RULES, - 'new_password_confirmation' => 'required', - ]); - $data['password'] = $request->input('new_password'); - - // Request to update account Email } elseif ($request->input('do_action') === 'email') { $data['email'] = $request->input('new_email'); - - // Request to update account Identity } elseif ($request->input('do_action') === 'identity') { $data = $request->only(['name_first', 'name_last', 'username']); - - // Unknown, hit em with a 404 - } else { - return abort(404); } - if ( - in_array($request->input('do_action'), ['email', 'password']) - && ! password_verify($request->input('current_password'), $request->user()->password) - ) { - Alert::danger(trans('base.account.invalid_pass'))->flash(); - - return redirect()->route('account'); - } - - try { - $repo = new oldUserRepository; - $repo->update($request->user()->id, $data); - Alert::success('Your account details were successfully updated.')->flash(); - } catch (DisplayValidationException $ex) { - return redirect()->route('account')->withErrors(json_decode($ex->getMessage())); - } catch (\Exception $ex) { - Log::error($ex); - Alert::danger(trans('base.account.exception'))->flash(); - } + $this->updateService->handle($request->user()->id, $data); + $this->alert->success(trans('base.account.details_updated'))->flash(); return redirect()->route('account'); } diff --git a/app/Http/Controllers/Base/IndexController.php b/app/Http/Controllers/Base/IndexController.php index e9d9e7682..504163008 100644 --- a/app/Http/Controllers/Base/IndexController.php +++ b/app/Http/Controllers/Base/IndexController.php @@ -26,11 +26,45 @@ namespace Pterodactyl\Http\Controllers\Base; use Illuminate\Http\Request; -use Pterodactyl\Models\Server; +use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Http\Controllers\Controller; +use Pterodactyl\Services\Servers\ServerAccessHelperService; +use Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface as DaemonServerRepositoryInterface; class IndexController extends Controller { + /** + * @var \Pterodactyl\Services\Servers\ServerAccessHelperService + */ + protected $access; + + /** + * @var \Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface + */ + protected $daemonRepository; + + /** + * @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface + */ + protected $repository; + + /** + * IndexController constructor. + * + * @param \Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface $daemonRepository + * @param \Pterodactyl\Services\Servers\ServerAccessHelperService $access + * @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository + */ + public function __construct( + DaemonServerRepositoryInterface $daemonRepository, + ServerAccessHelperService $access, + ServerRepositoryInterface $repository + ) { + $this->access = $access; + $this->daemonRepository = $daemonRepository; + $this->repository = $repository; + } + /** * Returns listing of user's servers. * @@ -39,38 +73,11 @@ class IndexController extends Controller */ public function getIndex(Request $request) { - $servers = $request->user()->access()->with('user'); + $servers = $this->repository->search($request->input('query'))->filterUserAccessServers( + $request->user()->id, $request->user()->root_admin, 'all', ['user'] + ); - if (! is_null($request->input('query'))) { - $servers->search($request->input('query')); - } - - return view('base.index', [ - 'servers' => $servers->paginate(config('pterodactyl.paginate.frontend.servers')), - ]); - } - - /** - * Generate a random string. - * - * @param \Illuminate\Http\Request $request - * @param int $length - * @return string - * @deprecated - */ - public function getPassword(Request $request, $length = 16) - { - $length = ($length < 8) ? 8 : $length; - - $returnable = false; - while (! $returnable) { - $generated = str_random($length); - if (preg_match('/[A-Z]+[a-z]+[0-9]+/', $generated)) { - $returnable = true; - } - } - - return $generated; + return view('base.index', ['servers' => $servers]); } /** @@ -79,31 +86,23 @@ class IndexController extends Controller * @param \Illuminate\Http\Request $request * @param string $uuid * @return \Illuminate\Http\JsonResponse + * @throws \Exception */ public function status(Request $request, $uuid) { - $server = Server::byUuid($uuid); - - if (! $server) { - return response()->json([], 404); - } + $server = $this->access->handle($uuid, $request->user()); if (! $server->installed) { return response()->json(['status' => 20]); - } - - if ($server->suspended) { + } elseif ($server->suspended) { return response()->json(['status' => 30]); } - try { - $res = $server->guzzleClient()->request('GET', '/server'); - if ($res->getStatusCode() === 200) { - return response()->json(json_decode($res->getBody())); - } - } catch (\Exception $e) { - } + $response = $this->daemonRepository->setNode($server->node_id) + ->setAccessServer($server->uuid) + ->setAccessToken($server->daemonSecret) + ->details(); - return response()->json([]); + return response()->json(json_decode($response->getBody())); } } diff --git a/app/Http/Controllers/Base/SecurityController.php b/app/Http/Controllers/Base/SecurityController.php index 5a143e658..b44e6e0f7 100644 --- a/app/Http/Controllers/Base/SecurityController.php +++ b/app/Http/Controllers/Base/SecurityController.php @@ -25,14 +25,64 @@ namespace Pterodactyl\Http\Controllers\Base; -use Alert; -use Google2FA; +use Illuminate\Contracts\Config\Repository as ConfigRepository; +use Illuminate\Contracts\Session\Session; use Illuminate\Http\Request; -use Pterodactyl\Models\Session; +use Prologue\Alerts\AlertsMessageBag; +use Pterodactyl\Contracts\Repository\SessionRepositoryInterface; +use Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid; use Pterodactyl\Http\Controllers\Controller; +use Pterodactyl\Services\Users\ToggleTwoFactorService; +use Pterodactyl\Services\Users\TwoFactorSetupService; class SecurityController extends Controller { + /** + * @var \Prologue\Alerts\AlertsMessageBag + */ + protected $alert; + + /** + * @var \Illuminate\Contracts\Config\Repository + */ + protected $config; + + /** + * @var \Pterodactyl\Contracts\Repository\SessionRepositoryInterface + */ + protected $repository; + + /** + * @var \Illuminate\Contracts\Session\Session + */ + protected $session; + + /** + * @var \Pterodactyl\Services\Users\ToggleTwoFactorService + */ + protected $toggleTwoFactorService; + + /** + * @var \Pterodactyl\Services\Users\TwoFactorSetupService + */ + protected $twoFactorSetupService; + + public function __construct( + AlertsMessageBag $alert, + ConfigRepository $config, + Session $session, + SessionRepositoryInterface $repository, + ToggleTwoFactorService $toggleTwoFactorService, + TwoFactorSetupService $twoFactorSetupService + ) { + $this->alert = $alert; + $this->config = $config; + $this->repository = $repository; + $this->session = $session; + $this->toggleTwoFactorService = $toggleTwoFactorService; + $this->twoFactorSetupService = $twoFactorSetupService; + } + /** * Returns Security Management Page. * @@ -41,8 +91,12 @@ class SecurityController extends Controller */ public function index(Request $request) { + if ($this->config->get('session.driver') === 'database') { + $activeSessions = $this->repository->getUserSessions($request->user()->id); + } + return view('base.security', [ - 'sessions' => Session::where('user_id', $request->user()->id)->get(), + 'sessions' => $activeSessions ?? null, ]); } @@ -52,22 +106,13 @@ class SecurityController extends Controller * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function generateTotp(Request $request) { - $user = $request->user(); - - $user->totp_secret = Google2FA::generateSecretKey(); - $user->save(); - - return response()->json([ - 'qrImage' => Google2FA::getQRCodeGoogleUrl( - 'Pterodactyl', - $user->email, - $user->totp_secret - ), - 'secret' => $user->totp_secret, - ]); + return response()->json($this->twoFactorSetupService->handle($request->user())); } /** @@ -78,18 +123,13 @@ class SecurityController extends Controller */ public function setTotp(Request $request) { - if (! $request->has('token')) { - return response()->json([ - 'error' => 'Request is missing token parameter.', - ], 500); - } + try { + $this->toggleTwoFactorService->handle($request->user(), $request->input('token')); - $user = $request->user(); - if ($user->toggleTotp($request->input('token'))) { return response('true'); + } catch (TwoFactorAuthenticationTokenInvalid $exception) { + return response('false'); } - - return response('false'); } /** @@ -100,19 +140,12 @@ class SecurityController extends Controller */ public function disableTotp(Request $request) { - if (! $request->has('token')) { - Alert::danger('Missing required `token` field in request.')->flash(); - - return redirect()->route('account.security'); + try { + $this->toggleTwoFactorService->handle($request->user(), $request->input('token'), false); + } catch (TwoFactorAuthenticationTokenInvalid $exception) { + $this->alert->danger(trans('base.security.2fa_disable_error'))->flash(); } - $user = $request->user(); - if ($user->toggleTotp($request->input('token'))) { - return redirect()->route('account.security'); - } - - Alert::danger('The TOTP token provided was invalid.')->flash(); - return redirect()->route('account.security'); } @@ -125,7 +158,7 @@ class SecurityController extends Controller */ public function revoke(Request $request, $id) { - Session::where('user_id', $request->user()->id)->findOrFail($id)->delete(); + $this->repository->deleteUserSession($request->user()->id, $id); return redirect()->route('account.security'); } diff --git a/app/Http/Requests/Base/AccountDataFormRequest.php b/app/Http/Requests/Base/AccountDataFormRequest.php new file mode 100644 index 000000000..a9573106f --- /dev/null +++ b/app/Http/Requests/Base/AccountDataFormRequest.php @@ -0,0 +1,85 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Http\Requests\Base; + +use Pterodactyl\Exceptions\Http\Base\InvalidPasswordProvidedException; +use Pterodactyl\Models\User; +use Pterodactyl\Http\Requests\FrontendUserFormRequest; + +class AccountDataFormRequest extends FrontendUserFormRequest +{ + /** + * @return bool + * @throws \Pterodactyl\Exceptions\Http\Base\InvalidPasswordProvidedException + */ + public function authorize() + { + if (! parent::authorize()) { + return false; + } + + // Verify password matches when changing password or email. + if (in_array($this->input('do_action'), ['password', 'email'])) { + if (! password_verify($this->input('current_password'), $this->user()->password)) { + throw new InvalidPasswordProvidedException(trans('base.account.invalid_password')); + } + } + + return true; + } + + /** + * @return array + */ + public function rules() + { + $modelRules = User::getUpdateRulesForId($this->user()->id); + + switch ($this->input('do_action')) { + case 'email': + $rules = [ + 'new_email' => array_get($modelRules, 'email'), + ]; + break; + case 'password': + $rules = [ + 'new_password' => 'required|confirmed|string|min:8', + 'new_password_confirmation' => 'required', + ]; + break; + case 'identity': + $rules = [ + 'name_first' => array_get($modelRules, 'name_first'), + 'name_last' => array_get($modelRules, 'name_last'), + 'username' => array_get($modelRules, 'username'), + ]; + break; + default: + abort(422); + } + + return $rules; + } +} diff --git a/app/Http/Requests/ApiKeyRequest.php b/app/Http/Requests/Base/ApiKeyFormRequest.php similarity index 91% rename from app/Http/Requests/ApiKeyRequest.php rename to app/Http/Requests/Base/ApiKeyFormRequest.php index 52b8f90ea..33b2541cd 100644 --- a/app/Http/Requests/ApiKeyRequest.php +++ b/app/Http/Requests/Base/ApiKeyFormRequest.php @@ -22,11 +22,12 @@ * SOFTWARE. */ -namespace Pterodactyl\Http\Requests; +namespace Pterodactyl\Http\Requests\Base; use IPTools\Network; +use Pterodactyl\Http\Requests\FrontendUserFormRequest; -class ApiKeyRequest extends BaseFormRequest +class ApiKeyFormRequest extends FrontendUserFormRequest { /** * Rules applied to data passed in this request. @@ -58,7 +59,7 @@ class ApiKeyRequest extends BaseFormRequest } } - $this->merge(['allowed_ips' => $loop], $this->except('allowed_ips')); + $this->merge(['allowed_ips' => $loop]); } /** @@ -69,12 +70,11 @@ class ApiKeyRequest extends BaseFormRequest public function withValidator($validator) { $validator->after(function ($validator) { + /* @var \Illuminate\Validation\Validator $validator */ if (empty($this->input('permissions')) && empty($this->input('admin_permissions'))) { $validator->errors()->add('permissions', 'At least one permission must be selected.'); } - }); - $validator->after(function ($validator) { foreach ($this->input('allowed_ips') as $ip) { $ip = trim($ip); diff --git a/app/Http/Requests/BaseFormRequest.php b/app/Http/Requests/FrontendUserFormRequest.php similarity index 94% rename from app/Http/Requests/BaseFormRequest.php rename to app/Http/Requests/FrontendUserFormRequest.php index 7d5274bb3..404003c31 100644 --- a/app/Http/Requests/BaseFormRequest.php +++ b/app/Http/Requests/FrontendUserFormRequest.php @@ -26,8 +26,10 @@ namespace Pterodactyl\Http\Requests; use Illuminate\Foundation\Http\FormRequest; -class BaseFormRequest extends FormRequest +abstract class FrontendUserFormRequest extends FormRequest { + abstract public function rules(); + /** * Determine if a user is authorized to access this endpoint. * diff --git a/app/Models/User.php b/app/Models/User.php index d9f99d019..a34935223 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -50,21 +50,6 @@ class User extends Model implements { use Authenticatable, Authorizable, CanResetPassword, Eloquence, Notifiable, Validable; - /** - * The rules for user passwords. - * - * @var string - * @deprecated - */ - const PASSWORD_RULES = 'regex:((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})'; - - /** - * The regex rules for usernames. - * - * @var string - */ - const USERNAME_RULES = 'regex:/^([\w\d\.\-]{1,255})$/'; - /** * Level of servers to display when using access() on a user. * @@ -92,9 +77,9 @@ class User extends Model implements * @var array */ protected $casts = [ - 'root_admin' => 'integer', - 'use_totp' => 'integer', - 'gravatar' => 'integer', + 'root_admin' => 'boolean', + 'use_totp' => 'boolean', + 'gravatar' => 'boolean', ]; /** @@ -135,11 +120,11 @@ class User extends Model implements * @var array */ protected static $applicationRules = [ - 'email' => 'required|email', - 'username' => 'required|alpha_dash', - 'name_first' => 'required|string', - 'name_last' => 'required|string', - 'password' => 'sometimes|regex:((?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,})', + 'email' => 'required', + 'username' => 'required', + 'name_first' => 'required', + 'name_last' => 'required', + 'password' => 'sometimes', ]; /** @@ -148,10 +133,10 @@ class User extends Model implements * @var array */ protected static $dataIntegrityRules = [ - 'email' => 'unique:users,email', - 'username' => 'between:1,255|unique:users,username', - 'name_first' => 'between:1,255', - 'name_last' => 'between:1,255', + 'email' => 'email|unique:users,email', + 'username' => 'alpha_dash|between:1,255|unique:users,username', + 'name_first' => 'string|between:1,255', + 'name_last' => 'string|between:1,255', 'password' => 'nullable|string', 'root_admin' => 'boolean', 'language' => 'string|between:2,5', diff --git a/app/Providers/RepositoryServiceProvider.php b/app/Providers/RepositoryServiceProvider.php index a0a85fae1..d30ae3228 100644 --- a/app/Providers/RepositoryServiceProvider.php +++ b/app/Providers/RepositoryServiceProvider.php @@ -25,10 +25,16 @@ namespace Pterodactyl\Providers; use Illuminate\Support\ServiceProvider; +use Pterodactyl\Contracts\Repository\PermissionRepositoryInterface; +use Pterodactyl\Contracts\Repository\SessionRepositoryInterface; +use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface; use Pterodactyl\Repositories\Daemon\FileRepository; use Pterodactyl\Repositories\Daemon\PowerRepository; use Pterodactyl\Repositories\Eloquent\NodeRepository; use Pterodactyl\Repositories\Eloquent\PackRepository; +use Pterodactyl\Repositories\Eloquent\PermissionRepository; +use Pterodactyl\Repositories\Eloquent\SessionRepository; +use Pterodactyl\Repositories\Eloquent\SubuserRepository; use Pterodactyl\Repositories\Eloquent\UserRepository; use Pterodactyl\Repositories\Daemon\CommandRepository; use Pterodactyl\Repositories\Eloquent\ApiKeyRepository; @@ -83,11 +89,14 @@ class RepositoryServiceProvider extends ServiceProvider $this->app->bind(NodeRepositoryInterface::class, NodeRepository::class); $this->app->bind(OptionVariableRepositoryInterface::class, OptionVariableRepository::class); $this->app->bind(PackRepositoryInterface::class, PackRepository::class); + $this->app->bind(PermissionRepositoryInterface::class, PermissionRepository::class); $this->app->bind(ServerRepositoryInterface::class, ServerRepository::class); $this->app->bind(ServerVariableRepositoryInterface::class, ServerVariableRepository::class); $this->app->bind(ServiceRepositoryInterface::class, ServiceRepository::class); $this->app->bind(ServiceOptionRepositoryInterface::class, ServiceOptionRepository::class); $this->app->bind(ServiceVariableRepositoryInterface::class, ServiceVariableRepository::class); + $this->app->bind(SessionRepositoryInterface::class, SessionRepository::class); + $this->app->bind(SubuserRepositoryInterface::class, SubuserRepository::class); $this->app->bind(UserRepositoryInterface::class, UserRepository::class); // Daemon Repositories diff --git a/app/Repositories/Daemon/ServerRepository.php b/app/Repositories/Daemon/ServerRepository.php index 594bb1752..db2f31e6e 100644 --- a/app/Repositories/Daemon/ServerRepository.php +++ b/app/Repositories/Daemon/ServerRepository.php @@ -161,4 +161,12 @@ class ServerRepository extends BaseRepository implements ServerRepositoryInterfa { return $this->getHttpClient()->request('DELETE', '/servers'); } + + /** + * {@inheritdoc} + */ + public function details() + { + return $this->getHttpClient()->request('GET', '/servers'); + } } diff --git a/app/Repositories/Eloquent/ServerRepository.php b/app/Repositories/Eloquent/ServerRepository.php index 05ba80390..ec89052bd 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -28,6 +28,7 @@ use Pterodactyl\Models\Server; use Pterodactyl\Repositories\Concerns\Searchable; use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; +use Webmozart\Assert\Assert; class ServerRepository extends EloquentRepository implements ServerRepositoryInterface { @@ -149,4 +150,73 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt 'pack' => (! is_null($instance->pack_id)) ? $instance->pack->uuid : null, ]; } + + /** + * {@inheritdoc} + */ + public function getUserAccessServers($user) + { + Assert::numeric($user, 'First argument passed to getUserAccessServers must be numeric, received %s.'); + + $subuser = $this->app->make(SubuserRepository::class); + + return $this->getBuilder()->select('id')->where('owner_id', $user)->union( + $subuser->getBuilder()->select('server_id')->where('user_id', $user) + )->pluck('id')->all(); + } + + /** + * {@inheritdoc} + */ + public function filterUserAccessServers($user, $admin = false, $level = 'all', array $relations = []) + { + Assert::numeric($user, 'First argument passed to filterUserAccessServers must be numeric, received %s.'); + Assert::boolean($admin, 'Second argument passed to filterUserAccessServers must be boolean, received %s.'); + Assert::stringNotEmpty($level, 'Third argument passed to filterUserAccessServers must be a non-empty string, received %s.'); + + $instance = $this->getBuilder()->with($relations); + + // If access level is set to owner, only display servers + // that the user owns. + if ($level === 'owner') { + $instance->where('owner_id', $user); + } + + // If set to all, display all servers they can access, including + // those they access as an admin. + // + // If set to subuser, only return the servers they can access because + // they are owner, or marked as a subuser of the server. + if (($level === 'all' && ! $admin) || $level === 'subuser') { + $instance->whereIn('id', $this->getUserAccessServers($user)); + } + + // If set to admin, only display the servers a user can access + // as an administrator (leaves out owned and subuser of). + if ($level === 'admin' && $admin) { + $instance->whereIn('id', $this->getUserAccessServers($user)); + } + + return $instance->search($this->searchTerm)->paginate( + $this->app->make('config')->get('pterodactyl.paginate.frontend.servers') + ); + } + + /** + * {@inheritdoc} + */ + public function getByUuid($uuid) + { + Assert::stringNotEmpty($uuid, 'First argument passed to getByUuid must be a non-empty string, received %s.'); + + $instance = $this->getBuilder()->with('service', 'node')->where(function ($query) use ($uuid) { + $query->where('uuidShort', $uuid)->orWhere('uuid', $uuid); + })->first($this->getColumns()); + + if (! $instance) { + throw new RecordNotFoundException; + } + + return $instance; + } } diff --git a/app/Repositories/Eloquent/SessionRepository.php b/app/Repositories/Eloquent/SessionRepository.php new file mode 100644 index 000000000..928feb56a --- /dev/null +++ b/app/Repositories/Eloquent/SessionRepository.php @@ -0,0 +1,55 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Repositories\Eloquent; + +use Pterodactyl\Models\Session; +use Pterodactyl\Contracts\Repository\SessionRepositoryInterface; + +class SessionRepository extends EloquentRepository implements SessionRepositoryInterface +{ + /** + * {@inheritdoc} + */ + public function model() + { + return Session::class; + } + + /** + * {@inheritdoc} + */ + public function getUserSessions($user) + { + return $this->getBuilder()->where('user_id', $user)->get($this->getColumns()); + } + + /** + * {@inheritdoc} + */ + public function deleteUserSession($user, $session) + { + return $this->getBuilder()->where('user_id', $user)->where('id', $session)->delete(); + } +} diff --git a/app/Services/Api/KeyService.php b/app/Services/Api/KeyCreationService.php similarity index 89% rename from app/Services/Api/KeyService.php rename to app/Services/Api/KeyCreationService.php index fc67b8926..4beaf3a4d 100644 --- a/app/Services/Api/KeyService.php +++ b/app/Services/Api/KeyCreationService.php @@ -28,7 +28,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Contracts\Encryption\Encrypter; use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface; -class KeyService +class KeyCreationService { const PUB_CRYPTO_BYTES = 8; const PRIV_CRYPTO_BYTES = 32; @@ -36,7 +36,7 @@ class KeyService /** * @var \Illuminate\Database\ConnectionInterface */ - protected $database; + protected $connection; /** * @var \Illuminate\Contracts\Encryption\Encrypter @@ -57,18 +57,18 @@ class KeyService * ApiKeyService constructor. * * @param \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface $repository - * @param \Illuminate\Database\ConnectionInterface $database + * @param \Illuminate\Database\ConnectionInterface $connection * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter * @param \Pterodactyl\Services\Api\PermissionService $permissionService */ public function __construct( ApiKeyRepositoryInterface $repository, - ConnectionInterface $database, + ConnectionInterface $connection, Encrypter $encrypter, PermissionService $permissionService ) { $this->repository = $repository; - $this->database = $database; + $this->connection = $connection; $this->encrypter = $encrypter; $this->permissionService = $permissionService; } @@ -84,13 +84,13 @@ class KeyService * @throws \Exception * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - public function create(array $data, array $permissions, array $administrative = []) + public function handle(array $data, array $permissions, array $administrative = []) { $publicKey = bin2hex(random_bytes(self::PUB_CRYPTO_BYTES)); $secretKey = bin2hex(random_bytes(self::PRIV_CRYPTO_BYTES)); // Start a Transaction - $this->database->beginTransaction(); + $this->connection->beginTransaction(); $data = array_merge($data, [ 'public' => $publicKey, @@ -128,19 +128,8 @@ class KeyService $this->permissionService->create($instance->id, $permission); } - $this->database->commit(); + $this->connection->commit(); return $secretKey; } - - /** - * Delete the API key and associated permissions from the database. - * - * @param int $id - * @return bool|null - */ - public function revoke($id) - { - return $this->repository->delete($id); - } } diff --git a/app/Services/Servers/ServerAccessHelperService.php b/app/Services/Servers/ServerAccessHelperService.php new file mode 100644 index 000000000..4ee770127 --- /dev/null +++ b/app/Services/Servers/ServerAccessHelperService.php @@ -0,0 +1,71 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Services\Servers; + +use Illuminate\Cache\Repository as CacheRepository; +use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; +use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface; +use Pterodactyl\Contracts\Repository\UserRepositoryInterface; +use Pterodactyl\Models\User; + +class ServerAccessHelperService +{ + public function __construct( + CacheRepository $cache, + ServerRepositoryInterface $repository, + SubuserRepositoryInterface $subuserRepository, + UserRepositoryInterface $userRepository + ) { + $this->cache = $cache; + $this->repository = $repository; + $this->subuserRepository = $subuserRepository; + $this->userRepository = $userRepository; + } + + public function handle($uuid, $user) + { + if (! $user instanceof User) { + $user = $this->userRepository->find($user); + } + + $server = $this->repository->getByUuid($uuid); + if (! $user->root_admin) { + if (! in_array($server->id, $this->repository->getUserAccessServers($user->id))) { + throw new \Exception('User does not have access.'); + } + + if ($server->owner_id !== $user->id) { + $subuser = $this->subuserRepository->withColumns('daemonSecret')->findWhere([ + ['user_id', '=', $user->id], + ['server_id', '=', $server->id], + ]); + + $server->daemonSecret = $subuser->daemonToken; + } + } + + return $server; + } +} diff --git a/app/Services/Users/ToggleTwoFactorService.php b/app/Services/Users/ToggleTwoFactorService.php new file mode 100644 index 000000000..f731c13b5 --- /dev/null +++ b/app/Services/Users/ToggleTwoFactorService.php @@ -0,0 +1,84 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Services\Users; + +use PragmaRX\Google2FA\Contracts\Google2FA; +use Pterodactyl\Contracts\Repository\UserRepositoryInterface; +use Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid; +use Pterodactyl\Models\User; + +class ToggleTwoFactorService +{ + /** + * @var \PragmaRX\Google2FA\Contracts\Google2FA + */ + protected $google2FA; + + /** + * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface + */ + protected $repository; + + /** + * ToggleTwoFactorService constructor. + * + * @param \PragmaRX\Google2FA\Contracts\Google2FA $google2FA + * @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository + */ + public function __construct( + Google2FA $google2FA, + UserRepositoryInterface $repository + ) { + $this->google2FA = $google2FA; + $this->repository = $repository; + } + + /** + * @param int|\Pterodactyl\Models\User $user + * @param string $token + * @param null|bool $toggleState + * @return bool + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + * @throws \Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid + */ + public function handle($user, $token, $toggleState = null) + { + if (! $user instanceof User) { + $user = $this->repository->find($user); + } + + if (! $this->google2FA->verifyKey($user->totp_secret, $token, 2)) { + throw new TwoFactorAuthenticationTokenInvalid; + } + + $this->repository->withoutFresh()->update($user->id, [ + 'use_totp' => (is_null($toggleState) ? ! $user->use_totp : $toggleState), + ]); + + return true; + } +} diff --git a/app/Services/Users/TwoFactorSetupService.php b/app/Services/Users/TwoFactorSetupService.php new file mode 100644 index 000000000..d959ef6a0 --- /dev/null +++ b/app/Services/Users/TwoFactorSetupService.php @@ -0,0 +1,91 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Pterodactyl\Services\Users; + +use Illuminate\Contracts\Config\Repository as ConfigRepository; +use PragmaRX\Google2FA\Contracts\Google2FA; +use Pterodactyl\Contracts\Repository\UserRepositoryInterface; +use Pterodactyl\Models\User; + +class TwoFactorSetupService +{ + /** + * @var \Illuminate\Contracts\Config\Repository + */ + protected $config; + + /** + * @var \PragmaRX\Google2FA\Contracts\Google2FA + */ + protected $google2FA; + + /** + * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface + */ + protected $repository; + + /** + * TwoFactorSetupService constructor. + * + * @param \Illuminate\Contracts\Config\Repository $config + * @param \PragmaRX\Google2FA\Contracts\Google2FA $google2FA + * @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $repository + */ + public function __construct( + ConfigRepository $config, + Google2FA $google2FA, + UserRepositoryInterface $repository + ) { + $this->config = $config; + $this->google2FA = $google2FA; + $this->repository = $repository; + } + + /** + * Generate a 2FA token and store it in the database. + * + * @param int|\Pterodactyl\Models\User $user + * @return array + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function handle($user) + { + if (! $user instanceof User) { + $user = $this->repository->find($user); + } + + $secret = $this->google2FA->generateSecretKey(); + $image = $this->google2FA->getQRCodeGoogleUrl($this->config->get('app.name'), $user->email, $secret); + + $this->repository->withoutFresh()->update($user->id, ['totp_secret' => $secret]); + + return [ + 'qrImage' => $image, + 'secret' => $secret, + ]; + } +} diff --git a/app/Services/Users/UserUpdateService.php b/app/Services/Users/UserUpdateService.php index 646b19407..99ce63667 100644 --- a/app/Services/Users/UserUpdateService.php +++ b/app/Services/Users/UserUpdateService.php @@ -61,6 +61,7 @@ class UserUpdateService * @return mixed * * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function handle($id, array $data) { diff --git a/resources/lang/en/base.php b/resources/lang/en/base.php index 2c5242dfc..9c7bd9d0d 100644 --- a/resources/lang/en/base.php +++ b/resources/lang/en/base.php @@ -33,6 +33,7 @@ return [ 'header_sub' => 'Manage your API access keys.', 'list' => 'API Keys', 'create_new' => 'Create New API key', + 'keypair_created' => 'An API Key-Pair has been generated. Your API secret token is :token. Please take note of this key as it will not be displayed again.', ], 'new' => [ 'header' => 'New API Key', @@ -207,6 +208,8 @@ return [ ], ], 'account' => [ + 'details_updated' => 'Your account details have been successfully updated.', + 'invalid_password' => 'The password provided for your account was not valid.', 'header' => 'Your Account', 'header_sub' => 'Manage your account details.', 'update_pass' => 'Update Password', @@ -219,10 +222,9 @@ return [ 'last_name' => 'Last Name', 'update_identitity' => 'Update Identity', 'username_help' => 'Your username must be unique to your account, and may only contain the following characters: :requirements.', - 'invalid_pass' => 'The password provided was not valid for this account.', - 'exception' => 'An error occurred while attempting to update your account.', ], 'security' => [ + 'session_mgmt_disabled' => 'Your host has not enabled the ability to manage account sessions via this interface.', 'header' => 'Account Security', 'header_sub' => 'Control active sessions and 2-Factor Authentication.', 'sessions' => 'Active Sessions', @@ -234,5 +236,6 @@ return [ 'enable_2fa' => 'Enable 2-Factor Authentication', '2fa_qr' => 'Confgure 2FA on Your Device', '2fa_checkpoint_help' => 'Use the 2FA application on your phone to take a picture of the QR code to the left, or manually enter the code under it. Once you have done so, generate a token and enter it below.', + '2fa_disable_error' => 'The 2FA token provided was not valid. Protection has not been disabled for this account.', ], ]; diff --git a/resources/themes/pterodactyl/base/security.blade.php b/resources/themes/pterodactyl/base/security.blade.php index 666a790bc..8f3908db5 100644 --- a/resources/themes/pterodactyl/base/security.blade.php +++ b/resources/themes/pterodactyl/base/security.blade.php @@ -39,30 +39,36 @@

@lang('base.security.sessions')

-
- - - - - - - - - @foreach($sessions as $session) + @if(!is_null($sessions)) +
+
@lang('strings.id')@lang('strings.ip')@lang('strings.last_activity')
+ - - - - + + + + - @endforeach - -
{{ substr($session->id, 0, 6) }}{{ $session->ip_address }}{{ Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }} - - - - @lang('strings.id')@lang('strings.ip')@lang('strings.last_activity')
-
+ @foreach($sessions as $session) + + {{ substr($session->id, 0, 6) }} + {{ $session->ip_address }} + {{ Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }} + + + + + + + @endforeach + + + + @else +
+

@lang('base.security.session_mgmt_disabled')

+
+ @endif
diff --git a/routes/base.php b/routes/base.php index 4b06bb645..adb100fe2 100644 --- a/routes/base.php +++ b/routes/base.php @@ -24,10 +24,6 @@ Route::get('/', 'IndexController@getIndex')->name('index'); Route::get('/status/{server}', 'IndexController@status')->name('index.status'); -Route::get('/index', function () { - redirect()->route('index'); -}); - /* |-------------------------------------------------------------------------- | Account Controller Routes diff --git a/tests/Assertions/ControllerAssertionsTrait.php b/tests/Assertions/ControllerAssertionsTrait.php index 5e04512d1..1ed835c89 100644 --- a/tests/Assertions/ControllerAssertionsTrait.php +++ b/tests/Assertions/ControllerAssertionsTrait.php @@ -83,4 +83,13 @@ trait ControllerAssertionsTrait { PHPUnit_Framework_Assert::assertEquals($value, array_get($view->getData(), $attribute)); } + + /** + * @param string $route + * @param \Illuminate\Http\RedirectResponse $response + */ + public function assertRouteRedirectEquals($route, $response) + { + PHPUnit_Framework_Assert::assertEquals(route($route), $response->getTargetUrl()); + } } diff --git a/tests/Unit/Http/Controllers/Base/AccountControllerTest.php b/tests/Unit/Http/Controllers/Base/AccountControllerTest.php new file mode 100644 index 000000000..1f9b556fc --- /dev/null +++ b/tests/Unit/Http/Controllers/Base/AccountControllerTest.php @@ -0,0 +1,132 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Tests\Unit\Http\Controllers\Base; + +use Mockery as m; +use Prologue\Alerts\AlertsMessageBag; +use Pterodactyl\Http\Controllers\Base\AccountController; +use Pterodactyl\Http\Requests\Base\AccountDataFormRequest; +use Pterodactyl\Services\Users\UserUpdateService; +use Tests\Assertions\ControllerAssertionsTrait; +use Tests\TestCase; + +class AccountControllerTest extends TestCase +{ + use ControllerAssertionsTrait; + + /** + * @var \Prologue\Alerts\AlertsMessageBag + */ + protected $alert; + + /** + * @var \Pterodactyl\Http\Controllers\Base\AccountController + */ + protected $controller; + + /** + * @var \Pterodactyl\Http\Requests\Base\AccountDataFormRequest + */ + protected $request; + + /** + * @var \Pterodactyl\Services\Users\UserUpdateService + */ + protected $updateService; + + /** + * Setup tests. + */ + public function setUp() + { + parent::setUp(); + + $this->alert = m::mock(AlertsMessageBag::class); + $this->request = m::mock(AccountDataFormRequest::class); + $this->updateService = m::mock(UserUpdateService::class); + + $this->controller = new AccountController($this->alert, $this->updateService); + } + + /** + * Test the index controller. + */ + public function testIndexController() + { + $response = $this->controller->index(); + + $this->assertViewNameEquals('base.account', $response); + } + + /** + * Test controller when password is being updated. + */ + public function testUpdateControllerForPassword() + { + $this->request->shouldReceive('input')->with('do_action')->andReturn('password'); + $this->request->shouldReceive('input')->with('new_password')->once()->andReturn('test-password'); + + $this->request->shouldReceive('user')->withNoArgs()->once()->andReturn((object) ['id' => 1]); + $this->updateService->shouldReceive('handle')->with(1, ['password' => 'test-password'])->once()->andReturnNull(); + $this->alert->shouldReceive('success->flash')->once()->andReturnNull(); + + $response = $this->controller->update($this->request); + $this->assertRouteRedirectEquals('account', $response); + } + + /** + * Test controller when email is being updated. + */ + public function testUpdateControllerForEmail() + { + $this->request->shouldReceive('input')->with('do_action')->andReturn('email'); + $this->request->shouldReceive('input')->with('new_email')->once()->andReturn('test@example.com'); + + $this->request->shouldReceive('user')->withNoArgs()->once()->andReturn((object) ['id' => 1]); + $this->updateService->shouldReceive('handle')->with(1, ['email' => 'test@example.com'])->once()->andReturnNull(); + $this->alert->shouldReceive('success->flash')->once()->andReturnNull(); + + $response = $this->controller->update($this->request); + $this->assertRouteRedirectEquals('account', $response); + } + + /** + * Test controller when identity is being updated. + */ + public function testUpdateControllerForIdentity() + { + $this->request->shouldReceive('input')->with('do_action')->andReturn('identity'); + $this->request->shouldReceive('only')->with(['name_first', 'name_last', 'username'])->once()->andReturn([ + 'test_data' => 'value', + ]); + + $this->request->shouldReceive('user')->withNoArgs()->once()->andReturn((object) ['id' => 1]); + $this->updateService->shouldReceive('handle')->with(1, ['test_data' => 'value'])->once()->andReturnNull(); + $this->alert->shouldReceive('success->flash')->once()->andReturnNull(); + + $response = $this->controller->update($this->request); + $this->assertRouteRedirectEquals('account', $response); + } +} diff --git a/tests/Unit/Services/Api/KeyServiceTest.php b/tests/Unit/Services/Api/KeyCreationServiceTest.php similarity index 72% rename from tests/Unit/Services/Api/KeyServiceTest.php rename to tests/Unit/Services/Api/KeyCreationServiceTest.php index b4912fcab..fb9afd62d 100644 --- a/tests/Unit/Services/Api/KeyServiceTest.php +++ b/tests/Unit/Services/Api/KeyCreationServiceTest.php @@ -27,20 +27,20 @@ namespace Tests\Unit\Services\Api; use Mockery as m; use Tests\TestCase; use phpmock\phpunit\PHPMock; -use Pterodactyl\Services\Api\KeyService; +use Pterodactyl\Services\Api\KeyCreationService; use Illuminate\Database\ConnectionInterface; use Illuminate\Contracts\Encryption\Encrypter; use Pterodactyl\Services\Api\PermissionService; use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface; -class KeyServiceTest extends TestCase +class KeyCreationServiceTest extends TestCase { use PHPMock; /** * @var \Illuminate\Database\ConnectionInterface */ - protected $database; + protected $connection; /** * @var \Illuminate\Contracts\Encryption\Encrypter @@ -58,7 +58,7 @@ class KeyServiceTest extends TestCase protected $repository; /** - * @var \Pterodactyl\Services\Api\KeyService + * @var \Pterodactyl\Services\Api\KeyCreationService */ protected $service; @@ -66,14 +66,14 @@ class KeyServiceTest extends TestCase { parent::setUp(); - $this->database = m::mock(ConnectionInterface::class); + $this->connection = m::mock(ConnectionInterface::class); $this->encrypter = m::mock(Encrypter::class); $this->permissions = m::mock(PermissionService::class); $this->repository = m::mock(ApiKeyRepositoryInterface::class); - $this->service = new KeyService( + $this->service = new KeyCreationService( $this->repository, - $this->database, + $this->connection, $this->encrypter, $this->permissions ); @@ -82,21 +82,17 @@ class KeyServiceTest extends TestCase /** * Test that the service is able to create a keypair and assign the correct permissions. */ - public function test_create_function() + public function testKeyIsCreated() { - $this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'random_bytes') - ->expects($this->exactly(2)) - ->willReturnCallback(function ($bytes) { - return hex2bin(str_pad('', $bytes * 2, '0')); - }); + $this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'bin2hex') + ->expects($this->exactly(2))->willReturn('bin2hex'); - $this->database->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); - $this->encrypter->shouldReceive('encrypt')->with(str_pad('', 64, '0')) - ->once()->andReturn('encrypted-secret'); + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->encrypter->shouldReceive('encrypt')->with('bin2hex')->once()->andReturn('encrypted-secret'); $this->repository->shouldReceive('create')->with([ 'test-data' => 'test', - 'public' => str_pad('', 16, '0'), + 'public' => 'bin2hex', 'secret' => 'encrypted-secret', ], true, true)->once()->andReturn((object) ['id' => 1]); @@ -108,25 +104,15 @@ class KeyServiceTest extends TestCase $this->permissions->shouldReceive('create')->with(1, 'user.server-list')->once()->andReturnNull(); $this->permissions->shouldReceive('create')->with(1, 'server-create')->once()->andReturnNull(); - $this->database->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); - $response = $this->service->create( + $response = $this->service->handle( ['test-data' => 'test'], ['invalid-node', 'server-list'], ['invalid-node', 'server-create'] ); $this->assertNotEmpty($response); - $this->assertEquals(str_pad('', 64, '0'), $response); - } - - /** - * Test that an API key can be revoked. - */ - public function test_revoke_function() - { - $this->repository->shouldReceive('delete')->with(1)->once()->andReturn(true); - - $this->assertTrue($this->service->revoke(1)); + $this->assertEquals('bin2hex', $response); } } diff --git a/tests/Unit/Services/Users/ToggleTwoFactorServiceTest.php b/tests/Unit/Services/Users/ToggleTwoFactorServiceTest.php new file mode 100644 index 000000000..8714f9748 --- /dev/null +++ b/tests/Unit/Services/Users/ToggleTwoFactorServiceTest.php @@ -0,0 +1,132 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Tests\Unit\Services\Users; + +use Mockery as m; +use PragmaRX\Google2FA\Contracts\Google2FA; +use Pterodactyl\Contracts\Repository\UserRepositoryInterface; +use Pterodactyl\Models\User; +use Pterodactyl\Services\Users\ToggleTwoFactorService; +use Tests\TestCase; + +class ToggleTwoFactorServiceTest extends TestCase +{ + /** + * @var \PragmaRX\Google2FA\Contracts\Google2FA + */ + protected $google2FA; + + /** + * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface + */ + protected $repository; + + /** + * @var \Pterodactyl\Services\Users\ToggleTwoFactorService + */ + protected $service; + + /** + * Setup tests. + */ + public function setUp() + { + parent::setUp(); + + $this->google2FA = m::mock(Google2FA::class); + $this->repository = m::mock(UserRepositoryInterface::class); + + $this->service = new ToggleTwoFactorService($this->google2FA, $this->repository); + } + + /** + * Test that 2FA can be enabled for a user. + */ + public function testTwoFactorIsEnabledForUser() + { + $model = factory(User::class)->make(['totp_secret' => 'secret', 'use_totp' => false]); + + $this->google2FA->shouldReceive('verifyKey')->with($model->totp_secret, 'test-token', 2)->once()->andReturn(true); + $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() + ->shouldReceive('update')->with($model->id, ['use_totp' => true])->once()->andReturnNull(); + + $this->assertTrue($this->service->handle($model, 'test-token')); + } + + /** + * Test that 2FA can be disabled for a user. + */ + public function testTwoFactorIsDisabled() + { + $model = factory(User::class)->make(['totp_secret' => 'secret', 'use_totp' => true]); + + $this->google2FA->shouldReceive('verifyKey')->with($model->totp_secret, 'test-token', 2)->once()->andReturn(true); + $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() + ->shouldReceive('update')->with($model->id, ['use_totp' => false])->once()->andReturnNull(); + + $this->assertTrue($this->service->handle($model, 'test-token')); + } + + /** + * Test that 2FA will remain disabled for a user. + */ + public function testTwoFactorRemainsDisabledForUser() + { + $model = factory(User::class)->make(['totp_secret' => 'secret', 'use_totp' => false]); + + $this->google2FA->shouldReceive('verifyKey')->with($model->totp_secret, 'test-token', 2)->once()->andReturn(true); + $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() + ->shouldReceive('update')->with($model->id, ['use_totp' => false])->once()->andReturnNull(); + + $this->assertTrue($this->service->handle($model, 'test-token', false)); + } + + /** + * Test that an exception is thrown if the token provided is invalid. + * + * @expectedException \Pterodactyl\Exceptions\Service\User\TwoFactorAuthenticationTokenInvalid + */ + public function testExceptionIsThrownIfTokenIsInvalid() + { + $model = factory(User::class)->make(); + $this->google2FA->shouldReceive('verifyKey')->once()->andReturn(false); + + $this->service->handle($model, 'test-token'); + } + + /** + * Test that an integer can be passed in place of a user model. + */ + public function testIntegerCanBePassedInPlaceOfUserModel() + { + $model = factory(User::class)->make(['totp_secret' => 'secret', 'use_totp' => false]); + + $this->repository->shouldReceive('find')->with($model->id)->once()->andReturn($model); + $this->google2FA->shouldReceive('verifyKey')->once()->andReturn(true); + $this->repository->shouldReceive('withoutFresh->update')->once()->andReturnNull(); + + $this->assertTrue($this->service->handle($model->id, 'test-token')); + } +} diff --git a/tests/Unit/Services/Users/TwoFactorSetupServiceTest.php b/tests/Unit/Services/Users/TwoFactorSetupServiceTest.php new file mode 100644 index 000000000..5d5b3ad93 --- /dev/null +++ b/tests/Unit/Services/Users/TwoFactorSetupServiceTest.php @@ -0,0 +1,108 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Tests\Unit\Services\Users; + +use Illuminate\Contracts\Config\Repository; +use Mockery as m; +use PragmaRX\Google2FA\Contracts\Google2FA; +use Pterodactyl\Contracts\Repository\UserRepositoryInterface; +use Pterodactyl\Models\User; +use Pterodactyl\Services\Users\TwoFactorSetupService; +use Tests\TestCase; + +class TwoFactorSetupServiceTest extends TestCase +{ + /** + * @var \Illuminate\Contracts\Config\Repository + */ + protected $config; + + /** + * @var \PragmaRX\Google2FA\Contracts\Google2FA + */ + protected $google2FA; + + /** + * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface + */ + protected $repository; + + /** + * @var \Pterodactyl\Services\Users\TwoFactorSetupService + */ + protected $service; + + /** + * Setup tests. + */ + public function setUp() + { + parent::setUp(); + + $this->config = m::mock(Repository::class); + $this->google2FA = m::mock(Google2FA::class); + $this->repository = m::mock(UserRepositoryInterface::class); + + $this->service = new TwoFactorSetupService($this->config, $this->google2FA, $this->repository); + } + + /** + * Test that the correct data is returned. + */ + public function testSecretAndImageAreReturned() + { + $model = factory(User::class)->make(); + + $this->google2FA->shouldReceive('generateSecretKey')->withNoArgs()->once()->andReturn('secretKey'); + $this->config->shouldReceive('get')->with('app.name')->once()->andReturn('CompanyName'); + $this->google2FA->shouldReceive('getQRCodeGoogleUrl')->with('CompanyName', $model->email, 'secretKey') + ->once()->andReturn('http://url.com'); + $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() + ->shouldReceive('update')->with($model->id, ['totp_secret' => 'secretKey'])->once()->andReturnNull(); + + $response = $this->service->handle($model); + $this->assertNotEmpty($response); + $this->assertArrayHasKey('qrImage', $response); + $this->assertArrayHasKey('secret', $response); + $this->assertEquals('http://url.com', $response['qrImage']); + $this->assertEquals('secretKey', $response['secret']); + } + + /** + * Test that an integer can be passed in place of the user model. + */ + public function testIntegerCanBePassedInPlaceOfUserModel() + { + $model = factory(User::class)->make(); + + $this->repository->shouldReceive('find')->with($model->id)->once()->andReturn($model); + $this->google2FA->shouldReceive('generateSecretKey')->withNoArgs()->once()->andReturnNull(); + $this->config->shouldReceive('get')->with('app.name')->once()->andReturnNull(); + $this->google2FA->shouldReceive('getQRCodeGoogleUrl')->once()->andReturnNull(); + $this->repository->shouldReceive('withoutFresh->update')->once()->andReturnNull(); + + $this->assertTrue(is_array($this->service->handle($model->id))); + } +}