From e8fc74f4d5e72d23f7010c886527bb74005f35cd Mon Sep 17 00:00:00 2001 From: Dan Wallis Date: Wed, 13 Mar 2024 17:20:23 +0000 Subject: [PATCH] Initial commit --- .gitignore | 2 + Makefile | 16 ++ README.md | 6 + composer-require-checker.json | 15 ++ composer.json | 57 +++++++ phpcs.xml | 15 ++ src/Controller/Adminhtml/Login/Refresh.php | 142 ++++++++++++++++++ src/Plugin/Magento/Captcha.php | 28 ++++ src/Plugin/Magento/SetAdminUserLocale.php | 84 +++++++++++ src/Plugin/Magento/TwoFactorAuth/Bypass.php | 34 +++++ src/Scope/Config.php | 50 ++++++ src/etc/adminhtml/csp_whitelist.xml | 11 ++ src/etc/adminhtml/di.xml | 18 +++ src/etc/adminhtml/routes.xml | 9 ++ src/etc/adminhtml/system.xml | 42 ++++++ src/etc/config.xml | 12 ++ src/etc/module.xml | 13 ++ src/registration.php | 9 ++ .../adminhtml/layout/adminhtml_auth_login.xml | 13 ++ src/view/adminhtml/layout/default.xml | 16 ++ src/view/adminhtml/templates/default.phtml | 9 ++ src/view/adminhtml/templates/login.phtml | 7 + src/view/adminhtml/web/default.less | 29 ++++ src/view/adminhtml/web/fillPassword.js | 23 +++ src/view/adminhtml/web/login.js | 81 ++++++++++ 25 files changed, 741 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 composer-require-checker.json create mode 100644 composer.json create mode 100644 phpcs.xml create mode 100644 src/Controller/Adminhtml/Login/Refresh.php create mode 100644 src/Plugin/Magento/Captcha.php create mode 100644 src/Plugin/Magento/SetAdminUserLocale.php create mode 100644 src/Plugin/Magento/TwoFactorAuth/Bypass.php create mode 100644 src/Scope/Config.php create mode 100644 src/etc/adminhtml/csp_whitelist.xml create mode 100644 src/etc/adminhtml/di.xml create mode 100644 src/etc/adminhtml/routes.xml create mode 100644 src/etc/adminhtml/system.xml create mode 100644 src/etc/config.xml create mode 100644 src/etc/module.xml create mode 100644 src/registration.php create mode 100644 src/view/adminhtml/layout/adminhtml_auth_login.xml create mode 100644 src/view/adminhtml/layout/default.xml create mode 100644 src/view/adminhtml/templates/default.phtml create mode 100644 src/view/adminhtml/templates/login.phtml create mode 100644 src/view/adminhtml/web/default.less create mode 100644 src/view/adminhtml/web/fillPassword.js create mode 100644 src/view/adminhtml/web/login.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff72e2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/composer.lock +/vendor diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1eccd3c --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +it: coding-standards dependency-analysis + +coding-standards: vendor + composer normalize + vendor/bin/phpcbf || true + vendor/bin/phpcs + +dependency-analysis: vendor + vendor/bin/composer-require-checker check --verbose + +vendor: composer.json composer.lock + composer validate --strict + composer --no-interaction install --no-progress + +composer.lock: + test -f composer.lock || echo '{}' > composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..7671ab9 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Fredden_AdminAuth - fredden/magento2-module-admin-authentication + +## Installation +This extension is hosted on packagist.org should be installed from there: + +* `composer require fredden/magento2-module-admin-authentication` diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..2f65138 --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,15 @@ +{ + "symbol-whitelist": [ + "array", + "bool", + "callable", + "false", + "int", + "parent", + "self", + "string", + "true", + "void", + "Magento\\User\\Model\\UserFactory" + ] +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e86ee3d --- /dev/null +++ b/composer.json @@ -0,0 +1,57 @@ +{ + "name": "fredden/magento2-module-admin-authentication", + "description": "A Magento 2 module that allows access to Magento admin via Fredden Auth Portal", + "license": "CC-BY-NC-SA-4.0", + "type": "magento2-module", + "authors": [ + { + "name": "Dan Wallis", + "email": "dan@wallis.nz" + } + ], + "homepage": "https://github.com/fredden/magento2-module-admin-authentication", + "require": { + "php": "~8.2.0 || ~8.3.0", + "ext-json": "*", + "ext-random": "*", + "firebase/php-jwt": "^5.5 || ^6.0", + "magento/framework": "^102.0 || ^103.0", + "magento/module-backend": "^101.0 || ^102.0", + "magento/module-captcha": "^100.0.2", + "magento/module-config": "^101.0", + "magento/module-two-factor-auth": "^1.0", + "magento/module-user": "^101.0" + }, + "require-dev": { + "bitexpert/phpstan-magento": "^0.11.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "maglnet/composer-require-checker": "^3.8", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "squizlabs/php_codesniffer": "^3.9" + }, + "repositories": { + "magento": { + "type": "composer", + "url": "https://repo-magento-mirror.fooman.co.nz/" + } + }, + "minimum-stability": "stable", + "autoload": { + "psr-4": { + "Fredden\\AdminAuth\\": "src" + }, + "files": [ + "src/registration.php" + ] + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "magento/composer-dependency-version-audit-plugin": false, + "magento/magento-composer-installer": false, + "phpstan/extension-installer": true + } + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..164d62c --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,15 @@ + + + src + + + + + + + + + + + + diff --git a/src/Controller/Adminhtml/Login/Refresh.php b/src/Controller/Adminhtml/Login/Refresh.php new file mode 100644 index 0000000..16cdabf --- /dev/null +++ b/src/Controller/Adminhtml/Login/Refresh.php @@ -0,0 +1,142 @@ +getRequest()->getPost('token'); + + try { + $payload = $this->parseToken($token); + } catch (Exception $e) { + $result = $this->resultJsonFactory->create(); + return $result->setData( + [ + 'status' => 'failure', + 'message' => 'Unable to verify token - ' . $e->getMessage(), + ] + ); + } + + $errors = []; + foreach ($payload['emails'] as $emailAddress) { + $user = $this->userFactory->create(); + $user->setEmail($emailAddress); + $data = $this->userResource->userExists($user); + + if (!$data) { + $errors[$emailAddress] = 'No account for ' . $emailAddress; + continue; + } + + // Existing user found + $user->load($data['user_id']); + + if (!$user->getIsActive()) { + $errors[$emailAddress] = 'Account for ' . $emailAddress . ' is disabled.'; + continue; + } + + return $this->doLogin($user); + } + + if ($errors === []) { + $errors[] = 'No email addresses provided'; + } + + $result = $this->resultJsonFactory->create(); + return $result->setData( + [ + 'status' => 'failure', + 'message' => implode("\n\n", $errors), + ] + ); + } + + private function doLogin(User $user): ResultInterface + { + $result = $this->resultJsonFactory->create(); + + $password = bin2hex(random_bytes(32)); + $user->setPassword($password); + $user->setPasswordConfirmation($password); + $user->save(); + + try { + $this->authModel->login($user->getUserName(), $password); + } catch (Exception $e) { + return $result->setData( + [ + 'status' => 'failure', + 'message' => $e->getMessage(), + 'username' => $user->getUserName(), + 'password' => $password, + ] + ); + } + + // This is used elsewhere in this module to restrict other features to + // only when logging in via this tool. + $this->authSession->setFreddenAdminAuth(true); + + return $result->setData( + [ + 'status' => 'success', + 'message' => 'Logged in as ' . $user->getUserName(), + 'username' => $user->getUserName(), + 'password' => $password, + ] + ); + } + + private function parseToken(string $token): array + { + try { + $keys = JWK::parseKeySet(['keys' => $this->config->getAuthKeys()]); + return (array) JWT::decode($token, $keys); + } catch (Throwable) { + $keys = JWK::parseKeySet(['keys' => $this->config->getAuthKeys(false)]); + return (array) JWT::decode($token, $keys); + } + } +} diff --git a/src/Plugin/Magento/Captcha.php b/src/Plugin/Magento/Captcha.php new file mode 100644 index 0000000..fba884f --- /dev/null +++ b/src/Plugin/Magento/Captcha.php @@ -0,0 +1,28 @@ +request->getRouteName() === 'Fredden_AdminAuth' + && $this->request->getControllerName() === 'login' + && $this->request->getActionName() === 'refresh' + ) { + return false; + } + + return $result; + } +} diff --git a/src/Plugin/Magento/SetAdminUserLocale.php b/src/Plugin/Magento/SetAdminUserLocale.php new file mode 100644 index 0000000..48b7a84 --- /dev/null +++ b/src/Plugin/Magento/SetAdminUserLocale.php @@ -0,0 +1,84 @@ +deployedLocales->getOptionLocales(); + + if (!$availableOptions) { + return $result; + } + + $currentUserLocale = $subject->getInterfaceLocale(); + $locales = []; + + foreach ($availableOptions as $option) { + if ($option['value'] === $currentUserLocale) { + // User's current locale is available here; there's nothing for us to do. + return $result; + } + + $locales[$option['value']] = $option['value']; + $locales[substr($option['value'], 0, 2)] = $option['value']; + } + + $preferredLocales = []; + + if ($header = $this->request->getHeader('accept-language')) { + $languages = explode(',', $header); + foreach ($languages as $languageOption) { + // TODO: parse and use ';q=' values to weight/sort options. + $languageOption = explode(';', $languageOption)[0]; + $languageOption = trim($languageOption); + $languageOption = str_replace('-', '_', $languageOption); + + $preferredLocales[] = $languageOption; + } + } + + $preferredLocales[] = 'en'; // Add English as a fallback option + + foreach ($preferredLocales as $localeOption) { + if ($localeOption === 'en' && isset($locales['en_GB'])) { + // English is preferred. + $localeOption = 'en_GB'; + } + + if ($localeOption === 'en' && isset($locales['en_US'])) { + // American is next best if English isn't available. + $localeOption = 'en_US'; + } + + if (isset($locales[$localeOption])) { + $subject->setInterfaceLocale($locales[$localeOption]); + return $result; + } + } + + // We didn't find a locale that suits the user's preferences. Let's just + // give them ANY locale that exists. A working admin in a foreign + // language is better than a completely broken admin. The user can set + // another locale when they work out where to click. + $subject->setInterfaceLocale($option['value']); + // $option['value'] comes from the foreach() on line 44. There will + // always be at least one locale in that loop due to Magento internals. + + return $result; + } +} diff --git a/src/Plugin/Magento/TwoFactorAuth/Bypass.php b/src/Plugin/Magento/TwoFactorAuth/Bypass.php new file mode 100644 index 0000000..17c758a --- /dev/null +++ b/src/Plugin/Magento/TwoFactorAuth/Bypass.php @@ -0,0 +1,34 @@ +authSession->getFreddenAdminAuth() === true + && $this->authSession->isLoggedIn() + && $this->config->isEnabled() + && $this->config->isAllowedToBypassTwoFactor() + ) { + return; + } + + $proceed($observer); + } +} diff --git a/src/Scope/Config.php b/src/Scope/Config.php new file mode 100644 index 0000000..a545512 --- /dev/null +++ b/src/Scope/Config.php @@ -0,0 +1,50 @@ +cache->load(self::CACHE_KEY_AUTH_KEYS)) { + return json_decode($cacheEntry, true, 32, JSON_THROW_ON_ERROR); + } + + $this->httpClient->get(self::URL_AUTH_KEYS); + if ($this->httpClient->getStatus() !== 200) { + return []; + } + + $keys = json_decode($this->httpClient->getBody(), true, 32, JSON_THROW_ON_ERROR); + + $this->cache->save(json_encode($keys), self::CACHE_KEY_AUTH_KEYS); + + return $keys; + } + + public function isAllowedToBypassTwoFactor(): bool + { + return $this->scopeConfig->isSetFlag(self::XML_PATH_ALLOWED_BYPASS_2FA); + } + + public function isEnabled(): bool + { + return $this->scopeConfig->isSetFlag(self::XML_PATH_IS_ENABLED); + } +} diff --git a/src/etc/adminhtml/csp_whitelist.xml b/src/etc/adminhtml/csp_whitelist.xml new file mode 100644 index 0000000..a3f9ca7 --- /dev/null +++ b/src/etc/adminhtml/csp_whitelist.xml @@ -0,0 +1,11 @@ + + + + + + auth.fredden.com + + + + diff --git a/src/etc/adminhtml/di.xml b/src/etc/adminhtml/di.xml new file mode 100644 index 0000000..5f90da2 --- /dev/null +++ b/src/etc/adminhtml/di.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/src/etc/adminhtml/routes.xml b/src/etc/adminhtml/routes.xml new file mode 100644 index 0000000..e99a2cd --- /dev/null +++ b/src/etc/adminhtml/routes.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/src/etc/adminhtml/system.xml b/src/etc/adminhtml/system.xml new file mode 100644 index 0000000..24d71fb --- /dev/null +++ b/src/etc/adminhtml/system.xml @@ -0,0 +1,42 @@ + + + +
+ + + + + Magento\Config\Model\Config\Source\Yesno + + + + Allow users of this authentication method to bypass Magento's built-in two-factor authentication requirements + Magento\Config\Model\Config\Source\Yesno + + 1 + + + +
+
+
diff --git a/src/etc/config.xml b/src/etc/config.xml new file mode 100644 index 0000000..60f2610 --- /dev/null +++ b/src/etc/config.xml @@ -0,0 +1,12 @@ + + + + + + 1 + 1 + + + + diff --git a/src/etc/module.xml b/src/etc/module.xml new file mode 100644 index 0000000..b18f42e --- /dev/null +++ b/src/etc/module.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/src/registration.php b/src/registration.php new file mode 100644 index 0000000..987f311 --- /dev/null +++ b/src/registration.php @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/src/view/adminhtml/layout/default.xml b/src/view/adminhtml/layout/default.xml new file mode 100644 index 0000000..cc51ae8 --- /dev/null +++ b/src/view/adminhtml/layout/default.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/src/view/adminhtml/templates/default.phtml b/src/view/adminhtml/templates/default.phtml new file mode 100644 index 0000000..dd36332 --- /dev/null +++ b/src/view/adminhtml/templates/default.phtml @@ -0,0 +1,9 @@ + + +escapeJs($escaper->escapeUrl($block->getUrl('fredden_admin_auth/login/refresh'))) ?>" + } +}'> + +