Page MenuHomePhorge

No OneTemporary

diff --git a/src/.env.example b/src/.env.example
index f015f1bf..b093a100 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -1,189 +1,185 @@
APP_NAME=Kolab
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://kolab.local
#APP_PASSPHRASE=
APP_PUBLIC_URL=https://kolab.local
APP_DOMAIN=kolab.local
APP_WEBSITE_DOMAIN=kolab.local
APP_THEME=default
APP_TENANT_ID=5
APP_LOCALE=en
APP_LOCALES=
APP_WITH_ADMIN=1
APP_WITH_RESELLER=1
APP_WITH_SERVICES=1
APP_WITH_FILES=1
APP_LDAP=1
APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';"
APP_HEADER_XFO=sameorigin
SIGNUP_LIMIT_EMAIL=0
SIGNUP_LIMIT_IP=0
ASSET_URL=https://kolab.local
WEBMAIL_URL=/roundcubemail/
SUPPORT_URL=/support
SUPPORT_EMAIL=
LOG_CHANNEL=stack
LOG_SLOW_REQUESTS=5
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_DATABASE=kolabdev
DB_HOST=mariadb
DB_PASSWORD=kolab
DB_PORT=3306
DB_USERNAME=kolabdev
BROADCAST_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=file
SESSION_LIFETIME=120
OPENEXCHANGERATES_API_KEY="from openexchangerates.org"
MFA_DSN=mysql://roundcube:Welcome2KolabSystems@mariadb/roundcube
MFA_TOTP_DIGITS=6
MFA_TOTP_INTERVAL=30
MFA_TOTP_DIGEST=sha1
IMAP_URI=ssl://kolab:11993
IMAP_HOST=172.18.0.5
IMAP_ADMIN_LOGIN=cyrus-admin
IMAP_ADMIN_PASSWORD=Welcome2KolabSystems
IMAP_VERIFY_HOST=false
IMAP_VERIFY_PEER=false
LDAP_BASE_DN="dc=mgmt,dc=com"
LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com"
LDAP_HOSTS=kolab
LDAP_PORT=389
LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_SERVICE_BIND_PW="Welcome2KolabSystems"
LDAP_USE_SSL=false
LDAP_USE_TLS=false
# Administrative
LDAP_ADMIN_BIND_DN="cn=Directory Manager"
LDAP_ADMIN_BIND_PW="Welcome2KolabSystems"
LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com"
# Hosted (public registration)
LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_HOSTED_BIND_PW="Welcome2KolabSystems"
LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com"
COTURN_PUBLIC_IP='172.18.0.1'
COTURN_STATIC_SECRET="Welcome2KolabSystems"
MEET_WEBHOOK_TOKEN=Welcome2KolabSystems
MEET_SERVER_TOKEN=Welcome2KolabSystems
MEET_SERVER_URLS=https://kolab.local/meetmedia/api/
MEET_SERVER_VERIFY_TLS=false
MEET_WEBRTC_LISTEN_IP='172.18.0.1'
MEET_PUBLIC_DOMAIN=kolab.local
MEET_TURN_SERVER='turn:172.18.0.1:3478'
MEET_LISTENING_HOST=172.18.0.1
PGP_ENABLE=true
PGP_BINARY=/usr/bin/gpg
PGP_AGENT=/usr/bin/gpg-agent
PGP_GPGCONF=/usr/bin/gpgconf
PGP_LENGTH=
# Set these to IP addresses you serve WOAT with.
# Have the domain owner point _woat.<hosted-domain> NS RRs refer to ns0{1,2}.<provider-domain>
WOAT_NS1=ns01.domain.tld
WOAT_NS2=ns02.domain.tld
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
OCTANE_HTTP_HOST=127.0.0.1
SWOOLE_PACKAGE_MAX_LENGTH=10485760
PAYMENT_PROVIDER=
MOLLIE_KEY=
STRIPE_KEY=
STRIPE_PUBLIC_KEY=
STRIPE_WEBHOOK_SECRET=
MAIL_DRIVER=log
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@example.com"
MAIL_FROM_NAME="Example.com"
MAIL_REPLYTO_ADDRESS="replyto@example.com"
MAIL_REPLYTO_NAME=null
DNS_TTL=3600
DNS_SPF="v=spf1 mx -all"
DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com."
DNS_COPY_FROM=null
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_ASSET_PATH='/'
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Generate with ./artisan passport:client --password
#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=
PASSWORD_POLICY=
COMPANY_NAME=
COMPANY_ADDRESS=
COMPANY_DETAILS=
COMPANY_EMAIL=
COMPANY_LOGO=
COMPANY_FOOTER=
VAT_COUNTRIES=CH,LI
VAT_RATE=7.7
KB_ACCOUNT_DELETE=
KB_ACCOUNT_SUSPENDED=
KB_PAYMENT_SYSTEM=
KOLAB_SSL_CERTIFICATE=/etc/pki/tls/certs/kolab.hosted.com.cert
KOLAB_SSL_CERTIFICATE_FULLCHAIN=/etc/pki/tls/certs/kolab.hosted.com.chain.pem
KOLAB_SSL_CERTIFICATE_KEY=/etc/pki/tls/certs/kolab.hosted.com.key
PROXY_SSL_CERTIFICATE=/etc/certs/imap.hosted.com.cert
PROXY_SSL_CERTIFICATE_KEY=/etc/certs/imap.hosted.com.key
diff --git a/src/app/Auth/PassportClient.php b/src/app/Auth/PassportClient.php
new file mode 100644
index 00000000..507d5ee7
--- /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
index 4b444846..7878a51e 100644
--- a/src/app/CompanionApp.php
+++ b/src/app/CompanionApp.php
@@ -1,84 +1,115 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
+use App\Traits\UuidStrKeyTrait;
/**
* The eloquent definition of a CompanionApp.
*
* A CompanionApp is an kolab companion app that the user registered
*/
class CompanionApp extends Model
{
+ use UuidStrKeyTrait;
+
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'name',
'user_id',
'device_id',
'notification_token',
'mfa_enabled',
];
/**
* Send a notification via firebase.
*
* @param array $deviceIds A list of device id's to send the notification to
* @param array $data The data to include in the notification.
*
* @throws \Exception on notification failure
* @return bool true if a notification has been sent
*/
private static function pushFirebaseNotification($deviceIds, $data): bool
{
\Log::debug("sending notification to " . var_export($deviceIds, true));
$apiKey = \config('firebase.api_key');
$client = new \GuzzleHttp\Client(
[
'verify' => \config('firebase.api_verify_tls')
]
);
$response = $client->request(
'POST',
\config('firebase.api_url'),
[
'headers' => [
'Authorization' => "key={$apiKey}",
],
'json' => [
'registration_ids' => $deviceIds,
'data' => $data
]
]
);
if ($response->getStatusCode() != 200) {
throw new \Exception('FCM Send Error: ' . $response->getStatusCode());
}
return true;
}
/**
* Send a notification to a user.
*
* @throws \Exception on notification failure
* @return bool true if a notification has been sent
*/
public static function notifyUser($userId, $data): bool
{
$notificationTokens = CompanionApp::where('user_id', $userId)
->where('mfa_enabled', true)
->pluck('notification_token')
->all();
if (empty($notificationTokens)) {
\Log::debug("There is no 2fa device to notify.");
return false;
}
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
index 1bfebc55..85cc4f15 100644
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -1,197 +1,197 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Laravel\Passport\TokenRepository;
use Laravel\Passport\RefreshTokenRepository;
class AuthController extends Controller
{
/**
* Get the authenticated User
*
* @return \Illuminate\Http\JsonResponse
*/
public function info()
{
$user = Auth::guard()->user();
if (!empty(request()->input('refresh'))) {
return $this->refreshAndRespond(request(), $user);
}
$response = V4\UsersController::userResponse($user);
return response()->json($response);
}
/**
* Helper method for other controllers with user auto-logon
* functionality
*
* @param \App\User $user User model object
* @param string $password Plain text password
* @param string|null $secondFactor Second factor code if available
*/
public static function logonResponse(User $user, string $password, string $secondFactor = null)
{
$proxyRequest = Request::create('/oauth/token', 'POST', [
'username' => $user->email,
'password' => $password,
'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());
$tokenResponse = app()->handle($proxyRequest);
return self::respondWithToken($tokenResponse, $user);
}
/**
* Get an oauth token via given credentials.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse
*/
public function login(Request $request)
{
$v = Validator::make(
$request->all(),
[
'email' => 'required|min:3',
'password' => 'required|min:1',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$user = \App\User::where('email', $request->email)->first();
if (!$user) {
return response()->json(['status' => 'error', 'message' => \trans('auth.failed')], 401);
}
return self::logonResponse($user, $request->password, $request->secondfactor);
}
/**
* Get the user (geo) location
*
* @return \Illuminate\Http\JsonResponse
*/
public function location()
{
$ip = request()->ip();
$response = [
'ipAddress' => $ip,
'countryCode' => \App\Utils::countryForIP($ip, ''),
];
return response()->json($response);
}
/**
* Log the user out (Invalidate the token)
*
* @return \Illuminate\Http\JsonResponse
*/
public function logout()
{
$tokenId = Auth::user()->token()->id;
$tokenRepository = app(TokenRepository::class);
$refreshTokenRepository = app(RefreshTokenRepository::class);
// Revoke an access token...
$tokenRepository->revokeAccessToken($tokenId);
// Revoke all of the token's refresh tokens...
$refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId);
return response()->json([
'status' => 'success',
'message' => \trans('auth.logoutsuccess')
]);
}
/**
* Refresh a token.
*
* @return \Illuminate\Http\JsonResponse
*/
public function refresh(Request $request)
{
return self::refreshAndRespond($request);
}
/**
* Refresh the token and respond with it.
*
* @param \Illuminate\Http\Request $request The API request.
* @param ?\App\User $user The user being authenticated
*
* @return \Illuminate\Http\JsonResponse
*/
protected static function refreshAndRespond(Request $request, $user = null)
{
$proxyRequest = Request::create('/oauth/token', 'POST', [
'grant_type' => 'refresh_token',
'refresh_token' => $request->refresh_token,
'client_id' => \config('auth.proxy.client_id'),
'client_secret' => \config('auth.proxy.client_secret'),
]);
$tokenResponse = app()->handle($proxyRequest);
return self::respondWithToken($tokenResponse, $user);
}
/**
* Get the token array structure.
*
* @param \Illuminate\Http\JsonResponse $tokenResponse The response containing the token.
* @param ?\App\User $user The user being authenticated
*
* @return \Illuminate\Http\JsonResponse
*/
protected static function respondWithToken($tokenResponse, $user = null)
{
$data = json_decode($tokenResponse->getContent());
if ($tokenResponse->getStatusCode() != 200) {
if (isset($data->error) && $data->error == 'secondfactor' && isset($data->error_description)) {
$errors = ['secondfactor' => $data->error_description];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json(['status' => 'error', 'message' => \trans('auth.failed')], 401);
}
if ($user) {
$response = V4\UsersController::userResponse($user);
} else {
$response = [];
}
$response['status'] = 'success';
$response['access_token'] = $data->access_token;
$response['refresh_token'] = $data->refresh_token;
$response['token_type'] = 'bearer';
$response['expires_in'] = $data->expires_in;
return response()->json($response);
}
}
diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php
index 540c3a25..4bfa2971 100644
--- a/src/app/Http/Controllers/API/V4/CompanionAppsController.php
+++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php
@@ -1,204 +1,269 @@
<?php
namespace App\Http\Controllers\API\V4;
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.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function register(Request $request)
{
$user = $this->guard()->user();
$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',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$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
*
* @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)->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.
*
* @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();
+ return array_merge($device->toArray(), [
+ 'isReady' => $device->isPaired()
+ ]);
}
);
$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());
+ return response()->json(array_merge($result->toArray(), [
+ 'statusInfo' => [
+ 'isReady' => $result->isPaired()
+ ]
+ ]));
}
/**
* Retrieve the pairing information encoded into a qrcode image.
*
* @return \Illuminate\Http\JsonResponse
*/
- public function pairing()
+ public function pairing($id)
{
- $user = $this->guard()->user();
+ $result = \App\CompanionApp::find($id);
+ if (!$result) {
+ return $this->errorResponse(404);
+ }
- $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);
+ $user = $this->guard()->user();
+ if ($user->id != $result->user_id) {
+ return $this->errorResponse(403);
}
+ $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
])
);
return response()->json($response);
}
}
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
index 173b226b..d65d5780 100644
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -1,86 +1,88 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\RequestLogger::class,
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\DevelConfig::class,
\App\Http\Middleware\Locale::class,
\App\Http\Middleware\ContentSecurityPolicy::class,
// FIXME: CORS handling added here, I didn't find a nice way
// to add this only to the API routes
// \App\Http\Middleware\Cors::class,
];
/**
* The application's route middleware groups.
*
* @var array<string, array<int, class-string|string>>
*/
protected $middlewareGroups = [
'web' => [
// \App\Http\Middleware\EncryptCookies::class,
// \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
// \Illuminate\Session\Middleware\StartSession::class,
// \Illuminate\Session\Middleware\AuthenticateSession::class,
// \Illuminate\View\Middleware\ShareErrorsFromSession::class,
// \App\Http\Middleware\VerifyCsrfToken::class,
// \Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// 'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array<string, class-string|string>
*/
protected $routeMiddleware = [
'admin' => \App\Http\Middleware\AuthenticateAdmin::class,
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'reseller' => \App\Http\Middleware\AuthenticateReseller::class,
'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,
];
/**
* Handle an incoming HTTP request.
*
* @param \Illuminate\Http\Request $request HTTP Request object
*
* @return \Illuminate\Http\Response
*/
public function handle($request)
{
// Overwrite the http request object
return parent::handle(Request::createFrom($request));
}
}
diff --git a/src/app/Observers/Passport/TokenObserver.php b/src/app/Observers/Passport/TokenObserver.php
new file mode 100644
index 00000000..52bb91e2
--- /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
index 5125d679..ae50a36e 100644
--- a/src/app/Providers/AuthServiceProvider.php
+++ b/src/app/Providers/AuthServiceProvider.php
@@ -1,49 +1,57 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider
{
/**
* The policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
// 'App\Model' => 'App\Policies\ModelPolicy',
];
/**
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{
$this->registerPolicies();
// Hashes all secrets and thus makes them non-recoverable
/* Passport::hashClientSecrets(); */
// Only enable routes for access tokens
Passport::routes(
function ($router) {
$router->forAccessTokens();
// Override the default route to avoid rate-limiting.
Route::post('/token', [
'uses' => 'AccessTokenController@issueToken',
'as' => 'passport.token',
]);
}
);
+ 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
index f20346d5..24e536d9 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,786 +1,807 @@
<?php
namespace App;
use App\AuthAttempt;
use App\Traits\AliasesTrait;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\EmailPropertyTrait;
use App\Traits\UserConfigTrait;
use App\Traits\UuidIntKeyTrait;
use App\Traits\SettingsTrait;
use App\Traits\StatusPropertyTrait;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $password
* @property string $password_ldap
* @property string $role
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable
{
use AliasesTrait;
use BelongsToTenantTrait;
use EntitleableTrait;
use EmailPropertyTrait;
use HasApiTokens;
use NullableFields;
use UserConfigTrait;
use UuidIntKeyTrait;
use SettingsTrait;
use SoftDeletes;
use StatusPropertyTrait;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
// user in "limited feature-set" state
public const STATUS_DEGRADED = 1 << 6;
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/** @var array<int, string> The attributes that should be hidden for arrays */
protected $hidden = [
'password',
'password_ldap',
'role'
];
/** @var array<int, string> The attributes that can be null */
protected $nullable = [
'password',
'password_ldap'
];
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i:s',
'deleted_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
Wallet::class, // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
return $user->assignPackageAndWallet($package, $this->wallets()->first());
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Degrade the user
*
* @return void
*/
public function degrade(): void
{
if ($this->isDegraded()) {
return;
}
$this->status |= User::STATUS_DEGRADED;
$this->save();
}
/**
* List the domains to which this user is entitled.
*
* @param bool $with_accounts Include domains assigned to wallets
* the current user controls but not owns.
* @param bool $with_public Include active public domains (for the user tenant).
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function domains($with_accounts = true, $with_public = true)
{
$domains = $this->entitleables(Domain::class, $with_accounts);
if ($with_public) {
$domains->orWhere(function ($query) {
if (!$this->tenant_id) {
$query->where('tenant_id', $this->tenant_id);
} else {
$query->withEnvTenantContext();
}
$query->where('domains.type', '&', Domain::TYPE_PUBLIC)
->where('domains.status', '&', Domain::STATUS_ACTIVE);
});
}
return $domains;
}
/**
* Return entitleable objects of a specified type controlled by the current user.
*
* @param string $class Object class
* @param bool $with_accounts Include objects assigned to wallets
* the current user controls, but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
private function entitleables(string $class, bool $with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
$object = new $class();
$table = $object->getTable();
return $object->select("{$table}.*")
->whereExists(function ($query) use ($table, $wallets, $class) {
$query->select(DB::raw(1))
->from('entitlements')
->whereColumn('entitleable_id', "{$table}.id")
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', $class);
});
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User|null User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
/**
* Storage items for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function fsItems()
{
return $this->hasMany(Fs\Item::class);
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
return $this->entitleables(Group::class, $with_accounts);
}
/**
* Returns whether this user (or its wallet owner) is degraded.
*
* @param bool $owner Check also the wallet owner instead just the user himself
*
* @return bool
*/
public function isDegraded(bool $owner = false): bool
{
if ($this->status & self::STATUS_DEGRADED) {
return true;
}
if ($owner && ($wallet = $this->wallet())) {
return $wallet->owner && $wallet->owner->isDegraded();
}
return false;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
$settings = $this->getSettings(['first_name', 'last_name']);
$name = trim($settings['first_name'] . ' ' . $settings['last_name']);
if (empty($name) && $fallback) {
return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')]));
}
return $name;
}
/**
* Old passwords for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function passwords()
{
return $this->hasMany(UserPassword::class);
}
/**
* Return resources controlled by the current user.
*
* @param bool $with_accounts Include resources assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function resources($with_accounts = true)
{
return $this->entitleables(Resource::class, $with_accounts);
}
/**
* Return rooms controlled by the current user.
*
* @param bool $with_accounts Include rooms assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function rooms($with_accounts = true)
{
return $this->entitleables(Meet\Room::class, $with_accounts);
}
/**
* Return shared folders controlled by the current user.
*
* @param bool $with_accounts Include folders assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function sharedFolders($with_accounts = true)
{
return $this->entitleables(SharedFolder::class, $with_accounts);
}
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
if (!$setting) {
return false;
}
$whitelist = json_decode($setting);
$matchFound = false;
foreach ($whitelist as $entry) {
if (substr($entry, 0, 1) == '/') {
$match = preg_match($entry, $clientName);
if ($match) {
$matchFound = true;
}
continue;
}
if (substr($entry, 0, 1) == '.') {
if (substr($clientName, (-1 * strlen($entry))) == $entry) {
$matchFound = true;
}
continue;
}
if ($entry == $clientName) {
$matchFound = true;
continue;
}
}
return $matchFound;
}
/**
* Un-degrade this user.
*
* @return void
*/
public function undegrade(): void
{
if (!$this->isDegraded()) {
return;
}
$this->status ^= User::STATUS_DEGRADED;
$this->save();
}
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
return $this->entitleables(User::class, $with_accounts);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany(VerificationCode::class, 'user_id', 'id');
}
/**
* Wallets this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function wallets()
{
return $this->hasMany(Wallet::class);
}
/**
* User password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = Hash::make($password);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
/**
* User LDAP password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordLdapAttribute($password)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
self::STATUS_DEGRADED,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
/**
* Validate the user credentials
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param bool $updatePassword Store the password if currently empty
*
* @return bool true on success
*/
public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool
{
$authenticated = false;
if ($this->email === \strtolower($username)) {
if (!empty($this->password)) {
if (Hash::check($password, $this->password)) {
$authenticated = true;
}
} elseif (!empty($this->password_ldap)) {
if (substr($this->password_ldap, 0, 6) == "{SSHA}") {
$salt = substr(base64_decode(substr($this->password_ldap, 6)), 20);
$hash = '{SSHA}' . base64_encode(
sha1($password . $salt, true) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
} elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") {
$salt = substr(base64_decode(substr($this->password_ldap, 9)), 64);
$hash = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password . $salt)) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
}
} else {
\Log::error("Incomplete credentials for {$this->email}");
}
}
if ($authenticated) {
// TODO: update last login time
if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) {
$this->password = $password;
$this->save();
}
}
return $authenticated;
}
/**
* Validate request location regarding geo-lockin
*
* @param string $ip IP address to check, usually request()->ip()
*
* @return bool
*/
public function validateLocation($ip): bool
{
$countryCodes = json_decode($this->getSetting('limit_geo', "[]"));
if (empty($countryCodes)) {
return true;
}
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
*
* @param string $username The username
* @param string $password The password in plain text
* @param ?string $clientIP The IP address of the client
*
* @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;
if (!$clientIP) {
$clientIP = request()->ip();
}
$user = User::where('email', $username)->first();
if (!$user) {
$error = AuthAttempt::REASON_NOTFOUND;
}
// Check user password
if (!$error && !$user->validateCredentials($username, $password)) {
$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;
+ }
}
}
if ($error) {
if ($user && empty($attempt)) {
$attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
if (!$attempt->isAccepted()) {
$attempt->deny($error);
$attempt->save();
$attempt->notify();
}
}
if ($user) {
\Log::info("Authentication failed for {$user->email}");
}
return ['reason' => $error, 'errorMessage' => $message ?? \trans("auth.error.{$error}")];
}
\Log::info("Successful authentication for {$user->email}");
return ['user' => $user];
}
/**
* Hook for passport
*
* @throws \Throwable
*
* @return \App\User User model object if found
*/
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) {
// This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'}
throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401);
}
// TODO: Display specific error message if 2FA via Companion App was expected?
throw OAuthServerException::invalidCredentials();
}
return $result['user'];
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index 6e553fe4..50f6aee1 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,578 +1,579 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* Small utility functions for App.
*/
class Utils
{
// Note: Removed '0', 'O', '1', 'I' as problematic with some fonts
public const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/**
* Exchange rates for unit tests
*/
private static $testRates;
/**
* Count the number of lines in a file.
*
* Useful for progress bars.
*
* @param string $file The filepath to count the lines of.
*
* @return int
*/
public static function countLines($file)
{
$fh = fopen($file, 'rb');
$numLines = 0;
while (!feof($fh)) {
$numLines += substr_count(fread($fh, 8192), "\n");
}
fclose($fh);
return $numLines;
}
/**
* Return the country ISO code for an IP address.
*
* @param string $ip IP address
* @param string $fallback Fallback country code
*
* @return string
*/
public static function countryForIP($ip, $fallback = 'CH')
{
if (strpos($ip, ':') === false) {
$net = \App\IP4Net::getNet($ip);
} else {
$net = \App\IP6Net::getNet($ip);
}
return $net && $net->country ? $net->country : $fallback;
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Converts an email address to lower case. Keeps the LMTP shared folder
* addresses character case intact.
*
* @param string $email Email address
*
* @return string Email address
*/
public static function emailToLower(string $email): string
{
// For LMTP shared folder address lower case the domain part only
if (str_starts_with($email, 'shared+shared/')) {
$pos = strrpos($email, '@');
$domain = substr($email, $pos + 1);
$local = substr($email, 0, strlen($email) - strlen($domain) - 1);
return $local . '@' . strtolower($domain);
}
return strtolower($email);
}
/**
* Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
*
* @return string
*/
public static function generatePassphrase()
{
if (\config('app.env') == 'production') {
throw new \Exception("Thou shall not pass!");
}
if (\config('app.passphrase')) {
return \config('app.passphrase');
}
$alphaLow = 'abcdefghijklmnopqrstuvwxyz';
$alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$num = '0123456789';
$stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
$source = $alphaLow . $alphaUp . $num . $stdSpecial;
$result = '';
for ($x = 0; $x < 16; $x++) {
$result .= substr($source, rand(0, (strlen($source) - 1)), 1);
}
return $result;
}
/**
* Find an object that is the recipient for the specified address.
*
* @param string $address
*
* @return array
*/
public static function findObjectsByRecipientAddress($address)
{
$address = \App\Utils::normalizeAddress($address);
list($local, $domainName) = explode('@', $address);
$domain = \App\Domain::where('namespace', $domainName)->first();
if (!$domain) {
return [];
}
$user = \App\User::where('email', $address)->first();
if ($user) {
return [$user];
}
$userAliases = \App\UserAlias::where('alias', $address)->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
$userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
return [];
}
/**
* Retrieve the network ID and Type from a client address
*
* @param string $clientAddress The IPv4 or IPv6 address.
*
* @return array An array of ID and class or null and null.
*/
public static function getNetFromAddress($clientAddress)
{
if (strpos($clientAddress, ':') === false) {
$net = \App\IP4Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP4Net::class];
}
} else {
$net = \App\IP6Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP6Net::class];
}
}
return [null, null];
}
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
$lastaddrbin = hex2bin($lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
/**
* Normalize an email address.
*
* This means to lowercase and strip components separated with recipient delimiters.
*
* @param ?string $address The address to normalize
* @param bool $asArray Return an array with local and domain part
*
* @return string|array Normalized email address as string or array
*/
public static function normalizeAddress(?string $address, bool $asArray = false)
{
if ($address === null || $address === '') {
return $asArray ? ['', ''] : '';
}
$address = self::emailToLower($address);
if (strpos($address, '@') === false) {
return $asArray ? [$address, ''] : $address;
}
list($local, $domain) = explode('@', $address);
if (strpos($local, '+') !== false) {
$local = explode('+', $local)[0];
}
return $asArray ? [$local, $domain] : "{$local}@{$domain}";
}
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* Returns a UUID in the form of an integer.
*
* @return int
*/
public static function uuidInt(): int
{
$hex = self::uuidStr();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
return (string) Str::uuid();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create self URL
*
* @param string $route Route/Path/URL
* @param int|null $tenantId Current tenant
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
public static function serviceUrl(string $route, $tenantId = null): string
{
if (preg_match('|^https?://|i', $route)) {
return $route;
}
$url = \App\Tenant::getConfig($tenantId, 'app.public_url');
if (!$url) {
$url = \App\Tenant::getConfig($tenantId, 'app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$opts = [
'app.name',
'app.url',
'app.domain',
'app.theme',
'app.webmail_url',
'app.support_email',
'app.company.copyright',
+ 'app.companion_download_link',
'mail.from.address'
];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
} elseif ($req_domain == "reseller.$sys_domain") {
$env['jsapp'] = 'reseller.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
$env['languages'] = \App\Http\Controllers\ContentController::locales();
$env['menu'] = \App\Http\Controllers\ContentController::menu();
return $env;
}
/**
* Set test exchange rates.
*
* @param array $rates: Exchange rates
*/
public static function setTestExchangeRates(array $rates): void
{
self::$testRates = $rates;
}
/**
* Retrieve an exchange rate.
*
* @param string $sourceCurrency: Currency from which to convert
* @param string $targetCurrency: Currency to convert to
*
* @return float Exchange rate
*/
public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
{
if (strcasecmp($sourceCurrency, $targetCurrency) == 0) {
return 1.0;
}
if (isset(self::$testRates[$targetCurrency])) {
return floatval(self::$testRates[$targetCurrency]);
}
$currencyFile = resource_path("exchangerates-$sourceCurrency.php");
//Attempt to find the reverse exchange rate, if we don't have the file for the source currency
if (!file_exists($currencyFile)) {
$rates = include resource_path("exchangerates-$targetCurrency.php");
if (!isset($rates[$sourceCurrency])) {
throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency);
}
return 1.0 / floatval($rates[$sourceCurrency]);
}
$rates = include $currencyFile;
if (!isset($rates[$targetCurrency])) {
throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
}
return floatval($rates[$targetCurrency]);
}
}
diff --git a/src/config/app.php b/src/config/app.php
index 37b706ad..1fdaf069 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,279 +1,283 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application. This value is used when the
| framework needs to place the application's name in a notification or
| any other location as required by the application or its packages.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| your application so that it is used when running Artisan tasks.
*/
'url' => env('APP_URL', 'http://localhost'),
'passphrase' => env('APP_PASSPHRASE', null),
'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
'asset_url' => env('ASSET_URL'),
'support_url' => env('SUPPORT_URL', null),
'support_email' => env('SUPPORT_EMAIL', null),
'webmail_url' => env('WEBMAIL_URL', null),
'theme' => env('APP_THEME', 'default'),
'tenant_id' => env('APP_TENANT_ID', null),
'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')),
/*
|--------------------------------------------------------------------------
| Application Domain
|--------------------------------------------------------------------------
|
| System domain used for user signup (kolab identity)
*/
'domain' => env('APP_DOMAIN', 'domain.tld'),
'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')),
'services_domain' => env(
'APP_SERVICES_DOMAIN',
"services." . env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld'))
),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
'locale' => env('APP_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Application Fallback Locale
|--------------------------------------------------------------------------
|
| The fallback locale determines the locale to use when the current one
| is not available. You may change the value to correspond to any of
| the language folders that are provided through your application.
|
*/
'fallback_locale' => 'en',
/*
|--------------------------------------------------------------------------
| Faker Locale
|--------------------------------------------------------------------------
|
| This locale will be used by the Faker PHP library when generating fake
| data for your database seeds. For example, this will be used to get
| localized telephone numbers, street address information and more.
|
*/
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\PassportServiceProvider::class,
App\Providers\RouteServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->toArray(),
'headers' => [
'csp' => env('APP_HEADER_CSP', ""),
'xfo' => env('APP_HEADER_XFO', ""),
],
// Locations of knowledge base articles
'kb' => [
// An article about suspended accounts
'account_suspended' => env('KB_ACCOUNT_SUSPENDED'),
// An article about a way to delete an owned account
'account_delete' => env('KB_ACCOUNT_DELETE'),
// An article about the payment system
'payment_system' => env('KB_PAYMENT_SYSTEM'),
],
'company' => [
'name' => env('COMPANY_NAME'),
'address' => env('COMPANY_ADDRESS'),
'details' => env('COMPANY_DETAILS'),
'email' => env('COMPANY_EMAIL'),
'logo' => env('COMPANY_LOGO'),
'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')),
'copyright' => env('COMPANY_COPYRIGHT', env('COMPANY_NAME', 'Apheleia IT AG')),
],
'storage' => [
'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB
],
'vat' => [
'countries' => env('VAT_COUNTRIES'),
'rate' => (float) env('VAT_RATE'),
],
'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255',
'payment' => [
'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'),
'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'),
],
'with_ldap' => (bool) env('APP_LDAP', true),
'with_imap' => (bool) env('APP_IMAP', false),
'with_admin' => (bool) env('APP_WITH_ADMIN', false),
'with_files' => (bool) env('APP_WITH_FILES', false),
'with_reseller' => (bool) env('APP_WITH_RESELLER', false),
'with_services' => (bool) env('APP_WITH_SERVICES', false),
'signup' => [
'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0),
'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0),
],
'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
index 00000000..daf0e866
--- /dev/null
+++ b/src/database/migrations/2022_11_04_120000_companion_app_uuids_oauth_client.php
@@ -0,0 +1,70 @@
+<?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()
+ {
+ Schema::dropIfExists('companion_apps');
+ Schema::create('companion_apps', function (Blueprint $table) {
+ $table->string('id', 36);
+ $table->string('oauth_client_id', 36)->nullable();
+ $table->bigInteger('user_id');
+ // Seems to grow over time, no clear specification.
+ // Typically below 200 bytes, but some mention up to 350 bytes.
+ $table->string('notification_token', 512)->nullable();
+ // 16 byte for android, 36 for ios. May change over tyme
+ $table->string('device_id', 64)->default("");
+ $table->string('name')->nullable();
+ $table->boolean('mfa_enabled')->default(false);
+ $table->timestamps();
+
+ $table->primary('id');
+
+ $table->foreign('user_id')
+ ->references('id')->on('users')
+ ->onDelete('cascade')
+ ->onUpdate('cascade');
+
+ $table->foreign('oauth_client_id')
+ ->references('id')->on('oauth_clients')
+ ->onDelete('set null');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('companion_apps');
+ Schema::create('companion_apps', function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('user_id');
+ // Seems to grow over time, no clear specification.
+ // Typically below 200 bytes, but some mention up to 350 bytes.
+ $table->string('notification_token', 512)->nullable();
+ // 16 byte for android, 36 for ios. May change over tyme
+ $table->string('device_id', 64);
+ $table->string('name')->nullable();
+ $table->boolean('mfa_enabled');
+ $table->timestamps();
+
+ $table->foreign('user_id')
+ ->references('id')->on('users')
+ ->onDelete('cascade')
+ ->onUpdate('cascade');
+ });
+ }
+}
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
index 00000000..41379266
--- /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/database/seeds/local/OauthClientSeeder.php b/src/database/seeds/local/OauthClientSeeder.php
index d51998de..5a608406 100644
--- a/src/database/seeds/local/OauthClientSeeder.php
+++ b/src/database/seeds/local/OauthClientSeeder.php
@@ -1,49 +1,33 @@
<?php
namespace Database\Seeds\Local;
use Laravel\Passport\Passport;
use Illuminate\Database\Seeder;
class OauthClientSeeder extends Seeder
{
/**
* Run the database seeds.
*
* This emulates './artisan passport:client --password --name="Kolab Password Grant Client" --provider=users'
*
* @return void
*/
public function run()
{
$client = Passport::client()->forceFill([
'user_id' => null,
'name' => "Kolab Password Grant Client",
'secret' => \config('auth.proxy.client_secret'),
'provider' => 'users',
'redirect' => 'https://' . \config('app.website_domain'),
'personal_access_client' => 0,
'password_client' => 1,
'revoked' => false,
]);
$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/user/routes.js b/src/resources/js/user/routes.js
index f5ce89b2..c8c4e036 100644
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -1,193 +1,200 @@
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import PasswordResetComponent from '../../vue/PasswordReset'
import SignupComponent from '../../vue/Signup'
// Here's a list of lazy-loaded components
// 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')
const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info')
const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List')
const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info')
const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List')
const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info')
const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List')
const RoomInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/Info')
const RoomListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/List')
const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings')
const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info')
const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List')
const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info')
const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List')
const UserProfileComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Profile')
const UserProfileDeleteComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/ProfileDelete')
const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet')
const MeetComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue')
const routes = [
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/distlist/:list',
name: 'distlist',
component: DistlistInfoComponent,
meta: { requiresAuth: true, perm: 'distlists' }
},
{
path: '/distlists',
name: 'distlists',
component: DistlistListComponent,
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' }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainInfoComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/domains',
name: 'domains',
component: DomainListComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/file/:file',
name: 'file',
component: FileInfoComponent,
meta: { requiresAuth: true /*, perm: 'files' */ }
},
{
path: '/files',
name: 'files',
component: FileListComponent,
meta: { requiresAuth: true, perm: 'files' }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
name: 'meet',
path: '/meet/:room',
component: MeetComponent,
meta: { loading: true }
},
{
path: '/password-reset/:code?',
name: 'password-reset',
component: PasswordResetComponent
},
{
path: '/profile',
name: 'profile',
component: UserProfileComponent,
meta: { requiresAuth: true }
},
{
path: '/profile/delete',
name: 'profile-delete',
component: UserProfileDeleteComponent,
meta: { requiresAuth: true }
},
{
path: '/resource/:resource',
name: 'resource',
component: ResourceInfoComponent,
meta: { requiresAuth: true, perm: 'resources' }
},
{
path: '/resources',
name: 'resources',
component: ResourceListComponent,
meta: { requiresAuth: true, perm: 'resources' }
},
{
path: '/room/:room',
name: 'room',
component: RoomInfoComponent,
meta: { requiresAuth: true, perm: 'rooms' }
},
{
path: '/rooms',
name: 'rooms',
component: RoomListComponent,
meta: { requiresAuth: true, perm: 'rooms' }
},
{
path: '/settings',
name: 'settings',
component: SettingsComponent,
meta: { requiresAuth: true, perm: 'settings' }
},
{
path: '/shared-folder/:folder',
name: 'shared-folder',
component: SharedFolderInfoComponent,
meta: { requiresAuth: true, perm: 'folders' }
},
{
path: '/shared-folders',
name: 'shared-folders',
component: SharedFolderListComponent,
meta: { requiresAuth: true, perm: 'folders' }
},
{
path: '/signup/invite/:param',
name: 'signup-invite',
component: SignupComponent
},
{
path: '/signup/:param?',
alias: '/signup/voucher/:param',
name: 'signup',
component: SignupComponent
},
{
path: '/user/:user',
name: 'user',
component: UserInfoComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/users',
name: 'users',
component: UserListComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/wallet',
name: 'wallet',
component: WalletComponent,
meta: { requiresAuth: true, perm: 'wallets' }
},
{
name: '404',
path: '*',
component: PageComponent
}
]
export default routes
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index e955bec3..97586cd8 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,143 +1,144 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => 'Created',
'chart-deleted' => 'Deleted',
'chart-average' => 'average',
'chart-allusers' => 'All Users - last year',
'chart-discounts' => 'Discounts',
'chart-vouchers' => 'Vouchers',
'chart-income' => 'Income in :currency - last 8 weeks',
'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.',
'planbutton' => 'Choose :plan',
'process-async' => 'Setup process has been pushed. Please wait.',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-resource-ldap-ready' => 'Failed to create a resource.',
'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-resource-new' => 'Registering a resource...',
'process-resource-imap-ready' => 'Creating a shared folder...',
'process-resource-ldap-ready' => 'Creating a resource...',
'process-shared-folder-new' => 'Registering a shared folder...',
'process-shared-folder-imap-ready' => 'Creating a shared folder...',
'process-shared-folder-ldap-ready' => 'Creating a shared folder...',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
'distlist-delete-success' => 'Distribution list deleted successfully.',
'distlist-suspend-success' => 'Distribution list suspended successfully.',
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'domain-setconfig-success' => 'Domain settings updated successfully.',
'file-create-success' => 'File created successfully.',
'file-delete-success' => 'File deleted successfully.',
'file-update-success' => 'File updated successfully.',
'file-permissions-create-success' => 'File permissions created successfully.',
'file-permissions-update-success' => 'File permissions updated successfully.',
'file-permissions-delete-success' => 'File permissions deleted successfully.',
'resource-update-success' => 'Resource updated successfully.',
'resource-create-success' => 'Resource created successfully.',
'resource-delete-success' => 'Resource deleted successfully.',
'resource-setconfig-success' => 'Resource settings updated successfully.',
'room-update-success' => 'Room updated successfully.',
'room-create-success' => 'Room created successfully.',
'room-delete-success' => 'Room deleted successfully.',
'room-setconfig-success' => 'Room configuration updated successfully.',
'room-unsupported-option-error' => 'Invalid room configuration option.',
'shared-folder-update-success' => 'Shared folder updated successfully.',
'shared-folder-create-success' => 'Shared folder created successfully.',
'shared-folder-delete-success' => 'Shared folder deleted successfully.',
'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
'user-reset-geo-lock-success' => 'Geo-lockin setup reset successfully.',
'user-setconfig-success' => 'User settings updated successfully.',
'user-set-sku-success' => 'The subscription added successfully.',
'user-set-sku-already-exists' => 'The subscription already exists.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
'search-foundxshared-folders' => ':x shared folders have been found.',
'search-foundxusers' => ':x user accounts have been found.',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.',
'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.',
'signup-invitation-delete-success' => 'Invitation deleted successfully.',
'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.',
'support-request-success' => 'Support request submitted successfully.',
'support-request-error' => 'Failed to submit the support request.',
'siteuser' => ':site User',
'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
'password-reset-code-delete-success' => 'Password reset code deleted successfully.',
'password-rule-min' => 'Minimum password length: :param characters',
'password-rule-max' => 'Maximum password length: :param characters',
'password-rule-lower' => 'Password contains a lower-case character',
'password-rule-upper' => 'Password contains an upper-case character',
'password-rule-digit' => 'Password contains a digit',
'password-rule-special' => 'Password contains a special character',
'password-rule-last' => 'Password cannot be the same as the last :param passwords',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
'wallet-notice-today' => 'You will run out of credit today, top up your balance now.',
'wallet-notice-trial' => 'You are in your free trial period.',
'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.',
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index a6e674b3..71a1f9bc 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,539 +1,559 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'faq' => "FAQ",
],
'btn' => [
'add' => "Add",
'accept' => "Accept",
'back' => "Back",
'cancel' => "Cancel",
'close' => "Close",
'continue' => "Continue",
'copy' => "Copy",
'delete' => "Delete",
'deny' => "Deny",
'download' => "Download",
'edit' => "Edit",
'file' => "Choose file...",
'moreinfo' => "More information",
'refresh' => "Refresh",
'reset' => "Reset",
'resend' => "Resend",
'save' => "Save",
'search' => "Search",
'share' => "Share",
'signup' => "Sign Up",
'submit' => "Submit",
'suspend' => "Suspend",
'unsuspend' => "Unsuspend",
'verify' => "Verify",
],
'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.",
- 'pair-new' => "Pair new device",
+ '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.",
+ '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 pair the device again.",
+ 'pairing-successful' => "Your companion app is paired and ready to be used " .
+ "as a multi-factor authentication device.",
],
'dashboard' => [
'beta' => "beta",
'distlists' => "Distribution lists",
'chat' => "Video chat",
'companion' => "Companion app",
'domains' => "Domains",
'files' => "Files",
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
'settings' => "Settings",
'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
'stats' => "Stats",
],
'distlist' => [
'list-title' => "Distribution list | Distribution lists",
'create' => "Create list",
'delete' => "Delete list",
'email' => "Email",
'list-empty' => "There are no distribution lists in this account.",
'name' => "Name",
'new' => "New distribution list",
'recipients' => "Recipients",
'sender-policy' => "Sender Access List",
'sender-policy-text' => "With this list you can specify who can send mail to the distribution list."
. " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to."
. " If the list is empty, mail from anyone is allowed.",
],
'domain' => [
'delete' => "Delete domain",
'delete-domain' => "Delete {domain}",
'delete-text' => "Do you really want to delete this domain permanently?"
. " This is only possible if there are no users, aliases or other objects in this domain."
. " Please note that this action cannot be undone.",
'dns-verify' => "Domain DNS verification sample:",
'dns-config' => "Domain DNS configuration sample:",
'list-empty' => "There are no domains in this account.",
'namespace' => "Namespace",
'spf-whitelist' => "SPF Whitelist",
'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
. "which systems are allowed to send emails with an envelope sender address within said domain.",
'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.",
'verify' => "Domain verification",
'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
'verify-dns-txt' => "TXT entry with value:",
'verify-dns-cname' => "or CNAME entry:",
'verify-outro' => "When this is done press the button below to start the verification.",
'verify-sample' => "Here's a sample zone file for your domain:",
'config' => "Domain configuration",
'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
'create' => "Create domain",
'new' => "New domain",
],
'error' => [
'400' => "Bad request",
'401' => "Unauthorized",
'403' => "Access denied",
'404' => "Not found",
'405' => "Method not allowed",
'500' => "Internal server error",
'unknown' => "Unknown Error",
'server' => "Server Error",
'form' => "Form validation error",
],
'file' => [
'create' => "Create file",
'delete' => "Delete file",
'list-empty' => "There are no files in this account.",
'mimetype' => "Mimetype",
'mtime' => "Modified",
'new' => "New file",
'search' => "File name",
'sharing' => "Sharing",
'sharing-links-text' => "You can share the file with other users by giving them read-only access "
. "to the file via a unique link.",
],
'form' => [
'acl' => "Access rights",
'acl-full' => "All",
'acl-read-only' => "Read-only",
'acl-read-write' => "Read-write",
'amount' => "Amount",
'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
+ 'companion' => "Companion App",
'date' => "Date",
'description' => "Description",
'details' => "Details",
'disabled' => "disabled",
'domain' => "Domain",
'email' => "Email Address",
'emails' => "Email Addresses",
'enabled' => "enabled",
'firstname' => "First Name",
'general' => "General",
'geolocation' => "Your current location: {location}",
'lastname' => "Last Name",
'name' => "Name",
'months' => "months",
'none' => "none",
'norestrictions' => "No restrictions",
'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
'phone' => "Phone",
'selectcountries' => "Select countries",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
'size' => "Size",
'status' => "Status",
'subscriptions' => "Subscriptions",
'surname' => "Surname",
'type' => "Type",
'unknown' => "unknown",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
'created' => "Created",
'deleted' => "Deleted",
],
'invitation' => [
'create' => "Create invite(s)",
'create-title' => "Invite for a signup",
'create-email' => "Enter an email address of the person you want to invite.",
'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
'list-empty' => "There are no invitations in the database.",
'title' => "Signup invitations",
'search' => "Email address or domain",
'send' => "Send invite(s)",
'status-completed' => "User signed up",
'status-failed' => "Sending failed",
'status-sent' => "Sent",
'status-new' => "Not sent yet",
],
'lang' => [
'en' => "English",
'de' => "German",
'fr' => "French",
'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
'sign_in' => "Sign in",
'signing_in' => "Signing in...",
'webmail' => "Webmail"
],
'meet' => [
// Room options dialog
'options' => "Room options",
'password' => "Password",
'password-none' => "none",
'password-clear' => "Clear password",
'password-set' => "Set password",
'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
'lock' => "Locked room",
'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
'nomedia' => "Subscribers only",
'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
. " Moderators will be able to promote them to publishers throughout the session.",
// Room menu
'partcnt' => "Number of participants",
'menu-audio-mute' => "Mute audio",
'menu-audio-unmute' => "Unmute audio",
'menu-video-mute' => "Mute video",
'menu-video-unmute' => "Unmute video",
'menu-screen' => "Share screen",
'menu-hand-lower' => "Lower hand",
'menu-hand-raise' => "Raise hand",
'menu-channel' => "Interpreted language channel",
'menu-chat' => "Chat",
'menu-fullscreen' => "Full screen",
'menu-fullscreen-exit' => "Exit full screen",
'menu-leave' => "Leave session",
// Room setup screen
'setup-title' => "Set up your session",
'mic' => "Microphone",
'cam' => "Camera",
'nick' => "Nickname",
'nick-placeholder' => "Your name",
'join' => "JOIN",
'joinnow' => "JOIN NOW",
'imaowner' => "I'm the owner",
// Room
'qa' => "Q & A",
'leave-title' => "Room closed",
'leave-body' => "The session has been closed by the room owner.",
'media-title' => "Media setup",
'join-request' => "Join request",
'join-requested' => "{user} requested to join.",
// Status messages
'status-init' => "Checking the room...",
'status-323' => "The room is closed. Please, wait for the owner to start the session.",
'status-324' => "The room is closed. It will be open for others after you join.",
'status-325' => "The room is ready. Please, provide a valid password.",
'status-326' => "The room is locked. Please, enter your name and try again.",
'status-327' => "Waiting for permission to join the room.",
'status-404' => "The room does not exist.",
'status-429' => "Too many requests. Please, wait.",
'status-500' => "Failed to connect to the room. Server error.",
// Other menus
'media-setup' => "Media setup",
'perm' => "Permissions",
'perm-av' => "Audio &amp; Video publishing",
'perm-mod' => "Moderation",
'lang-int' => "Language interpreter",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Login",
'logout' => "Logout",
'signup' => "Signup",
'toggle' => "Toggle navigation",
],
'msg' => [
'initializing' => "Initializing...",
'loading' => "Loading...",
'loading-failed' => "Failed to load data.",
'notfound' => "Resource not found.",
'info' => "Information",
'error' => "Error",
'uploading' => "Uploading...",
'warning' => "Warning",
'success' => "Success",
],
'nav' => [
'more' => "Load more",
'step' => "Step {i}/{n}",
],
'password' => [
'link-invalid' => "The password reset code is expired or invalid.",
'reset' => "Password Reset",
'reset-step1' => "Enter your email address to reset your password.",
'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
'reset-step2' => "We sent out a confirmation code to your external email address."
. " Enter the code we sent you, or click the link in the message.",
],
'resource' => [
'create' => "Create resource",
'delete' => "Delete resource",
'invitation-policy' => "Invitation policy",
'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically"
. " if there is no conflicting event on the requested time slot. Invitation policy allows"
. " for rejecting such requests or to require a manual acceptance from a specified user.",
'ipolicy-manual' => "Manual (tentative)",
'ipolicy-accept' => "Accept",
'ipolicy-reject' => "Reject",
'list-title' => "Resource | Resources",
'list-empty' => "There are no resources in this account.",
'new' => "New resource",
],
'room' => [
'create' => "Create room",
'delete' => "Delete room",
'copy-location' => "Copy room location",
'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.",
'goto' => "Enter the room",
'list-empty' => "There are no conference rooms in this account.",
'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.",
'list-title' => "Voice & video conferencing rooms",
'moderators' => "Moderators",
'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.",
'new' => "New room",
'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.",
'title' => "Room: {name}",
'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.",
],
'settings' => [
'password-policy' => "Password Policy",
'password-retention' => "Password Retention",
'password-max-age' => "Require a password change every",
],
'shf' => [
'aliases-none' => "This shared folder has no email aliases.",
'create' => "Create folder",
'delete' => "Delete folder",
'acl-text' => "Defines user permissions to access the shared folder.",
'list-title' => "Shared folder | Shared folders",
'list-empty' => "There are no shared folders in this account.",
'new' => "New shared folder",
'type-mail' => "Mail",
'type-event' => "Calendar",
'type-contact' => "Address Book",
'type-task' => "Tasks",
'type-note' => "Notes",
'type-file' => "Files",
],
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
'title' => "Sign Up",
'step1' => "Sign up to start your free month.",
'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
'step3' => "Create your {app} identity (you can choose additional addresses later).",
'token' => "Signup authorization token",
'voucher' => "Voucher Code",
],
'status' => [
'prepare-account' => "We are preparing your account.",
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
'prepare-shared-folder' => "We are preparing the shared folder.",
'prepare-user' => "We are preparing the user account.",
'prepare-hint' => "Some features may be missing or readonly at the moment.",
'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
'ready-account' => "Your account is almost ready.",
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
'ready-shared-folder' => "The shared-folder is almost ready.",
'ready-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify domain",
'degraded' => "Degraded",
'deleted' => "Deleted",
'suspended' => "Suspended",
'notready' => "Not Ready",
'active' => "Active",
],
'support' => [
'title' => "Contact Support",
'id' => "Customer number or email address you have with us",
'id-pl' => "e.g. 12345678 or the affected email address",
'id-hint' => "Leave blank if you are not a customer yet",
'name' => "Name",
'name-pl' => "how we should call you in our reply",
'email' => "Working email address",
'email-pl' => "make sure we can reach you at this address",
'summary' => "Issue Summary",
'summary-pl' => "one sentence that summarizes your issue",
'expl' => "Issue Explanation",
],
'user' => [
'2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.",
'2fa-hint2' => "Please, make sure to confirm the user identity properly.",
'add-beta' => "Enable beta program",
'address' => "Address",
'aliases' => "Aliases",
'aliases-none' => "This user has no email aliases.",
'add-bonus' => "Add bonus",
'add-bonus-title' => "Add a bonus to the wallet",
'add-penalty' => "Add penalty",
'add-penalty-title' => "Add a penalty to the wallet",
'auto-payment' => "Auto-payment",
'auto-payment-text' => "Fill up by <b>{amount}</b> when under <b>{balance}</b> using {method}",
'country' => "Country",
'create' => "Create user",
'custno' => "Customer No.",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
'delete' => "Delete user",
'delete-account' => "Delete this account?",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
. " This will delete all account data and withdraw the permission to access the email account."
. " Please note that this action cannot be undone.",
'discount' => "Discount",
'discount-hint' => "applied discount",
'discount-title' => "Account discount",
'distlists' => "Distribution lists",
'domains' => "Domains",
'ext-email' => "External Email",
'email-aliases' => "Email Aliases",
'finances' => "Finances",
'geolimit' => "Geo-lockin",
'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.",
'greylisting' => "Greylisting",
'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender "
. "is temporarily rejected. The originating server should try again after a delay. "
. "This time the email will be accepted. Spammers usually do not reattempt mail delivery.",
'imapproxy' => "IMAP proxy",
'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.",
'list-title' => "User accounts",
'list-empty' => "There are no users in this account.",
'managed-by' => "Managed by",
'new' => "New user account",
'org' => "Organization",
'package' => "Package",
'pass-input' => "Enter password",
'pass-link' => "Set via link",
'pass-link-label' => "Link:",
'pass-link-hint' => "Press Submit to activate the link",
'passwordpolicy' => "Password Policy",
'price' => "Price",
'profile-title' => "Your profile",
'profile-delete' => "Delete account",
'profile-delete-title' => "Delete this account?",
'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.",
'profile-delete-warning' => "This operation is irreversible",
'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.",
'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. "
. "The best tool for improvement is feedback from users, and we would like to ask "
. "for a few words about your reasons for leaving our service. Please send your feedback to <a href=\"{href}\">{email}</a>.",
'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
'reset-2fa' => "Reset 2-Factor Auth",
'reset-2fa-title' => "2-Factor Authentication Reset",
'resources' => "Resources",
'title' => "User account",
'search' => "User email address or name",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
'subscription' => "Subscription",
'subscriptions-none' => "This user has no subscriptions.",
'users' => "Users",
],
'wallet' => [
'add-credit' => "Add credit",
'auto-payment-cancel' => "Cancel auto-payment",
'auto-payment-change' => "Change auto-payment",
'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.",
'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose."
. " You can cancel or change the auto-payment option at any time.",
'auto-payment-setup' => "Set up auto-payment",
'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.",
'auto-payment-info' => "Auto-payment is <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.",
'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.",
'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
'auto-payment-update' => "Update auto-payment",
'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.",
'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.",
'fill-up' => "Fill up by",
'history' => "History",
'month' => "month",
'noperm' => "Only account owners can access a wallet.",
'norefund' => "The money in your wallet is non-refundable.",
'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.",
'payment-method' => "Method of payment: {method}",
'payment-warning' => "You will be charged for {price}.",
'pending-payments' => "Pending Payments",
'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.",
'pending-payments-none' => "There are no pending payments for this account.",
'receipts' => "Receipts",
'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
'title' => "Account balance",
'top-up' => "Top up your wallet",
'transactions' => "Transactions",
'transactions-none' => "There are no transactions for this account.",
'when-below' => "when account balance is below",
],
];
diff --git a/src/resources/vue/CompanionApp.vue b/src/resources/vue/CompanionApp.vue
deleted file mode 100644
index ac8dc514..00000000
--- 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
index 00000000..fd08a585
--- /dev/null
+++ b/src/resources/vue/CompanionApp/Info.vue
@@ -0,0 +1,133 @@
+<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">
+ <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
index 00000000..023c3818
--- /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
index 00000000..284c9feb
--- /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
index a7a11a5a..61e61b97 100644
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -1,105 +1,105 @@
<template>
<div class="container" dusk="dashboard-component">
<status-component :status="status" @status-update="statusUpdate"></status-component>
<div id="dashboard-nav">
<router-link class="card link-profile" :to="{ name: 'profile' }">
<svg-icon icon="user-gear"></svg-icon><span>{{ $t('dashboard.profile') }}</span>
</router-link>
<router-link v-if="status.enableDomains" class="card link-domains" :to="{ name: 'domains' }">
<svg-icon icon="globe"></svg-icon><span>{{ $t('dashboard.domains') }}</span>
</router-link>
<router-link v-if="status.enableUsers" class="card link-users" :to="{ name: 'users' }">
<svg-icon icon="user-group"></svg-icon><span>{{ $t('dashboard.users') }}</span>
</router-link>
<router-link v-if="status.enableDistlists" class="card link-distlists" :to="{ name: 'distlists' }">
<svg-icon icon="users"></svg-icon><span>{{ $t('dashboard.distlists') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableResources" class="card link-resources" :to="{ name: 'resources' }">
<svg-icon icon="gear"></svg-icon><span>{{ $t('dashboard.resources') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableFolders" class="card link-shared-folders" :to="{ name: 'shared-folders' }">
<svg-icon icon="folder-open"></svg-icon><span>{{ $t('dashboard.shared-folders') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
<svg-icon icon="wallet"></svg-icon><span>{{ $t('dashboard.wallet') }}</span>
<span v-if="balance < 0" class="badge bg-danger">{{ $root.price(balance, currency) }}</span>
</router-link>
<router-link v-if="status.enableRooms" class="card link-chat" :to="{ name: 'rooms' }">
<svg-icon icon="comments"></svg-icon><span>{{ $t('dashboard.chat') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableFiles" class="card link-files" :to="{ name: 'files' }">
<svg-icon icon="folder-closed"></svg-icon><span>{{ $t('dashboard.files') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableSettings" class="card link-settings" :to="{ name: 'settings' }">
<svg-icon icon="sliders"></svg-icon><span>{{ $t('dashboard.settings') }}</span>
</router-link>
<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>
</div>
</div>
</template>
<script>
import StatusComponent from './Widgets/Status'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faComments').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
require('@fortawesome/free-solid-svg-icons/faEnvelope').definition,
require('@fortawesome/free-solid-svg-icons/faFolderOpen').definition,
require('@fortawesome/free-solid-svg-icons/faFolderClosed').definition,
require('@fortawesome/free-solid-svg-icons/faGear').definition,
require('@fortawesome/free-solid-svg-icons/faGlobe').definition,
require('@fortawesome/free-solid-svg-icons/faMobileScreen').definition,
require('@fortawesome/free-solid-svg-icons/faSliders').definition,
require('@fortawesome/free-solid-svg-icons/faUserGear').definition,
require('@fortawesome/free-solid-svg-icons/faUsers').definition,
require('@fortawesome/free-solid-svg-icons/faUserGroup').definition,
require('@fortawesome/free-solid-svg-icons/faWallet').definition,
)
export default {
components: {
StatusComponent
},
data() {
return {
status: {},
balance: 0,
currency: '',
webmailURL: window.config['app.webmail_url']
}
},
mounted() {
this.status = this.$root.authInfo.statusInfo
this.getBalance(this.$root.authInfo)
},
methods: {
getBalance(authInfo) {
this.balance = 0;
// TODO: currencies, multi-wallets, accounts
authInfo.wallets.forEach(wallet => {
this.balance += wallet.balance
this.currency = wallet.currency
})
},
statusUpdate(user) {
this.status = Object.assign({}, this.status, user)
this.$root.authInfo.statusInfo = this.status
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index f4928eb6..8b2c2651 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,273 +1,282 @@
<?php
use App\Http\Controllers\API;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::group(
[
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('login', [API\AuthController::class, 'login']);
Route::group(
['middleware' => 'auth:api'],
function () {
Route::get('info', [API\AuthController::class, 'info']);
Route::post('info', [API\AuthController::class, 'info']);
Route::get('location', [API\AuthController::class, 'location']);
Route::post('logout', [API\AuthController::class, 'logout']);
Route::post('refresh', [API\AuthController::class, 'refresh']);
}
);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']);
Route::post('password-reset/init', [API\PasswordResetController::class, 'init']);
Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
Route::post('signup/init', [API\SignupController::class, 'init']);
Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']);
Route::get('signup/plans', [API\SignupController::class, 'plans']);
Route::post('signup/verify', [API\SignupController::class, 'verify']);
Route::post('signup', [API\SignupController::class, 'signup']);
}
);
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']);
Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']);
Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
if (\config('app.with_files')) {
Route::apiResource('files', API\V4\FilesController::class);
Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']);
Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']);
Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']);
Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']);
Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload'])
- ->withoutMiddleware(['auth:api'])
+ ->withoutMiddleware(['auth:api', 'scope:api'])
->middleware(['api']);
Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download'])
- ->withoutMiddleware(['auth:api']);
+ ->withoutMiddleware(['auth:api', 'scope:api']);
}
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']);
Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']);
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('rooms', API\V4\RoomsController::class);
Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']);
Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']);
Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom'])
- ->withoutMiddleware(['auth:api']);
+ ->withoutMiddleware(['auth:api', 'scope:api']);
Route::apiResource('resources', API\V4\ResourcesController::class);
Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']);
Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']);
Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']);
Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']);
Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']);
Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']);
Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']);
Route::get('password-policy', [API\PasswordPolicyController::class, 'index']);
Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']);
Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']);
Route::post('payments', [API\V4\PaymentsController::class, 'store']);
//Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']);
Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']);
Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']);
Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']);
Route::post('support/request', [API\V4\SupportController::class, 'request'])
- ->withoutMiddleware(['auth:api'])
+ ->withoutMiddleware(['auth:api', 'scope:api'])
->middleware(['api']);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => 'webhooks'
],
function () {
Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']);
Route::post('meet', [API\V4\MeetController::class, 'webhook']);
}
);
if (\config('app.with_services')) {
Route::group(
[
'domain' => \config('app.services_domain'),
'prefix' => 'webhooks'
],
function () {
Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']);
Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']);
Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']);
Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']);
Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']);
Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']);
Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']);
}
);
}
if (\config('app.with_admin')) {
Route::group(
[
'domain' => 'admin.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']);
Route::apiResource('groups', API\V4\Admin\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']);
Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']);
Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']);
Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']);
}
);
}
if (\config('app.with_reseller')) {
Route::group(
[
'domain' => 'reseller.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'reseller'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']);
Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']);
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']);
Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']);
Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']);
Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']);
Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']);
Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']);
Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']);
Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']);
Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']);
}
);
}
diff --git a/src/tests/Feature/Controller/CompanionAppsTest.php b/src/tests/Feature/Controller/CompanionAppsTest.php
index 84a75c3c..e9b16e16 100644
--- a/src/tests/Feature/Controller/CompanionAppsTest.php
+++ b/src/tests/Feature/Controller/CompanionAppsTest.php
@@ -1,200 +1,324 @@
<?php
namespace Tests\Feature\Controller;
use App\User;
use App\CompanionApp;
use Laravel\Passport\Token;
+use Laravel\Passport\Passport;
use Laravel\Passport\TokenRepository;
use Tests\TestCase;
class CompanionAppsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('CompanionAppsTest1@userscontroller.com');
$this->deleteTestUser('CompanionAppsTest2@userscontroller.com');
$this->deleteTestCompanionApp('testdevice');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('CompanionAppsTest1@userscontroller.com');
$this->deleteTestUser('CompanionAppsTest2@userscontroller.com');
$this->deleteTestCompanionApp('testdevice');
parent::tearDown();
}
/**
- * 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_id = $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');
$companionApp = $this->getTestCompanionApp(
'testdevice',
$user,
[
'notification_token' => 'notificationtoken',
'mfa_enabled' => 1,
'name' => 'testname',
]
);
- $response = $this->actingAs($user)->get("api/v4/companion");
+ $response = $this->actingAs($user)->get("api/v4/companions");
$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"
+ "api/v4/companions"
);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$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_id != 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
index e2ead157..55303897 100644
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -1,94 +1,108 @@
<?php
namespace Tests;
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
{
use TestCaseTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// Disable throttling
$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
*/
protected static function useRegularUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
\config(
[
'app.url' => str_replace(
['//admin.', '//reseller.', '//services.'],
['//', '//', '//'],
\config('app.url')
)
]
);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the admin UI location
*/
protected static function useAdminUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
// reset to base
self::useRegularUrl();
// then modify it
\config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the reseller UI location
*/
protected static function useResellerUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
// reset to base
self::useRegularUrl();
// then modify it
\config(['app.url' => str_replace('//', '//reseller.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the services location
*/
protected static function useServicesUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
// reset to base
self::useRegularUrl();
// then modify it
\config(['app.url' => str_replace('//', '//services.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jun 28, 4:30 AM (5 h, 15 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
201415
Default Alt Text
(184 KB)

Event Timeline