diff --git a/app/Extensions/WebauthnTwoFAuthUserProvider.php b/app/Extensions/WebauthnTwoFAuthUserProvider.php index e4a2956a..d059456b 100644 --- a/app/Extensions/WebauthnTwoFAuthUserProvider.php +++ b/app/Extensions/WebauthnTwoFAuthUserProvider.php @@ -21,7 +21,7 @@ class WebauthnTwoFAuthUserProvider extends WebAuthnUserProvider return $this->validateWebAuthn(); } - // If the user disabled the fallback is enabled, we will validate the credential password. + // If the user disabled the fallback, we will validate the credential password. return $user->preferences['useWebauthnOnly'] == false && EloquentUserProvider::validateCredentials($user, $credentials); } } diff --git a/app/Policies/GroupPolicy.php b/app/Policies/GroupPolicy.php index f0e2882d..c6133250 100644 --- a/app/Policies/GroupPolicy.php +++ b/app/Policies/GroupPolicy.php @@ -17,10 +17,10 @@ class GroupPolicy * @param \App\Models\User $user * @return \Illuminate\Auth\Access\Response|bool */ - public function viewAny(User $user) - { - return false; - } + // public function viewAny(User $user) + // { + // return false; + // } /** * Determine whether the user can view the model. @@ -48,19 +48,19 @@ class GroupPolicy * @param \Illuminate\Support\Collection $groups * @return \Illuminate\Auth\Access\Response|bool */ - public function viewEach(User $user, Group $group, $groups) - { - $can = $this->isOwnerOfEach($user, $groups); + // public function viewEach(User $user, Group $group, $groups) + // { + // $can = $this->isOwnerOfEach($user, $groups); - if (! $can) { - $ids = $groups->map(function ($group, $key) { - return $group->id; - }); - Log::notice(sprintf('User ID #%s cannot view all groups in IDs #%s', $user->id, implode(',', $ids->toArray()))); - } + // if (! $can) { + // $ids = $groups->map(function ($group, $key) { + // return $group->id; + // }); + // Log::notice(sprintf('User ID #%s cannot view all groups in IDs #%s', $user->id, implode(',', $ids->toArray()))); + // } - return $can; - } + // return $can; + // } /** * Determine whether the user can create models. @@ -101,19 +101,19 @@ class GroupPolicy * @param \Illuminate\Support\Collection $groups * @return \Illuminate\Auth\Access\Response|bool */ - public function updateEach(User $user, Group $group, $groups) - { - $can = $this->isOwnerOfEach($user, $groups); + // public function updateEach(User $user, Group $group, $groups) + // { + // $can = $this->isOwnerOfEach($user, $groups); - if (! $can) { - $ids = $groups->map(function ($group, $key) { - return $group->id; - }); - Log::notice(sprintf('User ID #%s cannot update all groups in IDs #%s', $user->id, implode(',', $ids->toArray()))); - } + // if (! $can) { + // $ids = $groups->map(function ($group, $key) { + // return $group->id; + // }); + // Log::notice(sprintf('User ID #%s cannot update all groups in IDs #%s', $user->id, implode(',', $ids->toArray()))); + // } - return $can; - } + // return $can; + // } /** * Determine whether the user can delete the model. @@ -141,19 +141,19 @@ class GroupPolicy * @param \Illuminate\Support\Collection $groups * @return \Illuminate\Auth\Access\Response|bool */ - public function deleteEach(User $user, Group $group, $groups) - { - $can = $this->isOwnerOfEach($user, $groups); + // public function deleteEach(User $user, Group $group, $groups) + // { + // $can = $this->isOwnerOfEach($user, $groups); - if (! $can) { - $ids = $groups->map(function ($group, $key) { - return $group->id; - }); - Log::notice(sprintf('User ID #%s cannot delete all groups in IDs #%s', $user->id, implode(',', $ids->toArray()))); - } + // if (! $can) { + // $ids = $groups->map(function ($group, $key) { + // return $group->id; + // }); + // Log::notice(sprintf('User ID #%s cannot delete all groups in IDs #%s', $user->id, implode(',', $ids->toArray()))); + // } - return $can; - } + // return $can; + // } /** * Determine whether the user can restore the model. @@ -162,10 +162,10 @@ class GroupPolicy * @param \App\Models\Group $group * @return \Illuminate\Auth\Access\Response|bool */ - public function restore(User $user, Group $group) - { - return $this->isOwnerOf($user, $group); - } + // public function restore(User $user, Group $group) + // { + + // } /** * Determine whether the user can permanently delete the model. @@ -174,8 +174,8 @@ class GroupPolicy * @param \App\Models\Group $group * @return \Illuminate\Auth\Access\Response|bool */ - public function forceDelete(User $user, Group $group) - { - return $this->isOwnerOf($user, $group); - } + // public function forceDelete(User $user, Group $group) + // { + + // } } diff --git a/app/Policies/TwoFAccountPolicy.php b/app/Policies/TwoFAccountPolicy.php index a705d846..6e43423f 100644 --- a/app/Policies/TwoFAccountPolicy.php +++ b/app/Policies/TwoFAccountPolicy.php @@ -17,10 +17,10 @@ class TwoFAccountPolicy * @param \App\Models\User $user * @return \Illuminate\Auth\Access\Response|bool */ - public function viewAny(User $user) - { - return false; - } + // public function viewAny(User $user) + // { + // return false; + // } /** * Determine whether the user can view the model. @@ -162,10 +162,10 @@ class TwoFAccountPolicy * @param \App\Models\TwoFAccount $twofaccount * @return \Illuminate\Auth\Access\Response|bool */ - public function restore(User $user, TwoFAccount $twofaccount) - { - return $this->isOwnerOf($user, $twofaccount); - } + // public function restore(User $user, TwoFAccount $twofaccount) + // { + + // } /** * Determine whether the user can permanently delete the model. @@ -174,8 +174,8 @@ class TwoFAccountPolicy * @param \App\Models\TwoFAccount $twofaccount * @return \Illuminate\Auth\Access\Response|bool */ - public function forceDelete(User $user, TwoFAccount $twofaccount) - { - return $this->isOwnerOf($user, $twofaccount); - } + // public function forceDelete(User $user, TwoFAccount $twofaccount) + // { + + // } } diff --git a/app/Services/Migrators/TwoFAuthMigrator.php b/app/Services/Migrators/TwoFAuthMigrator.php index b0b9379d..be9bdbf1 100644 --- a/app/Services/Migrators/TwoFAuthMigrator.php +++ b/app/Services/Migrators/TwoFAuthMigrator.php @@ -45,7 +45,7 @@ class TwoFAuthMigrator extends Migrator if (is_null($json)) { Log::error('2FAuth JSON migration data cannot be read'); - throw new InvalidMigrationDataException('2FAS Auth'); + throw new InvalidMigrationDataException('2FAuth'); } $twofaccounts = []; diff --git a/tests/Api/v1/Controllers/GroupControllerTest.php b/tests/Api/v1/Controllers/GroupControllerTest.php index bc91d65a..bbdc36c9 100644 --- a/tests/Api/v1/Controllers/GroupControllerTest.php +++ b/tests/Api/v1/Controllers/GroupControllerTest.php @@ -10,6 +10,9 @@ use Tests\FeatureTestCase; /** * @covers \App\Api\v1\Controllers\GroupController * @covers \App\Api\v1\Resources\GroupResource + * @covers \App\Listeners\ResetUsersPreference + * @covers \App\Policies\GroupPolicy + * @covers \App\Models\Group */ class GroupControllerTest extends FeatureTestCase { @@ -444,4 +447,27 @@ class GroupControllerTest extends FeatureTestCase 'message', ]); } + + /** + * @test + */ + public function test_destroy_group_resets_user_preferences() + { + // Set the default group to a specific one + $this->user['preferences->defaultGroup'] = $this->userGroupA->id; + // Set the active group + $this->user['preferences->activeGroup'] = $this->userGroupA->id; + $this->user->save(); + + $this->assertEquals($this->userGroupA->id, $this->user->preferences['defaultGroup']); + $this->assertEquals($this->userGroupA->id, $this->user->preferences['activeGroup']); + + $this->actingAs($this->user, 'api-guard') + ->json('DELETE', '/api/v1/groups/' . $this->userGroupA->id); + + $this->user->refresh(); + + $this->assertEquals(0, $this->user->preferences['defaultGroup']); + $this->assertEquals(0, $this->user->preferences['activeGroup']); + } } diff --git a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php index bc1f06fd..12b60671 100644 --- a/tests/Api/v1/Controllers/TwoFAccountControllerTest.php +++ b/tests/Api/v1/Controllers/TwoFAccountControllerTest.php @@ -15,10 +15,14 @@ use Tests\FeatureTestCase; /** * @covers \App\Api\v1\Controllers\TwoFAccountController + * @covers \App\Api\v1\Resources\TwoFAccountCollection * @covers \App\Api\v1\Resources\TwoFAccountReadResource * @covers \App\Api\v1\Resources\TwoFAccountStoreResource + * @covers \App\Api\v1\Resources\TwoFAccountExportResource + * @covers \App\Api\v1\Resources\TwoFAccountExportCollection * @covers \App\Providers\MigrationServiceProvider * @covers \App\Providers\TwoFAuthServiceProvider + * @covers \App\Policies\TwoFAccountPolicy */ class TwoFAccountControllerTest extends FeatureTestCase { @@ -91,6 +95,27 @@ class TwoFAccountControllerTest extends FeatureTestCase 'counter', ]; + private const VALID_EXPORT_STRUTURE = [ + 'app', + 'schema', + 'datetime', + 'data' => [ + '*' => [ + 'otp_type', + 'account', + 'service', + 'icon', + 'icon_mime', + 'icon_file', + 'secret', + 'digits', + 'algorithm', + 'period', + 'counter', + 'legacy_uri', + ], ], + ]; + private const JSON_FRAGMENTS_FOR_CUSTOM_TOTP = [ 'service' => OtpTestData::SERVICE, 'account' => OtpTestData::ACCOUNT, @@ -868,6 +893,65 @@ class TwoFAccountControllerTest extends FeatureTestCase ]); } + /** + * @test + */ + public function test_export_returns_json_migration_resource() + { + $this->twofaccountA = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP); + $this->twofaccountB = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP); + + $this->actingAs($this->user, 'api-guard') + ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountA->id . ',' . $this->twofaccountB->id) + ->assertOk() + ->assertJsonStructure(self::VALID_EXPORT_STRUTURE) + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP) + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_HOTP); + } + + /** + * @test + */ + public function test_export_too_many_ids_returns_bad_request() + { + TwoFAccount::factory()->count(102)->for($this->user)->create(); + + $ids = DB::table('twofaccounts')->where('user_id', $this->user->id)->pluck('id')->implode(','); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('GET', '/api/v1/twofaccounts/export?ids=' . $ids) + ->assertStatus(400) + ->assertJsonStructure([ + 'message', + 'reason', + ]); + } + + /** + * @test + */ + public function test_export_missing_twofaccount_returns_existing_ones_only() + { + $this->twofaccountA = TwoFAccount::factory()->for($this->user)->create(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP); + + $response = $this->actingAs($this->user, 'api-guard') + ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountA->id . ',1000') + ->assertJsonFragment(self::JSON_FRAGMENTS_FOR_DEFAULT_TOTP); + } + + /** + * @test + */ + public function test_export_twofaccount_of_another_user_is_forbidden() + { + $response = $this->actingAs($this->user, 'api-guard') + ->json('GET', '/api/v1/twofaccounts/export?ids=' . $this->twofaccountC->id) + ->assertForbidden() + ->assertJsonStructure([ + 'message', + ]); + } + /** * @test */ @@ -1155,8 +1239,6 @@ class TwoFAccountControllerTest extends FeatureTestCase { TwoFAccount::factory()->count(3)->for($this->user)->create(); - $ids = DB::table('twofaccounts')->where('user_id', $this->user->id)->pluck('id')->implode(','); - $response = $this->actingAs($this->user, 'api-guard') ->json('DELETE', '/api/v1/twofaccounts?ids=' . $this->twofaccountA->id . ',' . $this->twofaccountB->id) ->assertNoContent(); diff --git a/tests/Data/MigrationTestData.php b/tests/Data/MigrationTestData.php index 0b233f1a..1ef0bf82 100644 --- a/tests/Data/MigrationTestData.php +++ b/tests/Data/MigrationTestData.php @@ -106,7 +106,7 @@ class MigrationTestData "name": "' . OtpTestData::ACCOUNT . '", "issuer": "' . OtpTestData::SERVICE . '", "note": "", - "icon": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPg0KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwMDBlOWMiLz4NCiAgIDxwYXRoIGQ9Im03MDAuMiA0NjYuNSA2MS4yLTEwNi4zYzIzLjYgNDEuNiAzNy4yIDg5LjggMzcuMiAxNDEuMSAwIDY4LjgtMjQuMyAxMzEuOS02NC43IDE4MS40SDU3NS44bDQ4LjctODQuNmgtNjQuNGw3NS44LTEzMS43IDY0LjMuMXptLTU1LjQtMTI1LjJMNDQ4LjMgNjgyLjVsLjEuMkgyOTAuMWMtNDAuNS00OS41LTY0LjctMTEyLjYtNjQuNy0xODEuNCAwLTUxLjQgMTMuNi05OS42IDM3LjMtMTQxLjNsMTAyLjUgMTc4LjIgMTEzLjMtMTk3aDE2Ni4zeiIgc3R5bGU9ImZpbGw6I2ZmZiIvPg0KPC9zdmc+DQo=", + "icon": "' . OtpTestData::ICON_SVG_DATA_ENCODED . '", "icon_mime": "image\/svg+xml", "info": { "secret": "' . OtpTestData::SECRET . '", @@ -463,4 +463,249 @@ class MigrationTestData }, "db": "1rX0ajzsxNbhN2hvnNCMBNooLlzqwz\/LMT3bNEIJjPH+zIvIbA6GVVPHLpna+yvjxLPKVkt1OQig==" }'; + + const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": null, + "icon_mime": null, + "icon_file": null, + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + }, + { + "otp_type": "hotp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": null, + "icon_mime": null, + "icon_file": null, + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": null, + "counter": ' . OtpTestData::COUNTER_CUSTOM . ', + "legacy_uri": "' . OtpTestData::HOTP_FULL_CUSTOM_URI_NO_IMG . '" + }, + { + "otp_type": "steamtotp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::STEAM . '", + "icon": null, + "icon_mime": null, + "icon_file": null, + "secret": "' . OtpTestData::STEAM_SECRET . '", + "digits": ' . OtpTestData::DIGITS_STEAM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::STEAM_TOTP_URI . '" + } + ] + }'; + + const VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": null, + "icon_mime": null, + "icon_file": null, + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + }, + { + "otp_type": "Xotp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": null, + "icon_mime": null, + "icon_file": null, + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + } + ] + }'; + + const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_SVG_ICON = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": "' . OtpTestData::ICON_SVG . '", + "icon_mime": "image\/svg+xml", + "icon_file": "' . OtpTestData::ICON_SVG_DATA_ENCODED . '", + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + } + ] + }'; + + const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_JPG_ICON = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": "' . OtpTestData::ICON_JPEG . '", + "icon_mime": "image\/svg+xml", + "icon_file": "' . OtpTestData::ICON_JPEG_DATA . '", + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + } + ] + }'; + + const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_PNG_ICON = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": "' . OtpTestData::ICON_PNG . '", + "icon_mime": "image\/svg+xml", + "icon_file": "' . OtpTestData::ICON_PNG_DATA . '", + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + } + ] + }'; + + const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_BMP_ICON = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": "' . OtpTestData::ICON_BMP . '", + "icon_mime": "image\/svg+xml", + "icon_file": "' . OtpTestData::ICON_BMP_DATA . '", + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + } + ] + }'; + + const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_WEBP_ICON = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": "' . OtpTestData::ICON_WEBP . '", + "icon_mime": "image\/svg+xml", + "icon_file": "' . OtpTestData::ICON_WEBP_DATA . '", + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + } + ] + }'; + + const VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_ICON = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + { + "otp_type": "totp", + "account": "' . OtpTestData::ACCOUNT . '", + "service": "' . OtpTestData::SERVICE . '", + "icon": "' . OtpTestData::ICON_PNG . '", + "icon_mime": "image\/gif", + "icon_file": "' . OtpTestData::ICON_PNG_DATA . '", + "secret": "' . OtpTestData::SECRET . '", + "digits": ' . OtpTestData::DIGITS_CUSTOM . ', + "algorithm": "' . OtpTestData::ALGORITHM_CUSTOM . '", + "period": ' . OtpTestData::PERIOD_CUSTOM . ', + "counter": null, + "legacy_uri": "' . OtpTestData::TOTP_FULL_CUSTOM_URI_NO_IMG . '" + } + ] + }'; + + const INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD = ' + { + "app": "2fauth_v3.4.1", + "schema": 1, + "datetime": "2022-12-14T14:53:06.173939Z", + "data": + [ + , + ] + }'; } diff --git a/tests/Data/OtpTestData.php b/tests/Data/OtpTestData.php index e3f92b48..3f647315 100644 --- a/tests/Data/OtpTestData.php +++ b/tests/Data/OtpTestData.php @@ -54,6 +54,8 @@ class OtpTestData const ICON_SVG_DATA = ''; + const ICON_SVG_DATA_ENCODED = 'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiPg0KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwMDBlOWMiLz4NCiAgIDxwYXRoIGQ9Im03MDAuMiA0NjYuNSA2MS4yLTEwNi4zYzIzLjYgNDEuNiAzNy4yIDg5LjggMzcuMiAxNDEuMSAwIDY4LjgtMjQuMyAxMzEuOS02NC43IDE4MS40SDU3NS44bDQ4LjctODQuNmgtNjQuNGw3NS44LTEzMS43IDY0LjMuMXptLTU1LjQtMTI1LjJMNDQ4LjMgNjgyLjVsLjEuMkgyOTAuMWMtNDAuNS00OS41LTY0LjctMTEyLjYtNjQuNy0xODEuNCAwLTUxLjQgMTMuNi05OS42IDM3LjMtMTQxLjNsMTAyLjUgMTc4LjIgMTEzLjMtMTk3aDE2Ni4zeiIgc3R5bGU9ImZpbGw6I2ZmZiIvPg0KPC9zdmc+DQo='; + const TOTP_FULL_CUSTOM_URI_NO_IMG = 'otpauth://totp/' . self::SERVICE . ':' . self::ACCOUNT . '?secret=' . self::SECRET . '&issuer=' . self::SERVICE . '&digits=' . self::DIGITS_CUSTOM . '&period=' . self::PERIOD_CUSTOM . '&algorithm=' . self::ALGORITHM_CUSTOM; const TOTP_FULL_CUSTOM_URI = self::TOTP_FULL_CUSTOM_URI_NO_IMG . '&image=' . self::IMAGE; diff --git a/tests/Feature/Http/Auth/RegisterControllerTest.php b/tests/Feature/Http/Auth/RegisterControllerTest.php index f0230032..6718590b 100644 --- a/tests/Feature/Http/Auth/RegisterControllerTest.php +++ b/tests/Feature/Http/Auth/RegisterControllerTest.php @@ -7,6 +7,7 @@ use Tests\FeatureTestCase; /** * @covers \App\Http\Controllers\Auth\RegisterController + * @covers \App\Http\Requests\UserStoreRequest */ class RegisterControllerTest extends FeatureTestCase { diff --git a/tests/Feature/Http/Auth/UserControllerTest.php b/tests/Feature/Http/Auth/UserControllerTest.php index ba905cfc..3b9610ef 100644 --- a/tests/Feature/Http/Auth/UserControllerTest.php +++ b/tests/Feature/Http/Auth/UserControllerTest.php @@ -2,7 +2,6 @@ namespace Tests\Feature\Http\Auth; -use App\Facades\Settings; use App\Models\Group; use App\Models\TwoFAccount; use App\Models\User; @@ -12,6 +11,7 @@ use Tests\FeatureTestCase; /** * @covers \App\Http\Controllers\Auth\UserController * @covers \App\Http\Middleware\RejectIfDemoMode + * @covers \App\Http\Requests\UserUpdateRequest */ class UserControllerTest extends FeatureTestCase { @@ -63,6 +63,33 @@ class UserControllerTest extends FeatureTestCase ]); } + /** + * @test + */ + public function test_update_user_with_uppercased_email_returns_success() + { + $response = $this->actingAs($this->user, 'web-guard') + ->json('PUT', '/user', [ + 'name' => self::NEW_USERNAME, + 'email' => strtoupper(self::NEW_EMAIL), + 'password' => self::PASSWORD, + ]) + ->assertOk() + ->assertExactJson([ + 'name' => self::NEW_USERNAME, + 'id' => $this->user->id, + 'email' => self::NEW_EMAIL, + 'is_admin' => false, + ]); + + $this->assertDatabaseHas('users', [ + 'name' => self::NEW_USERNAME, + 'id' => $this->user->id, + 'email' => self::NEW_EMAIL, + 'is_admin' => false, + ]); + } + /** * @test */ @@ -70,7 +97,7 @@ class UserControllerTest extends FeatureTestCase { Config::set('2fauth.config.isDemoApp', true); - $name = $this->user->name; + $name = $this->user->name; $email = $this->user->email; $response = $this->actingAs($this->user, 'web-guard') @@ -88,9 +115,9 @@ class UserControllerTest extends FeatureTestCase ]); $this->assertDatabaseHas('users', [ - 'name' => $name, - 'id' => $this->user->id, - 'email' => $email, + 'name' => $name, + 'id' => $this->user->id, + 'email' => $email, ]); } diff --git a/tests/Feature/Http/Auth/WebAuthnLoginControllerTest.php b/tests/Feature/Http/Auth/WebAuthnLoginControllerTest.php index c1370389..1a6628c0 100644 --- a/tests/Feature/Http/Auth/WebAuthnLoginControllerTest.php +++ b/tests/Feature/Http/Auth/WebAuthnLoginControllerTest.php @@ -12,6 +12,7 @@ use Tests\FeatureTestCase; /** * @covers \App\Http\Controllers\Auth\WebAuthnLoginController * @covers \App\Models\User + * @covers \App\Extensions\WebauthnTwoFAuthUserProvider */ class WebAuthnLoginControllerTest extends FeatureTestCase { @@ -120,8 +121,8 @@ class WebAuthnLoginControllerTest extends FeatureTestCase $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk() ->assertJsonFragment([ - 'message' => 'authenticated', - 'name' => $this->user->name, + 'message' => 'authenticated', + 'name' => $this->user->name, ]) ->assertJsonStructure([ 'message', @@ -175,6 +176,26 @@ class WebAuthnLoginControllerTest extends FeatureTestCase ]); } + /** + * @test + */ + public function test_legacy_login_is_rejected_when_webauthn_only_is_enable() + { + $this->user = User::factory()->create([ + 'email' => self::EMAIL, + ]); + + // Set to webauthn only + $this->user['preferences->useWebauthnOnly'] = true; + $this->user->save(); + + $response = $this->json('POST', '/user/login', [ + 'email' => self::EMAIL, + 'password' => 'password', + ]) + ->assertUnauthorized(); + } + /** * @test * @@ -215,8 +236,8 @@ class WebAuthnLoginControllerTest extends FeatureTestCase $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE) ->assertOk() ->assertJsonFragment([ - 'message' => 'authenticated', - 'name' => $this->user->name, + 'message' => 'authenticated', + 'name' => $this->user->name, ]) ->assertJsonStructure([ 'message', @@ -289,7 +310,7 @@ class WebAuthnLoginControllerTest extends FeatureTestCase false, )]); - for ($i=0; $i < $throttle - 1; $i++) { + for ($i = 0; $i < $throttle - 1; $i++) { $this->json('POST', '/webauthn/login', self::ASSERTION_RESPONSE_INVALID); } diff --git a/tests/Feature/Http/Middlewares/AdminOnlyMiddlewareTest.php b/tests/Feature/Http/Middlewares/AdminOnlyMiddlewareTest.php new file mode 100644 index 00000000..a132342b --- /dev/null +++ b/tests/Feature/Http/Middlewares/AdminOnlyMiddlewareTest.php @@ -0,0 +1,54 @@ +expectException(AuthorizationException::class); + + /** + * @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable + */ + $user = User::factory()->create(); + + $this->actingAs($user); + + $request = Request::create('/admin', 'GET'); + $middleware = new AdminOnly; + + $response = $middleware->handle($request, function () { + }); + } + + /** + * @test + */ + public function test_admins_pass() + { + /** + * @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable + */ + $admin = User::factory()->administrator()->create(); + + $this->actingAs($admin); + + $request = Request::create('/admin', 'GET'); + $middleware = new AdminOnly; + + $response = $middleware->handle($request, function () { + }); + + $this->assertNull($response); + } +} diff --git a/tests/Feature/Http/Requests/WebauthnAssertedRequestTest.php b/tests/Feature/Http/Requests/WebauthnAssertedRequestTest.php new file mode 100644 index 00000000..9adcad6a --- /dev/null +++ b/tests/Feature/Http/Requests/WebauthnAssertedRequestTest.php @@ -0,0 +1,80 @@ +rules()); + + $this->assertFalse($validator->fails()); + } + + /** + * Provide Valid data for validation test + */ + public function provideValidData() : array + { + return [ + [[ + 'id' => 'string', + 'rawId' => 'string', + 'type' => 'string', + 'response' => [ + 'clientDataJSON' => 'string', + 'authenticatorData' => 'string', + 'signature' => 'string', + 'userHandle' => null, + ], + 'email' => 'valid@email.com', + ]], + ]; + } + + /** + * @dataProvider provideInvalidData + */ + public function test_invalid_data(array $data) : void + { + $request = new WebauthnAssertedRequest(); + $validator = Validator::make($data, $request->rules()); + + $this->assertTrue($validator->fails()); + } + + /** + * Provide invalid data for validation test + */ + public function provideInvalidData() : array + { + return [ + [[ + 'email' => '', // required + ]], + [[ + 'email' => true, // email + ]], + [[ + 'email' => 0, // email + ]], + [[ + 'email' => 'sdfsdf@', // email + ]], + ]; + } +} diff --git a/tests/Feature/Models/UserModelTest.php b/tests/Feature/Models/UserModelTest.php new file mode 100644 index 00000000..f9de4113 --- /dev/null +++ b/tests/Feature/Models/UserModelTest.php @@ -0,0 +1,35 @@ +count(4)->create(); + + $firstAdmin = User::factory()->administrator()->create([ + 'name' => 'first', + ]); + $secondAdmin = User::factory()->administrator()->create([ + 'name' => 'secondAdmin', + ]); + + $admins = User::admins()->get(); + + $this->assertCount(2, $admins); + $this->assertEquals($admins[0]->is_admin, true); + $this->assertEquals($admins[1]->is_admin, true); + $this->assertEquals($admins[0]->name, $firstAdmin->name); + $this->assertEquals($admins[1]->name, $secondAdmin->name); + } +} diff --git a/tests/Unit/Exceptions/HandlerTest.php b/tests/Unit/Exceptions/HandlerTest.php index 39997c40..d005c5aa 100644 --- a/tests/Unit/Exceptions/HandlerTest.php +++ b/tests/Unit/Exceptions/HandlerTest.php @@ -174,4 +174,29 @@ class HandlerTest extends TestCase 'message', ]); } + + /** + * @test + */ + public function test_AccessDeniedException_returns_forbidden_json_response() + { + $request = $this->createMock(Request::class); + $instance = new Handler($this->createMock(Container::class)); + $class = new \ReflectionClass(Handler::class); + + $method = $class->getMethod('render'); + $method->setAccessible(true); + + $mockException = $this->createMock(\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException::class); + + $response = $method->invokeArgs($instance, [$request, $mockException]); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $response = \Illuminate\Testing\TestResponse::fromBaseResponse($response); + $response->assertStatus(403) + ->assertJsonStructure([ + 'message', + ]); + } } diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php index 3865fea6..4e43e7a1 100644 --- a/tests/Unit/HelpersTest.php +++ b/tests/Unit/HelpersTest.php @@ -135,4 +135,98 @@ class HelpersTest extends TestCase ], ]; } + + /** + * @test + * + * @dataProvider commaSeparatedToArrayProvider + */ + public function test_commaSeparatedToArray_returns_ids_in_array($str, $expected) + { + $array = Helpers::commaSeparatedToArray($str); + + $this->assertEquals($expected, $array); + } + + /** + * Provide data for cleanVersionNumber() tests + */ + public function commaSeparatedToArrayProvider() + { + return [ + 'NOMINAL' => [ + '1,2,3', + [1, 2, 3], + ], + 'DUPLICATE' => [ + '1,2,2,3', + [1, 2, 2, 3], + ], + ]; + } + + /** + * @test + * + * @dataProvider invalidCommaSeparatedToArrayProvider + */ + public function test_commaSeparatedToArray_returns_unchanged_ids($str, $expected) + { + $array = Helpers::commaSeparatedToArray($str); + + $this->assertEquals($expected, $array); + } + + /** + * Provide data for cleanVersionNumber() tests + */ + public function invalidCommaSeparatedToArrayProvider() + { + return [ + 'INVALID_IDS_LEADING_SPACES' => [ + '1, 2,3', + '1, 2,3', + ], + 'INVALID_IDS_TRAILING_SPACES' => [ + '1,2 ,3', + '1,2 ,3', + ], + 'INVALID_IDS_BAD_SEPARATOR' => [ + '1/2/3', + '1/2/3', + ], + 'INVALID_IDS_NOT_DIGIT' => [ + 'a,b,c', + 'a,b,c', + ], + 'INVALID_IDS_MISSING_DIGIT' => [ + '1,,3', + '1,,3', + ], + 'INVALID_IDS_LEADING_COMMA' => [ + ',2,3', + ',2,3', + ], + 'INVALID_IDS_TRAILING_COMMA' => [ + '1,2,', + '1,2,', + ], + 'NOT_STRING_BOOLEAN' => [ + true, + true, + ], + 'NOT_STRING_INT' => [ + 1, + 1, + ], + 'NOT_STRING_ARRAY' => [ + [1], + [1], + ], + 'NOT_STRING_NULL' => [ + null, + null, + ], + ]; + } } diff --git a/tests/Unit/MigratorTest.php b/tests/Unit/MigratorTest.php index 0b8a0172..a06da4a0 100644 --- a/tests/Unit/MigratorTest.php +++ b/tests/Unit/MigratorTest.php @@ -13,6 +13,7 @@ use App\Services\Migrators\GoogleAuthMigrator; use App\Services\Migrators\Migrator; use App\Services\Migrators\PlainTextMigrator; use App\Services\Migrators\TwoFASMigrator; +use App\Services\Migrators\TwoFAuthMigrator; use App\Services\SettingService; use Illuminate\Support\Facades\Storage; use Mockery; @@ -30,6 +31,7 @@ use Tests\TestCase; * @covers \App\Services\Migrators\TwoFASMigrator * @covers \App\Services\Migrators\PlainTextMigrator * @covers \App\Services\Migrators\GoogleAuthMigrator + * @covers \App\Services\Migrators\TwoFAuthMigrator * * @uses \App\Models\TwoFAccount */ @@ -208,6 +210,12 @@ class MigratorTest extends TestCase 'gauth', $hasSteam = false, ], + '2FAUTH_MIGRATION_PAYLOAD' => [ + new TwoFAuthMigrator(), + MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD, + 'custom', + $hasSteam = true, + ], ]; } @@ -273,6 +281,10 @@ class MigratorTest extends TestCase new GoogleAuthMigrator(), MigrationTestData::GOOGLE_AUTH_MIGRATION_URI_WITH_INVALID_DATA, ], + 'INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD' => [ + new TwoFAuthMigrator(), + MigrationTestData::INVALID_2FAUTH_JSON_MIGRATION_PAYLOAD, + ], ]; } @@ -313,6 +325,10 @@ class MigratorTest extends TestCase new TwoFASMigrator(), MigrationTestData::VALID_2FAS_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE, ], + 'VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE' => [ + new TwoFAuthMigrator(), + MigrationTestData::VALID_2FAUTH_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_OTP_TYPE, + ], ]; } @@ -394,12 +410,71 @@ class MigratorTest extends TestCase Storage::disk('icons')->assertDirectoryEmpty('/'); } + /** + * @test + * + * @dataProvider TwoFAuthWithIconMigrationProvider + */ + public function test_migrate_2fauth_payload_with_icon_sets_and_stores_the_icon($migration) + { + Storage::fake('icons'); + + $migrator = new TwoFAuthMigrator(); + $accounts = $migrator->migrate($migration); + + $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts); + $this->assertCount(1, $accounts); + + Storage::disk('icons')->assertExists($accounts->first()->icon); + } + + /** + * Provide data for TwoFAccount store tests + */ + public function TwoFAuthWithIconMigrationProvider() + { + return [ + 'SVG' => [ + MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_SVG_ICON, + ], + 'PNG' => [ + MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_PNG_ICON, + ], + 'JPG' => [ + MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_JPG_ICON, + ], + 'BMP' => [ + MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_BMP_ICON, + ], + 'WEBP' => [ + MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_WEBP_ICON, + ], + ]; + } + + /** + * @test + */ + public function test_migrate_2fauth_payload_with_unsupported_icon_does_not_fail() + { + Storage::fake('icons'); + + $migrator = new TwoFAuthMigrator(); + $accounts = $migrator->migrate(MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD_WITH_UNSUPPORTED_ICON); + + $this->assertContainsOnlyInstancesOf(TwoFAccount::class, $accounts); + $this->assertCount(1, $accounts); + + $this->assertNull($this->fakeTwofaccount->icon); + Storage::disk('icons')->assertDirectoryEmpty('/'); + } + /** * @test * * @dataProvider factoryProvider */ - public function test_factory_returns_plain_text_migrator($payload, $migratorClass) + public function test_factory_returns_relevant_migrator($payload, $migratorClass) { $factory = new MigratorFactory(); @@ -434,6 +509,10 @@ class MigratorTest extends TestCase MigrationTestData::GOOGLE_AUTH_MIGRATION_URI, GoogleAuthMigrator::class, ], + '2FAUTH_MIGRATION_URI' => [ + MigrationTestData::VALID_2FAUTH_JSON_MIGRATION_PAYLOAD, + TwoFAuthMigrator::class, + ], ]; }