diff --git a/bin/phpstan b/bin/phpstan index 22a4a751..099b8871 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -1,11 +1,11 @@ #!/bin/bash cwd=$(dirname $0) pushd ${cwd}/../src/ -php -dmemory_limit=256M \ +php -dmemory_limit=320M \ vendor/bin/phpstan \ analyse popd diff --git a/src/.env.example b/src/.env.example index c61a9214..2c9d9e58 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,89 +1,94 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com LOG_CHANNEL=stack DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=log CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 +2FA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube +2FA_TOTP_DIGITS=6 +2FA_TOTP_INTERVAL=30 +2FA_TOTP_DIGEST=sha1 + IMAP_URI=ssl://127.0.0.1:993 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=127.0.0.1 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" REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 MOLLIE_KEY= MAIL_DRIVER=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=null 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= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" JWT_SECRET= diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php new file mode 100644 index 00000000..ac8f2175 --- /dev/null +++ b/src/app/Auth/SecondFactor.php @@ -0,0 +1,335 @@ + [], + ]; + + + /** + * Class constructor + * + * @param \App\User $user User object + */ + public function __construct($user) + { + $this->user = $user; + + parent::__construct(); + } + + /** + * Validate 2-factor authentication code + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse|null + */ + public function requestHandler($request) + { + // get list of configured authentication factors + $factors = $this->factors(); + + // do nothing if no factors configured + if (empty($factors)) { + return null; + } + + if (empty($request->secondfactor) || !is_string($request->secondfactor)) { + $errors = ['secondfactor' => \trans('validation.2fareq')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + // try to verify each configured factor + foreach ($factors as $factor) { + // verify the submitted code + // if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') { + // if ($request->secondfactor === 'dummy') { + // return null; + // } + // } else + if ($this->verify($factor, $request->secondfactor)) { + return null; + } + } + + $errors = ['secondfactor' => \trans('validation.2fainvalid')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + /** + * Remove all configured 2FA methods for the current user + * + * @return bool True on success, False otherwise + */ + public function removeFactors(): bool + { + $this->cache = []; + + $prefs = []; + $prefs[$this->key2property('blob')] = null; + $prefs[$this->key2property('factors')] = null; + + return $this->savePrefs($prefs); + } + + /** + * Returns a list of 2nd factor methods configured for the user + */ + public function factors(): array + { + // First check if the user has the 2FA SKU + $sku_2fa = Sku::where('title', '2fa')->first(); + + if ($sku_2fa) { + $has_2fa = $this->user->entitlements()->where('sku_id', $sku_2fa->id)->first(); + + if ($has_2fa) { + $factors = (array) $this->enumerate(); + $factors = array_unique($factors); + + return $factors; + } + } + + return []; + } + + /** + * Helper method to verify the given method/code tuple + * + * @param string $factor Factor identifier (:) + * @param string $code Authentication code + * + * @return bool True on successful validation + */ + protected function verify($factor, $code): bool + { + if ($driver = $this->getDriver($factor)) { + return $driver->verify($code, time()); + } + + return false; + } + + /** + * Load driver class for the given authentication factor + * + * @param string $factor Factor identifier (:) + * + * @return \Kolab2FA\Driver\Base + */ + protected function getDriver(string $factor) + { + list($method) = explode(':', $factor, 2); + + $config = \config('2fa.' . $method, []); + + $driver = \Kolab2FA\Driver\Base::factory($factor, $config); + + // configure driver + $driver->storage = $this; + $driver->username = $this->user->email; + + return $driver; + } + + /** + * Helper for seeding a Roundcube account with 2FA setup + * for testing. + * + * @param string $email Email address + */ + public static function seed(string $email): void + { + $config = [ + 'kolab_2fa_blob' => [ + 'totp:8132a46b1f741f88de25f47e' => [ + 'label' => 'Mobile app (TOTP)', + 'created' => 1584573552, + 'secret' => 'UAF477LDHZNWVLNA', + 'active' => true, + ], + // 'dummy:dummy' => [ + // 'active' => true, + // ], + ], + 'kolab_2fa_factors' => [ + 'totp:8132a46b1f741f88de25f47e', + // 'dummy:dummy', + ] + ]; + + self::dbh()->table('users')->updateOrInsert( + ['username' => $email, 'mail_host' => '127.0.0.1'], + ['preferences' => serialize($config)] + ); + } + + /** + * Helper for generating current TOTP code for a test user + * + * @param string $email Email address + * + * @return string Generated code + */ + public static function code(string $email): string + { + $sf = new self(User::where('email', $email)->first()); + $driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e'); + + return (string) $driver->get_code(); + } + + + //****************************************************** + // Methods required by Kolab2FA Storage Base + //****************************************************** + + /** + * Initialize the storage driver with the given config options + */ + public function init(array $config) + { + $this->config = array_merge($this->config, $config); + } + + /** + * List methods activated for this user + */ + public function enumerate() + { + if ($factors = $this->getFactors()) { + return array_keys(array_filter($factors, function ($prop) { + return !empty($prop['active']); + })); + } + + return []; + } + + /** + * Read data for the given key + */ + public function read($key) + { + \Log::debug(__METHOD__ . ' ' . $key); + + if (!isset($this->cache[$key])) { + $factors = $this->getFactors(); + $this->cache[$key] = $factors[$key]; + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + \Log::debug(__METHOD__ . ' ' . @json_encode($value)); + + // TODO: Not implemented + return false; + } + + /** + * Remove the data stored for the given key + */ + public function remove($key) + { + return $this->write($key, null); + } + + /** + * + */ + protected function getFactors(): array + { + $prefs = $this->getPrefs(); + $key = $this->key2property('blob'); + + return isset($prefs[$key]) ? (array) $prefs[$key] : []; + } + + /** + * + */ + protected function key2property($key) + { + // map key to configured property name + if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { + return $this->config['keymap'][$key]; + } + + // default + return 'kolab_2fa_' . $key; + } + + /** + * Gets user preferences from Roundcube users table + */ + protected function getPrefs() + { + $user = $this->dbh()->table('users') + ->select('preferences') + ->where('username', strtolower($this->user->email)) + ->first(); + + return $user ? (array) unserialize($user->preferences) : null; + } + + /** + * Saves user preferences in Roundcube users table. + * This will merge into old preferences + */ + protected function savePrefs($prefs) + { + $old_prefs = $this->getPrefs(); + + if (!is_array($old_prefs)) { + return false; + } + + $prefs = array_merge($old_prefs, $prefs); + + $this->dbh()->table('users') + ->where('username', strtolower($this->user->email)) + ->update(['preferences' => serialize($prefs)]); + + return true; + } + + /** + * Init connection to the Roundcube database + */ + public static function dbh() + { + $dsn = \config('2fa.dsn'); + + if (empty($dsn)) { + \Log::warning("2-FACTOR database not configured"); + + return DB::connection(\config('database.default')); + } + + \Config::set('database.connections.2fa', ['url' => $dsn]); + + return DB::connection('2fa'); + } +} diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index a71f8663..c005ae24 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,649 +1,655 @@ middleware('auth:api', ['except' => ['login']]); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { $token = auth()->login($user); return response()->json([ 'status' => 'success', 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => Auth::guard()->factory()->getTTL() * 60, ]); } /** * Delete a user. * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function destroy($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); return response()->json($result); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { $user = $this->guard()->user(); $response = $this->userResponse($user); return response()->json($response); } /** * Get a JWT 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:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { + $sf = new \App\Auth\SecondFactor($this->guard()->user()); + + if ($response = $sf->requestHandler($request)) { + return $response; + } + return $this->respondWithToken($token); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { return $this->respondWithToken($this->guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json( [ 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => $this->guard()->factory()->getTTL() * 60 ] ); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); // Simplified Entitlement/SKU information, // TODO: I agree this format may need to be extended in future $response['skus'] = []; foreach ($user->entitlements as $ent) { $sku = $ent->sku; $response['skus'][$sku->id] = [ // 'cost' => $ent->cost, 'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1, ]; } return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); return [ 'process' => $process, 'isReady' => $all === $checked, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $user_name = !empty($settings['first_name']) ? $settings['first_name'] : ''; if (!empty($settings['last_name'])) { $user_name .= ' ' . $settings['last_name']; } DB::beginTransaction(); // Create user record $user = User::create([ 'name' => $user_name, 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } /** * Update user entitlements. * * @param \App\User $user The user * @param array|null $skus Set of SKUs for the user */ protected function updateEntitlements(User $user, $skus) { if (!is_array($skus)) { return; } // Existing SKUs // FIXME: Is there really no query builder method to get result indexed // by some column or primary key? $all_skus = Sku::all()->mapWithKeys(function ($sku) { return [$sku->id => $sku]; }); // Existing user entitlements // Note: We sort them by cost, so e.g. for storage we get these free first $entitlements = $user->entitlements()->orderBy('cost')->get(); // Go through existing entitlements and remove those no longer needed foreach ($entitlements as $ent) { $sku_id = $ent->sku_id; if (array_key_exists($sku_id, $skus)) { // An existing entitlement exists on the requested list $skus[$sku_id] -= 1; if ($skus[$sku_id] < 0) { $ent->delete(); } } elseif ($all_skus[$sku_id]->handler_class != \App\Handlers\Mailbox::class) { // An existing entitlement does not exists on the requested list // Never delete 'mailbox' SKU $ent->delete(); } } // Add missing entitlements foreach ($skus as $sku_id => $count) { if ($count > 0 && $all_skus->has($sku_id)) { $user->assignSku($all_skus[$sku_id], $count); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ protected function userResponse(User $user): array { $response = $user->toArray(); // Settings // TODO: It might be reasonable to limit the list of settings here to these // that are safe and are used in the UI $response['settings'] = []; foreach ($user->settings as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->toArray(); $response['accounts'] = $user->accounts->toArray(); $response['wallet'] = $user->wallet()->toArray(); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse The response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:512', 'last_name' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, false)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateEmail($alias, $controller, true)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); } /** * Email address (login or alias) validation * * @param string $email Email address * @param \App\User $user The account owner * @param bool $is_alias The email is an alias * * @return string Error message on validation error */ protected static function validateEmail(string $email, User $user, bool $is_alias = false): ?string { $attribute = $is_alias ? 'alias' : 'email'; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => $attribute]); } list($login, $domain) = explode('@', $email); // Check if domain exists $domain = Domain::where('namespace', Str::lower($domain))->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( [$attribute => $login], [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()[$attribute][0]; } // Check if it is one of domains available to the user // TODO: We should have a helper that returns "flat" array with domain names // I guess we could use pluck() somehow $domains = array_map( function ($domain) { return $domain->namespace; }, $user->domains() ); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if user with specified address already exists if (User::findByEmail($email)) { return \trans('validation.entryexists', ['attribute' => $attribute]); } return null; } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index c2c961cd..54291a02 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,56 +1,73 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } } + + /** + * Handle the entitlement "deleted" event. + * + * @param \App\Entitlement $entitlement The entitlement. + * + * @return void + */ + public function deleted(Entitlement $entitlement) + { + // Remove all configured 2FA methods from Roundcube database + if ($entitlement->sku->title == '2fa') { + // FIXME: Should that be an async job? + $sf = new \App\Auth\SecondFactor($entitlement->entitleable); + $sf->removeFactors(); + } + } } diff --git a/src/composer.json b/src/composer.json index 3996ac16..efc5c403 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,85 +1,86 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.1.3", "doctrine/dbal": "^2.9", "fideloper/proxy": "^4.0", "geoip2/geoip2": "^2.9", "iatstuti/laravel-nullable-fields": "*", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/tinker": "^1.0", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", + "spomky-labs/otphp": "~4.0.0", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", "tymon/jwt-auth": "^1.0" }, "require-dev": { "beyondcode/laravel-dump-server": "^1.0", "beyondcode/laravel-er-diagram-generator": "^1.3", "filp/whoops": "^2.0", "fzaninotto/faker": "^1.4", "laravel/dusk": "~5.9.2", "mockery/mockery": "^1.0", "nunomaduro/collision": "^3.0", "nunomaduro/larastan": "^0.4", "phpstan/phpstan": "0.11.19", "phpunit/phpunit": "^7.5" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "database/factories", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/2fa.php b/src/config/2fa.php new file mode 100644 index 00000000..a134f920 --- /dev/null +++ b/src/config/2fa.php @@ -0,0 +1,14 @@ + [ + 'digits' => (int) env('2FA_TOTP_DIGITS', 6), + 'interval' => (int) env('2FA_TOTP_INTERVAL', 30), + 'digest' => env('2FA_TOTP_DIGEST', 'sha1'), + 'issuer' => env('APP_NAME', 'Laravel'), + ], + + 'dsn' => env('2FA_DSN'), + +]; diff --git a/src/config/database.php b/src/config/database.php index 921769ca..59109682 100644 --- a/src/config/database.php +++ b/src/config/database.php @@ -1,147 +1,146 @@ env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- | | Here are each of the database connections setup for your application. | Of course, examples of configuring each database platform that is | supported by Laravel is shown below to make development simple. | | | All database work in Laravel is done through the PHP PDO facilities | so make sure you have the driver for your particular database of | choice installed on your machine before you begin development. | */ 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, 'schema' => 'public', 'sslmode' => 'prefer', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', 'localhost'), 'port' => env('DB_PORT', '1433'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, ], - ], /* |-------------------------------------------------------------------------- | Migration Repository Table |-------------------------------------------------------------------------- | | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run in the database. | */ 'migrations' => 'migrations', /* |-------------------------------------------------------------------------- | Redis Databases |-------------------------------------------------------------------------- | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system | such as APC or Memcached. Laravel makes it easy to dig right in. | */ 'redis' => [ 'client' => env('REDIS_CLIENT', 'predis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'predis'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), ], 'default' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_DB', 0), ], 'cache' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_CACHE_DB', 1), ], ], ]; diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php index 96b34b87..b312b040 100644 --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,116 +1,122 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'name' => 'John Doe', 'email' => 'john@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'name' => 'Jack Daniels', 'email' => 'jack@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($package_kolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonths(1); $entitlement->updated_at = Carbon::now()->subMonths(1); $entitlement->save(); } $ned = User::create( [ 'name' => 'Edward Flanders', 'email' => 'ned@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $ned->setSettings( [ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', 'country' => 'US' ] ); $john->assignPackage($package_kolab, $ned); // Ned is a controller on Jack's wallet $john->wallets()->first()->addController($ned); + // Ned is also our 2FA test user + $sku2fa = Sku::firstOrCreate(['title' => '2fa']); + $ned->assignSku($sku2fa); + SecondFactor::seed('ned@kolab.org'); + factory(User::class, 10)->create(); } } diff --git a/src/include/Kolab2FA/Driver/Base.php b/src/include/Kolab2FA/Driver/Base.php new file mode 100644 index 00000000..c8b19ce9 --- /dev/null +++ b/src/include/Kolab2FA/Driver/Base.php @@ -0,0 +1,354 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +abstract class Base +{ + public $method; + public $id; + public $storage; + public $username; + + protected $config = array(); + protected $props = array(); + protected $user_props = array(); + protected $pending_changes = false; + protected $temporary = false; + protected $allowed_props = array('username'); + + public $user_settings = array( + 'active' => array( + 'type' => 'boolean', + 'editable' => false, + 'hidden' => false, + 'default' => false, + ), + 'label' => array( + 'type' => 'text', + 'editable' => true, + 'label' => 'label', + 'generator' => 'default_label', + ), + 'created' => array( + 'type' => 'datetime', + 'editable' => false, + 'hidden' => false, + 'label' => 'created', + 'generator' => 'time', + ), + ); + + /** + * Static factory method + */ + public static function factory($id, $config) + { + list($method) = explode(':', $id); + + $classmap = array( + 'totp' => '\\Kolab2FA\\Driver\\TOTP', + 'hotp' => '\\Kolab2FA\\Driver\\HOTP', + 'yubikey' => '\\Kolab2FA\\Driver\\Yubikey', + ); + + $cls = $classmap[strtolower($method)]; + if ($cls && class_exists($cls)) { + return new $cls($config, $id); + } + + throw new Exception("Unknown 2FA driver '$method'"); + } + + /** + * Default constructor + */ + public function __construct($config = null, $id = null) + { + $this->init($config); + + if (!empty($id) && $id != $this->method) { + $this->id = $id; + } + else { // generate random ID + $this->id = $this->method . ':' . bin2hex(openssl_random_pseudo_bytes(12)); + $this->temporary = true; + } + } + + /** + * Initialize the driver with the given config options + */ + public function init($config) + { + if (is_array($config)) { + $this->config = array_merge($this->config, $config); + } + + if (!empty($config['storage'])) { + $this->storage = \Kolab2FA\Storage\Base::factory($config['storage'], $config['storage_config']); + } + } + + /** + * Verify the submitted authentication code + * + * @param string $code The 2nd authentication factor to verify + * @param int $timestamp Timestamp of authentication process (window start) + * @return boolean True if valid, false otherwise + */ + abstract function verify($code, $timestamp = null); + + /** + * Getter for user-visible properties + */ + public function props($force = false) + { + $data = array(); + + foreach ($this->user_settings as $key => $p) { + if (!empty($p['private'])) { + continue; + } + + $data[$key] = array( + 'type' => $p['type'], + 'editable' => $p['editable'], + 'hidden' => $p['hidden'], + 'label' => $p['label'], + 'value' => $this->get($key, $force), + ); + + // format value into text + switch ($p['type']) { + case 'boolean': + $data[$key]['value'] = (bool)$data[$key]['value']; + $data[$key]['text'] = $data[$key]['value'] ? 'yes' : 'no'; + break; + + case 'datetime': + if (is_numeric($data[$key]['value'])) { + $data[$key]['text'] = date('c', $data[$key]['value']); + break; + } + + default: + $data[$key]['text'] = $data[$key]['value']; + } + } + + return $data; + } + + /** + * Implement this method if the driver can be prpvisioned via QR code + */ + /* abstract function get_provisioning_uri(); */ + + /** + * Generate a random secret string + */ + public function generate_secret($length = 16) + { + // Base32 characters + $chars = array( + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7 + 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15 + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23 + 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31 + ); + + $secret = ''; + for ($i = 0; $i < $length; $i++) { + $secret .= $chars[array_rand($chars)]; + } + return $secret; + } + + /** + * Generate the default label based on the method + */ + public function default_label() + { + if (class_exists('\\rcmail', false)) { + return \rcmail::get_instance()->gettext($this->method, 'kolab_2fa'); + } + + return strtoupper($this->method); + } + + /** + * Get current code (for testing) + */ + public function get_code() + { + // to be overriden by a driver + return ''; + } + + /** + * Getter for read-only access to driver properties + */ + public function get($key, $force = false) + { + // this is a per-user property: get from persistent storage + if (isset($this->user_settings[$key])) { + $value = $this->get_user_prop($key); + + // generate property value + if (!isset($value) && $force && $this->user_settings[$key]['generator']) { + $func = $this->user_settings[$key]['generator']; + if (is_string($func) && !is_callable($func)) { + $func = array($this, $func); + } + if (is_callable($func)) { + $value = call_user_func($func); + } + if (isset($value)) { + $this->set_user_prop($key, $value); + } + } + } + else { + $value = $this->props[$key]; + } + + return $value; + } + + /** + * Setter for restricted access to driver properties + */ + public function set($key, $value, $persistent = true) + { + // store as per-user property + if (isset($this->user_settings[$key])) { + if ($persistent) { + return $this->set_user_prop($key, $value); + } + $this->user_props[$key] = $value; + } + + $setter = 'set_' . $key; + if (method_exists($this, $setter)) { + call_user_func(array($this, $setter), $value); + } + else if (in_array($key, $this->allowed_props)) { + $this->props[$key] = $value; + } + + return true; + } + + /** + * Commit changes to storage + */ + public function commit() + { + if (!empty($this->user_props) && $this->storage && $this->pending_changes) { + if ($this->storage->write($this->id, $this->user_props)) { + $this->pending_changes = false; + $this->temporary = false; + } + } + + return !$this->pending_changes; + } + + /** + * Dedicated setter for the username property + */ + public function set_username($username) + { + $this->props['username'] = $username; + + if ($this->storage) { + $this->storage->set_username($username); + } + + return true; + } + + /** + * Clear data stored for this driver + */ + public function clear() + { + if ($this->storage) { + return $this->storage->remove($this->id); + } + + return false; + } + + /** + * Getter for per-user properties for this method + */ + protected function get_user_prop($key) + { + if (!isset($this->user_props[$key]) && $this->storage && !$this->pending_changes && !$this->temporary) { + $this->user_props = (array)$this->storage->read($this->id); + } + + return $this->user_props[$key]; + } + + /** + * Setter for per-user properties for this method + */ + protected function set_user_prop($key, $value) + { + $this->pending_changes |= ($this->user_props[$key] !== $value); + $this->user_props[$key] = $value; + return true; + } + + /** + * Magic getter for read-only access to driver properties + */ + public function __get($key) + { + // this is a per-user property: get from persistent storage + if (isset($this->user_settings[$key])) { + return $this->get_user_prop($key); + } + + return $this->props[$key]; + } + + /** + * Magic setter for restricted access to driver properties + */ + public function __set($key, $value) + { + $this->set($key, $value, false); + } + + /** + * Magic check if driver property is defined + */ + public function __isset($key) + { + return isset($this->props[$key]); + } +} diff --git a/src/include/Kolab2FA/Driver/Exception.php b/src/include/Kolab2FA/Driver/Exception.php new file mode 100644 index 00000000..627cb447 --- /dev/null +++ b/src/include/Kolab2FA/Driver/Exception.php @@ -0,0 +1,8 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +class HOTP extends Base +{ + public $method = 'hotp'; + + protected $config = array( + 'digits' => 6, + 'window' => 4, + 'digest' => 'sha1', + ); + + protected $backend; + + /** + * + */ + public function init($config) + { + parent::init($config); + + $this->user_settings += array( + 'secret' => array( + 'type' => 'text', + 'private' => true, + 'label' => 'secret', + 'generator' => 'generate_secret', + ), + 'counter' => array( + 'type' => 'integer', + 'editable' => false, + 'hidden' => true, + 'generator' => 'random_counter', + ), + ); + + // copy config options + $this->backend = new \Kolab2FA\OTP\HOTP(); + $this->backend + ->setDigits($this->config['digits']) + ->setDigest($this->config['digest']) + ->setIssuer($this->config['issuer']) + ->setIssuerIncludedAsParameter(true); + } + + /** + * + */ + public function verify($code, $timestamp = null) + { + // get my secret from the user storage + $secret = $this->get('secret'); + $counter = $this->get('counter'); + + if (!strlen($secret)) { + // LOG: "no secret set for user $this->username" + // rcube::console("VERIFY HOTP: no secret set for user $this->username"); + return false; + } + + try { + $this->backend->setLabel($this->username)->setSecret($secret)->setCounter(intval($this->get('counter'))); + $pass = $this->backend->verify($code, $counter, $this->config['window']); + + // store incremented counter value + $this->set('counter', $this->backend->getCounter()); + $this->commit(); + } + catch (\Exception $e) { + // LOG: exception + // rcube::console("VERIFY HOTP: $this->id, " . strval($e)); + $pass = false; + } + + // rcube::console('VERIFY HOTP', $this->username, $secret, $counter, $code, $pass); + return $pass; + } + + /** + * + */ + public function get_provisioning_uri() + { + if (!$this->secret) { + // generate new secret and store it + $this->set('secret', $this->get('secret', true)); + $this->set('counter', $this->get('counter', true)); + $this->set('created', $this->get('created', true)); + $this->commit(); + } + + // TODO: deny call if already active? + + $this->backend->setLabel($this->username)->setSecret($this->secret)->setCounter(intval($this->get('counter'))); + return $this->backend->getProvisioningUri(); + } + + /** + * Generate a random counter value + */ + public function random_counter() + { + return mt_rand(1, 999); + } +} diff --git a/src/include/Kolab2FA/Driver/TOTP.php b/src/include/Kolab2FA/Driver/TOTP.php new file mode 100644 index 00000000..dd4845b2 --- /dev/null +++ b/src/include/Kolab2FA/Driver/TOTP.php @@ -0,0 +1,137 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +class TOTP extends Base +{ + public $method = 'totp'; + + protected $config = array( + 'digits' => 6, + 'interval' => 30, + 'digest' => 'sha1', + ); + + protected $backend; + + /** + * + */ + public function init($config) + { + parent::init($config); + + $this->user_settings += array( + 'secret' => array( + 'type' => 'text', + 'private' => true, + 'label' => 'secret', + 'generator' => 'generate_secret', + ), + ); + + // copy config options + $this->backend = new \Kolab2FA\OTP\TOTP(); + $this->backend + ->setDigits($this->config['digits']) + ->setInterval($this->config['interval']) + ->setDigest($this->config['digest']) + ->setIssuer($this->config['issuer']) + ->setIssuerIncludedAsParameter(true); + } + + /** + * + */ + public function verify($code, $timestamp = null) + { + // get my secret from the user storage + $secret = $this->get('secret'); + + if (!strlen($secret)) { + // LOG: "no secret set for user $this->username" + // rcube::console("VERIFY TOTP: no secret set for user $this->username"); + return false; + } + + $this->backend->setLabel($this->username)->setSecret($secret); + + // PHP gets a string, but we're comparing integers. + $code = (int)$code; +//$code = (string) $code; + // Pass a window to indicate the maximum timeslip between client (mobile + // device) and server. + $pass = $this->backend->verify($code, $timestamp, 150); + + // try all codes from $timestamp till now + if (!$pass && $timestamp) { + $now = time(); + while (!$pass && $timestamp < $now) { + $pass = $code === $this->backend->at($timestamp); + $timestamp += $this->config['interval']; + } + } + + // rcube::console('VERIFY TOTP', $this->username, $secret, $code, $timestamp, $pass); + return $pass; + } + + /** + * Get current code (for testing) + */ + public function get_code() + { + // get my secret from the user storage + $secret = $this->get('secret'); + + if (!strlen($secret)) { + return; + } + + $this->backend->setLabel($this->username)->setSecret($secret); + + return $this->backend->at(time()); + } + + /** + * + */ + public function get_provisioning_uri() + { + // rcube::console('PROV', $this->secret); + if (!$this->secret) { + // generate new secret and store it + $this->set('secret', $this->get('secret', true)); + $this->set('created', $this->get('created', true)); + // rcube::console('PROV2', $this->secret); + $this->commit(); + } + + // TODO: deny call if already active? + + $this->backend->setLabel($this->username)->setSecret($this->secret); + return $this->backend->getProvisioningUri(); + } + +} diff --git a/src/include/Kolab2FA/Driver/Yubikey.php b/src/include/Kolab2FA/Driver/Yubikey.php new file mode 100644 index 00000000..dd10bb33 --- /dev/null +++ b/src/include/Kolab2FA/Driver/Yubikey.php @@ -0,0 +1,126 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Driver; + +class Yubikey extends Base +{ + public $method = 'yubikey'; + + protected $backend; + + /** + * + */ + public function init($config) + { + parent::init($config); + + $this->user_settings += array( + 'yubikeyid' => array( + 'type' => 'text', + 'editable' => true, + 'label' => 'secret', + ), + ); + + // initialize validator + $this->backend = new \Yubikey\Validate($this->config['apikey'], $this->config['clientid']); + + // set configured validation hosts + if (!empty($this->config['hosts'])) { + $this->backend->setHosts((array)$this->config['hosts']); + } + + if (isset($this->config['use_https'])) { + $this->backend->setUseSecure((bool)$this->config['use_https']); + } + } + + /** + * + */ + public function verify($code, $timestamp = null) + { + // get my secret from the user storage + $keyid = $this->get('yubikeyid'); + $pass = false; + + if (!strlen($keyid)) { + // LOG: "no key registered for user $this->username" + return false; + } + + // check key prefix with associated Yubikey ID + if (strpos($code, $keyid) === 0) { + try { + $response = $this->backend->check($code); + $pass = $response->success() === true; + } + catch (\Exception $e) { + // TODO: log exception + } + } + + // rcube::console('VERIFY Yubikey', $this->username, $keyid, $code, $pass); + return $pass; + } + + /** + * @override + */ + public function set($key, $value) + { + if ($key == 'yubikeyid' && strlen($value) > 12) { + // verify the submitted code + try { + $response = $this->backend->check($value); + if ($response->success() !== true) { + // TODO: report error + return false; + } + } + catch (\Exception $e) { + return false; + } + + // truncate the submitted yubikey code to 12 characters + $value = substr($value, 0, 12); + } + + return parent::set($key, $value); + } + + /** + * @override + */ + protected function set_user_prop($key, $value) + { + // set created timestamp + if ($key !== 'created' && !isset($this->created)) { + parent::set_user_prop('created', $this->get('created', true)); + } + + return parent::set_user_prop($key, $value); + } +} diff --git a/src/include/Kolab2FA/Log/Logger.php b/src/include/Kolab2FA/Log/Logger.php new file mode 100644 index 00000000..7fccd5bc --- /dev/null +++ b/src/include/Kolab2FA/Log/Logger.php @@ -0,0 +1,42 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Log; + +interface Logger +{ + /** + * Setter for the log name + */ + public function set_name($name); + + /** + * Setter for the minimum log level + */ + public function set_level($level); + + /** + * Do log the given message at the given level + */ + public function log($level, $message); +} diff --git a/src/include/Kolab2FA/Log/RcubeLogger.php b/src/include/Kolab2FA/Log/RcubeLogger.php new file mode 100644 index 00000000..1befb473 --- /dev/null +++ b/src/include/Kolab2FA/Log/RcubeLogger.php @@ -0,0 +1,80 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Log; + +use \rcube; + +class RcubeLogger implements Logger +{ + protected $name = null; + protected $level = LOG_DEBUG; + + public function __construct($name = null) + { + if ($name !== null) { + $this->set_name($name); + } + } + + public function set_name($name) + { + $this->name = $name; + } + + public function set_level($name) + { + $this->level = $level; + } + + public function log($level, $message) + { + if (!is_string($message)) { + $message = var_export($message, true); + } + + switch ($level) { + case LOG_DEBUG: + case LOG_INFO: + case LOG_NOTICE: + if ($level >= $this->level) { + rcube::write_log($this->name ?: 'console', $message); + } + break; + + case LOG_EMERGE: + case LOG_ALERT: + case LOG_CRIT: + case LOG_ERR: + case LOG_WARNING: + rcube::raise_error(array( + 'code' => 600, + 'type' => 'php', + 'message' => $message, + ), true, false); + break; + } + } +} + diff --git a/src/include/Kolab2FA/Log/Syslog.php b/src/include/Kolab2FA/Log/Syslog.php new file mode 100644 index 00000000..68ad18a8 --- /dev/null +++ b/src/include/Kolab2FA/Log/Syslog.php @@ -0,0 +1,54 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Log; + +use \rcube; + +class Syslog implements Logger +{ + protected $name = 'Kolab2FA'; + protected $level = LOG_INFO; + + public function set_name($name) + { + $this->name = $name; + } + + public function set_level($name) + { + $this->level = $level; + } + + public function log($level, $message) + { + if ($level >= $this->level) { + if (!is_string($message)) { + $message = var_export($message, true); + } + + syslog($level, '[' . $this->name . '] ' . $message); + } + } +} diff --git a/src/include/Kolab2FA/OTP/HOTP.php b/src/include/Kolab2FA/OTP/HOTP.php new file mode 100644 index 00000000..530b0d37 --- /dev/null +++ b/src/include/Kolab2FA/OTP/HOTP.php @@ -0,0 +1,58 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +namespace Kolab2FA\OTP; + +use OTPHP\HOTP as Base; + +class HOTP extends Base +{ + use OTP; + protected $counter = 0; + + public function setCounter($counter) + { + if (!is_integer($counter) || $counter < 0) { + throw new \Exception('Counter must be at least 0.'); + } + $this->counter = $counter; + + return $this; + } + + public function getCounter() + { + return $this->counter; + } + + public function updateCounter($counter) + { + $this->counter = $counter; + + return $this; + } +} diff --git a/src/include/Kolab2FA/OTP/OTP.php b/src/include/Kolab2FA/OTP/OTP.php new file mode 100644 index 00000000..87c27ecc --- /dev/null +++ b/src/include/Kolab2FA/OTP/OTP.php @@ -0,0 +1,133 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\OTP; + +trait OTP +{ + protected $secret = null; + protected $issuer = null; + protected $issuer_included_as_parameter = false; + protected $label = null; + protected $digest = 'sha1'; + protected $digits = 6; + + public function setSecret($secret) + { + $this->secret = $secret; + + return $this; + } + + public function getSecret() + { + return $this->secret; + } + + public function setLabel($label) + { + if ($this->hasSemicolon($label)) { + throw new \Exception('Label must not contain a semi-colon.'); + } + $this->label = $label; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + public function setIssuer($issuer) + { + if ($this->hasSemicolon($issuer)) { + throw new \Exception('Issuer must not contain a semi-colon.'); + } + $this->issuer = $issuer; + + return $this; + } + + public function getIssuer() + { + return $this->issuer; + } + + public function isIssuerIncludedAsParameter() + { + return $this->issuer_included_as_parameter; + } + + public function setIssuerIncludedAsParameter($issuer_included_as_parameter) + { + $this->issuer_included_as_parameter = $issuer_included_as_parameter; + + return $this; + } + + public function setDigits($digits) + { + if (!is_numeric($digits) || $digits < 1) { + throw new \Exception('Digits must be at least 1.'); + } + $this->digits = $digits; + + return $this; + } + + public function getDigits() + { + return $this->digits; + } + + public function setDigest($digest) + { + if (!in_array($digest, array('md5', 'sha1', 'sha256', 'sha512'))) { + throw new \Exception("'$digest' digest is not supported."); + } + $this->digest = $digest; + + return $this; + } + + public function getDigest() + { + return $this->digest; + } + + private function hasSemicolon($value) + { + $semicolons = array(':', '%3A', '%3a'); + foreach ($semicolons as $semicolon) { + if (false !== strpos($value, $semicolon)) { + return true; + } + } + + return false; + } +} diff --git a/src/include/Kolab2FA/OTP/TOTP.php b/src/include/Kolab2FA/OTP/TOTP.php new file mode 100644 index 00000000..d972897c --- /dev/null +++ b/src/include/Kolab2FA/OTP/TOTP.php @@ -0,0 +1,50 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\OTP; + +use OTPHP\TOTP as Base; + +class TOTP extends Base +{ + use OTP; + protected $interval = 30; + + public function setInterval($interval) + { + if (!is_integer($interval) || $interval < 1) { + throw new \Exception('Interval must be at least 1.'); + } + $this->interval = $interval; + + return $this; + } + + public function getInterval() + { + return $this->interval; + } +} diff --git a/src/include/Kolab2FA/Storage/Base.php b/src/include/Kolab2FA/Storage/Base.php new file mode 100644 index 00000000..2cf96b86 --- /dev/null +++ b/src/include/Kolab2FA/Storage/Base.php @@ -0,0 +1,127 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Storage; + +use \Kolab2FA\Log; + + +abstract class Base +{ + public $username = null; + protected $config = array(); + protected $logger; + + /** + * + */ + public static function factory($backend, $config) + { + $classmap = array( + 'ldap' => '\\Kolab2FA\\Storage\\LDAP', + 'roundcube' => '\\Kolab2FA\\Storage\\RcubeUser', + 'rcubeuser' => '\\Kolab2FA\\Storage\\RcubeUser', + ); + + $cls = $classmap[strtolower($backend)]; + if ($cls && class_exists($cls)) { + return new $cls($config); + } + + throw new Exception("Unknown storage backend '$backend'"); + } + + /** + * Default constructor + */ + public function __construct($config = null) + { + if (is_array($config)) { + $this->init($config); + } + } + + /** + * Initialize the driver with the given config options + */ + public function init(array $config) + { + $this->config = array_merge($this->config, $config); + + // use syslog logger by default + $this->set_logger(new Log\Syslog()); + } + + /** + * + */ + public function set_logger(Log\Logger $logger) + { + $this->logger = $logger; + + if (!empty($this->config['debug'])) { + $this->logger->set_level(LOG_DEBUG); + } + else if (isset($this->config['loglevel'])) { + $this->logger->set_level($this->config['loglevel']); + } + } + + /** + * Set username to store data for + */ + public function set_username($username) + { + $this->username = $username; + } + + /** + * Send messager to the logging system + */ + protected function log($level, $message) + { + if ($this->logger) { + $this->logger->log($level, $message); + } + } + + /** + * List keys holding settings for 2-factor-authentication + */ + abstract public function enumerate(); + + /** + * Read data for the given key + */ + abstract public function read($key); + + /** + * Save data for the given key + */ + abstract public function write($key, $value); + + /** + * Remove the data stored for the given key + */ + abstract public function remove($key); +} diff --git a/src/include/Kolab2FA/Storage/Exception.php b/src/include/Kolab2FA/Storage/Exception.php new file mode 100644 index 00000000..5407acfb --- /dev/null +++ b/src/include/Kolab2FA/Storage/Exception.php @@ -0,0 +1,8 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Storage; + +use \Net_LDAP3; +use \Kolab2FA\Log\Logger; + +class LDAP extends Base +{ + public $userdn; + public $ready; + + private $cache = array(); + private $ldapcache = array(); + private $conn; + private $error; + + public function init(array $config) + { + parent::init($config); + + $this->conn = new Net_LDAP3($config); + $this->conn->config_set('log_hook', array($this, 'log')); + + $this->conn->connect(); + + $bind_pass = $this->config['bind_pass']; + $bind_user = $this->config['bind_user']; + $bind_dn = $this->config['bind_dn']; + + $this->ready = $this->conn->bind($bind_dn, $bind_pass); + + if (!$this->ready) { + throw new Exception("LDAP storage not ready: " . $this->error); + } + } + + /** + * List/set methods activated for this user + */ + public function enumerate($active = true) + { + $filter = $this->parse_vars($this->config['filter'], '*'); + $base_dn = $this->parse_vars($this->config['base_dn'], '*'); + $scope = $this->config['scope'] ?: 'sub'; + $ids = array(); + + if ($this->ready && ($result = $this->conn->search($base_dn, $filter, $scope, array($this->config['fieldmap']['id'], $this->config['fieldmap']['active'])))) { + foreach ($result as $dn => $entry) { + $rec = $this->field_mapping($dn, Net_LDAP3::normalize_entry($entry, true)); + if (!empty($rec['id']) && ($active === null || $active == $rec['active'])) { + $ids[] = $rec['id']; + } + } + } + + // TODO: cache this in memory + + return $ids; + } + + /** + * Read data for the given key + */ + public function read($key) + { + if (!isset($this->cache[$key])) { + $this->cache[$key] = $this->get_ldap_record($this->username, $key); + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + $success = false; + $ldap_attrs = array(); + + if (is_array($value)) { + // add some default values + $value += (array)$this->config['defaults'] + array('active' => false, 'username' => $this->username, 'userdn' => $this->userdn); + + foreach ($value as $k => $val) { + if ($attr = $this->config['fieldmap'][$k]) { + $ldap_attrs[$attr] = $this->value_mapping($k, $val, false); + } + } + } + else { + // invalid data structure + return false; + } + + // update existing record + if ($rec = $this->get_ldap_record($this->username, $key)) { + $old_attrs = $rec['_raw']; + $new_attrs = array_merge($old_attrs, $ldap_attrs); + + $result = $this->conn->modify_entry($rec['_dn'], $old_attrs, $new_attrs); + $success = !empty($result); + } + // insert new record + else if ($this->ready) { + $entry_dn = $this->get_entry_dn($this->username, $key); + + // add object class attribute + $me = $this; + $ldap_attrs['objectclass'] = array_map(function($cls) use ($me, $key) { + return $me->parse_vars($cls, $key); + }, (array)$this->config['objectclass']); + + $success = $this->conn->add_entry($entry_dn, $ldap_attrs); + } + + if ($success) { + $this->cache[$key] = $value; + $this->ldapcache = array(); + + // cleanup: remove disabled/inactive/temporary entries + if ($value['active']) { + foreach ($this->enumerate(false) as $id) { + if ($id != $key) { + $this->remove($id); + } + } + + // set user roles according to active factors + $this->set_user_roles(); + } + } + + return $success; + } + + /** + * Remove the data stored for the given key + */ + public function remove($key) + { + if ($this->ready) { + $entry_dn = $this->get_entry_dn($this->username, $key); + $success = $this->conn->delete_entry($entry_dn); + + // set user roles according to active factors + if ($success) { + $this->set_user_roles(); + } + + return $success; + } + + return false; + } + + /** + * Set username to store data for + */ + public function set_username($username) + { + parent::set_username($username); + + // reset cached values + $this->cache = array(); + $this->ldapcache = array(); + } + + /** + * + */ + protected function set_user_roles() + { + if (!$this->ready || !$this->userdn || empty($this->config['user_roles'])) { + return false; + } + + $auth_roles = array(); + foreach ($this->enumerate(true) as $id) { + foreach ($this->config['user_roles'] as $prefix => $role) { + if (strpos($id, $prefix) === 0) { + $auth_roles[] = $role; + } + } + } + + $role_attr = $this->config['fieldmap']['roles'] ?: 'nsroledn'; + if ($user_attrs = $this->conn->get_entry($this->userdn, array($role_attr))) { + $internals = array_values($this->config['user_roles']); + $new_attrs = $old_attrs = Net_LDAP3::normalize_entry($user_attrs); + $new_attrs[$role_attr] = array_merge( + array_unique($auth_roles), + array_filter((array)$old_attrs[$role_attr], function($f) use ($internals) { return !in_array($f, $internals); }) + ); + + $result = $this->conn->modify_entry($this->userdn, $old_attrs, $new_attrs); + return !empty($result); + } + + return false; + } + + /** + * Fetches user data from LDAP addressbook + */ + protected function get_ldap_record($user, $key) + { + $entry_dn = $this->get_entry_dn($user, $key); + + if (!isset($this->ldapcache[$entry_dn])) { + $this->ldapcache[$entry_dn] = array(); + + if ($this->ready && ($entry = $this->conn->get_entry($entry_dn, array_values($this->config['fieldmap'])))) { + $this->ldapcache[$entry_dn] = $this->field_mapping($entry_dn, Net_LDAP3::normalize_entry($entry, true)); + } + } + + return $this->ldapcache[$entry_dn]; + } + + /** + * Compose a full DN for the given record identifier + */ + protected function get_entry_dn($user, $key) + { + $base_dn = $this->parse_vars($this->config['base_dn'], $key); + return sprintf('%s=%s,%s', $this->config['rdn'], Net_LDAP3::quote_string($key, true), $base_dn); + } + + /** + * Maps LDAP attributes to defined fields + */ + protected function field_mapping($dn, $entry) + { + $entry['_dn'] = $dn; + $entry['_raw'] = $entry; + + // fields mapping + foreach ($this->config['fieldmap'] as $field => $attr) { + $attr_lc = strtolower($attr); + if (isset($entry[$attr_lc])) { + $entry[$field] = $this->value_mapping($field, $entry[$attr_lc], true); + } + else if (isset($entry[$attr])) { + $entry[$field] = $this->value_mapping($field, $entry[$attr], true); + } + } + + return $entry; + } + + /** + * + */ + protected function value_mapping($attr, $value, $reverse = false) + { + if ($map = $this->config['valuemap'][$attr]) { + if ($reverse) { + $map = array_flip($map); + } + + if (is_array($value)) { + $value = array_filter(array_map(function($val) use ($map) { + return $map[$val]; + }, $value)); + } + else { + $value = $map[$value]; + } + } + + // convert (date) type + switch ($this->config['attrtypes'][$attr]) { + case 'datetime': + $ts = is_numeric($value) ? $value : strtotime($value); + if ($ts) { + $value = gmdate($reverse ? 'U' : 'YmdHi\Z', $ts); + } + break; + + case 'integer': + $value = intval($value); + break; + } + + return $value; + } + + /** + * Prepares filter query for LDAP search + */ + protected function parse_vars($str, $key) + { + $user = $this->username; + + if (strpos($user, '@') > 0) { + list($u, $d) = explode('@', $user); + } + else if ($this->userdn) { + $u = $this->userdn; + $d = trim(str_replace(',dc=', '.', substr($u, strpos($u, ',dc='))), '.'); + } + + if ($this->userdn) { + $user = $this->userdn; + } + + // build hierarchal domain string + $dc = $this->conn->domain_root_dn($d); + + $class = $this->config['classmap'] ? $this->config['classmap']['*'] : '*'; + + // map key to objectclass + if (is_array($this->config['classmap'])) { + foreach ($this->config['classmap'] as $k => $c) { + if (strpos($key, $k) === 0) { + $class = $c; + break; + } + } + } + + $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $user, '%u' => $u, '%c' => $class); + + return strtr($str, $replaces); + } + +} diff --git a/src/include/Kolab2FA/Storage/RcubeUser.php b/src/include/Kolab2FA/Storage/RcubeUser.php new file mode 100644 index 00000000..1167aa77 --- /dev/null +++ b/src/include/Kolab2FA/Storage/RcubeUser.php @@ -0,0 +1,195 @@ + + * + * Copyright (C) 2015, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Kolab2FA\Storage; + +use \rcmail; +use \rcube_user; + +class RcubeUser extends Base +{ + // sefault config + protected $config = array( + 'keymap' => array(), + ); + + private $cache = array(); + private $user; + + public function init(array $config) + { + parent::init($config); + + $rcmail = rcmail::get_instance(); + $this->config['hostname'] = $rcmail->user->ID ? $rcmail->user->data['mail_host'] : $_SESSION['hostname']; + } + + /** + * List/set methods activated for this user + */ + public function enumerate() + { + if ($factors = $this->get_factors()) { + return array_keys(array_filter($factors, function($prop) { + return !empty($prop['active']); + })); + } + + return array(); + } + + /** + * Read data for the given key + */ + public function read($key) + { + if (!isset($this->cache[$key])) { + $factors = $this->get_factors(); + $this->log(LOG_DEBUG, 'RcubeUser::read() ' . $key); + $this->cache[$key] = $factors[$key]; + } + + return $this->cache[$key]; + } + + /** + * Save data for the given key + */ + public function write($key, $value) + { + $this->log(LOG_DEBUG, 'RcubeUser::write() ' . @json_encode($value)); + + if ($user = $this->get_user($this->username)) { + $this->cache[$key] = $value; + + $factors = $this->get_factors(); + $factors[$key] = $value; + + $pkey = $this->key2property('blob'); + $save_data = array($pkey => $factors); + $update_index = false; + + // remove entry + if ($value === null) { + unset($factors[$key]); + $update_index = true; + } + // remove non-active entries + else if (!empty($value['active'])) { + $factors = array_filter($factors, function($prop) { + return !empty($prop['active']); + }); + $update_index = true; + } + + // update the index of active factors + if ($update_index) { + $save_data[$this->key2property('factors')] = array_keys( + array_filter($factors, function($prop) { + return !empty($prop['active']); + }) + ); + } + + $success = $user->save_prefs($save_data, true); + + if (!$success) { + $this->log(LOG_WARNING, sprintf('Failed to save prefs for user %s', $this->username)); + } + + return $success; + } + + return false; + } + + /** + * Remove the data stored for the given key + */ + public function remove($key) + { + return $this->write($key, null); + } + + /** + * Set username to store data for + */ + public function set_username($username) + { + parent::set_username($username); + + // reset cached values + $this->cache = array(); + $this->user = null; + } + + /** + * Helper method to get a rcube_user instance for storing prefs + */ + private function get_user($username) + { + // use global instance if we have a valid Roundcube session + $rcmail = rcmail::get_instance(); + if ($rcmail->user->ID && $rcmail->user->get_username() == $username) { + return $rcmail->user; + } + + if (!$this->user) { + $this->user = rcube_user::query($username, $this->config['hostname']); + } + + if (!$this->user) { + $this->log(LOG_WARNING, sprintf('No user record found for %s @ %s', $username, $this->config['hostname'])); + } + + return $this->user; + } + + /** + * + */ + private function get_factors() + { + if ($user = $this->get_user($this->username)) { + $prefs = $user->get_prefs(); + return (array)$prefs[$this->key2property('blob')]; + } + + return null; + } + + /** + * + */ + private function key2property($key) + { + // map key to configured property name + if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { + return $this->config['keymap'][$key]; + } + + // default + return 'kolab_2fa_' . $key; + } + +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 39ad643f..6b7eede9 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,288 +1,289 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Menu' import router from './routes' import store from './store' import FontAwesomeIcon from './fontawesome' import VueToastr from '@deveodk/vue-toastr' window.Vue = require('vue') Vue.component('svg-icon', FontAwesomeIcon) Vue.use(VueToastr, { defaultPosition: 'toast-bottom-right', defaultTimeout: 5000 }) const vTooltip = (el, binding) => { const t = [] if (binding.modifiers.focus) t.push('focus') if (binding.modifiers.hover) t.push('hover') if (binding.modifiers.click) t.push('click') if (!t.length) t.push('hover') $(el).tooltip({ title: binding.value, placement: binding.arg || 'top', trigger: t.join(' '), html: !!binding.modifiers.html, }); } Vue.directive('tooltip', { bind: vTooltip, update: vTooltip, unbind (el) { $(el).tooltip('dispose') } }) // Add a response interceptor for general/validation error handler // This have to be before Vue and Router setup. Otherwise we would // not be able to handle axios responses initiated from inside // components created/mounted handlers (e.g. signup code verification link) window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { - var error_msg + let error_msg + let status = error.response ? error.response.status : 200 - if (error.response && error.response.status == 422) { + if (error.response && status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.listinput')) { // List input widget let list = input.next('.listinput-widget') list.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) list.addClass('is-invalid').next('.invalid-feedback').remove() list.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } return false } }); }) $('form .is-invalid:not(.listinput-widget)').first().focus() } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toastr('error', error_msg || "Server Error", 'Error') // Pass the error as-is return Promise.reject(error) } ) const app = new Vue({ el: '#app', components: { 'app-component': AppComponent, 'menu-component': MenuComponent }, store, router, data() { return { isLoading: true } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element $('#app').append($('
Loading
')) }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, price(price) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' }) }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js index 604659b7..cca3f5d6 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,37 +1,43 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, faSquare, } from '@fortawesome/free-regular-svg-icons' import { faCheck, faGlobe, faInfoCircle, + faLock, + faKey, + faSignInAlt, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet } from '@fortawesome/free-solid-svg-icons' // Register only these icons we need library.add( faCheckSquare, - faSquare, faCheck, faGlobe, faInfoCircle, + faLock, + faKey, + faSignInAlt, + faSquare, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet ) export default FontAwesomeIcon diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index 7c672437..7aeaf78c 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,19 +1,20 @@ 'Invalid username or password.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', + ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 2c5d0876..ca6f5fea 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,162 +1,165 @@ 'The :attribute must be accepted.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', + + '2fareq' => 'Second factor code is required.', + '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.', 'packageinvalid' => 'Invalid package selected.', 'packagerequired' => 'Package is required.', 'usernotexists' => 'Unable to find user.', 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index 339f9afa..30b02fb9 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,85 +1,80 @@ + - - diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 12b2dafe..50c2531e 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,191 +1,206 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } + /** + * Assert Toast element content (and close it) + */ + public function assertToast($type, $title, $message) + { + return $this->withinBody(function ($browser) use ($type, $title, $message) { + $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { + $browser->assertToastTitle($title) + ->assertToastMessage($message) + ->closeToast(); + }); + }); + } + /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename)) { sleep(2); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 3fb95140..e008f2d9 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,159 +1,202 @@ browse(function (Browser $browser) { $browser->visit(new Home()); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Error message $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Invalid username or password.') ->closeToast(); }); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }) ->assertUser('john@kolab.org'); // Assert no "Account status" for this account $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->click('.link-logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } + + /** + * Test 2-Factor Authentication + * + * @depends testLogoutByURL + */ + public function test2FA(): void + { + $this->browse(function (Browser $browser) { + // Test missing 2fa code + $browser->on(new Home()) + ->type('@email-input', 'ned@kolab.org') + ->type('@password-input', 'simple123') + ->press('form button') + ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') + ->assertSeeIn( + '@second-factor-input.is-invalid + .invalid-feedback', + 'Second factor code is required.' + ) + ->assertFocused('@second-factor-input') + ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + + // Test invalid code + $browser->type('@second-factor-input', '123456') + ->press('form button') + ->waitUntilMissing('@second-factor-input.is-invalid') + ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') + ->assertSeeIn( + '@second-factor-input.is-invalid + .invalid-feedback', + 'Second factor code is invalid.' + ) + ->assertFocused('@second-factor-input') + ->assertToast(Toast::TYPE_ERROR, 'Error', 'Form validation error'); + + $code = \App\Auth\SecondFactor::code('ned@kolab.org'); + + // Test valid (TOTP) code + $browser->type('@second-factor-input', $code) + ->press('form button') + ->waitUntilMissing('@second-factor-input.is-invalid') + ->waitForLocation('/dashboard')->on(new Dashboard()); + }); + } } diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php index cea31d50..7dd18d41 100644 --- a/src/tests/Browser/Pages/Home.php +++ b/src/tests/Browser/Pages/Home.php @@ -1,65 +1,73 @@ waitForLocation($this->url()) ->assertVisible('form.form-signin'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', + '@email-input' => '#inputEmail', + '@password-input' => '#inputPassword', + '@second-factor-input' => '#secondfactor', ]; } /** * Submit logon form. * * @param \Laravel\Dusk\Browser $browser The browser object * @param string $username User name * @param string $password User password * @param bool $wait_for_dashboard * * @return void */ public function submitLogon($browser, $username, $password, $wait_for_dashboard = false) { - $browser - ->type('#inputEmail', $username) - ->type('#inputPassword', $password) - ->press('form button'); + $browser->type('@email-input', $username) + ->type('@password-input', $password); + + if ($username == 'ned@kolab.org') { + $code = \App\Auth\SecondFactor::code('ned@kolab.org'); + $browser->type('@second-factor-input', $code); + } + + $browser->press('form button'); if ($wait_for_dashboard) { $browser->waitForLocation('/dashboard'); } } } diff --git a/src/tests/Feature/Auth/SecondFactorTest.php b/src/tests/Feature/Auth/SecondFactorTest.php new file mode 100644 index 00000000..511369b9 --- /dev/null +++ b/src/tests/Feature/Auth/SecondFactorTest.php @@ -0,0 +1,63 @@ +deleteTestUser('entitlement-test@kolabnow.com'); + } + + public function tearDown(): void + { + $this->deleteTestUser('entitlement-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test that 2FA config is removed from Roundcube database + * on entitlement delete + */ + public function testEntitlementDelete(): void + { + // Create the user, and assign 2FA to him, and add Roundcube setup + $sku_2fa = Sku::where('title', '2fa')->first(); + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $user->assignSku($sku_2fa); + SecondFactor::seed('entitlement-test@kolabnow.com'); + + $entitlement = Entitlement::where('sku_id', $sku_2fa->id) + ->where('entitleable_id', $user->id) + ->first(); + + $this->assertTrue(!empty($entitlement)); + + $sf = new SecondFactor($user); + $factors = $sf->factors(); + + $this->assertCount(2, $factors); + $this->assertSame('totp:8132a46b1f741f88de25f47e', $factors[0]); + $this->assertSame('dummy:dummy', $factors[1]); + + // Delete the entitlement, expect all configured 2FA methods in Roundcube removed + $entitlement->delete(); + + $this->assertTrue($entitlement->trashed()); + + $sf = new SecondFactor($user); + $factors = $sf->factors(); + + $this->assertCount(0, $factors); + } +} diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 0ad2406c..20460a34 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,837 +1,841 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); // Note: Details of the content are tested in testUserResponse() } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do either of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(0, $json); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($john->email, $json[1]['email']); $this->assertSame($ned->email, $json[2]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $this->assertArrayHasKey('isImapReady', $json[0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($john->email, $json[1]['email']); $this->assertSame($ned->email, $json[2]['email']); } /** * Test /api/auth/login */ public function testLogin(): string { // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); + // TODO: We have browser tests for 2FA but we should probably also test it here + return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } public function testRefresh(): void { // TODO $this->markTestIncomplete(); } public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::where('title', 'storage')->first(); $groupware_sku = Sku::where('title', 'groupware')->first(); $mailbox_sku = Sku::where('title', 'mailbox')->first(); + $secondfactor_sku = Sku::where('title', '2fa')->first(); - $this->assertCount(3, $json['skus']); + $this->assertCount(4, $json['skus']); $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); + $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@kolab.org', $aliases[0]->alias); $this->assertSame('useralias2@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Test acting as account controller (not owner) /* // FIXME: How do we know to which wallet the new user should be assigned to? $this->deleteTestUser('john2.doe2@kolab.org'); $response = $this->actingAs($ned)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); */ $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on setting an alias to other user's domain // and missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@kolab.org'] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(1, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $package_lite = Package::where('title', 'lite')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $sku_storage = Sku::where('title', 'storage')->first(); $sku_groupware = Sku::where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 3, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage']); $this->assertSame([0, 0, 25], $storage_cost); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { // TODO: Test more cases of entitlements update $this->markTestIncomplete(); } /** * List of alias validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, true, 'The specified alias is invalid.'], [".@$domain", $john, true, 'The specified alias is invalid.'], ["test123456@localhost", $john, true, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'], ["$domain", $john, false, 'The specified email is invalid.'], [".@$domain", $john, false, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, true, 'The specified alias is not available.'], ["administrator@$domain", $john, true, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, true, 'The specified domain is not available.'], // existing alias of other user ["jack.daniels@kolab.org", $john, true, 'The specified alias is not available.'], // existing user ["jack@kolab.org", $john, true, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, true, null], // valid (public domain) ["test.test@$domain", $john, true, null], ]; } /** * User email/alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($alias, $user, $is_alias, $expected_result): void { $result = $this->invokeMethod(new UsersController(), 'validateEmail', [$alias, $user, $is_alias]); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 8bd655c0..cd6c8592 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,107 +1,108 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Tests for User::AddEntitlement() */ public function testUserAddEntitlement(): void { $package_domain = Package::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $sku_domain = Sku::where('title', 'domain-hosting')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_kolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(4, $owner->entitlements()->get()); $this->assertCount(1, $sku_domain->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(2, $sku_mailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(9, $wallet->entitlements); $this->backdateEntitlements($owner->entitlements, Carbon::now()->subMonths(1)); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } public function testAddExistingEntitlement(): void { $this->markTestIncomplete(); } public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); $entitlement = Entitlement::where('wallet_id', $wallet->id)->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $e_sku = $entitlement->sku; $this->assertSame($sku->id, $e_sku->id); $e_wallet = $entitlement->wallet; $this->assertSame($wallet->id, $e_wallet->id); $e_entitleable = $entitlement->entitleable; $this->assertEquals($user->id, $e_entitleable->id); $this->assertTrue($e_entitleable instanceof \App\User); } } diff --git a/src/tests/data/2fa-code.png b/src/tests/data/2fa-code.png new file mode 100644 index 00000000..d31fe26e Binary files /dev/null and b/src/tests/data/2fa-code.png differ