diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API\V4\Admin; use App\Domain; +use App\Sku; use App\User; use App\UserAlias; use App\UserSetting; @@ -74,6 +75,36 @@ return response()->json($result); } + /** + * Reset 2-Factor Authentication for the user + * + * @param \Illuminate\Http\Request $request The API request. + * @params string $id User identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function reset2FA(Request $request, $id) + { + $user = User::find($id); + + if (empty($user)) { + return $this->errorResponse(404); + } + + $sku = Sku::where('title', '2fa')->first(); + + // Note: we do select first, so the observer can delete + // 2FA preferences from Roundcube database, so don't + // be tempted to replace first() with delete() below + $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); + $entitlement->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.user-reset-2fa-success'), + ]); + } + /** * Suspend the user * diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -37,6 +37,7 @@ 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', + 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -176,7 +176,7 @@
- +
@@ -184,7 +184,7 @@ - + @@ -199,6 +199,9 @@
¹ applied discount: {{ discount }}% - {{ discount_description }} +
+ +
@@ -342,6 +345,28 @@ + + @@ -369,10 +394,12 @@ discount_description: '', discounts: [], external_email: '', + has2FA: false, wallet: {}, walletReload: false, domains: [], skus: [], + sku2FA: null, users: [], user: { aliases: [], @@ -437,6 +464,11 @@ } this.skus.push(item) + + if (sku.title == '2fa') { + this.has2FA = true + this.sku2FA = sku.id + } } }) }) @@ -513,6 +545,20 @@ this.walletReload = true this.$nextTick(() => { this.walletReload = false }) }, + reset2FA() { + $('#reset-2fa-dialog').modal('hide') + axios.post('/api/v4/users/' + this.user.id + '/reset2FA') + .then(response => { + if (response.data.status == 'success') { + this.$toast.success(response.data.message) + this.skus = this.skus.filter(sku => sku.id != this.sku2FA) + this.has2FA = false + } + }) + }, + reset2FADialog() { + $('#reset-2fa-dialog').modal() + }, submitDiscount() { $('#discount-dialog').modal('hide') diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -103,6 +103,7 @@ Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); + Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -2,7 +2,9 @@ namespace Tests\Browser\Admin; +use App\Auth\SecondFactor; use App\Discount; +use App\Sku; use App\User; use Tests\Browser; use Tests\Browser\Components\Dialog; @@ -130,7 +132,8 @@ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF') - ->assertMissing('table tfoot'); + ->assertMissing('table tfoot') + ->assertMissing('#reset2fa'); }); // Assert Domains tab @@ -300,7 +303,8 @@ ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertMissing('table tfoot') - ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') + ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth'); }); // We don't expect John's domains here @@ -398,4 +402,36 @@ ->assertMissing('@user-info #button-unsuspend'); }); } + + /** + * Test resetting 2FA for the user + */ + public function testReset2FA(): void + { + $this->browse(function (Browser $browser) { + $this->deleteTestUser('userstest1@kolabnow.com'); + $user = $this->getTestUser('userstest1@kolabnow.com'); + $sku2fa = Sku::firstOrCreate(['title' => '2fa']); + $user->assignSku($sku2fa); + SecondFactor::seed('userstest1@kolabnow.com'); + + $browser->visit(new UserPage($user->id)) + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { + $browser->waitFor('#reset2fa') + ->assertVisible('#sku' . $sku2fa->id); + }) + ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)') + ->click('#reset2fa') + ->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', '2-Factor Authentication Reset') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Reset') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.') + ->assertMissing('#sku' . $sku2fa->id) + ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)'); + }); + } } diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Controller\Admin; +use App\Auth\SecondFactor; +use App\Sku; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -146,6 +148,45 @@ $this->assertCount(0, $json['list']); } + /** + * Test reseting 2FA (POST /api/v4/users//reset2FA) + */ + public function testReset2FA(): void + { + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + $sku2fa = Sku::firstOrCreate(['title' => '2fa']); + $user->assignSku($sku2fa); + SecondFactor::seed('userscontrollertest1@userscontroller.com'); + + // Test unauthorized access to admin API + $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(403); + + $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); + $this->assertCount(1, $entitlements); + + $sf = new SecondFactor($user); + $this->assertCount(1, $sf->factors()); + + // Test reseting 2FA + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("2-Factor authentication reset successfully.", $json['message']); + $this->assertCount(2, $json); + + $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); + $this->assertCount(0, $entitlements); + + $sf = new SecondFactor($user); + $this->assertCount(0, $sf->factors()); + } + /** * Test user suspending (POST /api/v4/users//suspend) */ diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -2,7 +2,6 @@ namespace Tests\Feature; -use App\Auth\SecondFactor; use App\Domain; use App\Entitlement; use App\Package;
Subscription
{{ sku.name }} {{ sku.price }}