Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117750918
D3029.1775184242.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
32 KB
Referenced Files
None
Subscribers
None
D3029.1775184242.diff
View Options
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 @@
+<template>
+ <div class="container" dusk="companionapp-component">
+ <div class="card">
+ <div class="card-body">
+ <div class="card-title">
+ <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
+ {{ $t('companion.title') }}
+ </div>
+ <div class="card-text">
+ <p>
+ {{ $t('companion.description') }}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <ul class="nav nav-tabs mt-2" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-qrcode" href="#companion-qrcode" role="tab" aria-controls="companion-qrcode" aria-selected="true" @click="$root.tab">
+ {{ $t('companion.pair-new') }}
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-list" href="#companion-list" role="tab" aria-controls="companion-list" aria-selected="false" @click="$root.tab">
+ {{ $t('companion.paired') }}
+ </a>
+ </li>
+ </ul>
+
+ <div class="tab-content">
+ <div class="tab-pane active" id="companion-qrcode" role="tabpanel" aria-labelledby="tab-qrcode">
+ <div class="card-body">
+ <div class="card-text">
+ <p>
+ {{ $t('companion.pairing-instructions') }}
+ </p>
+ <p>
+ <img :src="qrcode" />
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="companion-list" role="tabpanel" aria-labelledby="tab-list">
+ <div class="card-body">
+ <companionapp-list class="card-text"></companionapp-list>
+ </div>
+ </div>
+ </div>
+
+ </div>
+</template>
+
+<script>
+ import CompanionappList from './Widgets/CompanionappList'
+
+ export default {
+ components: {
+ CompanionappList
+ },
+ data() {
+ return {
+ qrcode: ""
+ }
+ },
+ mounted() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/companion/pairing')
+ .then(response => {
+ this.$root.stopLoading()
+ this.qrcode = response.data.qrcode
+ })
+ .catch(this.$root.errorHandler)
+ },
+ methods: {
+ },
+ }
+</script>
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 @@
<a v-if="webmailURL" class="card link-webmail" :href="webmailURL">
<svg-icon icon="envelope"></svg-icon><span class="name">{{ $t('dashboard.webmail') }}</span>
</a>
+ <router-link v-if="status.enableCompanionapps" class="card link-companionapp" :to="{ name: 'companion' }">
+ <svg-icon icon="mobile"></svg-icon><span class="name">{{ $t('dashboard.companion') }}</span>
+ <span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
+ </router-link>
</div>
</div>
</template>
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 @@
+<template>
+ <div>
+ <btn icon="trash-alt" class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()">
+ {{ $t('companion.delete') }}
+ </btn>
+ <table class="table table-sm m-0 entries">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('companion.name') }}</th>
+ <th scope="col">{{ $t('companion.deviceid') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="entry in entries" :id="'entry' + entry.id" :key="entry.id">
+ <td class="description">{{ entry.name }}</td>
+ <td class="description">{{ entry.device_id }}</td>
+ </tr>
+ </tbody>
+ <list-foot :text="$t('companion.nodevices')" :colspan="2"></list-foot>
+ </table>
+ <list-more v-if="hasMore" :on-click="loadMore"></list-more>
+ <div id="delete-warning" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ $t('companion.remove-devices') }}</h5>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
+ </div>
+ <div class="modal-body">
+ <p>{{ $t('companion.remove-devices-text') }}</p>
+ </div>
+ <div class="modal-footer">
+ <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
+ <btn class="btn-danger modal-action" data-bs-dismiss="modal" @click="removeDevices()" icon="trash-alt">{{ $t('btn.delete') }}</btn>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import { Modal } from 'bootstrap'
+ import ListTools from './ListTools'
+
+ export default {
+ mixins: [ ListTools ],
+ props: {
+ },
+ data() {
+ return {
+ entries: []
+ }
+ },
+ mounted() {
+ this.loadMore({ reset: true })
+ $('#delete-warning')[0].addEventListener('shown.bs.modal', event => {
+ $(event.target).find('button.modal-cancel').focus()
+ })
+ },
+ methods: {
+ loadMore(params) {
+ this.listSearch('entries', '/api/v4/companion/', params)
+ },
+ removeDevices() {
+ axios.post('/api/v4/companion/revoke')
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ }
+ this.loadMore({ reset: true })
+ })
+ .catch(this.$root.errorHandler)
+ },
+ showDeleteConfirmation() {
+ // Display the warning
+ new Modal('#delete-warning').show()
+ },
+ }
+ }
+</script>
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.
*
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 2:44 AM (2 h, 48 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822249
Default Alt Text
D3029.1775184242.diff (32 KB)
Attached To
Mode
D3029: CompanionApp support
Attached
Detach File
Event Timeline