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/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php new file mode 100644 index 00000000..7848f029 --- /dev/null +++ b/src/app/Auth/SecondFactor.php @@ -0,0 +1,243 @@ +user = Auth::guard()->user(); + + // get list of configured authentication factors + $factors = $this->factors(); + + // do nothing if no factors configured + if (empty($factors)) { + return null; + } + + // flag session for 2nd factor verification + $_SESSION['2fa_time'] = time(); + $_SESSION['2fa_nonce'] = bin2hex(openssl_random_pseudo_bytes(32)); + $_SESSION['2fa_factors'] = $factors; + $_SESSION['2fa_username'] = $this->username; + $_SESSION['2fa_account'] = $user; + $_SESSION['2fa_apitoken'] = API\Client::get_user_instance()->get_session_token(); + + // define login form + $nonce = $_SESSION['2fa_nonce']; + + $methods = array_unique( + array_map(function ($factor) { + list($method, $id) = explode(':', $factor); + return $method; + }, + $factors + ) + ); + + $required = count($methods) == 1; + + foreach ($methods as $i => $method) { + $methods[$i] = array( + 'name' => "${nonce}-${method}", + 'label' => \trans("login.$method"), + 'required' => $required, + ); + } + + return [ + 'second-factor' => $methods + ]; + } + + /** + * Validate 2-factor authentication code + */ + public function verify($post) + { + if (empty($this->username) + || empty($_SESSION['2fa_username']) + || $_SESSION['2fa_username'] != $post['username'] + ) { + return; + } + + $time = $_SESSION['2fa_time']; + $nonce = $_SESSION['2fa_nonce']; + $factors = (array) $_SESSION['2fa_factors']; + $expired = $time < time() - \config('2fa.timeout', 120); + $verified = false; + + if (!empty($factors) && !empty($nonce) && !$expired) { + // try to verify each configured factor + foreach ($factors as $factor) { + list($method) = explode(':', $factor, 2); + + // verify the submitted code + $code = strip_tags($_POST["${nonce}-${method}"]); + if ($code && ($verified = $this->verify_factor_auth($factor, $code))) { + // accept first successful method + break; + } + } + } + +/* + if (!$verified) { + \Log::info("2-FACTOR failure for {$this->user->name}"); + $this->output->add_message(T('login.invalid2facode'), 'warning'); + $this->login($_SESSION['2fa_account']); + return; + } + + // setup user session + $user = $_SESSION['2fa_account']; + // API\Client::get_user_instance()->set_session_token($_SESSION['2fa_apitoken']); + + // clean up + unset($_SESSION['2fa_time'], $_SESSION['2fa_nonce'], $_SESSION['2fa_factors'], + $_SESSION['2fa_username'], $_SESSION['2fa_account'], $_SESSION['2fa_apitoken']); + + return $user; +*/ + } + + /** + * Remove all configured 2FA methods for the current user + * + * @return bool True on success, False otherwise + */ + public function removeFactors(): bool + { + if ($this->user && ($storage = $this->get_storage($this->user->email))) { + return $storage->remove_all_factors(); + } + + return false; + } + + /** + * Returns a list of 2nd factor methods configured for the user + */ + protected function factors(): ?array + { + $sku_2fa = Sku::where('title', '2fa')->first(); + $has_2fa = $this->user->entitlements()->where('sku_id', $sku_2fa->id)->first(); + + if ($has_2fa) { + if ($storage = $this->get_storage($this->user->email)) { + $factors = (array) $storage->enumerate(); + $factors = array_unique($factors); + + return $factors; + } + } + + return null; + } + + /** + * Helper method to verify the given method/code tuple + * + * @param string $factor Factor identifier (:) + * @param string $code Authentication code + * + * @return boolean + */ + protected function verify_factor_auth($factor, $code) + { + if (strlen($code) && ($driver = $this->get_driver($factor))) { + $driver->username = $this->user->email; + + try { + // verify the submitted code + return $driver->verify($code, $_SESSION['2fa_time']); + } + catch (\Exception $e) { + \Log::error("2-FACTOR failure for {$this->user->email}: " . $e->getMessage()); + } + } + + return false; + } + + /** + * Load driver class for the given authentication factor + * + * @param string $factor Factor identifier (:) + * + * @return Kolab2FA\Driver\Base + */ + protected function get_driver($factor) + { + list($method) = explode(':', $factor, 2); + + if ($this->drivers[$factor]) { + return $this->drivers[$factor]; + } + + $config = \config('2fa.' . $method, array()); + + // use product name as "issuer" + if (empty($config['issuer'])) { + $config['issuer'] = \config('app.name'); + } + + try { + $driver = \Kolab2FA\Driver\Base::factory($factor, $config); + + // configure driver + $driver->storage = $this->get_storage(); + $driver->username = $this->user->email; + + return $driver; + } + catch (\Exception $e) { + \Log::error("2-FACTOR driver failure for {$this->user->email}: " . $e->getMessage()); + } + } + + /** + * Getter for a storage instance singleton + */ + protected function get_storage($for = null) + { + if (!isset($this->storage) || (!empty($for) && $this->storage->username !== $for)) { + $config = \config('2fa', array()); + + try { + $this->storage = new SecondFactorStorage($config); + $this->storage->set_username($for); +//TODO $this->storage->set_logger(new \Kolab2FA\Log\RcubeLogger()); + } + catch (\Exception $e) { + $this->storage = null; + \Log::error("2-FACTOR storage failure for {$for}: " . $e->getMessage()); + } + } + + return $this->storage; + } +} diff --git a/src/app/Auth/SecondFactorStorage.php b/src/app/Auth/SecondFactorStorage.php new file mode 100644 index 00000000..7a7e7ab6 --- /dev/null +++ b/src/app/Auth/SecondFactorStorage.php @@ -0,0 +1,147 @@ + array(), + ); + + private $cache = array(); + + + /** + * 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) + { + \Log::debug(__METHOD__ . ' ' . $key); + + if (!isset($this->cache[$key])) { + $factors = $this->get_factors(); + $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); + } + + /** + * Set username to store data for + */ + public function set_username($username) + { + parent::set_username($username); + + // reset cached values + $this->cache = array(); + } + + public function remove_all_factors() + { + $this->cache = array(); + + $prefs = array(); + $prefs[$this->key2property('blob')] = null; + $prefs[$this->key2property('factors')] = null; + + return $this->username ? $this->save_prefs($prefs) : false; + } + + /** + * + */ + private function get_factors() + { + $prefs = $this->get_prefs(); + + return (array) $prefs[$this->key2property('blob')]; + } + + /** + * + */ + 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; + } + + /** + * Gets user preferences from Roundcube users table + */ + private function get_prefs() + { + $dbh = DB::connection('2fa'); + $user = $dbh->table('users') + ->select('preferences') + ->where('username', strtolower($this->username)) + ->first(); + + return $user ? (array) unserialize($user->preferences) : null; + } + + /** + * Saves user preferences in Roundcube users table. + * This will merge into old preferences + */ + private function save_prefs($prefs) + { + $old_prefs = $this->get_prefs(); + + if (!is_array($old_prefs)) { + return false; + } + + $prefs = array_merge($old_prefs, $prefs); + + $dbh = DB::connection('2fa'); + $dbh->table('users') + ->where('username', strtolower($this->username)) + ->update(['preferences' => serialize($prefs)]); + + return true; + } +} diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index a71f8663..e007835c 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,649 +1,661 @@ 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)) { + // So right now we're doing this after the successful login, + // which mean we require username/password also on 2-stage request + + $sf = new \App\Auth\SecondFactor(); + + // If 2FA is not specified, we respond with 2FA methods + // so UI displays additional input(s) and submits the form back + // If is's specified we'll verify it, maybe we'll need to do this in a separate route + if ($response = $sf->login($request)) { + return response()->json($response, 401); + } + 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/composer.json b/src/composer.json index 3996ac16..ce4d5449 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": "~5.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/database.php b/src/config/database.php index 921769ca..b0316816 100644 --- a/src/config/database.php +++ b/src/config/database.php @@ -1,147 +1,151 @@ 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, ], + '2fa' => [ + // 'driver' => 'mysql', + 'url' => env('DB_2FA'), + ], ], /* |-------------------------------------------------------------------------- | 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/include/Kolab2FA/Driver/Base.php b/src/include/Kolab2FA/Driver/Base.php new file mode 100644 index 00000000..ec90239f --- /dev/null +++ b/src/include/Kolab2FA/Driver/Base.php @@ -0,0 +1,344 @@ + + * + * 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; + + 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 ($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 ($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); + } + + /** + * 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..5447c5ca --- /dev/null +++ b/src/include/Kolab2FA/Driver/TOTP.php @@ -0,0 +1,120 @@ + + * + * 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; + } + + /** + * + */ + 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..b6fbf86b --- /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(array $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..7b9ecf59 --- /dev/null +++ b/src/include/Kolab2FA/Storage/Base.php @@ -0,0 +1,128 @@ + + * + * 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', + 'hkccp' => '\\Kolab2FA\\Storage\\HKCCP', + ); + + $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 ($this->config['debug']) { + $this->logger->set_level(LOG_DEBUG); + } + else if ($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; + + 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..e9c9aa9f 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,288 +1,292 @@ /** * 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') + // Ignore error on login request returning 2FA "request" + if (status != 401 || !error.response || !error.response.data || !error.response.data['second-factor']) { + 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/lang/en/auth.php b/src/resources/lang/en/auth.php index 7c672437..939e832e 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,19 +1,22 @@ 'Invalid username or password.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', + + 'login.totp' => 'Mobile app (TOTP)', + 'login.yubikey' => 'Yubikey', ]; diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index 339f9afa..ae81e9db 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,85 +1,106 @@ +