diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 1b9632280..1f1bbd56c 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -27,27 +27,30 @@ namespace Pterodactyl\Http\Controllers\Admin; use Alert; use Illuminate\Http\Request; -use Pterodactyl\Contracts\Repositories\UserInterface; +use Prologue\Alerts\AlertsMessageBag; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Requests\Admin\UserFormRequest; use Pterodactyl\Models\User; use Pterodactyl\Http\Controllers\Controller; +use Pterodactyl\Services\UserService; class UserController extends Controller { /** - * @var \Pterodactyl\Repositories\Eloquent\UserRepository + * @var \Pterodactyl\Services\UserService */ - protected $repository; + protected $service; /** * UserController constructor. * - * @param \Pterodactyl\Contracts\Repositories\UserInterface $repository + * @param \Prologue\Alerts\AlertsMessageBag $alert + * @param \Pterodactyl\Services\UserService $service */ - public function __construct(UserInterface $repository) + public function __construct(AlertsMessageBag $alert, UserService $service) { - $this->repository = $repository; + $this->alert = $alert; + $this->service = $service; } /** @@ -58,7 +61,7 @@ class UserController extends Controller */ public function index(Request $request) { - $users = $this->repository->withCount('servers', 'subuserOf'); + $users = User::withCount('servers', 'subuserOf'); if (! is_null($request->input('query'))) { $users->search($request->input('query')); @@ -97,15 +100,17 @@ class UserController extends Controller * * @param \Pterodactyl\Models\User $user * @return \Illuminate\Http\RedirectResponse + * + * @throws \Exception */ public function delete(User $user) { try { - $this->repository->delete($user->id); + $this->service->delete($user); return redirect()->route('admin.users'); } catch (DisplayException $ex) { - Alert::danger($ex->getMessage())->flash(); + $this->alert->danger($ex->getMessage())->flash(); } return redirect()->route('admin.users.view', $user->id); @@ -116,11 +121,14 @@ class UserController extends Controller * * @param \Pterodactyl\Http\Requests\Admin\UserFormRequest $request * @return \Illuminate\Http\RedirectResponse + * + * @throws \Exception + * @throws \Throwable */ public function store(UserFormRequest $request) { - $user = $this->repository->create($request->normalize()); - Alert::success('Account has been successfully created.')->flash(); + $user = $this->service->create($request->normalize()); + $this->alert->success('Account has been successfully created.')->flash(); return redirect()->route('admin.users.view', $user->id); } @@ -134,7 +142,8 @@ class UserController extends Controller */ public function update(UserFormRequest $request, User $user) { - $this->repository->update($user->id, $request->normalize()); + $this->service->update($user, $request->normalize()); + $this->alert->success('User account has been updated.')->flash(); return redirect()->route('admin.users.view', $user->id); } @@ -147,7 +156,7 @@ class UserController extends Controller */ public function json(Request $request) { - return $this->repository->search($request->input('q'))->all([ + return User::search($request->input('q'))->all([ 'id', 'email', 'username', 'name_first', 'name_last', ])->transform(function ($item) { $item->md5 = md5(strtolower($item->email)); diff --git a/app/Http/Requests/Admin/UserFormRequest.php b/app/Http/Requests/Admin/UserFormRequest.php index 10d559771..09605a31a 100644 --- a/app/Http/Requests/Admin/UserFormRequest.php +++ b/app/Http/Requests/Admin/UserFormRequest.php @@ -25,7 +25,6 @@ namespace Pterodactyl\Http\Requests\Admin; use Pterodactyl\Models\User; -use Illuminate\Support\Facades\Hash; use Pterodactyl\Contracts\Repositories\UserInterface; class UserFormRequest extends AdminFormRequest @@ -45,21 +44,21 @@ class UserFormRequest extends AdminFormRequest { if ($this->method() === 'PATCH') { return [ - 'email' => 'sometimes|required|email|unique:users,email,' . $this->user->id, - 'username' => 'sometimes|required|alpha_dash|between:1,255|unique:users,username, ' . $this->user->id . '|' . User::USERNAME_RULES, - 'name_first' => 'sometimes|required|string|between:1,255', - 'name_last' => 'sometimes|required|string|between:1,255', + 'email' => 'required|email|unique:users,email,' . $this->user->id, + 'username' => 'required|alpha_dash|between:1,255|unique:users,username, ' . $this->user->id . '|' . User::USERNAME_RULES, + 'name_first' => 'required|string|between:1,255', + 'name_last' => 'required|string|between:1,255', 'password' => 'sometimes|nullable|' . User::PASSWORD_RULES, - 'root_admin' => 'sometimes|required|boolean', - 'language' => 'sometimes|required|string|min:1|max:5', - 'use_totp' => 'sometimes|required|boolean', - 'totp_secret' => 'sometimes|required|size:16', + 'root_admin' => 'required|boolean', +// 'language' => 'sometimes|required|string|min:1|max:5', +// 'use_totp' => 'sometimes|required|boolean', +// 'totp_secret' => 'sometimes|required|size:16', ]; } return [ - 'email' => 'required|email|unique:users,email,' . $this->user->id, - 'username' => 'required|alpha_dash|between:1,255|unique:users,username,' . $this->user->id . '|' . User::USERNAME_RULES, + 'email' => 'required|email|unique:users,email', + 'username' => 'required|alpha_dash|between:1,255|unique:users,username|' . User::USERNAME_RULES, 'name_first' => 'required|string|between:1,255', 'name_last' => 'required|string|between:1,255', 'password' => 'sometimes|nullable|' . User::PASSWORD_RULES, @@ -70,8 +69,11 @@ class UserFormRequest extends AdminFormRequest public function normalize() { - if ($this->has('password')) { - $this->merge(['password' => Hash::make($this->input('password'))]); + if ($this->method === 'PATCH') { + return array_merge( + $this->intersect('password'), + $this->only(['email', 'username', 'name_first', 'name_last', 'root_admin']) + ); } return parent::normalize(); diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index f6e160264..e75c053bd 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -24,13 +24,9 @@ namespace Pterodactyl\Observers; -use DB; -use Hash; -use Carbon; use Pterodactyl\Events; use Pterodactyl\Models\User; -use Pterodactyl\Notifications\AccountCreated; -use Pterodactyl\Services\UuidService; +use Pterodactyl\Services\Components\UuidService; class UserObserver { @@ -49,7 +45,7 @@ class UserObserver */ public function creating(User $user) { - $user->uuid = $this->uuid->generate(); + $user->uuid = $this->uuid->generate('users', 'uuid'); event(new Events\User\Creating($user)); } @@ -62,22 +58,6 @@ class UserObserver */ public function created(User $user) { - dd($user); - if ($user->password === 'unset') { - $token = hash_hmac('sha256', str_random(40), config('app.key')); - DB::table('password_resets')->insert([ - 'email' => $user->email, - 'token' => Hash::make($token), - 'created_at' => Carbon::now()->toDateTimeString(), - ]); - } - - $user->notify(new AccountCreated([ - 'name' => $user->name_first, - 'username' => $user->username, - 'token' => (isset($token)) ? $token : null, - ])); - event(new Events\User\Created($user)); } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 00d40bdbd..6a8403ff1 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Providers; use Illuminate\Support\Facades\Route; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; +use Pterodactyl\Models\User; class RouteServiceProvider extends ServiceProvider { diff --git a/app/Services/UuidService.php b/app/Services/Components/UuidService.php similarity index 98% rename from app/Services/UuidService.php rename to app/Services/Components/UuidService.php index 2b043731a..468a97f89 100644 --- a/app/Services/UuidService.php +++ b/app/Services/Components/UuidService.php @@ -22,7 +22,7 @@ * SOFTWARE. */ -namespace Pterodactyl\Services; +namespace Pterodactyl\Services\Components; use DB; use Uuid; diff --git a/app/Services/UserService.php b/app/Services/UserService.php new file mode 100644 index 000000000..81119f11b --- /dev/null +++ b/app/Services/UserService.php @@ -0,0 +1,187 @@ +. + * + * 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; + +use Illuminate\Config\Repository as ConfigRepository; +use Illuminate\Contracts\Auth\Guard; +use Illuminate\Contracts\Hashing\Hasher; +use Illuminate\Database\Connection; +use Pterodactyl\Exceptions\DisplayException; +use Pterodactyl\Models\User; +use Pterodactyl\Notifications\AccountCreated; +use Pterodactyl\Services\Components\UuidService; + +class UserService +{ + const HMAC_ALGO = 'sha256'; + + /** + * @var \Illuminate\Config\Repository + */ + protected $config; + + /** + * @var \Illuminate\Database\Connection + */ + protected $database; + + /** + * @var \Illuminate\Contracts\Auth\Guard + */ + protected $guard; + + /** + * @var \Illuminate\Contracts\Hashing\Hasher + */ + protected $hasher; + + /** + * @var \Pterodactyl\Services\Components\UuidService + */ + protected $uuid; + + /** + * UserService constructor. + * + * @param \Illuminate\Config\Repository $config + * @param \Illuminate\Database\Connection $database + * @param \Illuminate\Contracts\Auth\Guard $guard + * @param \Illuminate\Contracts\Hashing\Hasher $hasher + * @param \Pterodactyl\Services\Components\UuidService $uuid + */ + public function __construct( + ConfigRepository $config, + Connection $database, + Guard $guard, + Hasher $hasher, + UuidService $uuid + ) { + $this->config = $config; + $this->database = $database; + $this->guard = $guard; + $this->hasher = $hasher; + $this->uuid = $uuid; + } + + /** + * Assign a temporary password to an account and return an authentication token to + * email to the user for resetting their password. + * + * @param \Pterodactyl\Models\User $user + * @return string + */ + protected function assignTemporaryPassword(User $user) + { + $user->password = $this->hasher->make(str_random(30)); + + $token = hash_hmac(self::HMAC_ALGO, str_random(40), $this->config->get('app.key')); + + $this->database->table('password_resets')->insert([ + 'email' => $user->email, + 'token' => $this->hasher->make($token), + ]); + + return $token; + } + + /** + * Create a new user on the system. + * + * @param array $data + * @return \Pterodactyl\Models\User + * + * @throws \Exception + * @throws \Throwable + */ + public function create(array $data) + { + if (array_key_exists('password', $data) && ! empty($data['password'])) { + $data['password'] = $this->hasher->make($data['password']); + } + + $user = new User; + $user->fill($data); + + // Persist the data + $token = $this->database->transaction(function () use ($user) { + if (empty($user->password)) { + $token = $this->assignTemporaryPassword($user); + } + + $user->save(); + + return $token ?? null; + }); + + $user->notify(new AccountCreated([ + 'name' => $user->name_first, + 'username' => $user->username, + 'token' => $token, + ])); + + return $user; + } + + /** + * Update the user model. + * + * @param \Pterodactyl\Models\User $user + * @param array $data + * @return \Pterodactyl\Models\User + */ + public function update(User $user, array $data) + { + if (isset($data['password'])) { + $data['password'] = $this->hasher->make($data['password']); + } + + $user->fill($data)->save(); + + return $user; + } + + /** + * @param \Pterodactyl\Models\User $user + * @return bool|null + * @throws \Exception + * @throws \Pterodactyl\Exceptions\DisplayException + */ + public function delete(User $user) + { + if ($user->servers()->count() > 0) { + throw new DisplayException('Cannot delete an account that has active servers attached to it.'); + } + + if ($this->guard->check() && $this->guard->id() === $user->id) { + throw new DisplayException('You cannot delete your own account.'); + } + + if ($user->servers()->count() > 0) { + throw new DisplayException('Cannot delete an account that has active servers attached to it.'); + } + + return $user->delete(); + } +} diff --git a/config/app.php b/config/app.php index 0b6dbeeb6..3c3fb772d 100644 --- a/config/app.php +++ b/config/app.php @@ -163,7 +163,6 @@ return [ Pterodactyl\Providers\AppServiceProvider::class, Pterodactyl\Providers\AuthServiceProvider::class, Pterodactyl\Providers\EventServiceProvider::class, - Pterodactyl\Providers\RepositoryServiceProvider::class, Pterodactyl\Providers\RouteServiceProvider::class, Pterodactyl\Providers\MacroServiceProvider::class, Pterodactyl\Providers\PhraseAppTranslationProvider::class, diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 7295947ce..fe45d9de9 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -13,9 +13,21 @@ $factory->define(Pterodactyl\Models\User::class, function (Faker\Generator $faker) { return [ - 'name' => $faker->name, - 'email' => $faker->email, - 'password' => bcrypt(str_random(10)), - 'remember_token' => str_random(10), + 'external_id' => null, + 'uuid' => $faker->uuid, + 'username' => $faker->userName, + 'email' => $faker->safeEmail, + 'name_first' => $faker->firstName, + 'name_last' => $faker->lastName, + 'password' => bcrypt('password'), + 'language' => 'en', + 'root_admin' => false, + 'use_totp' => false, + ]; +}); + +$factory->state(Pterodactyl\Models\User::class, 'admin', function () { + return [ + 'root_admin' => true, ]; }); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 000000000..9ecda835a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,31 @@ + + + + + ./tests/Feature + + + + ./tests/Unit + + + + + ./app + + + + + + + + + diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php new file mode 100644 index 000000000..547152f6a --- /dev/null +++ b/tests/CreatesApplication.php @@ -0,0 +1,22 @@ +make(Kernel::class)->bootstrap(); + + return $app; + } +} diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index 2c3a83c83..000000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -visit('/') - ->see('Laravel 5'); - } -} diff --git a/tests/Feature/Services/UserServiceTest.php b/tests/Feature/Services/UserServiceTest.php new file mode 100644 index 000000000..1f95b53b1 --- /dev/null +++ b/tests/Feature/Services/UserServiceTest.php @@ -0,0 +1,156 @@ +. + * + * 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\Feature\Services; + +use Illuminate\Support\Facades\Notification; +use Pterodactyl\Models\User; +use Pterodactyl\Notifications\AccountCreated; +use Pterodactyl\Services\UserService; +use Tests\TestCase; + +class UserServiceTest extends TestCase +{ + protected $service; + + public function setUp() + { + parent::setUp(); + + $this->service = $this->app->make(UserService::class); + } + + public function testShouldReturnNewUserWithValidData() + { + Notification::fake(); + + $user = $this->service->create([ + 'email' => 'test_account@example.com', + 'username' => 'test_account', + 'password' => 'test_password', + 'name_first' => 'Test', + 'name_last' => 'Account', + 'root_admin' => false, + ]); + + $this->assertNotNull($user->uuid); + $this->assertNotEquals($user->password, 'test_password'); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'uuid' => $user->uuid, + 'email' => 'test_account@example.com', + 'root_admin' => '0', + ]); + + Notification::assertSentTo($user, AccountCreated::class, function ($notification) use ($user) { + $this->assertEquals($user->username, $notification->user->username); + $this->assertNull($notification->user->token); + + return true; + }); + } + + public function testShouldReturnNewUserWithPasswordTokenIfNoPasswordProvided() + { + Notification::fake(); + + $user = $this->service->create([ + 'email' => 'test_account@example.com', + 'username' => 'test_account', + 'name_first' => 'Test', + 'name_last' => 'Account', + 'root_admin' => false, + ]); + + $this->assertNotNull($user->uuid); + $this->assertNotNull($user->password); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'uuid' => $user->uuid, + 'email' => 'test_account@example.com', + 'root_admin' => '0', + ]); + + Notification::assertSentTo($user, AccountCreated::class, function ($notification) use ($user) { + $this->assertEquals($user->username, $notification->user->username); + $this->assertNotNull($notification->user->token); + + $this->assertDatabaseHas('password_resets', [ + 'email' => $user->email, + ]); + + return true; + }); + } + + public function testShouldUpdateUserModelInDatabase() + { + $user = factory(User::class)->create(); + + $response = $this->service->update($user, [ + 'email' => 'test_change@example.com', + 'password' => 'test_password', + ]); + + $this->assertInstanceOf(User::class, $response); + $this->assertEquals('test_change@example.com', $response->email); + $this->assertNotEquals($response->password, 'test_password'); + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'email' => 'test_change@example.com', + ]); + } + + public function testShouldDeleteUserFromDatabase() + { + $user = factory(User::class)->create(); + $service = $this->app->make(UserService::class); + + $response = $service->delete($user); + + $this->assertTrue($response); + $this->assertDatabaseMissing('users', [ + 'id' => $user->id, + 'uuid' => $user->uuid, + ]); + } + + /** + * @expectedException \Pterodactyl\Exceptions\DisplayException + */ + public function testShouldBlockDeletionOfOwnAccount() + { + $user = factory(User::class)->create(); + $this->actingAs($user); + + $this->service->delete($user); + } + + public function testAlgoForHashingShouldBeRegistered() + { + $this->assertArrayHasKey(UserService::HMAC_ALGO, array_flip(hash_algos())); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 916b8ad13..c1ac8acc8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,25 +1,11 @@ make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); - - return $app; - } + use CreatesApplication, DatabaseTransactions; } diff --git a/app/Providers/RepositoryServiceProvider.php b/tests/Unit/Services/UserServiceTest.php similarity index 55% rename from app/Providers/RepositoryServiceProvider.php rename to tests/Unit/Services/UserServiceTest.php index 745e6d05e..c45474698 100644 --- a/app/Providers/RepositoryServiceProvider.php +++ b/tests/Unit/Services/UserServiceTest.php @@ -1,5 +1,5 @@ . * @@ -22,19 +22,40 @@ * SOFTWARE. */ -namespace Pterodactyl\Providers; +namespace Tests\Unit\Services; -use Illuminate\Support\ServiceProvider; -use Pterodactyl\Contracts\Repositories\UserInterface; -use Pterodactyl\Repositories\Eloquent\UserRepository; +use Illuminate\Config\Repository; +use Illuminate\Contracts\Auth\Guard; +use Illuminate\Contracts\Hashing\Hasher; +use Illuminate\Database\Connection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Queue; +use \Mockery as m; +use Pterodactyl\Models\User; +use Pterodactyl\Services\Components\UuidService; +use Pterodactyl\Services\UserService; +use Tests\TestCase; -class RepositoryServiceProvider extends ServiceProvider +class UserServiceTest extends TestCase { - /** - * Register the repositories. - */ - public function register() + protected $service; + + public function setUp() { - $this->app->bind(UserInterface::class, UserRepository::class); + parent::setUp(); + + $this->config = m::mock(Repository::class); + $this->database = m::mock(Connection::class); + $this->guard = m::mock(Guard::class); + $this->hasher = m::mock(Hasher::class); + $this->uuid = m::mock(UuidService::class); + + $this->service = new UserService( + $this->config, + $this->database, + $this->guard, + $this->hasher, + $this->uuid + );; } }