diff --git a/.gitignore b/.gitignore index af964568..2db2eac1 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ yarn-error.log # PHP /vendor .php_cs.cache +.phpunit.result.cache #-------------------# # Operating Systems # diff --git a/app/Http/Controllers/Api/Client/WebauthnController.php b/app/Http/Controllers/Api/Client/WebauthnController.php index f988c7a1..c88d8eba 100644 --- a/app/Http/Controllers/Api/Client/WebauthnController.php +++ b/app/Http/Controllers/Api/Client/WebauthnController.php @@ -6,7 +6,7 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use LaravelWebauthn\Facades\Webauthn; -use LaravelWebauthn\Models\WebauthnKey; +use Pterodactyl\Models\WebauthnKey; use Webauthn\PublicKeyCredentialCreationOptions; use Illuminate\Database\Eloquent\ModelNotFoundException; use Pterodactyl\Transformers\Api\Client\WebauthnKeyTransformer; diff --git a/app/Models/User.php b/app/Models/User.php index 57b908e7..20f45c09 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,7 +5,6 @@ namespace Pterodactyl\Models; use Pterodactyl\Rules\Username; use Illuminate\Support\Collection; use Illuminate\Auth\Authenticatable; -use LaravelWebauthn\Models\WebauthnKey; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Auth\Passwords\CanResetPassword; @@ -42,7 +41,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\UserSSHKey|\Illuminate\Database\Eloquent\Collection $sshKeys * @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens - * @property \LaravelWebauthn\Models\WebauthnKey[]|\Illuminate\Database\Eloquent\Collection $webauthnKeys + * @property \Pterodactyl\Models\WebauthnKey[]|\Illuminate\Database\Eloquent\Collection $webauthnKeys */ class User extends Model implements AuthenticatableContract, diff --git a/app/Models/WebauthnKey.php b/app/Models/WebauthnKey.php new file mode 100644 index 00000000..78050802 --- /dev/null +++ b/app/Models/WebauthnKey.php @@ -0,0 +1,16 @@ +belongsTo(User::class); + } +} diff --git a/app/Transformers/Api/Client/WebauthnKeyTransformer.php b/app/Transformers/Api/Client/WebauthnKeyTransformer.php index b76b3a2d..5a6056a8 100644 --- a/app/Transformers/Api/Client/WebauthnKeyTransformer.php +++ b/app/Transformers/Api/Client/WebauthnKeyTransformer.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Transformers\Api\Client; -use LaravelWebauthn\Models\WebauthnKey; +use Pterodactyl\Models\WebauthnKey; class WebauthnKeyTransformer extends BaseClientTransformer { diff --git a/config/database.php b/config/database.php index 68f65a2e..681bd3e1 100644 --- a/config/database.php +++ b/config/database.php @@ -77,8 +77,15 @@ return [ 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', - 'strict' => false, - 'timezone' => env('DB_TIMEZONE', Time::getMySQLTimezoneOffset(env('APP_TIMEZONE', 'UTC'))), + 'strict' => env('TESTING_DB_STRICT_MODE', false), + 'timezone' => env('TESTING_DB_TIMEZONE', Time::getMySQLTimezoneOffset(env('APP_TIMEZONE', 'UTC'))), + 'sslmode' => env('TESTING_DB_SSLMODE', 'prefer'), + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + PDO::MYSQL_ATTR_SSL_CERT => env('MYSQL_ATTR_SSL_CERT'), + PDO::MYSQL_ATTR_SSL_KEY => env('MYSQL_ATTR_SSL_KEY'), + PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => env('MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', true), + ]) : [], ], ], diff --git a/database/Factories/WebauthnKeyFactory.php b/database/Factories/WebauthnKeyFactory.php new file mode 100644 index 00000000..6b7d4ebf --- /dev/null +++ b/database/Factories/WebauthnKeyFactory.php @@ -0,0 +1,24 @@ + $this->faker->name, + ]; + } +} diff --git a/tests/Traits/Http/RequestMockHelpers.php b/tests/Traits/Http/RequestMockHelpers.php index 668d809e..fe21c480 100644 --- a/tests/Traits/Http/RequestMockHelpers.php +++ b/tests/Traits/Http/RequestMockHelpers.php @@ -43,6 +43,7 @@ trait RequestMockHelpers */ public function generateRequestUserModel(array $args = []): User { + /** @var \Pterodactyl\Models\User $user */ $user = User::factory()->make($args); $this->setRequestUserModel($user); @@ -70,8 +71,9 @@ trait RequestMockHelpers /** * Set the active request object to be an instance of a mocked request. */ - protected function buildRequestMock() + protected function buildRequestMock($uri = '/') { +// $this->request = Request::create($uri); $this->request = m::mock($this->requestMockClass); if (!$this->request instanceof Request) { throw new InvalidArgumentException('Request mock class must be an instance of ' . Request::class . ' when mocked.'); diff --git a/tests/Unit/Http/Middleware/RequireTwoFactorAuthenticationTest.php b/tests/Unit/Http/Middleware/RequireTwoFactorAuthenticationTest.php new file mode 100644 index 00000000..57bb74f9 --- /dev/null +++ b/tests/Unit/Http/Middleware/RequireTwoFactorAuthenticationTest.php @@ -0,0 +1,317 @@ +alerts = m::mock(AlertsMessageBag::class); + } + + public function testNoRequirement__userWithout_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE); + + $user = $this->generateRequestUserModel(['use_totp' => false]); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testNoRequirement__userWithTotp_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE); + + $user = $this->generateRequestUserModel(['use_totp' => true]); + + $this->assertTrue($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testNoRequirement__userWithWebauthn_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE); + + /** @var \Pterodactyl\Models\User $user */ + $user = User::factory() + ->has(WebauthnKey::factory()->count(1)) + ->create(['use_totp' => false]); + $this->setRequestUserModel($user); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertNotEmpty($user->webauthnKeys); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testNoRequirement__guestUser() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_NONE); + + $this->setRequestUserModel(); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/auth/login'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn('auth.login'); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAllRequirement__userWithout_2fa() + { + $this->expectException(TwoFactorAuthRequiredException::class); + + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL); + + $user = $this->generateRequestUserModel(['use_totp' => false]); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAllRequirement__userWithTotp_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL); + + $user = $this->generateRequestUserModel(['use_totp' => true]); + + $this->assertTrue($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAllRequirement__ruserWithWebauthn_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL); + + /** @var \Pterodactyl\Models\User $user */ + $user = User::factory() + ->has(WebauthnKey::factory()->count(1)) + ->create(['use_totp' => false]); + $this->setRequestUserModel($user); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertNotEmpty($user->webauthnKeys); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAllRequirement__guestUser() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ALL); + + $this->setRequestUserModel(); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/auth/login'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn('auth.login'); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAdminRequirement__userWithout_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); + + $user = $this->generateRequestUserModel(['use_totp' => false]); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertFalse($user->root_admin); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAdminRequirement__adminUserWithout_2fa() + { + $this->expectException(TwoFactorAuthRequiredException::class); + + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); + + $user = $this->generateRequestUserModel(['use_totp' => false, 'root_admin' => true]); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertTrue($user->root_admin); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAdminRequirement__userWithTotp_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); + + $user = $this->generateRequestUserModel(['use_totp' => true]); + + $this->assertTrue($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertFalse($user->root_admin); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAdminRequirement__adminUserWithTotp_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); + + $user = $this->generateRequestUserModel(['use_totp' => true, 'root_admin' => true]); + + $this->assertTrue($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertTrue($user->root_admin); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAdminRequirement__userWithWebauthn_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); + + /** @var \Pterodactyl\Models\User $user */ + $user = User::factory()->has(WebauthnKey::factory()->count(1))->create(['use_totp' => false]); + $this->setRequestUserModel($user); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertFalse($user->root_admin); + $this->assertNotEmpty($user->webauthnKeys); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAdminRequirement__adminUserWithWebauthn_2fa() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); + + /** @var \Pterodactyl\Models\User $user */ + $user = User::factory() + ->has(WebauthnKey::factory()->count(1)) + ->create(['use_totp' => false, 'root_admin' => true]); + $this->setRequestUserModel($user); + + $this->assertFalse($user->use_totp); + $this->assertEmpty($user->totp_secret); + $this->assertEmpty($user->totp_authenticated_at); + $this->assertTrue($user->root_admin); + $this->assertNotEmpty($user->webauthnKeys); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn(null); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + public function testAdminRequirement__guestUser() + { + // Disable the 2FA requirement + config()->set('pterodactyl.auth.2fa_required', RequireTwoFactorAuthentication::LEVEL_ADMIN); + + $this->setRequestUserModel(); + + $this->request->shouldReceive('getRequestUri')->withNoArgs()->andReturn('/auth/login'); + $this->request->shouldReceive('route->getName')->withNoArgs()->andReturn('auth.login'); + $this->request->shouldReceive('isJson')->withNoArgs()->andReturn(true); + + $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); + } + + private function getMiddleware(): RequireTwoFactorAuthentication + { + return new RequireTwoFactorAuthentication($this->alerts); + } +}