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. NS RRs refer to ns0{1,2}. 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 @@ + 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 @@ 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 @@ 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 @@ 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 @@ */ 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> */ 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 */ 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 @@ +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 @@ */ 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 @@ The attributes that are mass assignable */ protected $fillable = [ 'id', 'email', 'password', 'password_ldap', 'status', ]; /** @var array The attributes that should be hidden for arrays */ protected $hidden = [ 'password', 'password_ldap', 'role' ]; /** @var array The attributes that can be null */ protected $nullable = [ 'password', 'password_ldap' ]; /** @var array 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 " 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 @@ 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 @@ 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 @@ +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 @@ +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 @@ 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 @@ '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 @@ [ '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: " + . "Download", + '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: .ess.barracuda.com.", '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 must have one of the following entries 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 & 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 {amount} when under {balance} 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 {email}.", '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 set to fill up your account by {amount} every time your account balance gets under {balance}.", '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 @@ - - - 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 @@ + + + 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 @@ + + 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 @@ + + + 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 @@ 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 @@ '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 @@ 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 @@ 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')); } }