Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F16552818
D3932.id11291.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Size
56 KB
Referenced Files
None
Subscribers
None
D3932.id11291.diff
View Options
diff --git a/config.local/src/.env b/config.local/src/.env
--- a/config.local/src/.env
+++ b/config.local/src/.env
@@ -204,7 +204,3 @@
PASSPORT_PROXY_OAUTH_CLIENT_ID=942edef5-3dbd-4a14-8e3e-d5d59b727bee
PASSPORT_PROXY_OAUTH_CLIENT_SECRET=L6L0n56ecvjjK0cJMjeeV1pPAeffUBO0YSSH63wf
-#Generated by php artisan passport:client --password, but can be left hardcoded (the seeder will pick it up)
-PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID=9566e018-f05d-425c-9915-420cdb9258bb
-PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET=XjgV6SU9shO0QFKaU6pQPRC5rJpyRezDJTSoGLgz
-
diff --git a/config.local/src/database/seeds/OauthClientSeeder.php b/config.local/src/database/seeds/OauthClientSeeder.php
--- a/config.local/src/database/seeds/OauthClientSeeder.php
+++ b/config.local/src/database/seeds/OauthClientSeeder.php
@@ -28,22 +28,6 @@
]);
$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/config.localhost/src/.env b/config.localhost/src/.env
--- a/config.localhost/src/.env
+++ b/config.localhost/src/.env
@@ -194,7 +194,3 @@
#Generated by php artisan passport:client --password, but can be left hardcoded (the seeder will pick it up)
PASSPORT_PROXY_OAUTH_CLIENT_ID=942edef5-3dbd-4a14-8e3e-d5d59b727bee
PASSPORT_PROXY_OAUTH_CLIENT_SECRET=L6L0n56ecvjjK0cJMjeeV1pPAeffUBO0YSSH63wf
-
-#Generated by php artisan passport:client --password, but can be left hardcoded (the seeder will pick it up)
-PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID=9566e018-f05d-425c-9915-420cdb9258bb
-PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET=XjgV6SU9shO0QFKaU6pQPRC5rJpyRezDJTSoGLgz
diff --git a/src/app/Auth/PassportClient.php b/src/app/Auth/PassportClient.php
new file mode 100644
--- /dev/null
+++ b/src/app/Auth/PassportClient.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Auth;
+
+use Illuminate\Database\Eloquent\Collection;
+
+/**
+ * Passport Client extended with allowed scopes
+ */
+class PassportClient extends \Laravel\Passport\Client
+{
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'allowed_scopes' => 'array',
+ ];
+
+ /**
+ * The allowed scopes for tokens instantiated by this client
+ *
+ * @return Array
+ * */
+ public function getAllowedScopes(): array
+ {
+ if ($this->allowed_scopes) {
+ return $this->allowed_scopes;
+ }
+ return [];
+ }
+}
diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php
--- a/src/app/CompanionApp.php
+++ b/src/app/CompanionApp.php
@@ -3,6 +3,7 @@
namespace App;
use Illuminate\Database\Eloquent\Model;
+use App\Traits\UuidIntKeyTrait;
/**
* The eloquent definition of a CompanionApp.
@@ -11,6 +12,8 @@
*/
class CompanionApp extends Model
{
+ use UuidIntKeyTrait;
+
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'name',
@@ -81,4 +84,32 @@
self::pushFirebaseNotification($notificationTokens, $data);
return true;
}
+
+ /**
+ * Returns whether this companion app is paired with a device.
+ *
+ * @return bool
+ */
+ public function isPaired(): bool
+ {
+ return !empty($this->device_id);
+ }
+
+ /**
+ * The PassportClient of this CompanionApp
+ *
+ * @return \App\Auth\PassportClient|null
+ */
+ public function passportClient()
+ {
+ return \App\Auth\PassportClient::find($this->oauth_client_id);
+ }
+
+ /**
+ * Set the PassportClient of this CompanionApp
+ */
+ public function setPassportClient(\App\Auth\PassportClient $client)
+ {
+ return $this->oauth_client_id = $client->id;
+ }
}
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -46,7 +46,7 @@
'grant_type' => 'password',
'client_id' => \config('auth.proxy.client_id'),
'client_secret' => \config('auth.proxy.client_secret'),
- 'scopes' => '[*]',
+ 'scope' => 'api',
'secondfactor' => $secondFactor
]);
$proxyRequest->headers->set('X-Client-IP', request()->ip());
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
@@ -5,15 +5,83 @@
use App\Http\Controllers\ResourceController;
use App\Utils;
use App\Tenant;
-use Laravel\Passport\Token;
-use Laravel\Passport\TokenRepository;
-use Laravel\Passport\RefreshTokenRepository;
+use Laravel\Passport\Passport;
+use Laravel\Passport\ClientRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
use BaconQrCode;
class CompanionAppsController extends ResourceController
{
+ /**
+ * Remove the specified companion app.
+ *
+ * @param string $id Companion app identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function destroy($id)
+ {
+ $companion = \App\CompanionApp::find($id);
+ if (!$companion) {
+ return $this->errorResponse(404);
+ }
+
+ $user = $this->guard()->user();
+ if ($user->id != $companion->user_id) {
+ return $this->errorResponse(403);
+ }
+
+ // Revoke client and tokens
+ $client = $companion->passportClient();
+ if ($client) {
+ $clientRepository = app(ClientRepository::class);
+ $clientRepository->delete($client);
+ }
+
+ $companion->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.companion-delete-success'),
+ ]);
+ }
+
+ /**
+ * Create a companion app.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function store(Request $request)
+ {
+ $user = $this->guard()->user();
+
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'name' => 'required|string|max:512',
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $app = \App\CompanionApp::create([
+ 'name' => $request->name,
+ 'user_id' => $user->id,
+ ]);
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.companion-create-success'),
+ 'id' => $app->id
+ ]);
+ }
+
/**
* Register a companion app.
*
@@ -28,9 +96,10 @@
$v = Validator::make(
$request->all(),
[
- 'notificationToken' => 'required|min:4|max:512',
- 'deviceId' => 'required|min:4|max:64',
- 'name' => 'required|max:512',
+ 'notificationToken' => 'required|string|min:4|max:512',
+ 'deviceId' => 'required|string|min:4|max:64',
+ 'companionId' => 'required|max:64',
+ 'name' => 'required|string|max:512',
]
);
@@ -40,32 +109,30 @@
$notificationToken = $request->notificationToken;
$deviceId = $request->deviceId;
+ $companionId = $request->companionId;
$name = $request->name;
\Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId} Name: {$name}");
- $app = \App\CompanionApp::where('device_id', $deviceId)->first();
+ $app = \App\CompanionApp::find($companionId);
if (!$app) {
- $app = new \App\CompanionApp();
- $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) {
- \Log::warning("User mismatch on device registration. Expected {$user->id} but found {$app->user_id}");
- return $this->errorResponse(403);
- }
+ return $this->errorResponse(404);
}
+ if ($app->user_id != $user->id) {
+ \Log::warning("User mismatch on device registration. Expected {$user->id} but found {$app->user_id}");
+ return $this->errorResponse(403);
+ }
+
+ $app->device_id = $deviceId;
+ $app->mfa_enabled = true;
+ $app->name = $name;
$app->notification_token = $notificationToken;
$app->save();
return response()->json(['status' => 'success']);
}
-
/**
* Generate a QR-code image for a string
*
@@ -83,34 +150,6 @@
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)->where('client_id', $clientIdentifier)->get();
-
- $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.
*
@@ -139,7 +178,9 @@
// Process the result
$result = $result->map(
function ($device) {
- return $device->toArray();
+ return array_merge($device->toArray(), [
+ 'isReady' => $device->isPaired()
+ ]);
}
);
@@ -171,7 +212,11 @@
return $this->errorResponse(403);
}
- return response()->json($result->toArray());
+ return response()->json(array_merge($result->toArray(), [
+ 'statusInfo' => [
+ 'isReady' => $result->isPaired()
+ ]
+ ]));
}
/**
@@ -179,9 +224,17 @@
*
* @return \Illuminate\Http\JsonResponse
*/
- public function pairing()
+ public function pairing($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);
+ }
$clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id');
$clientSecret = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_secret');
@@ -190,11 +243,30 @@
return $this->errorResponse(500);
}
+ $client = $result->passportClient();
+ if (!$client) {
+ $client = Passport::client()->forceFill([
+ 'user_id' => $user->id,
+ 'name' => "CompanionApp Password Grant Client",
+ 'secret' => Str::random(40),
+ 'provider' => 'users',
+ 'redirect' => 'https://' . \config('app.website_domain'),
+ 'personal_access_client' => 0,
+ 'password_client' => 1,
+ 'revoked' => false,
+ 'allowed_scopes' => "mfa"
+ ]);
+ $client->save();
+
+ $result->setPassportClient($client);
+ $result->save();
+ }
$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'),
+ "clientIdentifier" => $client->id,
+ "clientSecret" => $client->secret,
+ "companionId" => $id,
"username" => $user->email
])
);
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -69,6 +69,8 @@
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
+ 'scopes' => \Laravel\Passport\Http\Middleware\CheckScopes::class,
+ 'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,
];
/**
diff --git a/src/app/Observers/Passport/TokenObserver.php b/src/app/Observers/Passport/TokenObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/Passport/TokenObserver.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Observers\Passport;
+
+use Laravel\Passport\Token;
+
+class TokenObserver
+{
+ public function creating(Token $token): void
+ {
+ /** @var \App\Auth\PassportClient */
+ $client = $token->client;
+ $scopes = $token->scopes;
+ if ($scopes) {
+ $allowedScopes = $client->getAllowedScopes();
+ if (!empty($allowedScopes)) {
+ $scopes = array_intersect($scopes, $allowedScopes);
+ }
+ $scopes = array_unique($scopes, SORT_REGULAR);
+ $token->scopes = $scopes;
+ }
+ }
+}
diff --git a/src/app/Providers/AuthServiceProvider.php b/src/app/Providers/AuthServiceProvider.php
--- a/src/app/Providers/AuthServiceProvider.php
+++ b/src/app/Providers/AuthServiceProvider.php
@@ -42,8 +42,16 @@
}
);
+ Passport::tokensCan([
+ 'api' => 'Access API',
+ 'mfa' => 'Access MFA API',
+ ]);
+
Passport::tokensExpireIn(now()->addMinutes(\config('auth.token_expiry_minutes')));
Passport::refreshTokensExpireIn(now()->addMinutes(\config('auth.refresh_token_expiry_minutes')));
Passport::personalAccessTokensExpireIn(now()->addMonths(6));
+
+ Passport::useClientModel(\App\Auth\PassportClient::class);
+ Passport::tokenModel()::observe(\App\Observers\Passport\TokenObserver::class);
}
}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -686,6 +686,18 @@
return in_array(\App\Utils::countryForIP($ip), $countryCodes);
}
+ /**
+ * Check if multi factor verification is enabled
+ *
+ * @return bool
+ */
+ public function mfaEnabled(): bool
+ {
+ return \App\CompanionApp::where('user_id', $this->id)
+ ->where('mfa_enabled', true)
+ ->exists();
+ }
+
/**
* Retrieve and authenticate a user
*
@@ -695,7 +707,7 @@
*
* @return array ['user', 'reason', 'errorMessage']
*/
- public static function findAndAuthenticate($username, $password, $clientIP = null): array
+ public static function findAndAuthenticate($username, $password, $clientIP = null, $verifyMFA = true): array
{
$error = null;
@@ -714,26 +726,28 @@
$error = AuthAttempt::REASON_PASSWORD;
}
- // Check user (request) location
- if (!$error && !$user->validateLocation($clientIP)) {
- $error = AuthAttempt::REASON_GEOLOCATION;
- }
+ if ($verifyMFA) {
+ // Check user (request) location
+ if (!$error && !$user->validateLocation($clientIP)) {
+ $error = AuthAttempt::REASON_GEOLOCATION;
+ }
- // Check 2FA
- if (!$error) {
- try {
- (new \App\Auth\SecondFactor($user))->validate(request()->secondfactor);
- } catch (\Exception $e) {
- $error = AuthAttempt::REASON_2FA_GENERIC;
- $message = $e->getMessage();
+ // Check 2FA
+ if (!$error) {
+ try {
+ (new \App\Auth\SecondFactor($user))->validate(request()->secondfactor);
+ } catch (\Exception $e) {
+ $error = AuthAttempt::REASON_2FA_GENERIC;
+ $message = $e->getMessage();
+ }
}
- }
- // Check 2FA - Companion App
- if (!$error && \App\CompanionApp::where('user_id', $user->id)->exists()) {
- $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
- if (!$attempt->waitFor2FA()) {
- $error = AuthAttempt::REASON_2FA;
+ // Check 2FA - Companion App
+ if (!$error && $user->mfaEnabled()) {
+ $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
+ if (!$attempt->waitFor2FA()) {
+ $error = AuthAttempt::REASON_2FA;
+ }
}
}
@@ -768,7 +782,14 @@
*/
public static function findAndValidateForPassport($username, $password): User
{
- $result = self::findAndAuthenticate($username, $password);
+ $verifyMFA = true;
+ if (request()->scope == "mfa") {
+ \Log::info("Not validating MFA because this is a request for an mfa scope.");
+ // Don't verify MFA if this is only an mfa token.
+ // If we didn't do this, we couldn't pair backup devices.
+ $verifyMFA = false;
+ }
+ $result = self::findAndAuthenticate($username, $password, null, $verifyMFA);
if (isset($result['reason'])) {
if ($result['reason'] == AuthAttempt::REASON_2FA_GENERIC) {
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -505,6 +505,7 @@
'app.webmail_url',
'app.support_email',
'app.company.copyright',
+ 'app.companion_download_link',
'mail.from.address'
];
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -277,5 +277,9 @@
'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')),
'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')),
- 'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', ''))
+ 'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')),
+ 'companion_download_link' => env(
+ 'COMPANION_DOWNLOAD_LINK',
+ "https://mirror.apheleia-it.ch/pub/companion-app-beta.apk"
+ )
];
diff --git a/src/database/migrations/2022_11_04_120000_companion_app_uuids_oauth_client.php b/src/database/migrations/2022_11_04_120000_companion_app_uuids_oauth_client.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2022_11_04_120000_companion_app_uuids_oauth_client.php
@@ -0,0 +1,49 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CompanionAppUuidsOauthClient extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ \App\CompanionApp::truncate();
+ Schema::table(
+ 'companion_apps',
+ function (Blueprint $table) {
+ $table->bigInteger('id')->change();
+ $table->string('oauth_client_id', 36)->nullable();
+ $table->string('device_id')->default("")->nullable(false)->change();
+ $table->boolean('mfa_enabled')->default(false)->change();
+ $table->foreign('oauth_client_id')->references('id')->on('oauth_clients')->onDelete('set null');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ \App\CompanionApp::truncate();
+ Schema::table(
+ 'companion_apps',
+ function (Blueprint $table) {
+ $table->bigIncrements('id')->change();
+ $table->dropForeign(['oauth_client_id']);
+ $table->dropColumn('oauth_client_id');
+ $table->string('device_id')->nullable(true)->default(null)->change();
+ $table->boolean('mfa_enabled')->default(false)->change();
+ }
+ );
+ }
+}
diff --git a/src/database/migrations/2022_11_04_130000_oauth_client_scopes.php b/src/database/migrations/2022_11_04_130000_oauth_client_scopes.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2022_11_04_130000_oauth_client_scopes.php
@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class OauthClientScopes extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'oauth_clients',
+ function (Blueprint $table) {
+ $table->string('allowed_scopes')->nullable();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'oauth_clients',
+ function (Blueprint $table) {
+ $table->dropColumn('allowed_scopes');
+ }
+ );
+ }
+}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -22,8 +22,10 @@
// Note: This has to be before the app is created
// Note: You cannot use app inside of the function
window.router.beforeEach((to, from, next) => {
+ console.log("Bfore each ", to.name)
// check if the route requires authentication and user is not logged in
if (to.meta.requiresAuth && !routerState.isLoggedIn) {
+ console.log("redirecting to login")
// remember the original request, to use after login
routerState.afterLogin = to;
@@ -33,6 +35,7 @@
return
}
+ console.log("loading")
if (to.meta.loading) {
startLoading()
loadingRoute = to.name
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,7 +8,8 @@
// 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 CompanionAppInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/Info')
+const CompanionAppListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/List')
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')
@@ -50,9 +51,15 @@
meta: { requiresAuth: true, perm: 'distlists' }
},
{
- path: '/companion',
+ path: '/companion/:companion',
name: 'companion',
- component: CompanionAppComponent,
+ component: CompanionAppInfoComponent,
+ meta: { requiresAuth: true, perm: 'companionapps' }
+ },
+ {
+ path: '/companions',
+ name: 'companions',
+ component: CompanionAppListComponent,
meta: { requiresAuth: true, perm: 'companionapps' }
},
{
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
@@ -20,7 +20,8 @@
'chart-payers' => 'Payers - last year',
'chart-users' => 'Users - last 8 weeks',
- 'companion-deleteall-success' => 'All companion apps have been removed.',
+ 'companion-create-success' => 'Companion app has been created.',
+ 'companion-delete-success' => '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
@@ -40,19 +40,39 @@
],
'companion' => [
- 'title' => "Companion App",
+ 'title' => "Companion Apps",
+ 'companion' => "Companion App",
'name' => "Name",
- 'description' => "Use the Companion App on your mobile phone for advanced two factor authentication.",
+ 'create' => "Pair new device",
+ 'create-recovery-device' => "Prepare recovery code",
+ 'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.",
+ 'download-description' => "You may download the Companion App for Android here: "
+ . "<a href=\"{href}\">Download</a>",
+ 'description-detailed' => "Here is how this works: " .
+ "Pairing a device will automatically enable multi-factor autentication for all login attempts. " .
+ "This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " .
+ "Any authentication attempt will result in a notification on your device, " .
+ "that you can use to confirm if it was you, or deny otherwise. " .
+ "Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " .
+ "Unpair all your active devices to disable multi-factor authentication again.",
+ 'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " .
+ "will permanently lock you out of your account with no course for recovery. " .
+ "Always make sure you have a recovery QR-Code printed to pair a recovery device.",
'pair-new' => "Pair new device",
+ 'new' => "Pair new device",
+ 'recovery' => "Prepare recovery device",
'paired' => "Paired devices",
- 'pairing-instructions' => "Pair a new device using the following QR-Code:",
+ 'print' => "Print for backup",
+ 'pairing-instructions' => "Pair your device using the following QR-Code.",
+ 'recovery-device' => "Recovery Device",
'deviceid' => "Device ID",
'list-empty' => "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.",
+ 'delete' => "Delete/Unpair",
+ 'delete-companion' => "Delete/Unpair",
+ 'delete-text' => "You are about to delete this entry and unpair any paired companion app. " .
+ "This cannot be undone, but you can re-pair the device.",
+ 'pairing-successful' => "Your companion app is paired and ready to be used " .
+ "as a multi-factor authentication device.",
],
'dashboard' => [
@@ -151,6 +171,7 @@
'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
+ 'companion' => "Companion App",
'date' => "Date",
'description' => "Description",
'details' => "Details",
diff --git a/src/resources/vue/CompanionApp.vue b/src/resources/vue/CompanionApp.vue
deleted file mode 100644
--- a/src/resources/vue/CompanionApp.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<template>
- <div class="container" dusk="companionapp-component">
- <div class="card">
- <div class="card-body">
- <div class="card-title">
- {{ $t('companion.title') }}
- <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
- </div>
- <div class="card-text">
- <p>
- {{ $t('companion.description') }}
- </p>
- </div>
- </div>
- </div>
- <tabs class="mt-3" :tabs="['companion.pair-new','companion.paired']"></tabs>
- <div class="tab-content">
- <div class="tab-pane active" id="new" role="tabpanel" aria-labelledby="tab-new">
- <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="paired" role="tabpanel" aria-labelledby="tab-paired">
- <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() {
- axios.get('/api/v4/companion/pairing', { loading: true })
- .then(response => {
- this.qrcode = response.data.qrcode
- })
- .catch(this.$root.errorHandler)
- }
- }
-</script>
diff --git a/src/resources/vue/CompanionApp/Info.vue b/src/resources/vue/CompanionApp/Info.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/CompanionApp/Info.vue
@@ -0,0 +1,134 @@
+<template>
+ <div class="container">
+ <div class="card">
+ <div class="card-body">
+ <div class="card-title" v-if="companion_id === 'new'">{{ $t('companion.new') }}</div>
+ <div class="card-title" v-else-if="companion_id === 'recovery'">{{ $t('companion.recovery') }}</div>
+ <div class="card-title" v-else>{{ $t('form.companion') }}
+ <btn class="btn-outline-danger button-delete float-end" @click="$refs.deleteDialog.show()" icon="trash-can">{{ $t('companion.delete') }}</btn>
+ </div>
+ <div class="card-text">
+ <form @submit.prevent="submit" class="card-body">
+ <div class="row mb-3">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('companion.name') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="name" v-model="companion.name" :disabled="companion.id">
+ </div>
+ </div>
+ <btn v-if="!companion.id" class="btn-primary mt-3" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ <hr class="m-0" v-if="companion.id">
+ <div v-if="companion.id && !companion.isPaired" class="card-body" id="companion-verify">
+ <h5 class="mb-3">{{ $t('companion.pair-new') }}</h5>
+ <btn class="btn-outline-primary float-end" @click="printQRCode()" icon="print">{{ $t('companion.print') }}</btn>
+ <div class="card-text">
+ <p>
+ {{ $t('companion.pairing-instructions') }}
+ </p>
+ <p>
+ <img :src="qrcode" />
+ </p>
+ </div>
+ </div>
+ <div v-if="companion.isPaired" class="card-body" id="companion-config">
+ <div class="card-text">
+ <p>{{ $t('companion.pairing-successful', { app: $root.appName }) }}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <modal-dialog id="delete-warning" ref="deleteDialog" @click="deleteCompanion()" :buttons="['delete']" :cancel-focus="true"
+ :title="$t('companion.delete-companion', { companion: companion.name })"
+ >
+ <p>{{ $t('companion.delete-text') }}</p>
+ </modal-dialog>
+ </div>
+</template>
+
+<script>
+ import ListInput from '../Widgets/ListInput'
+ import ModalDialog from '../Widgets/ModalDialog'
+ import StatusComponent from '../Widgets/Status'
+ import SubscriptionSelect from '../Widgets/SubscriptionSelect'
+
+ import { library } from '@fortawesome/fontawesome-svg-core'
+
+ library.add(
+ require('@fortawesome/free-solid-svg-icons/faPrint').definition,
+ require('@fortawesome/free-solid-svg-icons/faRotate').definition
+ )
+
+ export default {
+ components: {
+ ListInput,
+ ModalDialog,
+ StatusComponent,
+ SubscriptionSelect
+ },
+ beforeRouteUpdate (to, from, next) {
+ // An event called when the route that renders this component has changed,
+ // but this component is reused in the new route.
+ // Required to handle links from /companion/XXX to /companion/YYY
+ next()
+ this.$parent.routerReload()
+ },
+ data() {
+ return {
+ companion_id: null,
+ companion: {},
+ qrcode: "",
+ status: {}
+ }
+ },
+ created() {
+ this.companion_id = this.$route.params.companion
+
+ if (this.companion_id !== 'new' && this.companion_id !== 'recovery') {
+ axios.get('/api/v4/companions/' + this.companion_id, { loader: true })
+ .then(response => {
+ this.companion = response.data
+ this.status = response.data.statusInfo
+ })
+ .catch(this.$root.errorHandler)
+
+ axios.get('/api/v4/companions/' + this.companion_id + '/pairing/', { loader: true })
+ .then(response => {
+ this.qrcode = response.data.qrcode
+ })
+ .catch(this.$root.errorHandler)
+ } else if (this.companion_id == 'recovery') {
+ this.companion = { name: this.$t("companion.recovery-device") }
+ }
+ },
+ methods: {
+ printQRCode() {
+ window.print();
+ },
+ deleteCompanion() {
+ axios.delete('/api/v4/companions/' + this.companion_id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'companions' })
+ }
+ })
+ },
+ statusUpdate(companion) {
+ this.companion = Object.assign({}, this.companion, companion)
+ },
+ submit() {
+ this.$root.clearFormValidation($('#general form'))
+
+ let post = this.$root.pick(this.companion, ['name'])
+
+ axios.post('/api/v4/companions', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ this.$router.replace({ name: 'companion' , params: { companion: response.data.id }})
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/CompanionApp/List.vue b/src/resources/vue/CompanionApp/List.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/CompanionApp/List.vue
@@ -0,0 +1,65 @@
+<template>
+ <div class="container">
+ <div class="card" id="companionapp-list">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $t('companion.title') }}
+ <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
+ <btn-router v-if="!$root.isDegraded()" class="btn-success float-end" to="companion/new" icon="mobile-screen">
+ {{ $t('companion.create') }}
+ </btn-router>
+ </div>
+ <div class="card-text">
+ <p>
+ {{ $t('companion.description') }}
+ </p>
+ <p v-if="appDownloadLink" v-html="$t('companion.download-description', { href: appDownloadLink})"></p>
+ <p>
+ {{ $t('companion.description-detailed') }}
+ </p>
+ <div class="alert alert-warning">
+ <p>
+ {{ $t('companion.description-warning') }}
+ </p>
+ <div>
+ <btn-router class="btn-success" to="companion/recovery" icon="mobile-screen">
+ {{ $t('companion.create-recovery-device') }}
+ </btn-router>
+ </div>
+ </div>
+ </div>
+ <div class="card-text">
+ <list-widget :list="companions"></list-widget>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+<script>
+ import ListWidget from './ListWidget'
+ import { library } from '@fortawesome/fontawesome-svg-core'
+
+ library.add(
+ require('@fortawesome/free-solid-svg-icons/faMobileScreen').definition,
+ )
+
+ export default {
+ components: {
+ ListWidget
+ },
+ data() {
+ return {
+ companions: [],
+ appDownloadLink: window.config['app.companion_download_link']
+ }
+ },
+ created() {
+ axios.get('/api/v4/companions', { loader: true })
+ .then(response => {
+ //TODO show "NOt paired" in device-id field
+ this.companions = response.data.list
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/CompanionApp/ListWidget.vue b/src/resources/vue/CompanionApp/ListWidget.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/CompanionApp/ListWidget.vue
@@ -0,0 +1,39 @@
+<template>
+ <list-table :list="list" :setup="setup"></list-table>
+</template>
+
+<script>
+ import { ListTable } from '../Widgets/ListTools'
+ import { library } from '@fortawesome/fontawesome-svg-core'
+
+ library.add(
+ require('@fortawesome/free-solid-svg-icons/faMobileScreen').definition,
+ )
+
+ export default {
+ components: {
+ ListTable
+ },
+ props: {
+ list: { type: Array, default: () => [] }
+ },
+ data() {
+ return {
+ setup: {
+ model: 'companion',
+ columns: [
+ {
+ prop: 'name',
+ icon: 'mobile-screen',
+ link: true
+ },
+ {
+ prop: 'device_id',
+ label: 'companion.deviceid'
+ }
+ ]
+ }
+ }
+ }
+ }
+</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
@@ -42,7 +42,7 @@
<a v-if="webmailURL" class="card link-webmail" :href="webmailURL">
<svg-icon icon="envelope"></svg-icon><span>{{ $t('dashboard.webmail') }}</span>
</a>
- <router-link v-if="status.enableCompanionapps" class="card link-companionapp" :to="{ name: 'companion' }">
+ <router-link v-if="status.enableCompanionapps" class="card link-companionapp" :to="{ name: 'companions' }">
<svg-icon icon="mobile-screen"></svg-icon><span>{{ $t('dashboard.companion') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -60,21 +60,30 @@
Route::group(
[
'domain' => \config('app.website_domain'),
- 'middleware' => 'auth:api',
+ 'middleware' => ['auth:api', 'scope:mfa,api'],
'prefix' => 'v4'
],
function () {
- Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']);
-
Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']);
Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']);
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::group(
+ [
+ 'domain' => \config('app.website_domain'),
+ 'middleware' => ['auth:api', 'scope:api'],
+ 'prefix' => 'v4'
+ ],
+ function () {
+ Route::apiResource('companions', API\V4\CompanionAppsController::class);
+ // This must not be accessible with the 2fa token,
+ // to prevent an attacker from pairing a new device with a stolen token.
+ Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']);
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']);
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
@@ -5,6 +5,7 @@
use App\User;
use App\CompanionApp;
use Laravel\Passport\Token;
+use Laravel\Passport\Passport;
use Laravel\Passport\TokenRepository;
use Tests\TestCase;
@@ -35,60 +36,111 @@
}
/**
- * Test registering the app
+ * Test creating the app
*/
- public function testRegister(): void
+ public function testStore(): void
{
$user = $this->getTestUser('CompanionAppsTest1@userscontroller.com');
- $notificationToken = "notificationToken";
- $deviceId = "deviceId";
$name = "testname";
- $response = $this->actingAs($user)->post(
- "api/v4/companion/register",
- ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name]
- );
-
+ $post = ['name' => $name];
+ $response = $this->actingAs($user)->post("api/v4/companions", $post);
$response->assertStatus(200);
- $companionApp = \App\CompanionApp::where('device_id', $deviceId)->first();
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Companion app has been created.", $json['message']);
+
+ $companionApp = \App\CompanionApp::where('name', $name)->first();
$this->assertTrue($companionApp != null);
- $this->assertEquals($deviceId, $companionApp->device_id);
$this->assertEquals($name, $companionApp->name);
- $this->assertEquals($notificationToken, $companionApp->notification_token);
+ $this->assertFalse((bool)$companionApp->mfa_enabled);
+ }
- // Test a token update
- $notificationToken = "notificationToken2";
- $response = $this->actingAs($user)->post(
- "api/v4/companion/register",
- ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name]
+ /**
+ * Test destroying the app
+ */
+ public function testDestroy(): void
+ {
+ $user = $this->getTestUser('CompanionAppsTest1@userscontroller.com');
+ $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com');
+
+ $response = $this->actingAs($user)->delete("api/v4/companions/foobar");
+ $response->assertStatus(404);
+
+ $companionApp = $this->getTestCompanionApp(
+ 'testdevice',
+ $user,
+ [
+ 'notification_token' => 'notificationtoken',
+ 'mfa_enabled' => 1,
+ 'name' => 'testname',
+ ]
);
- $response->assertStatus(200);
+ $client = Passport::client()->forceFill([
+ 'user_id' => $user->id,
+ 'name' => "CompanionApp Password Grant Client",
+ 'secret' => "VerySecret",
+ 'provider' => 'users',
+ 'redirect' => 'https://' . \config('app.website_domain'),
+ 'personal_access_client' => 0,
+ 'password_client' => 1,
+ 'revoked' => false,
+ 'allowed_scopes' => ["mfa"]
+ ]);
+ print(var_export($client, true));
+ $client->save();
+ $companionApp->oauth_client = $client->id;
+ $companionApp->save();
- $companionApp->refresh();
- $this->assertEquals($notificationToken, $companionApp->notification_token);
+ $tokenRepository = app(TokenRepository::class);
+ $tokenRepository->create([
+ 'id' => 'testtoken',
+ 'revoked' => false,
+ 'user_id' => $user->id,
+ 'client_id' => $client->id
+ ]);
- // Failing input valdiation
- $response = $this->actingAs($user)->post(
- "api/v4/companion/register",
- []
- );
- $response->assertStatus(422);
+ //Make sure we have a token to revoke
+ $tokenCount = Token::where('user_id', $user->id)->where('client_id', $client->id)->count();
+ $this->assertTrue($tokenCount > 0);
- // Other users device
- $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com');
- $response = $this->actingAs($user2)->post(
- "api/v4/companion/register",
- ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name]
- );
+
+ $response = $this->actingAs($user2)->delete("api/v4/companions/{$companionApp->id}");
$response->assertStatus(403);
+
+ $response = $this->actingAs($user)->delete("api/v4/companions/{$companionApp->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Companion app has been removed.", $json['message']);
+
+ $client->refresh();
+ $this->assertSame((bool)$client->revoked, true);
+
+ $companionApp = \App\CompanionApp::where('device_id', 'testdevice')->first();
+ $this->assertTrue($companionApp == null);
+
+ $tokenCount = Token::where('user_id', $user->id)
+ ->where('client_id', $client->id)
+ ->where('revoked', false)->count();
+ $this->assertSame(0, $tokenCount);
}
+
+ /**
+ * Test listing apps
+ */
public function testIndex(): void
{
- $response = $this->get("api/v4/companion");
+ $response = $this->get("api/v4/companions");
$response->assertStatus(401);
$user = $this->getTestUser('CompanionAppsTest1@userscontroller.com');
@@ -103,7 +155,7 @@
]
);
- $response = $this->actingAs($user)->get("api/v4/companion");
+ $response = $this->actingAs($user)->get("api/v4/companions");
$response->assertStatus(200);
$json = $response->json();
@@ -117,7 +169,7 @@
$user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com');
$response = $this->actingAs($user2)->get(
- "api/v4/companion"
+ "api/v4/companions"
);
$response->assertStatus(200);
@@ -126,75 +178,147 @@
$this->assertCount(0, $json['list']);
}
+
+ /**
+ * Test showing the app
+ */
public function testShow(): void
{
$user = $this->getTestUser('CompanionAppsTest1@userscontroller.com');
$companionApp = $this->getTestCompanionApp('testdevice', $user);
- $response = $this->get("api/v4/companion/{$companionApp->id}");
+ $response = $this->get("api/v4/companions/{$companionApp->id}");
$response->assertStatus(401);
- $response = $this->actingAs($user)->get("api/v4/companion/aaa");
+ $response = $this->actingAs($user)->get("api/v4/companions/aaa");
$response->assertStatus(404);
- $response = $this->actingAs($user)->get("api/v4/companion/{$companionApp->id}");
+ $response = $this->actingAs($user)->get("api/v4/companions/{$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 = $this->actingAs($user2)->get("api/v4/companions/{$companionApp->id}");
$response->assertStatus(403);
}
- public function testPairing(): void
- {
- $response = $this->get("api/v4/companion/pairing");
- $response->assertStatus(401);
+ /**
+ * Test registering the app
+ */
+ public function testRegister(): void
+ {
$user = $this->getTestUser('CompanionAppsTest1@userscontroller.com');
- $response = $this->actingAs($user)->get("api/v4/companion/pairing");
+
+ $companionApp = $this->getTestCompanionApp(
+ 'testdevice',
+ $user,
+ [
+ 'notification_token' => 'notificationtoken',
+ 'mfa_enabled' => 0,
+ 'name' => 'testname',
+ ]
+ );
+
+ $notificationToken = "notificationToken";
+ $deviceId = "deviceId";
+ $name = "testname";
+
+ $response = $this->actingAs($user)->post(
+ "api/v4/companion/register",
+ [
+ 'notificationToken' => $notificationToken,
+ 'deviceId' => $deviceId,
+ 'name' => $name,
+ 'companionId' => $companionApp->id
+ ]
+ );
+
$response->assertStatus(200);
- $json = $response->json();
- $this->assertArrayHasKey('qrcode', $json);
- $this->assertSame('data:image/svg+xml;base64,', substr($json['qrcode'], 0, 26));
+ $companionApp->refresh();
+ $this->assertTrue($companionApp != null);
+ $this->assertEquals($deviceId, $companionApp->device_id);
+ $this->assertEquals($name, $companionApp->name);
+ $this->assertEquals($notificationToken, $companionApp->notification_token);
+ $this->assertTrue((bool)$companionApp->mfa_enabled);
+
+ // Companion id required
+ $response = $this->actingAs($user)->post(
+ "api/v4/companion/register",
+ ['notificationToken' => $notificationToken, 'deviceId' => $deviceId, 'name' => $name]
+ );
+ $response->assertStatus(422);
+
+ // Test a token update
+ $notificationToken = "notificationToken2";
+ $response = $this->actingAs($user)->post(
+ "api/v4/companion/register",
+ [
+ 'notificationToken' => $notificationToken,
+ 'deviceId' => $deviceId,
+ 'name' => $name,
+ 'companionId' => $companionApp->id
+ ]
+ );
+
+ $response->assertStatus(200);
+
+ $companionApp->refresh();
+ $this->assertEquals($notificationToken, $companionApp->notification_token);
+
+ // Failing input valdiation
+ $response = $this->actingAs($user)->post(
+ "api/v4/companion/register",
+ []
+ );
+ $response->assertStatus(422);
+
+ // Other users device
+ $user2 = $this->getTestUser('CompanionAppsTest2@userscontroller.com');
+ $response = $this->actingAs($user2)->post(
+ "api/v4/companion/register",
+ [
+ 'notificationToken' => $notificationToken,
+ 'deviceId' => $deviceId,
+ 'name' => $name,
+ 'companionId' => $companionApp->id
+ ]
+ );
+ $response->assertStatus(403);
}
- public function testRevoke(): void
+
+ /**
+ * Test getting the pairing info
+ */
+ public function testPairing(): void
{
$user = $this->getTestUser('CompanionAppsTest1@userscontroller.com');
- $companionApp = $this->getTestCompanionApp('testdevice', $user);
- $clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id');
- $tokenRepository = app(TokenRepository::class);
- $tokenRepository->create([
- 'id' => 'testtoken',
- 'revoked' => false,
- 'user_id' => $user->id,
- 'client_id' => $clientIdentifier
- ]);
-
- //Make sure we have a token to revoke
- $tokenCount = Token::where('user_id', $user->id)->where('client_id', $clientIdentifier)->count();
- $this->assertTrue($tokenCount > 0);
+ $companionApp = $this->getTestCompanionApp(
+ 'testdevice',
+ $user,
+ [
+ 'notification_token' => 'notificationtoken',
+ 'mfa_enabled' => 0,
+ 'name' => 'testname',
+ ]
+ );
- $response = $this->post("api/v4/companion/revoke");
+ $response = $this->get("api/v4/companions/{$companionApp->id}/pairing");
$response->assertStatus(401);
- $response = $this->actingAs($user)->post("api/v4/companion/revoke");
+ $response = $this->actingAs($user)->get("api/v4/companions/{$companionApp->id}/pairing");
$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);
+ $companionApp->refresh();
+ $this->assertTrue($companionApp->oauth_client != null);
- $tokenCount = Token::where('user_id', $user->id)
- ->where('client_id', $clientIdentifier)
- ->where('revoked', false)->count();
- $this->assertSame(0, $tokenCount);
+ $json = $response->json();
+ $this->assertArrayHasKey('qrcode', $json);
+ $this->assertSame('data:image/svg+xml;base64,', substr($json['qrcode'], 0, 26));
}
}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -4,6 +4,8 @@
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Routing\Middleware\ThrottleRequests;
+use Illuminate\Contracts\Auth\Authenticatable;
+use Laravel\Passport\Passport;
abstract class TestCase extends BaseTestCase
{
@@ -20,6 +22,18 @@
$this->withoutMiddleware(ThrottleRequests::class);
}
+ /**
+ * Set the user as which we want to authenticate
+ */
+ public function actingAs(Authenticatable $user, $guard = null)
+ {
+ Passport::actingAs(
+ $user,
+ ['api']
+ );
+ return parent::actingAs($user, $guard);
+ }
+
/**
* Set baseURL to the regular UI location
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Wed, Oct 30, 5:47 PM (5 h, 17 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
10064451
Default Alt Text
D3932.id11291.diff (56 KB)
Attached To
Mode
D3932: MFA via CompanionApp
Attached
Detach File
Event Timeline
Log In to Comment