diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -157,6 +157,10 @@ #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= +# Generate with ./artisan passport:client --password +#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID= +#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET= + PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php --- a/src/app/Http/Controllers/API/V4/CompanionAppsController.php +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -2,11 +2,17 @@ namespace App\Http\Controllers\API\V4; -use App\Http\Controllers\Controller; +use App\Http\Controllers\ResourceController; +use App\Utils; +use App\Tenant; +use Laravel\Passport\Token; +use Laravel\Passport\TokenRepository; +use Laravel\Passport\RefreshTokenRepository; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; +use BaconQrCode; -class CompanionAppsController extends Controller +class CompanionAppsController extends ResourceController { /** * Register a companion app. @@ -24,6 +30,7 @@ [ 'notificationToken' => 'required|min:4|max:512', 'deviceId' => 'required|min:4|max:64', + 'name' => 'required|max:512', ] ); @@ -33,8 +40,9 @@ $notificationToken = $request->notificationToken; $deviceId = $request->deviceId; + $name = $request->name; - \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId}"); + \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId} Name: {$name}"); $app = \App\CompanionApp::where('device_id', $deviceId)->first(); if (!$app) { @@ -42,6 +50,7 @@ $app->user_id = $user->id; $app->device_id = $deviceId; $app->mfa_enabled = true; + $app->name = $name; } else { //FIXME this allows a user to probe for another users deviceId if ($app->user_id != $user->id) { @@ -55,4 +64,141 @@ return response()->json(['status' => 'success']); } + + + /** + * Generate a QR-code image for a string + * + * @param string $data data to encode + * + * @return string + */ + private static function generateQRCode($data) + { + $renderer_style = new BaconQrCode\Renderer\RendererStyle\RendererStyle(300, 1); + $renderer_image = new BaconQrCode\Renderer\Image\SvgImageBackEnd(); + $renderer = new BaconQrCode\Renderer\ImageRenderer($renderer_style, $renderer_image); + $writer = new BaconQrCode\Writer($renderer); + + return 'data:image/svg+xml;base64,' . base64_encode($writer->writeString($data)); + } + + /** + * Revoke all companion app devices. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function revokeAll() + { + $user = $this->guard()->user(); + \App\CompanionApp::where('user_id', $user->id)->delete(); + + // Revoke all companion app tokens + $clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'); + $tokens = Token::where('user_id', $user->id, 'client_id', $clientIdentifier); + + $tokenRepository = app(TokenRepository::class); + $refreshTokenRepository = app(RefreshTokenRepository::class); + + foreach ($tokens as $token) { + $tokenRepository->revokeAccessToken($token->id); + $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($token->id); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans("app.companion-deleteall-success"), + ]); + } + + /** + * List devices. + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + $search = trim(request()->input('search')); + $page = intval(request()->input('page')) ?: 1; + $pageSize = 20; + $hasMore = false; + + $result = \App\CompanionApp::where('user_id', $user->id); + + $result = $result->orderBy('created_at') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + // Process the result + $result = $result->map( + function ($device) { + return $device->toArray(); + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + ]; + + return response()->json($result); + } + + /** + * Get the information about the specified companion app. + * + * @param string $id CompanionApp identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $result = \App\CompanionApp::find($id); + if (!$result) { + return $this->errorResponse(404); + } + + $user = $this->guard()->user(); + if ($user->id != $result->user_id) { + return $this->errorResponse(403); + } + + return response()->json($result->toArray()); + } + + /** + * Retrieve the pairing information encoded into a qrcode image. + * + * @return \Illuminate\Http\JsonResponse + */ + public function pairing() + { + $user = $this->guard()->user(); + + $clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'); + $clientSecret = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_secret'); + if (empty($clientIdentifier) || empty($clientSecret)) { + \Log::warning("Empty client identifier or secret. Can't generate qr-code."); + return $this->errorResponse(500); + } + + $response['qrcode'] = self::generateQRCode( + json_encode([ + "serverUrl" => Utils::serviceUrl('', $user->tenant_id), + "clientIdentifier" => \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'), + "clientSecret" => \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_secret'), + "username" => $user->email + ]) + ); + + return response()->json($response); + } } diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -75,7 +75,7 @@ // attempts over the same authAttempt. // Check 2fa - if ($user->getSetting('2fa_enabled', false)) { + if (\App\CompanionApp::where('user_id', $user->id)->exists()) { $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$authAttempt->waitFor2FA()) { throw new \Exception("2fa failed"); diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -192,6 +192,7 @@ 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, + 'enableCompanionapps' => $isController && in_array('beta', $skus), ]; return array_merge($process, $result); diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -15,6 +15,7 @@ ], "require": { "php": "^8.0", + "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-dompdf": "^1.0.0", "doctrine/dbal": "^3.3.2", "dyrynda/laravel-nullable-fields": "^4.2.0", diff --git a/src/config/auth.php b/src/config/auth.php --- a/src/config/auth.php +++ b/src/config/auth.php @@ -118,6 +118,11 @@ 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], + 'companion_app' => [ + 'client_id' => env('PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID'), + 'client_secret' => env('PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET'), + ], + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/database/seeds/local/OauthClientSeeder.php b/src/database/seeds/local/OauthClientSeeder.php --- a/src/database/seeds/local/OauthClientSeeder.php +++ b/src/database/seeds/local/OauthClientSeeder.php @@ -30,5 +30,20 @@ $client->id = \config('auth.proxy.client_id'); $client->save(); + + $companionAppClient = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => "CompanionApp Password Grant Client", + 'secret' => \config('auth.companion_app.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain'), + 'personal_access_client' => 0, + 'password_client' => 1, + 'revoked' => false, + ]); + + $companionAppClient->id = \config('auth.companion_app.client_id'); + + $companionAppClient->save(); } } diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -103,7 +103,6 @@ 'country' => 'US', // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, - '2fa_enabled' => true ] ); diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -21,8 +21,9 @@ faUniversity, faExclamationCircle, faInfoCircle, - faLock, faKey, + faLock, + faMobile, faPlus, faSearch, faSignInAlt, @@ -57,8 +58,9 @@ faFolderOpen, faGlobe, faInfoCircle, - faLock, faKey, + faLock, + faMobile, faPlus, faSearch, faSignInAlt, diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -8,6 +8,7 @@ // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers +const CompanionAppComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp') const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard') const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info') const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List') @@ -45,6 +46,12 @@ component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, + { + path: '/companion', + name: 'companion', + component: CompanionAppComponent, + meta: { requiresAuth: true, perm: 'companionapps' } + }, { path: '/domain/:domain', name: 'domain', 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 @@ -19,6 +19,8 @@ 'chart-income' => 'Income in :currency - last 8 weeks', 'chart-users' => 'Users - last 8 weeks', + 'companion-deleteall-success' => 'All companion apps have been removed.', + 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -38,10 +38,27 @@ 'verify' => "Verify", ], + 'companion' => [ + 'title' => "Companion App", + 'name' => "Name", + 'description' => "Use the Companion App on your mobile phone for advanced two factor authentication.", + 'pair-new' => "Pair new device", + 'paired' => "Paired devices", + 'pairing-instructions' => "Pair a new device using the following QR-Code:", + 'deviceid' => "Device ID", + 'nodevices' => "There are currently no devices", + 'delete' => "Remove devices", + 'remove-devices' => "Remove Devices", + 'remove-devices-text' => "Do you really want to remove all devices permanently?" + . " Please note that this action cannot be undone, and you can only remove all devices together." + . " You may pair devices you would like to keep individually again.", + ], + 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", + 'companion' => "Companion app", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", diff --git a/src/resources/vue/CompanionApp.vue b/src/resources/vue/CompanionApp.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/CompanionApp.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -38,6 +38,10 @@ {{ $t('dashboard.webmail') }} + + {{ $t('dashboard.companion') }} + {{ $t('dashboard.beta') }} + diff --git a/src/resources/vue/Widgets/CompanionappList.vue b/src/resources/vue/Widgets/CompanionappList.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Widgets/CompanionappList.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -70,6 +70,11 @@ Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); + Route::get('companion/pairing', [API\V4\CompanionAppsController::class, 'pairing']); + Route::apiResource('companion', API\V4\CompanionAppsController::class); + Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); + Route::post('companion/revoke', [API\V4\CompanionAppsController::class, 'revokeAll']); + Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); Route::get('domains/{id}/skus', [API\V4\SkusController::class, 'domainSkus']); diff --git a/src/tests/Feature/Controller/CompanionAppsTest.php b/src/tests/Feature/Controller/CompanionAppsTest.php --- a/src/tests/Feature/Controller/CompanionAppsTest.php +++ b/src/tests/Feature/Controller/CompanionAppsTest.php @@ -15,8 +15,9 @@ { parent::setUp(); - $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); - $this->deleteTestDomain('userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest1@userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest2@userscontroller.com'); + $this->deleteTestCompanionApp('testdevice'); } /** @@ -24,8 +25,9 @@ */ public function tearDown(): void { - $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); - $this->deleteTestDomain('userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest1@userscontroller.com'); + $this->deleteTestUser('CompanionAppsTest2@userscontroller.com'); + $this->deleteTestCompanionApp('testdevice'); parent::tearDown(); } @@ -39,10 +41,11 @@ $notificationToken = "notificationToken"; $deviceId = "deviceId"; + $name = "testname"; $response = $this->actingAs($user)->post( "api/v4/companion/register", - ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name] ); $response->assertStatus(200); @@ -50,13 +53,14 @@ $companionApp = \App\CompanionApp::where('device_id', $deviceId)->first(); $this->assertTrue($companionApp != null); $this->assertEquals($deviceId, $companionApp->device_id); + $this->assertEquals($name, $companionApp->name); $this->assertEquals($notificationToken, $companionApp->notification_token); // Test a token update $notificationToken = "notificationToken2"; $response = $this->actingAs($user)->post( "api/v4/companion/register", - ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name] ); $response->assertStatus(200); @@ -75,8 +79,102 @@ $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com'); $response = $this->actingAs($user2)->post( "api/v4/companion/register", - ['notificationToken' => $notificationToken, 'deviceId' => $deviceId] + ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name] ); $response->assertStatus(403); } + + public function testIndex(): void + { + $response = $this->get("api/v4/companion"); + $response->assertStatus(401); + + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + + $companionApp = $this->getTestCompanionApp( + 'testdevice', + $user, + [ + 'notification_token' => 'notificationtoken', + 'mfa_enabled' => 1, + 'name' => 'testname', + ] + ); + + $response = $this->actingAs($user)->get("api/v4/companion"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['user_id']); + $this->assertSame($companionApp['device_id'], $json['list'][0]['device_id']); + $this->assertSame($companionApp['name'], $json['list'][0]['name']); + $this->assertSame($companionApp['notification_token'], $json['list'][0]['notification_token']); + $this->assertSame($companionApp['mfa_enabled'], $json['list'][0]['mfa_enabled']); + + $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com'); + $response = $this->actingAs($user2)->get( + "api/v4/companion" + ); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + } + + public function testShow(): void + { + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + $companionApp = $this->getTestCompanionApp('testdevice', $user); + + $response = $this->get("api/v4/companion/{$companionApp->id}"); + $response->assertStatus(401); + + $response = $this->actingAs($user)->get("api/v4/companion/aaa"); + $response->assertStatus(404); + + $response = $this->actingAs($user)->get("api/v4/companion/{$companionApp->id}"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertSame($companionApp->id, $json['id']); + + $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com'); + $response = $this->actingAs($user2)->get("api/v4/companion/{$companionApp->id}"); + $response->assertStatus(403); + } + + public function testPairing(): void + { + $response = $this->get("api/v4/companion/pairing"); + $response->assertStatus(401); + + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + $response = $this->actingAs($user)->get("api/v4/companion/pairing"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertArrayHasKey('qrcode', $json); + $this->assertSame('data:image/svg+xml;base64,', substr($json['qrcode'], 0, 26)); + } + + public function testRevoke(): void + { + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + $companionApp = $this->getTestCompanionApp('testdevice', $user); + + $response = $this->post("api/v4/companion/revoke"); + $response->assertStatus(401); + + $response = $this->actingAs($user)->post("api/v4/companion/revoke"); + $response->assertStatus(200); + $json = $response->json(); + $this->assertSame('success', $json['status']); + $this->assertArrayHasKey('message', $json); + + $companionApp = \App\CompanionApp::where('device_id', 'testdevice')->first(); + $this->assertTrue($companionApp == null); + } } diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -20,7 +20,6 @@ [ // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, - '2fa_enabled' => false ] ); $this->useServicesUrl(); @@ -36,7 +35,6 @@ [ // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, - '2fa_enabled' => false ] ); parent::tearDown(); @@ -143,17 +141,16 @@ $response->assertHeader('auth-server', '127.0.0.1'); $response->assertHeader('auth-port', '9143'); - // 2-FA without device - $john->setSettings( + + $companionApp = $this->getTestCompanionApp( + 'testdevice', + $john, [ - '2fa_enabled' => true, + 'notification_token' => 'notificationtoken', + 'mfa_enabled' => 1, + 'name' => 'testname', ] ); - \App\CompanionApp::where('user_id', $john->id)->delete(); - - $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); - $response->assertStatus(200); - $response->assertHeader('auth-status', 'authentication failure'); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); @@ -162,6 +159,18 @@ $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); + + // Deny + $authAttempt->deny(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'authentication failure'); + + // 2-FA without device + $companionApp->delete(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); + $response->assertStatus(200); + $response->assertHeader('auth-status', 'OK'); } /** @@ -224,17 +233,15 @@ $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); - - // 2-FA without device - $john->setSettings( + $companionApp = $this->getTestCompanionApp( + 'testdevice', + $john, [ - '2fa_enabled' => true, + 'notification_token' => 'notificationtoken', + 'mfa_enabled' => 1, + 'name' => 'testname', ] ); - \App\CompanionApp::where('user_id', $john->id)->delete(); - - $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); - $response->assertStatus(403); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); @@ -242,5 +249,15 @@ $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); + + // Deny + $authAttempt->deny(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(403); + + // 2-FA without device + $companionApp->delete(); + $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); + $response->assertStatus(200); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -3,6 +3,7 @@ namespace Tests; use App\Backends\LDAP; +use App\CompanionApp; use App\Domain; use App\Group; use App\Resource; @@ -365,6 +366,24 @@ $user->forceDelete(); } + /** + * Delete a test companion app whatever it takes. + * + * @coversNothing + */ + protected function deleteTestCompanionApp($deviceId) + { + Queue::fake(); + + $companionApp = CompanionApp::where('device_id', $deviceId)->first(); + + if (!$companionApp) { + return; + } + + $companionApp->forceDelete(); + } + /** * Helper to access protected property of an object */ @@ -486,6 +505,28 @@ return $user; } + /** + * Get CompanionApp object by deviceId, create it if needed. + * Skip LDAP jobs. + * + * @coversNothing + */ + protected function getTestCompanionApp($deviceId, $user, $attrib = []) + { + // Disable jobs (i.e. skip LDAP oprations) + Queue::fake(); + $companionApp = CompanionApp::firstOrCreate( + [ + 'device_id' => $deviceId, + 'user_id' => $user->id, + 'notification_token' => '', + 'mfa_enabled' => 1 + ], + $attrib + ); + return $companionApp; + } + /** * Call protected/private method of a class. *