diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -161,6 +161,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,14 @@ namespace App\Http\Controllers\API\V4; -use App\Http\Controllers\Controller; +use App\Http\Controllers\ResourceController; +use App\Utils; +use App\Tenant; 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 +27,7 @@ [ 'notificationToken' => 'required|min:4|max:512', 'deviceId' => 'required|min:4|max:64', + 'name' => 'required|max:512', ] ); @@ -33,8 +37,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 +47,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 +61,139 @@ 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)); + } + + /** + * Delete a companion app. + * + * @param string $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($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); + } + + $result->delete(); + return response()->json([ + 'status' => 'success', + 'message' => \trans("app.companion-delete-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/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": "^7.3", + "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.13", "dyrynda/laravel-nullable-fields": "*", 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/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-delete-success' => 'The companion app has 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,23 @@ '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", + 'forget-device' => "Forget Device" + ], + '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,59 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -63,13 +63,15 @@ 'prefix' => $prefix . 'api/v4' ], function () { - Route::post('companion/register', 'API\V4\CompanionAppsController@register'); - Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); + Route::get('companion/pairing', 'API\V4\CompanionAppsController@pairing'); + Route::apiResource('companion', 'API\V4\CompanionAppsController'); + Route::post('companion/register', 'API\V4\CompanionAppsController@register'); + Route::apiResource('domains', 'API\V4\DomainsController'); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/skus', 'API\V4\SkusController@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 testDelete(): void + { + $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com'); + $companionApp = $this->getTestCompanionApp('testdevice', $user); + + $response = $this->delete("api/v4/companion/{$companionApp->id}"); + $response->assertStatus(401); + + $response = $this->actingAs($user)->delete("api/v4/companion/{$companionApp->id}"); + $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/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. *