diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -86,6 +86,23 @@ } /** + * Get the user (geo) location + * + * @return \Illuminate\Http\JsonResponse + */ + public function location() + { + $ip = request()->ip(); + + $response = [ + 'ipAddress' => $ip, + 'countryCode' => \App\Utils::countryForIP($ip, ''), + ]; + + return response()->json($response); + } + + /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php --- a/src/app/Http/Controllers/API/PasswordResetController.php +++ b/src/app/Http/Controllers/API/PasswordResetController.php @@ -47,6 +47,13 @@ return response()->json(['status' => 'error', 'errors' => $errors], 422); } + // Geo-lockin check + if (!$user->validateLocation($request->ip())) { + // FIXME: Or maybe we should just throw some more generic error response/code? + $errors = ['email' => \trans('validation.geolockinerror')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + // Generate the verification code $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -179,6 +179,34 @@ } /** + * Reset Geo-Lockin for the user + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $id User identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function resetGeoLock(Request $request, $id) + { + $user = User::find($id); + + if (!$this->checkTenant($user)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canUpdate($user)) { + return $this->errorResponse(403); + } + + $user->setConfig(['limit_geo' => []]); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.user-reset-geo-lock-success'), + ]); + } + + /** * Set/Add a SKU for the user * * @param \Illuminate\Http\Request $request The API request. diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -41,6 +41,7 @@ // TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready) // TODO: validate the user is A-OK (active, not suspended, ldapready, imapready) + // TODO: we could use User::findAndAuthenticate() with some modifications here if (!Hash::check($password, $user->password)) { $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); @@ -54,21 +55,12 @@ } // validate country of origin against restrictions, otherwise bye bye - $countryCodes = json_decode($user->getSetting('limit_geo', "[]")); - - \Log::debug("Countries for {$user->email}: " . var_export($countryCodes, true)); - - if (!empty($countryCodes)) { - $country = \App\Utils::countryForIP($clientIP); - if (!in_array($country, $countryCodes)) { - \Log::info( - "Failed authentication attempt due to country code mismatch ({$country}) for user: {$login}" - ); - $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); - $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION); - $attempt->notify(); - throw new \Exception("Country code mismatch"); - } + if (!$user->validateLocation($clientIP)) { + \Log::info("Failed authentication attempt due to country code mismatch for user: {$login}"); + $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION); + $attempt->notify(); + throw new \Exception("Country code mismatch"); } // TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of @@ -81,6 +73,7 @@ throw new \Exception("2fa failed"); } } + return $user; } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -184,6 +184,7 @@ $result = [ 'skus' => $skus, + 'enableBeta' => in_array('beta', $skus), // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners diff --git a/src/app/IP4Net.php b/src/app/IP4Net.php --- a/src/app/IP4Net.php +++ b/src/app/IP4Net.php @@ -3,7 +3,6 @@ namespace App; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\DB; class IP4Net extends Model { @@ -21,10 +20,18 @@ 'updated_at' ]; + /** + * Get IP network by IP address + * + * @param string $ip IPv4 address + * + * @return ?\App\IP4Net IPv4 network record, Null if not found + */ public static function getNet($ip) { $where = 'INET_ATON(net_number) <= INET_ATON(?) and INET_ATON(net_broadcast) >= INET_ATON(?)'; - return IP4Net::whereRaw($where, [$ip, $ip]) + + return self::whereRaw($where, [$ip, $ip]) ->orderByRaw('INET_ATON(net_number), net_mask DESC') ->first(); } diff --git a/src/app/IP6Net.php b/src/app/IP6Net.php --- a/src/app/IP6Net.php +++ b/src/app/IP6Net.php @@ -3,7 +3,6 @@ namespace App; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\DB; class IP6Net extends Model { @@ -21,9 +20,17 @@ 'updated_at' ]; + /** + * Get IP network by IP address + * + * @param string $ip IPv6 address + * + * @return ?\App\IP6Net IPv6 network record, Null if not found + */ public static function getNet($ip) { $where = 'INET6_ATON(net_number) <= INET6_ATON(?) and INET6_ATON(net_broadcast) >= INET6_ATON(?)'; + return IP6Net::whereRaw($where, [$ip, $ip]) ->orderByRaw('INET6_ATON(net_number), net_mask DESC') ->first(); diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php --- a/src/app/Traits/UserConfigTrait.php +++ b/src/app/Traits/UserConfigTrait.php @@ -11,10 +11,11 @@ */ public function getConfig(): array { - $settings = $this->getSettings(['greylist_enabled', 'password_policy', 'max_password_age']); + $settings = $this->getSettings(['greylist_enabled', 'password_policy', 'max_password_age', 'limit_geo']); $config = [ 'greylist_enabled' => $settings['greylist_enabled'] !== 'false', + 'limit_geo' => $settings['limit_geo'] ? json_decode($settings['limit_geo'], true) : [], 'max_password_age' => $settings['max_password_age'], 'password_policy' => $settings['password_policy'], ]; @@ -36,6 +37,26 @@ foreach ($config as $key => $value) { if ($key == 'greylist_enabled') { $this->setSetting($key, $value ? 'true' : 'false'); + } elseif ($key == 'limit_geo') { + if (!is_array($value)) { + $errors[$key] = \trans('validation.invalid-limit-geo'); + continue; + } + + foreach ($value as $idx => $country) { + if (!preg_match('/^[a-zA-Z]{2}$/', $country)) { + $errors[$key] = \trans('validation.invalid-limit-geo'); + continue 2; + } + + $value[$idx] = \strtoupper($country); + } + + if (count($value) > 250) { + $errors[$key] = \trans('validation.invalid-limit-geo'); + } + + $this->setSetting($key, !empty($value) ? json_encode($value) : null); } elseif ($key == 'max_password_age') { $this->setSetting($key, intval($value) > 0 ? (int) $value : null); } elseif ($key == 'password_policy') { diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -673,6 +673,24 @@ } /** + * Validate request location regarding geo-lockin + * + * @param string $ip IP address to check, usually request()->ip() + * + * @return bool + */ + public function validateLocation($ip): bool + { + $countryCodes = json_decode($this->getSetting('limit_geo', "[]")); + + if (empty($countryCodes)) { + return true; + } + + return in_array(\App\Utils::countryForIP($ip), $countryCodes); + } + + /** * Retrieve and authenticate a user * * @param string $username The username. @@ -685,6 +703,9 @@ { $user = User::where('email', $username)->first(); + // TODO: 'reason' below could be AuthAttempt::REASON_* + // TODO: $secondFactor argument is not used anywhere + if (!$user) { return ['reason' => 'notfound', 'errorMessage' => "User not found."]; } @@ -693,6 +714,10 @@ return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; } + if (!$user->validateLocation(request()->ip())) { + return ['reason' => 'geolocation', 'errorMessage' => "Country code mismatch."]; + } + if (!$secondFactor) { // Check the request if there is a second factor provided // as fallback. @@ -720,6 +745,8 @@ $result = self::findAndAuthenticate($username, $password); if (isset($result['reason'])) { + // TODO: Shouldn't we create AuthAttempt record here? + if ($result['reason'] == 'secondfactor') { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -45,9 +45,12 @@ /** * Return the country ISO code for an IP address. * + * @param string $ip IP address + * @param string $fallback Fallback country code + * * @return string */ - public static function countryForIP($ip) + public static function countryForIP($ip, $fallback = 'CH') { if (strpos($ip, ':') === false) { $net = \App\IP4Net::getNet($ip); @@ -55,7 +58,7 @@ $net = \App\IP6Net::getNet($ip); } - return $net && $net->country ? $net->country : 'CH'; + return $net && $net->country ? $net->country : $fallback; } /** diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -132,7 +132,6 @@ 'last_name' => 'Flanders', 'currency' => 'USD', 'country' => 'US', - // 'limit_geo' => json_encode(["CH"]), 'guam_enabled' => false, ] ); diff --git a/src/package-lock.json b/src/package-lock.json --- a/src/package-lock.json +++ b/src/package-lock.json @@ -5877,120 +5877,6 @@ "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", "dev": true }, - "node_modules/html-loader": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-1.3.2.tgz", - "integrity": "sha512-DEkUwSd0sijK5PF3kRWspYi56XP7bTNkyg5YWSzBdjaSDmvCufep5c4Vpb3PBf6lUL0YPtLwBfy9fL0t5hBAGA==", - "dev": true, - "dependencies": { - "html-minifier-terser": "^5.1.1", - "htmlparser2": "^4.1.0", - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/html-loader/node_modules/schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/html-minifier-terser": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", - "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==", - "dev": true, - "dependencies": { - "camel-case": "^4.1.1", - "clean-css": "^4.2.3", - "commander": "^4.1.1", - "he": "^1.2.0", - "param-case": "^3.0.3", - "relateurl": "^0.2.7", - "terser": "^4.6.3" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/html-minifier-terser/node_modules/clean-css": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", - "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", - "dev": true, - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 4.0" - } - }, - "node_modules/html-minifier-terser/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/html-minifier-terser/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/html-minifier-terser/node_modules/terser": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", - "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", - "dev": true, - "dependencies": { - "commander": "^2.20.0", - "source-map": "~0.6.1", - "source-map-support": "~0.5.12" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, "node_modules/html-tags": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz", @@ -6866,6 +6752,93 @@ "node": ">=8" } }, + "node_modules/laravel-mix/node_modules/html-loader": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-1.3.2.tgz", + "integrity": "sha512-DEkUwSd0sijK5PF3kRWspYi56XP7bTNkyg5YWSzBdjaSDmvCufep5c4Vpb3PBf6lUL0YPtLwBfy9fL0t5hBAGA==", + "dev": true, + "dependencies": { + "html-minifier-terser": "^5.1.1", + "htmlparser2": "^4.1.0", + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/laravel-mix/node_modules/html-minifier-terser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", + "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==", + "dev": true, + "dependencies": { + "camel-case": "^4.1.1", + "clean-css": "^4.2.3", + "commander": "^4.1.1", + "he": "^1.2.0", + "param-case": "^3.0.3", + "relateurl": "^0.2.7", + "terser": "^4.6.3" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/laravel-mix/node_modules/html-minifier-terser/node_modules/clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "dev": true, + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/laravel-mix/node_modules/html-minifier-terser/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/laravel-mix/node_modules/html-minifier-terser/node_modules/terser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", + "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", + "dev": true, + "dependencies": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/laravel-mix/node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/laravel-mix/node_modules/schema-utils": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", @@ -6899,6 +6872,15 @@ "node": ">=10" } }, + "node_modules/laravel-mix/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/laravel-mix/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9565,7 +9547,7 @@ "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", "dev": true, "engines": { "node": ">= 0.10" @@ -11179,9 +11161,9 @@ } }, "node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/tty-browserify": { @@ -16931,88 +16913,6 @@ "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", "dev": true }, - "html-loader": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-1.3.2.tgz", - "integrity": "sha512-DEkUwSd0sijK5PF3kRWspYi56XP7bTNkyg5YWSzBdjaSDmvCufep5c4Vpb3PBf6lUL0YPtLwBfy9fL0t5hBAGA==", - "dev": true, - "requires": { - "html-minifier-terser": "^5.1.1", - "htmlparser2": "^4.1.0", - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "dependencies": { - "schema-utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", - "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - } - } - } - }, - "html-minifier-terser": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", - "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==", - "dev": true, - "requires": { - "camel-case": "^4.1.1", - "clean-css": "^4.2.3", - "commander": "^4.1.1", - "he": "^1.2.0", - "param-case": "^3.0.3", - "relateurl": "^0.2.7", - "terser": "^4.6.3" - }, - "dependencies": { - "clean-css": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", - "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", - "dev": true, - "requires": { - "source-map": "~0.6.0" - } - }, - "commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "terser": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", - "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", - "dev": true, - "requires": { - "commander": "^2.20.0", - "source-map": "~0.6.1", - "source-map-support": "~0.5.12" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - } - } - } - } - }, "html-tags": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz", @@ -17639,6 +17539,69 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "html-loader": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-1.3.2.tgz", + "integrity": "sha512-DEkUwSd0sijK5PF3kRWspYi56XP7bTNkyg5YWSzBdjaSDmvCufep5c4Vpb3PBf6lUL0YPtLwBfy9fL0t5hBAGA==", + "dev": true, + "requires": { + "html-minifier-terser": "^5.1.1", + "htmlparser2": "^4.1.0", + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + } + }, + "html-minifier-terser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz", + "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==", + "dev": true, + "requires": { + "camel-case": "^4.1.1", + "clean-css": "^4.2.3", + "commander": "^4.1.1", + "he": "^1.2.0", + "param-case": "^3.0.3", + "relateurl": "^0.2.7", + "terser": "^4.6.3" + }, + "dependencies": { + "clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + } + }, + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, + "terser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", + "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + } + } + } + } + }, "schema-utils": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", @@ -17659,6 +17622,12 @@ "lru-cache": "^6.0.0" } }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -19643,7 +19612,7 @@ "relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", "dev": true }, "remark": { @@ -20854,9 +20823,9 @@ "dev": true }, "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "tty-browserify": { diff --git a/src/public/images/world.svg b/src/public/images/world.svg new file mode 100644 --- /dev/null +++ b/src/public/images/world.svg @@ -0,0 +1,261 @@ + diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -77,6 +77,24 @@ }, methods: { clearFormValidation, + countriesText(list) { + if (list && list.length) { + let result = [] + + list.forEach(code => { + let country = window.config.countries[code] + if (country) { + result.push(country[1]) + } else { + console.warn(`Unknown country code: ${code}`) + } + }) + + return result.join(', ') + } + + return this.$t('form.norestrictions') + }, hasPermission(type) { const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(this.authInfo && this.authInfo.statusInfo[key]) diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -99,6 +99,7 @@ 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', + 'user-reset-geo-lock-success' => 'Geo-lockin setup reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -161,14 +161,17 @@ 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", + 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", 'name' => "Name", 'months' => "months", 'none' => "none", + 'norestrictions' => "No restrictions", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", + 'selectcountries' => "Select countries", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", @@ -176,6 +179,7 @@ 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", + 'unknown' => "unknown", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", @@ -448,6 +452,8 @@ 'ext-email' => "External Email", 'email-aliases' => "Email Aliases", 'finances' => "Finances", + 'geolimit' => "Geo-lockin", + 'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -159,11 +159,13 @@ 'file-name-exists' => 'The file name already exists.', 'file-name-invalid' => 'The file name is invalid.', 'file-name-toolong' => 'The file name is too long.', + 'geolockinerror' => 'The request location is not allowed.', 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', 'nameinvalid' => 'The specified name is invalid.', 'password-policy-error' => 'Specified password does not comply with the policy.', + 'invalid-limit-geo' => 'Specified configuration is invalid. Expected a list of two-letter country codes.', 'invalid-password-policy' => 'Specified password policy is invalid.', 'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.', 'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.', diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -286,6 +286,25 @@ .modal-body { overflow: auto !important; } + + &.fullscreen { + .modal-dialog { + height: 100%; + width: 100%; + max-width: calc(100vw - 1rem); + } + + .modal-content { + height: 100%; + max-height: 100% !important; + } + + .modal-body { + padding: 0; + margin: 1em; + overflow: hidden !important; + } + } } #status-box { diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -198,3 +198,56 @@ color: $main-color; } } + +.world-map { + height: 100%; + width: 100%; + text-align: center; + overflow: auto; + + svg { + height: 100%; + + .bg { + fill: $gray-600; + } + + [cc] { + fill: white; + stroke: $gray-600; + stroke-width: 0.25px; + cursor: pointer; + } + + [cc][aria-selected="true"] { + fill: $main-color; + stroke: #fff; + } + + [cc][data-location] { + fill: $success; + stroke: #fff; + } + + [cc]:focus, [cc]:hover { + fill: lighten($main-color, 20%) !important; + stroke: $main-color !important; + } + } + + & + .tools { + position: absolute; + right: 0; + bottom: 0; + background-color: rgba(211, 211, 211, 0.9); + } + + & + .tools + .location { + position: absolute; + top: 0; + background-color: rgba(211, 211, 211, 0.9); + max-width: calc(100% - 1em); + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -205,6 +205,15 @@ +