diff --git a/TwoFactorAuth/Api/CountryRepositoryInterface.php b/TwoFactorAuth/Api/CountryRepositoryInterface.php new file mode 100644 index 00000000..6a12aca6 --- /dev/null +++ b/TwoFactorAuth/Api/CountryRepositoryInterface.php @@ -0,0 +1,55 @@ +json = $json; + } + + /** + * @inheritdoc + */ + protected function _getElementHtml(AbstractElement $element) + { + $html = parent::_getElementHtml($element); + $config = [ + '#twofactorauth_general_force_providers' => [ + 'Magento_TwoFactorAuth/js/system/config/providers' => [ + 'modalTitleText' => $this->getModalTitleText(), + 'modalContentBody' => $this->getModalContentBody() + ] + ] + ]; + $html .= ''; + + return $html; + } + + /** + * Get text for the modal title heading when user switches to disable + * + * @return Phrase + */ + private function getModalTitleText() : Phrase + { + return __('Are you sure you want to disable all currently active providers?'); + } + + /** + * Get HTML for the modal content body when user switches to disable + * + * @return string + */ + private function getModalContentBody(): string + { + $templateFileName = $this->getTemplateFile( + 'Magento_TwoFactorAuth::system/config/providers/modal_content_body.phtml' + ); + + return $this->fetchView($templateFileName); + } +} diff --git a/TwoFactorAuth/Block/ChangeProvider.php b/TwoFactorAuth/Block/ChangeProvider.php new file mode 100644 index 00000000..e7c9a7c7 --- /dev/null +++ b/TwoFactorAuth/Block/ChangeProvider.php @@ -0,0 +1,115 @@ +tfa = $tfa; + $this->session = $session; + $this->userContext = $userContext; + } + + /** + * @inheritDoc + */ + protected function _toHtml() + { + $toActivate = $this->tfa->getProvidersToActivate($this->userContext->getUserId()); + + foreach ($toActivate as $toActivateProvider) { + if ($toActivateProvider->getCode() === $this->getData('provider')) { + return ''; + } + } + + return parent::_toHtml(); + } + + /** + * @inheritdoc + */ + public function getJsLayout() + { + $providers = []; + foreach ($this->getProvidersList() as $provider) { + if (!$provider->isActive($this->userContext->getUserId())) { + continue; + } + $providers[] = [ + 'code' => $provider->getCode(), + 'name' => $provider->getName(), + 'auth' => $this->getUrl($provider->getAuthAction()), + 'icon' => $this->getViewFileUrl($provider->getIcon()), + ]; + } + + $this->jsLayout['components']['tfa-change-provider']['switchIcon'] = + $this->getViewFileUrl('Magento_TwoFactorAuth::images/change_provider.png'); + $this->jsLayout['components']['tfa-change-provider']['providers'] = $providers; + + return parent::getJsLayout(); + } + + /** + * Get a list of available providers + * @return ProviderInterface[] + */ + private function getProvidersList(): array + { + $res = []; + + $providers = $this->tfa->getUserProviders((int) $this->userContext->getUserId()); + foreach ($providers as $provider) { + if ($provider->getCode() !== $this->getData('provider')) { + $res[] = $provider; + } + } + + return $res; + } +} diff --git a/TwoFactorAuth/Block/Configure.php b/TwoFactorAuth/Block/Configure.php new file mode 100644 index 00000000..6a12dacd --- /dev/null +++ b/TwoFactorAuth/Block/Configure.php @@ -0,0 +1,69 @@ +tfa = $tfa; + } + + /** + * Create list of providers for user to choose. + * + * @return array + */ + public function getProvidersList(): array + { + $selected = $this->tfa->getForcedProviders(); + $list = []; + foreach ($this->tfa->getAllEnabledProviders() as $provider) { + $list[] = [ + 'code' => $provider->getCode(), + 'name' => $provider->getName(), + 'icon' => $this->getViewFileUrl($provider->getIcon()), + 'selected' => in_array($provider, $selected, true) + ]; + } + + return $list; + } + + /** + * Get the form's action URL. + * + * @return string + */ + public function getActionUrl(): string + { + return $this->getUrl('tfa/tfa/configurepost'); + } +} diff --git a/TwoFactorAuth/Block/ConfigureLater.php b/TwoFactorAuth/Block/ConfigureLater.php new file mode 100644 index 00000000..446caca8 --- /dev/null +++ b/TwoFactorAuth/Block/ConfigureLater.php @@ -0,0 +1,96 @@ +tfa = $tfa; + $this->serializer = $serializer; + $this->formKey = $formKey; + $this->userContext = $userContext; + } + + /** + * @inheritDoc + */ + protected function _toHtml() + { + $userId = $this->userContext->getUserId(); + $providers = $this->tfa->getUserProviders($userId); + $toActivate = $this->tfa->getProvidersToActivate($userId); + + foreach ($toActivate as $toActivateProvider) { + if ($toActivateProvider->getCode() === $this->getData('provider') && count($providers) > 1) { + return parent::_toHtml(); + } + } + + return ''; + } + + /** + * Get a serialized string of post data for the configure later endpoint + * + * @return string + */ + public function getPostData(): string + { + return $this->serializer->serialize( + [ + 'action' => $this->getUrl('tfa/tfa/configurelater'), + 'data' => [ + 'provider' => $this->getData('provider'), + 'form_key' => $this->formKey->getFormKey() + ] + ] + ); + } +} diff --git a/TwoFactorAuth/Block/Provider/Authy/Auth.php b/TwoFactorAuth/Block/Provider/Authy/Auth.php new file mode 100644 index 00000000..18182240 --- /dev/null +++ b/TwoFactorAuth/Block/Provider/Authy/Auth.php @@ -0,0 +1,39 @@ +jsLayout['components']['tfa-auth']['postUrl'] = + $this->getUrl('*/*/authpost'); + + $this->jsLayout['components']['tfa-auth']['tokenRequestUrl'] = + $this->getUrl('*/*/token'); + + $this->jsLayout['components']['tfa-auth']['oneTouchUrl'] = + $this->getUrl('*/*/onetouch'); + + $this->jsLayout['components']['tfa-auth']['verifyOneTouchUrl'] = + $this->getUrl('*/*/verifyonetouch'); + + $this->jsLayout['components']['tfa-auth']['successUrl'] = + $this->getUrl($this->_urlBuilder->getStartupPageUrl()); + + return parent::getJsLayout(); + } +} diff --git a/TwoFactorAuth/Block/Provider/Authy/Configure.php b/TwoFactorAuth/Block/Provider/Authy/Configure.php new file mode 100644 index 00000000..87c84ed5 --- /dev/null +++ b/TwoFactorAuth/Block/Provider/Authy/Configure.php @@ -0,0 +1,68 @@ +countryCollectionFactory = $countryCollectionFactory; + } + + /** + * Get a country list + * return array + */ + private function getCountriesList() + { + return $this->countryCollectionFactory->create()->addOrder('name', 'asc')->getItems(); + } + + /** + * @inheritdoc + */ + public function getJsLayout() + { + $countries = []; + foreach ($this->getCountriesList() as $country) { + $countries[] = [ + 'dial_code' => $country->getDialCode(), + 'name' => $country->getName(), + ]; + } + + $this->jsLayout['components']['tfa-configure']['children']['register']['configurePostUrl'] = + $this->getUrl('*/*/configurepost'); + + $this->jsLayout['components']['tfa-configure']['children']['verify']['verifyPostUrl'] = + $this->getUrl('*/*/configureverifypost'); + + $this->jsLayout['components']['tfa-configure']['children']['verify']['successUrl'] = + $this->getUrl($this->_urlBuilder->getStartupPageUrl()); + + $this->jsLayout['components']['tfa-configure']['children']['register']['countries'] = + $countries; + + return parent::getJsLayout(); + } +} diff --git a/TwoFactorAuth/Block/Provider/Duo/Auth.php b/TwoFactorAuth/Block/Provider/Duo/Auth.php new file mode 100644 index 00000000..4f07843a --- /dev/null +++ b/TwoFactorAuth/Block/Provider/Duo/Auth.php @@ -0,0 +1,62 @@ +duoSecurity = $duoSecurity; + $this->session = $session; + } + + /** + * @inheritdoc + */ + public function getJsLayout() + { + $this->jsLayout['components']['tfa-auth']['postUrl'] = + $this->getUrl('*/*/authpost', ['form_key' => $this->getFormKey()]); + + $this->jsLayout['components']['tfa-auth']['signature'] = + $this->duoSecurity->getRequestSignature($this->session->getUser()); + + $this->jsLayout['components']['tfa-auth']['apiHost'] = + $this->duoSecurity->getApiHostname(); + + return parent::getJsLayout(); + } +} diff --git a/TwoFactorAuth/Block/Provider/Google/Auth.php b/TwoFactorAuth/Block/Provider/Google/Auth.php new file mode 100644 index 00000000..221c44ca --- /dev/null +++ b/TwoFactorAuth/Block/Provider/Google/Auth.php @@ -0,0 +1,30 @@ +jsLayout['components']['tfa-auth']['postUrl'] = + $this->getUrl('*/*/authpost'); + + $this->jsLayout['components']['tfa-auth']['successUrl'] = + $this->getUrl($this->_urlBuilder->getStartupPageUrl()); + + return parent::getJsLayout(); + } +} diff --git a/TwoFactorAuth/Block/Provider/Google/Configure.php b/TwoFactorAuth/Block/Provider/Google/Configure.php new file mode 100644 index 00000000..1d1227d3 --- /dev/null +++ b/TwoFactorAuth/Block/Provider/Google/Configure.php @@ -0,0 +1,62 @@ +session = $session; + $this->google = $google; + + parent::__construct($context, $data); + } + + /** + * @inheritdoc + */ + public function getJsLayout() + { + $this->jsLayout['components']['tfa-configure']['postUrl'] = + $this->getUrl('*/*/configurepost'); + + $this->jsLayout['components']['tfa-configure']['qrCodeUrl'] = + $this->getUrl('*/*/qr'); + + $this->jsLayout['components']['tfa-configure']['successUrl'] = + $this->getUrl($this->_urlBuilder->getStartupPageUrl()); + + $this->jsLayout['components']['tfa-configure']['secretCode'] = + $this->google->getSecretCode($this->session->getUser()); + + return parent::getJsLayout(); + } +} diff --git a/TwoFactorAuth/Block/Provider/U2fKey/Auth.php b/TwoFactorAuth/Block/Provider/U2fKey/Auth.php new file mode 100644 index 00000000..b884c75a --- /dev/null +++ b/TwoFactorAuth/Block/Provider/U2fKey/Auth.php @@ -0,0 +1,85 @@ +u2fKey = $u2fKey; + $this->u2fSession = $u2fSession; + $this->session = $session; + } + + /** + * @inheritdoc + */ + public function getJsLayout() + { + $this->jsLayout['components']['tfa-auth']['postUrl'] = + $this->getUrl('*/*/authpost'); + + $this->jsLayout['components']['tfa-auth']['successUrl'] = + $this->getUrl($this->_urlBuilder->getStartupPageUrl()); + + $this->jsLayout['components']['tfa-auth']['touchImageUrl'] = + $this->getViewFileUrl('Magento_TwoFactorAuth::images/u2f/touch.png'); + + $this->jsLayout['components']['tfa-auth']['authenticateData'] = $this->generateAuthenticateData(); + return parent::getJsLayout(); + } + + /** + * Get the data needed to authenticate a webauthn request + * + * @return array + */ + public function generateAuthenticateData(): array + { + $authenticateData = $this->u2fKey->getAuthenticateData($this->session->getUser()); + $this->u2fSession->setU2fChallenge($authenticateData['credentialRequestOptions']['challenge']); + + return $authenticateData; + } +} diff --git a/TwoFactorAuth/Block/Provider/U2fKey/Configure.php b/TwoFactorAuth/Block/Provider/U2fKey/Configure.php new file mode 100644 index 00000000..f11b2351 --- /dev/null +++ b/TwoFactorAuth/Block/Provider/U2fKey/Configure.php @@ -0,0 +1,87 @@ +u2fKey = $u2fKey; + $this->u2fSession = $u2fSession; + $this->session = $session; + } + + /** + * @inheritdoc + */ + public function getJsLayout() + { + $this->jsLayout['components']['tfa-configure']['postUrl'] = + $this->getUrl('*/*/configurepost'); + + $this->jsLayout['components']['tfa-configure']['successUrl'] = + $this->getUrl($this->_urlBuilder->getStartupPageUrl()); + + $this->jsLayout['components']['tfa-configure']['touchImageUrl'] = + $this->getViewFileUrl('Magento_TwoFactorAuth::images/u2f/touch.png'); + + $this->jsLayout['components']['tfa-configure']['registerData'] = $this->getRegisterData(); + + return parent::getJsLayout(); + } + + /** + * Get the data required to issue a WebAuthn request + * + * @return array + */ + public function getRegisterData(): array + { + $registerData = $this->u2fKey->getRegisterData($this->session->getUser()); + $this->u2fSession->setU2fChallenge($registerData['publicKey']['challenge']); + + return $registerData; + } +} diff --git a/TwoFactorAuth/Command/TfaProviders.php b/TwoFactorAuth/Command/TfaProviders.php new file mode 100644 index 00000000..d076b76e --- /dev/null +++ b/TwoFactorAuth/Command/TfaProviders.php @@ -0,0 +1,57 @@ +providerPool = $providerPool; + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this->setName('security:tfa:providers'); + $this->setDescription('List all available providers'); + + parent::configure(); + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $providers = $this->providerPool->getProviders(); + + foreach ($providers as $provider) { + $output->writeln(sprintf("%16s: %s", $provider->getCode(), $provider->getName())); + } + } +} diff --git a/TwoFactorAuth/Command/TfaReset.php b/TwoFactorAuth/Command/TfaReset.php new file mode 100644 index 00000000..67050c45 --- /dev/null +++ b/TwoFactorAuth/Command/TfaReset.php @@ -0,0 +1,100 @@ +userConfigManager = $userConfigManager; + $this->userResource = $userResource; + $this->userFactory = $userFactory; + $this->providerPool = $providerPool; + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this->setName('security:tfa:reset'); + $this->setDescription('Reset configuration for one user'); + + $this->addArgument('user', InputArgument::REQUIRED, __('Username')); + $this->addArgument('provider', InputArgument::REQUIRED, __('Provider code')); + + parent::configure(); + } + + /** + * @SuppressWarnings("PHPMD.UnusedFormalParameter") + * @throws LocalizedException + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $userName = $input->getArgument('user'); + $providerCode = $input->getArgument('provider'); + + $user = $this->userFactory->create(); + + $this->userResource->load($user, $userName, 'username'); + if (!$user->getId()) { + throw new LocalizedException(__('Unknown user %1', $userName)); + } + + $provider = $this->providerPool->getProviderByCode($providerCode); + + $this->userConfigManager->resetProviderConfig((int) $user->getId(), $providerCode); + + $output->writeln('' . __('Provider %1 has been reset for user %2', $provider->getName(), $userName)); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/AbstractAction.php b/TwoFactorAuth/Controller/Adminhtml/AbstractAction.php new file mode 100644 index 00000000..d6c9cee7 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/AbstractAction.php @@ -0,0 +1,30 @@ +_isAllowed()) { + $this->_response->setStatusHeader(403, '1.1', 'Forbidden'); + return $this->_redirect('*/auth/login'); + } + + return parent::dispatch($request); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/AbstractConfigureAction.php b/TwoFactorAuth/Controller/Adminhtml/AbstractConfigureAction.php new file mode 100644 index 00000000..e7892d60 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/AbstractConfigureAction.php @@ -0,0 +1,46 @@ +tokenVerifier = $tokenVerifier; + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + $isAllowed = parent::_isAllowed(); + if ($isAllowed) { + $isAllowed = $this->tokenVerifier->isConfigTokenProvided(); + } + + return $isAllowed; + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Auth.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Auth.php new file mode 100644 index 00000000..7c68ea64 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Auth.php @@ -0,0 +1,98 @@ +tfa = $tfa; + $this->session = $session; + $this->pageFactory = $pageFactory; + $this->userConfigManager = $userConfigManager; + } + + /** + * Get current user + * @return User|null + */ + private function getUser() + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + * @throws NoSuchEntityException + */ + public function execute() + { + $this->userConfigManager->setDefaultProvider((int) $this->getUser()->getId(), Authy::CODE); + return $this->pageFactory->create(); + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + $this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Authpost.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Authpost.php new file mode 100644 index 00000000..08c17fc0 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Authpost.php @@ -0,0 +1,143 @@ +tfa = $tfa; + $this->session = $session; + $this->jsonFactory = $jsonFactory; + $this->tfaSession = $tfaSession; + $this->authy = $authy; + $this->dataObjectFactory = $dataObjectFactory; + $this->alert = $alert; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $user = $this->getUser(); + $result = $this->jsonFactory->create(); + + try { + $this->authy->verify($user, $this->dataObjectFactory->create([ + 'data' => $this->getRequest()->getParams(), + ])); + $this->tfaSession->grantAccess(); + $result->setData(['success' => true]); + } catch (Exception $e) { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'Authy error', + AlertInterface::LEVEL_ERROR, + $this->getUser()->getUserName(), + $e->getMessage() + ); + + $result->setData(['success' => false, 'message' => $e->getMessage()]); + } + + return $result; + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + $this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Configure.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Configure.php new file mode 100644 index 00000000..a35c52ab --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Configure.php @@ -0,0 +1,93 @@ +pageFactory = $pageFactory; + $this->session = $session; + $this->tfa = $tfa; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + return $this->pageFactory->create(); + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + !$this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Configurepost.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Configurepost.php new file mode 100644 index 00000000..63efa4f6 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Configurepost.php @@ -0,0 +1,148 @@ +jsonFactory = $jsonFactory; + $this->session = $session; + $this->tfa = $tfa; + $this->alert = $alert; + $this->verification = $verification; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $request = $this->getRequest(); + $response = $this->jsonFactory->create(); + + try { + $res = []; + $this->verification->request( + $this->getUser(), + (string) $request->getParam('tfa_country'), + (string) $request->getParam('tfa_phone'), + (string) $request->getParam('tfa_method'), + $res + ); + + $this->alert->event( + 'Magento_TwoFactorAuth', + 'New authy verification request via ' . $request->getParam('tfa_method'), + AlertInterface::LEVEL_INFO, + $this->getUser()->getUserName() + ); + + $response->setData([ + 'success' => true, + 'message' => $res['message'], + 'seconds_to_expire' => (int) $res['seconds_to_expire'], + ]); + } catch (Exception $e) { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'Authy verification request failure via ' . $request->getParam('tfa_method'), + AlertInterface::LEVEL_ERROR, + $this->getUser()->getUserName(), + $e->getMessage() + ); + $response->setData(['success' => false, 'message' => $e->getMessage()]); + } + + return $response; + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + !$this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Configureverifypost.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Configureverifypost.php new file mode 100644 index 00000000..6b6c091e --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Configureverifypost.php @@ -0,0 +1,161 @@ +jsonFactory = $jsonFactory; + $this->session = $session; + $this->tfa = $tfa; + $this->tfaSession = $tfaSession; + $this->alert = $alert; + $this->verification = $verification; + $this->authy = $authy; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $verificationCode = $this->getRequest()->getParam('tfa_verify'); + $response = $this->jsonFactory->create(); + + try { + $this->verification->verify($this->getUser(), $verificationCode); + $this->authy->enroll($this->getUser()); + $this->tfaSession->grantAccess(); + + $this->alert->event( + 'Magento_TwoFactorAuth', + 'Authy identity verified', + AlertInterface::LEVEL_INFO, + $this->getUser()->getUserName() + ); + + $response->setData([ + 'success' => true, + ]); + } catch (Exception $e) { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'Authy identity verification failure', + AlertInterface::LEVEL_ERROR, + $this->getUser()->getUserName(), + $e->getMessage() + ); + + $response->setData([ + 'success' => false, + 'message' => $e->getMessage(), + ]); + } + + return $response; + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + !$this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Onetouch.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Onetouch.php new file mode 100644 index 00000000..45c9fd5c --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Onetouch.php @@ -0,0 +1,106 @@ +session = $session; + $this->jsonFactory = $jsonFactory; + $this->tfa = $tfa; + $this->oneTouch = $oneTouch; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $result = $this->jsonFactory->create(); + + try { + $approvalCode = $this->oneTouch->request($this->getUser()); + $res = ['success' => true, 'code' => $approvalCode]; + } catch (Exception $e) { + $result->setHttpResponseCode(500); + $res = ['success' => false, 'message' => $e->getMessage()]; + } + + $result->setData($res); + return $result; + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + $this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Token.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Token.php new file mode 100644 index 00000000..b1aa7700 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Token.php @@ -0,0 +1,108 @@ +session = $session; + $this->jsonFactory = $jsonFactory; + $this->tfa = $tfa; + $this->token = $token; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $via = $this->getRequest()->getParam('via'); + $result = $this->jsonFactory->create(); + + try { + $this->token->request($this->getUser(), $via); + $res = ['success' => true]; + } catch (Exception $e) { + $result->setHttpResponseCode(500); + $res = ['success' => false, 'message' => $e->getMessage()]; + } + + $result->setData($res); + return $result; + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + $this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Verify.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Verify.php new file mode 100644 index 00000000..21b8f4c0 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Verify.php @@ -0,0 +1,126 @@ +pageFactory = $pageFactory; + $this->session = $session; + $this->tfa = $tfa; + $this->userConfigManager = $userConfigManager; + $this->registry = $registry; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * Get verify information + * @return verify payload + * @throws NoSuchEntityException + */ + private function getVerifyInformation() + { + $providerConfig = $this->userConfigManager->getProviderConfig((int) $this->getUser()->getId(), Authy::CODE); + if (!isset($providerConfig['verify'])) { + return null; + } + + return $providerConfig['verify']; + } + + /** + * @inheritdoc + */ + public function execute() + { + $verifyInfo = $this->getVerifyInformation(); + $this->registry->register('tfa_authy_verify', $verifyInfo); + + return $this->pageFactory->create(); + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + $this->getVerifyInformation() && + !$this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Authy/Verifyonetouch.php b/TwoFactorAuth/Controller/Adminhtml/Authy/Verifyonetouch.php new file mode 100644 index 00000000..ee0befce --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Authy/Verifyonetouch.php @@ -0,0 +1,148 @@ +session = $session; + $this->jsonFactory = $jsonFactory; + $this->tfa = $tfa; + $this->tfaSession = $tfaSession; + $this->alert = $alert; + $this->oneTouch = $oneTouch; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $result = $this->jsonFactory->create(); + + try { + $res = $this->oneTouch->verify($this->getUser()); + if ($res === 'approved') { + $this->tfaSession->grantAccess(); + $res = ['success' => true, 'status' => 'approved']; + } else { + $res = ['success' => false, 'status' => $res]; + + if ($res === 'denied') { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'Authy onetouch auth denied', + AlertInterface::LEVEL_WARNING, + $this->getUser()->getUserName() + ); + } + } + } catch (Exception $e) { + $result->setHttpResponseCode(500); + $res = ['success' => false, 'message' => $e->getMessage()]; + + $this->alert->event( + 'Magento_TwoFactorAuth', + 'Authy onetouch error', + AlertInterface::LEVEL_ERROR, + $this->getUser()->getUserName(), + $e->getMessage() + ); + } + + $result->setData($res); + return $result; + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Authy::CODE) && + $this->tfa->getProvider(Authy::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Duo/Auth.php b/TwoFactorAuth/Controller/Adminhtml/Duo/Auth.php new file mode 100644 index 00000000..6c9bb734 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Duo/Auth.php @@ -0,0 +1,107 @@ +tfa = $tfa; + $this->session = $session; + $this->pageFactory = $pageFactory; + $this->userConfigManager = $userConfigManager; + $this->tokenVerifier = $tokenVerifier; + } + + /** + * Get current user + * @return \Magento\User\Model\User|null + */ + private function getUser() + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $this->userConfigManager->setDefaultProvider((int)$this->getUser()->getId(), DuoSecurity::CODE); + return $this->pageFactory->create(); + } + + /** + * Check if admin has permissions to visit related pages + * + * @return bool + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + // 1st time users must have the token. + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int)$user->getId(), DuoSecurity::CODE) + && ( + $this->userConfigManager->isProviderConfigurationActive((int)$user->getId(), DuoSecurity::CODE) + || $this->tokenVerifier->isConfigTokenProvided() + ); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Duo/Authpost.php b/TwoFactorAuth/Controller/Adminhtml/Duo/Authpost.php new file mode 100644 index 00000000..f21d5a8a --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Duo/Authpost.php @@ -0,0 +1,164 @@ +tfa = $tfa; + $this->session = $session; + $this->tfaSession = $tfaSession; + $this->duoSecurity = $duoSecurity; + $this->dataObjectFactory = $dataObjectFactory; + $this->alert = $alert; + $this->context = $context; + $this->tokenVerifier = $tokenVerifier; + $this->userConfig = $userConfig; + } + + /** + * Get current user + * @return \Magento\User\Model\User|null + */ + private function getUser() + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + */ + public function execute() + { + $user = $this->getUser(); + + if ($this->duoSecurity->verify($user, $this->dataObjectFactory->create([ + 'data' => $this->getRequest()->getParams(), + ]))) { + $this->tfa->getProvider(DuoSecurity::CODE)->activate((int) $user->getId()); + $this->tfaSession->grantAccess(); + return $this->_redirect($this->context->getBackendUrl()->getStartupPageUrl()); + } else { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'DuoSecurity invalid auth', + AlertInterface::LEVEL_WARNING, + $user->getUserName() + ); + + return $this->_redirect('*/*/auth'); + } + } + + /** + * Check if admin has permissions to visit related pages + * + * @return bool + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + // 1st time users must have the token. + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int)$user->getId(), DuoSecurity::CODE) + && ( + $this->userConfig->isProviderConfigurationActive((int)$user->getId(), DuoSecurity::CODE) + || $this->tokenVerifier->isConfigTokenProvided() + ); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Duo/Configure.php b/TwoFactorAuth/Controller/Adminhtml/Duo/Configure.php new file mode 100644 index 00000000..a02debaf --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Duo/Configure.php @@ -0,0 +1,30 @@ +_redirect('*/*/auth'); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Google/Auth.php b/TwoFactorAuth/Controller/Adminhtml/Google/Auth.php new file mode 100644 index 00000000..75a788ad --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Google/Auth.php @@ -0,0 +1,97 @@ +tfa = $tfa; + $this->session = $session; + $this->pageFactory = $pageFactory; + $this->userConfigManager = $userConfigManager; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritDoc + */ + public function execute() + { + $this->userConfigManager->setDefaultProvider((int) $this->getUser()->getId(), Google::CODE); + return $this->pageFactory->create(); + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Google::CODE) && + $this->tfa->getProvider(Google::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Google/Authpost.php b/TwoFactorAuth/Controller/Adminhtml/Google/Authpost.php new file mode 100644 index 00000000..75eac2e3 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Google/Authpost.php @@ -0,0 +1,134 @@ +tfa = $tfa; + $this->session = $session; + $this->jsonFactory = $jsonFactory; + $this->google = $google; + $this->tfaSession = $tfaSession; + $this->dataObjectFactory = $dataObjectFactory; + $this->alert = $alert; + } + + /** + * @inheritdoc + * @throws NoSuchEntityException + */ + public function execute() + { + $user = $this->session->getUser(); + $response = $this->jsonFactory->create(); + /** @var \Magento\Framework\DataObject $request */ + $request = $this->dataObjectFactory->create(['data' => $this->getRequest()->getParams()]); + + if ($this->google->verify($user, $request)) { + $this->tfaSession->grantAccess(); + $response->setData(['success' => true]); + } else { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'Google auth invalid token', + AlertInterface::LEVEL_WARNING, + $user->getUserName() + ); + + $response->setData(['success' => false, 'message' => 'Invalid code']); + } + + return $response; + } + + /** + * Check if admin has permissions to visit related pages + * + * @return bool + */ + protected function _isAllowed() + { + $user = $this->session->getUser(); + + return $user + && $this->tfa->getProviderIsAllowed((int)$user->getId(), Google::CODE) + && $this->tfa->getProvider(Google::CODE)->isActive((int)$user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Google/Configure.php b/TwoFactorAuth/Controller/Adminhtml/Google/Configure.php new file mode 100644 index 00000000..121469b5 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Google/Configure.php @@ -0,0 +1,94 @@ +tfa = $tfa; + $this->session = $session; + $this->pageFactory = $pageFactory; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritDoc + */ + public function execute() + { + return $this->pageFactory->create(); + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Google::CODE) && + !$this->tfa->getProvider(Google::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Google/Configurepost.php b/TwoFactorAuth/Controller/Adminhtml/Google/Configurepost.php new file mode 100644 index 00000000..7467660b --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Google/Configurepost.php @@ -0,0 +1,161 @@ +tfa = $tfa; + $this->session = $session; + $this->jsonFactory = $jsonFactory; + $this->google = $google; + $this->tfaSession = $tfaSession; + $this->dataObjectFactory = $dataObjectFactory; + $this->alert = $alert; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritdoc + * @return ResponseInterface|ResultInterface + * @throws NoSuchEntityException + */ + public function execute() + { + $response = $this->jsonFactory->create(); + + $user = $this->getUser(); + + if ($this->google->verify($user, $this->dataObjectFactory->create([ + 'data' => $this->getRequest()->getParams(), + ]))) { + $this->tfa->getProvider(Google::CODE)->activate((int) $user->getId()); + $this->tfaSession->grantAccess(); + + $this->alert->event( + 'Magento_TwoFactorAuth', + 'New Google Authenticator code issued', + AlertInterface::LEVEL_INFO, + $user->getUserName() + ); + + $response->setData([ + 'success' => true, + ]); + } else { + $response->setData([ + 'success' => false, + 'message' => 'Invalid code', + ]); + } + + return $response; + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Google::CODE) && + !$this->tfa->getProvider(Google::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Google/Qr.php b/TwoFactorAuth/Controller/Adminhtml/Google/Qr.php new file mode 100644 index 00000000..53f30bdf --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Google/Qr.php @@ -0,0 +1,101 @@ +tfa = $tfa; + $this->session = $session; + $this->rawResult = $rawResult; + $this->google = $google; + } + + /** + * Get current user + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritDoc + */ + public function execute() + { + $pngData = $this->google->getQrCodeAsPng($this->getUser()); + $this->rawResult + ->setHttpResponseCode(200) + ->setHeader('Content-Type', 'image/png') + ->setContents($pngData); + + return $this->rawResult; + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), Google::CODE) && + !$this->tfa->getProvider(Google::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Tfa/AccessDenied.php b/TwoFactorAuth/Controller/Adminhtml/Tfa/AccessDenied.php new file mode 100644 index 00000000..e064bcc1 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Tfa/AccessDenied.php @@ -0,0 +1,26 @@ +resultFactory->create(ResultFactory::TYPE_PAGE); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Tfa/Configure.php b/TwoFactorAuth/Controller/Adminhtml/Tfa/Configure.php new file mode 100644 index 00000000..6643e85c --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Tfa/Configure.php @@ -0,0 +1,71 @@ +tfa = $tfa; + $this->tokenVerifier = $tokenVerifier; + $this->session = $session; + } + + /** + * @inheritDoc + */ + public function execute() + { + $user = $this->session->getUser(); + if (!$this->tfa->getUserProviders((int)$user->getId()) && !$this->tokenVerifier->isConfigTokenProvided()) { + return $this->_redirect('tfa/tfa/requestconfig'); + } + + return $this->resultFactory->create(ResultFactory::TYPE_PAGE); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Tfa/ConfigureLater.php b/TwoFactorAuth/Controller/Adminhtml/Tfa/ConfigureLater.php new file mode 100644 index 00000000..dc07f8d6 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Tfa/ConfigureLater.php @@ -0,0 +1,108 @@ +tfa = $tfa; + $this->session = $session; + $this->userContext = $userContext; + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + $userId = $this->userContext->getUserId(); + $providers = $this->tfa->getUserProviders($userId); + $toActivate = $this->tfa->getProvidersToActivate($userId); + + foreach ($toActivate as $toActivateProvider) { + if ($toActivateProvider->getCode() === $this->_request->getParam('provider') && count($providers) > 1) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function execute() + { + $provider = $this->getRequest()->getParam('provider'); + $userId = $this->userContext->getUserId(); + $providers = $this->tfa->getUserProviders($userId); + $needActivation = $this->tfa->getProvidersToActivate($userId); + $providerCodes = []; + foreach ($providers as $forcedProvider) { + $providerCodes[] = $forcedProvider->getCode(); + } + + $currentlySkipped = $this->session->getSkippedProviderConfig(); + $currentlySkipped[$provider] = true; + + // Catch users trying to skip all available providers when there are none configured + if (count($needActivation) === count($providers) + && count($providerCodes) === count(array_intersect($providerCodes, array_keys($currentlySkipped))) + ) { + $this->messageManager->addErrorMessage( + __('At least one two-factor authentication provider must be configured.') + ); + $currentlySkipped = []; + } + + $this->session->setSkippedProviderConfig($currentlySkipped); + + $redirect = $this->resultRedirectFactory->create(); + return $redirect->setPath('tfa/tfa/index'); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Tfa/Configurepost.php b/TwoFactorAuth/Controller/Adminhtml/Tfa/Configurepost.php new file mode 100644 index 00000000..e47ef0ea --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Tfa/Configurepost.php @@ -0,0 +1,137 @@ +startUpUrl = $context->getBackendUrl()->getStartupPageUrl(); + $this->config = $config; + $this->configResource = $configResource; + $this->tfa = $tfa; + $this->tokenVerifier = $tokenVerifier; + $this->session = $session; + } + + /** + * Validate user input + * + * @param mixed $selected + * @return bool + */ + private function validate($selected): bool + { + $providerCodes = array_map( + function (ProviderInterface $provider): string { + return $provider->getCode(); + }, + $this->tfa->getAllEnabledProviders() + ); + + return is_array($selected) && !array_diff(array_keys($selected), $providerCodes); + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + $user = $this->session->getUser(); + if ($user && !$this->tokenVerifier->isConfigTokenProvided()) { + return false; + } + + return parent::_isAllowed(); + } + + /** + * @inheritDoc + */ + public function execute() + { + $selected = $this->getRequest()->getParam('tfa_selected'); + if ($this->validate($selected)) { + $this->configResource->saveConfig( + TfaInterface::XML_PATH_FORCED_PROVIDERS, + implode(',', array_keys($selected)) + ); + $this->config->reinit(); + $this->getMessageManager()->addSuccessMessage( + __('Two-Factory Authorization providers have been successfully configured') + ); + + return $this->_redirect($this->startUpUrl); + } else { + $this->getMessageManager()->addErrorMessage(__('Please select valid providers.')); + + return $this->_redirect('tfa/tfa/configure'); + } + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Tfa/Index.php b/TwoFactorAuth/Controller/Adminhtml/Tfa/Index.php new file mode 100644 index 00000000..1b57203c --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Tfa/Index.php @@ -0,0 +1,160 @@ +tfa = $tfa; + $this->session = $session; + $this->userConfigManager = $userConfigManager; + $this->context = $context; + $this->userConfigRequest = $userConfigRequestManager; + $this->userContext = $userContext; + } + + /** + * @inheritdoc + * @throws LocalizedException + */ + public function execute() + { + $userId = $this->userContext->getUserId(); + + if (!$this->tfa->getUserProviders($userId)) { + //If 2FA is not configured - request configuration. + return $this->_redirect('tfa/tfa/requestconfig'); + } + + $providersToConfigure = $this->tfa->getProvidersToActivate($userId); + $toActivateCodes = []; + foreach ($providersToConfigure as $toActivateProvider) { + $toActivateCodes[] = $toActivateProvider->getCode(); + } + $currentlySkipped = array_keys($this->session->getSkippedProviderConfig()); + $notSkippedProvidersToConfigured = array_diff($toActivateCodes, $currentlySkipped); + + if ($notSkippedProvidersToConfigured) { + foreach ($providersToConfigure as $providerToConfigure) { + if (in_array($providerToConfigure->getCode(), $notSkippedProvidersToConfigured)) { + //2FA provider not activated - redirect to the provider form. + return $this->_redirect($providerToConfigure->getConfigureAction()); + } + } + } + + $providerCode = ''; + + $defaultProviderCode = $this->userConfigManager->getDefaultProvider($userId); + if ($this->tfa->getProviderIsAllowed($userId, $defaultProviderCode) + && $this->tfa->getProvider($defaultProviderCode)->isActive($userId) + ) { + //If default provider was configured - select it. + $providerCode = $defaultProviderCode; + } + + if (!$providerCode) { + //Select one random provider. + $providers = $this->tfa->getUserProviders($userId); + if (!empty($providers)) { + foreach ($providers as $enabledProvider) { + /* + * The user has skipped all providers that need to be configured but there is + * also at least one already configured + */ + if (!in_array($enabledProvider->getCode(), $currentlySkipped) + && !in_array($enabledProvider->getCode(), $toActivateCodes) + ) { + $providerCode = $enabledProvider->getCode(); + break; + } + } + } + } + + if (!$providerCode) { + //Couldn't find provider - perhaps something is not configured properly. + return $this->_redirect($this->context->getBackendUrl()->getStartupPageUrl()); + } + + $provider = $this->tfa->getProvider($providerCode); + if ($provider) { + //Provider found, user will be challenged. + return $this->_redirect($provider->getAuthAction()); + } + + throw new LocalizedException(__('Internal error accessing 2FA index page')); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Tfa/Requestconfig.php b/TwoFactorAuth/Controller/Adminhtml/Tfa/Requestconfig.php new file mode 100644 index 00000000..ad21f41b --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Tfa/Requestconfig.php @@ -0,0 +1,103 @@ +configRequestManager = $configRequestManager; + $this->tokenVerifier = $tokenVerifier; + $this->tfa = $tfa; + $this->session = $session; + } + + /** + * @inheritDoc + */ + public function execute() + { + $user = $this->session->getUser(); + if (!$this->configRequestManager->isConfigurationRequiredFor((int)$user->getId())) { + throw new AuthorizationException(__('2FA is already configured for the user.')); + } + if ($this->tokenVerifier->isConfigTokenProvided()) { + if (!$this->tfa->getForcedProviders()) { + return $this->_redirect('tfa/tfa/configure'); + } else { + return $this->_redirect('tfa/tfa/index'); + } + } + + try { + $this->configRequestManager->sendConfigRequestTo($user); + } catch (AuthorizationException $exception) { + $this->messageManager->addErrorMessage( + 'Please ask an administrator with sufficient access to configure 2FA first' + ); + } catch (NotificationExceptionInterface $exception) { + $this->messageManager->addErrorMessage('Failed to send the message. Please contact the administrator'); + } + + return $this->resultFactory->create(ResultFactory::TYPE_PAGE); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/Tfa/Reset.php b/TwoFactorAuth/Controller/Adminhtml/Tfa/Reset.php new file mode 100644 index 00000000..57a99c04 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/Tfa/Reset.php @@ -0,0 +1,92 @@ +userResourceModel = $userResourceModel; + $this->userInterfaceFactory = $userFactory; + $this->tfa = $tfa; + } + + /** + * @inheritdoc + * @throws LocalizedException + */ + public function execute() + { + $userId = $this->getRequest()->getParam('id'); + $providerCode = $this->getRequest()->getParam('provider'); + + $user = $this->userInterfaceFactory->create(); + $this->userResourceModel->load($user, $userId); + + if (!$user->getId()) { + throw new LocalizedException(__('Invalid user')); + } + + $provider = $this->tfa->getProvider($providerCode); + if (!$provider) { + throw new LocalizedException(__('Unknown provider')); + } + + $provider->resetConfiguration((int) $user->getId()); + + $this->messageManager->addSuccessMessage(__('Configuration has been reset for this user')); + return $this->_redirect('adminhtml/user/edit', ['user_id' => $userId]); + } + + /** + * @inheritdoc + */ + protected function _isAllowed() + { + return parent::_isAllowed() && $this->_authorization->isAllowed('Magento_TwoFactorAuth::tfa'); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/U2f/Auth.php b/TwoFactorAuth/Controller/Adminhtml/U2f/Auth.php new file mode 100644 index 00000000..613dcfa6 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/U2f/Auth.php @@ -0,0 +1,87 @@ +tfa = $tfa; + $this->session = $session; + $this->pageFactory = $pageFactory; + $this->userConfigManager = $userConfigManager; + } + + /** + * @inheritDoc + */ + public function execute() + { + $this->userConfigManager->setDefaultProvider((int) $this->session->getUser()->getId(), U2fKey::CODE); + return $this->pageFactory->create(); + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + $user = $this->session->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), U2fKey::CODE) && + $this->tfa->getProvider(U2fKey::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/U2f/Authpost.php b/TwoFactorAuth/Controller/Adminhtml/U2f/Authpost.php new file mode 100644 index 00000000..70905c05 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/U2f/Authpost.php @@ -0,0 +1,169 @@ +tfa = $tfa; + $this->session = $session; + $this->u2fKey = $u2fKey; + $this->jsonFactory = $jsonFactory; + $this->tfaSession = $tfaSession; + $this->dataObjectFactory = $dataObjectFactory; + $this->alert = $alert; + $this->u2fSession = $u2fSession; + } + + /** + * @inheritDoc + */ + public function execute() + { + $result = $this->jsonFactory->create(); + + try { + $challenge = $this->u2fSession->getU2fChallenge(); + if (!empty($challenge)) { + $this->u2fKey->verify($this->getUser(), $this->dataObjectFactory->create([ + 'data' => [ + 'publicKeyCredential' => $this->getRequest()->getParams()['publicKeyCredential'], + 'originalChallenge' => $challenge + ] + ])); + $this->tfaSession->grantAccess(); + $this->u2fSession->setU2fChallenge(null); + + $res = ['success' => true]; + } else { + $res = ['success' => false]; + } + } catch (Exception $e) { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'U2F error', + AlertInterface::LEVEL_ERROR, + $this->getUser()->getUserName(), + $e->getMessage() + ); + + $res = ['success' => false, 'message' => $e->getMessage()]; + } + + $result->setData($res); + return $result; + } + + /** + * Retrieve the current authenticated user + * + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * Check if admin has permissions to visit related pages + * + * @return bool + */ + protected function _isAllowed() + { + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), U2fKey::CODE) && + $this->tfa->getProvider(U2fKey::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/U2f/Configure.php b/TwoFactorAuth/Controller/Adminhtml/U2f/Configure.php new file mode 100644 index 00000000..072492f2 --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/U2f/Configure.php @@ -0,0 +1,94 @@ +tfa = $tfa; + $this->session = $session; + parent::__construct($context, $tokenVerifier); + $this->pageFactory = $pageFactory; + } + + /** + * @inheritDoc + */ + public function execute() + { + return $this->pageFactory->create(); + } + + /** + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * C@inheritDoc + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), U2fKey::CODE) && + !$this->tfa->getProvider(U2fKey::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Controller/Adminhtml/U2f/Configurepost.php b/TwoFactorAuth/Controller/Adminhtml/U2f/Configurepost.php new file mode 100644 index 00000000..ecc8ef7e --- /dev/null +++ b/TwoFactorAuth/Controller/Adminhtml/U2f/Configurepost.php @@ -0,0 +1,168 @@ +tfa = $tfa; + $this->session = $session; + $this->u2fKey = $u2fKey; + $this->jsonFactory = $jsonFactory; + $this->tfaSession = $tfaSession; + $this->alert = $alert; + $this->u2fSession = $u2fSession; + } + + /** + * @inheritDoc + */ + public function execute() + { + $result = $this->jsonFactory->create(); + + try { + $challenge = $this->u2fSession->getU2fChallenge(); + if (!empty($challenge)) { + $data = [ + 'publicKeyCredential' => $this->getRequest()->getParam('publicKeyCredential'), + 'challenge' => $challenge + ]; + + $this->u2fKey->registerDevice($this->getUser(), $data); + $this->tfaSession->grantAccess(); + $this->u2fSession->setU2fChallenge(null); + + $this->alert->event( + 'Magento_TwoFactorAuth', + 'U2F New device registered', + AlertInterface::LEVEL_INFO, + $this->getUser()->getUserName() + ); + $res = ['success' => true]; + } else { + $res = ['success' => false]; + } + + } catch (\Throwable $e) { + $this->alert->event( + 'Magento_TwoFactorAuth', + 'U2F error while adding device', + AlertInterface::LEVEL_ERROR, + $this->getUser()->getUserName(), + $e->getMessage() + ); + + $res = ['success' => false, 'message' => $e->getMessage()]; + } + + $result->setData($res); + return $result; + } + + /** + * @return User|null + */ + private function getUser(): ?User + { + return $this->session->getUser(); + } + + /** + * @inheritDoc + */ + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->getUser(); + + return + $user && + $this->tfa->getProviderIsAllowed((int) $user->getId(), U2fKey::CODE) && + !$this->tfa->getProvider(U2fKey::CODE)->isActive((int) $user->getId()); + } +} diff --git a/TwoFactorAuth/Model/Alert.php b/TwoFactorAuth/Model/Alert.php new file mode 100644 index 00000000..f07efd55 --- /dev/null +++ b/TwoFactorAuth/Model/Alert.php @@ -0,0 +1,67 @@ +eventManager = $eventManager; + } + + /** + * Trigger a security suite event + * @param string $module + * @param string $message + * @param string $level + * @param string $username + * @param array|string $payload + * @return void + */ + public function event( + string $module, + string $message, + ?string $level = null, + ?string $username = null, + $payload = null + ): void { + if ($level === null) { + $level = self::LEVEL_INFO; + } + + $params = [ + AlertInterface::ALERT_PARAM_LEVEL => $level, + AlertInterface::ALERT_PARAM_MODULE => $module, + AlertInterface::ALERT_PARAM_MESSAGE => $message, + AlertInterface::ALERT_PARAM_USERNAME => $username, + AlertInterface::ALERT_PARAM_PAYLOAD => $payload, + ]; + + $genericEvent = AlertInterface::EVENT_PREFIX . '_event'; + $moduleEvent = AlertInterface::EVENT_PREFIX . '_event_' . strtolower($module); + $severityEvent = AlertInterface::EVENT_PREFIX . '_level_' . strtolower($level); + + $this->eventManager->dispatch($genericEvent, $params); + $this->eventManager->dispatch($moduleEvent, $params); + $this->eventManager->dispatch($severityEvent, $params); + } +} diff --git a/TwoFactorAuth/Model/AlertInterface.php b/TwoFactorAuth/Model/AlertInterface.php new file mode 100644 index 00000000..1d48f900 --- /dev/null +++ b/TwoFactorAuth/Model/AlertInterface.php @@ -0,0 +1,76 @@ +getValue(); + if ($value && !preg_match('%^[^./:]+\.duosecurity\.com$%', $value)) { + throw new ValidatorException(__('Invalid API hostname.')); + } + + return parent::beforeSave(); + } +} diff --git a/TwoFactorAuth/Model/Config/Backend/ForceProviders.php b/TwoFactorAuth/Model/Config/Backend/ForceProviders.php new file mode 100644 index 00000000..2009dae3 --- /dev/null +++ b/TwoFactorAuth/Model/Config/Backend/ForceProviders.php @@ -0,0 +1,85 @@ +tfa = $tfa; + } + + /** + * @inheritDoc + */ + public function beforeSave() + { + $codes = []; + $providers = $this->tfa->getAllProviders(); + foreach ($providers as $provider) { + $codes[] = $provider->getCode(); + } + + $value = $this->getValue(); + $validValues = is_array($value) ? array_intersect($codes, $value) : []; + if (empty($value) || !$validValues) { + throw new ValidatorException(__('You have to select at least one Two-Factor Authorization provider')); + } + + // Removes invalid codes + $this->setValue($validValues); + + return parent::beforeSave(); + } +} diff --git a/TwoFactorAuth/Model/Config/Source/EnabledProvider.php b/TwoFactorAuth/Model/Config/Source/EnabledProvider.php new file mode 100644 index 00000000..3b85fae2 --- /dev/null +++ b/TwoFactorAuth/Model/Config/Source/EnabledProvider.php @@ -0,0 +1,50 @@ +tfa = $tfa; + } + + /** + * @inheritDoc + */ + public function toOptionArray() + { + $providers = $this->tfa->getAllProviders(); + $res = []; + foreach ($providers as $provider) { + if ($provider->isEnabled()) { + $res[] = [ + 'value' => $provider->getCode(), + 'label' => $provider->getName(), + ]; + } + } + + return $res; + } +} diff --git a/TwoFactorAuth/Model/Config/Source/Provider.php b/TwoFactorAuth/Model/Config/Source/Provider.php new file mode 100644 index 00000000..3dc1e6eb --- /dev/null +++ b/TwoFactorAuth/Model/Config/Source/Provider.php @@ -0,0 +1,48 @@ +tfa = $tfa; + } + + /** + * @inheritDoc + */ + public function toOptionArray() + { + $providers = $this->tfa->getAllProviders(); + $res = []; + foreach ($providers as $provider) { + $res[] = [ + 'value' => $provider->getCode(), + 'label' => $provider->getName(), + ]; + } + + return $res; + } +} diff --git a/TwoFactorAuth/Model/Country.php b/TwoFactorAuth/Model/Country.php new file mode 100644 index 00000000..b7716965 --- /dev/null +++ b/TwoFactorAuth/Model/Country.php @@ -0,0 +1,89 @@ +dataObjectHelper = $dataObjectHelper; + $this->countryDataFactory = $countryDataFactory; + } + + /** + * @inheritDoc + */ + protected function _construct() + { + $this->_init(ResourceModel\Country::class); + } + + /** + * Retrieve Country model + * + * @return CountryInterface + */ + public function getDataModel(): CountryInterface + { + $countryData = $this->getData(); + + /** @var CountryInterface $countryDataObject */ + $countryDataObject = $this->countryDataFactory->create(); + + $this->dataObjectHelper->populateWithArray( + $countryDataObject, + $countryData, + CountryInterface::class + ); + $countryDataObject->setId($this->getId()); + + return $countryDataObject; + } +} diff --git a/TwoFactorAuth/Model/CountryRegistry.php b/TwoFactorAuth/Model/CountryRegistry.php new file mode 100644 index 00000000..da6364ba --- /dev/null +++ b/TwoFactorAuth/Model/CountryRegistry.php @@ -0,0 +1,88 @@ + [], + ]; + + /** + * Remove registry entity by id + * @param int $id + */ + public function removeById(int $id): void + { + if (isset($this->registry[$id])) { + unset($this->registry[$id]); + } + + foreach (array_keys($this->registryByKey) as $key) { + $reverseMap = array_flip($this->registryByKey[$key]); + if (isset($reverseMap[$id])) { + unset($this->registryByKey[$key][$reverseMap[$id]]); + } + } + } + + /** + * Push one object into registry + * @param int $id + * @return CountryInterface|null + */ + public function retrieveById(int $id): ?CountryInterface + { + if (isset($this->registry[$id])) { + return $this->registry[$id]; + } + + return null; + } + + /** + * Retrieve by Code value + * @param string $value + * @return CountryInterface|null + */ + public function retrieveByCode(string $value): ?CountryInterface + { + if (isset($this->registryByKey['code'][$value])) { + return $this->retrieveById($this->registryByKey['code'][$value]); + } + + return null; + } + + /** + * Push one object into registry + * @param Country $country + */ + public function push(Country $country): void + { + $this->registry[$country->getId()] = $country->getDataModel(); + foreach (array_keys($this->registryByKey) as $key) { + $this->registryByKey[$key][$country->getData($key)] = $country->getId(); + } + } +} diff --git a/TwoFactorAuth/Model/Data/Country.php b/TwoFactorAuth/Model/Data/Country.php new file mode 100644 index 00000000..6a93dc58 --- /dev/null +++ b/TwoFactorAuth/Model/Data/Country.php @@ -0,0 +1,98 @@ +_get(self::ID); + } + + /** + * {@inheritdoc} + */ + public function setId(int $value): void + { + $this->setData(self::ID, $value); + } + + /** + * {@inheritdoc} + */ + public function getCode(): string + { + return (string) $this->_get(self::CODE); + } + + /** + * {@inheritdoc} + */ + public function setCode(string $value): void + { + $this->setData(self::CODE, $value); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return (string) $this->_get(self::NAME); + } + + /** + * {@inheritdoc} + */ + public function setName(string $value): void + { + $this->setData(self::NAME, $value); + } + + /** + * {@inheritdoc} + */ + public function getDialCode(): string + { + return (string) $this->_get(self::DIAL_CODE); + } + + /** + * {@inheritdoc} + */ + public function setDialCode(string $value): void + { + $this->setData(self::DIAL_CODE, $value); + } + + /** + * {@inheritdoc} + */ + public function getExtensionAttributes(): ?CountryExtensionInterface + { + return $this->_get(self::EXTENSION_ATTRIBUTES_KEY); + } + + /** + * {@inheritdoc} + */ + public function setExtensionAttributes(CountryExtensionInterface $extensionAttributes): void + { + $this->setData(self::EXTENSION_ATTRIBUTES_KEY, $extensionAttributes); + } +} diff --git a/TwoFactorAuth/Model/Data/UserConfig.php b/TwoFactorAuth/Model/Data/UserConfig.php new file mode 100644 index 00000000..487e3c2d --- /dev/null +++ b/TwoFactorAuth/Model/Data/UserConfig.php @@ -0,0 +1,98 @@ +_get(self::ID); + } + + /** + * @inheritDoc + */ + public function setId(int $value): void + { + $this->setData(self::ID, $value); + } + + /** + * @inheritDoc + */ + public function getUserId(): int + { + return (int) $this->_get(self::USER_ID); + } + + /** + * @inheritDoc + */ + public function setUserId(int $value): void + { + $this->setData(self::USER_ID, $value); + } + + /** + * @inheritDoc + */ + public function getEncodedProviders(): string + { + return (string) $this->_get(self::ENCODED_PROVIDERS); + } + + /** + * @inheritDoc + */ + public function setEncodedProviders(string $value): void + { + $this->setData(self::ENCODED_PROVIDERS, $value); + } + + /** + * @inheritDoc + */ + public function getDefaultProvider(): string + { + return (string) $this->_get(self::DEFAULT_PROVIDER); + } + + /** + * @inheritDoc + */ + public function setDefaultProvider(string $value): void + { + $this->setData(self::DEFAULT_PROVIDER, $value); + } + + /** + * @inheritDoc + */ + public function getExtensionAttributes(): ?UserConfigExtensionInterface + { + return $this->_get(self::EXTENSION_ATTRIBUTES_KEY); + } + + /** + * @inheritDoc + */ + public function setExtensionAttributes(UserConfigExtensionInterface $extensionAttributes): void + { + $this->setData(self::EXTENSION_ATTRIBUTES_KEY, $extensionAttributes); + } +} diff --git a/TwoFactorAuth/Model/EmailUserNotifier.php b/TwoFactorAuth/Model/EmailUserNotifier.php new file mode 100644 index 00000000..f1cc1801 --- /dev/null +++ b/TwoFactorAuth/Model/EmailUserNotifier.php @@ -0,0 +1,125 @@ +scopeConfig = $scopeConfig; + $this->transportBuilder = $transportBuilder; + $this->storeManager = $storeManager; + $this->logger = $logger; + $this->url = $url; + } + + /** + * Send configuration related message to the admin user. + * + * @param User $user + * @param string $token + * @param string $emailTemplateId + * @return void + * @throws NotificationExceptionInterface + */ + private function sendConfigRequired(User $user, string $token, string $emailTemplateId): void + { + try { + $transport = $this->transportBuilder + ->setTemplateIdentifier($emailTemplateId) + ->setTemplateOptions([ + 'area' => 'adminhtml', + 'store' => 0 + ]) + ->setTemplateVars( + [ + 'username' => $user->getFirstName() . ' ' . $user->getLastName(), + 'token' => $token, + 'store_name' => $this->storeManager->getStore()->getFrontendName(), + 'url' => $this->url->getUrl('tfa/tfa/index', ['tfat' => $token]) + ] + ) + ->setFromByScope( + $this->scopeConfig->getValue('admin/emails/forgot_email_identity') + ) + ->addTo($user->getEmail(), $user->getFirstName() . ' ' . $user->getLastName()) + ->getTransport(); + $transport->sendMessage(); + } catch (\Throwable $exception) { + $this->logger->critical($exception); + throw new NotificationException('Failed to send 2FA E-mail to a user', 0, $exception); + } + } + + /** + * @inheritDoc + */ + public function sendUserConfigRequestMessage(User $user, string $token): void + { + $this->sendConfigRequired($user, $token, 'tfa_admin_user_config_required'); + } + + /** + * @inheritDoc + */ + public function sendAppConfigRequestMessage(User $user, string $token): void + { + $this->sendConfigRequired($user, $token, 'tfa_admin_app_config_required'); + } +} diff --git a/TwoFactorAuth/Model/Exception/NotificationException.php b/TwoFactorAuth/Model/Exception/NotificationException.php new file mode 100644 index 00000000..679cac51 --- /dev/null +++ b/TwoFactorAuth/Model/Exception/NotificationException.php @@ -0,0 +1,19 @@ +engine = $engine; + $this->userConfigManager = $userConfigManager; + $this->code = $code; + $this->name = $name; + $this->configureAction = $configureAction; + $this->authAction = $authAction; + $this->extraActions = $extraActions; + $this->canReset = $canReset; + $this->icon = $icon; + } + + /** + * @inheritDoc + */ + public function isEnabled(): bool + { + return $this->getEngine()->isEnabled(); + } + + /** + * @inheritDoc + */ + public function getEngine(): EngineInterface + { + return $this->engine; + } + + /** + * @inheritDoc + */ + public function getCode(): string + { + return $this->code; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return $this->name; + } + + /** + * @inheritDoc + */ + public function getIcon(): string + { + return $this->icon; + } + + /** + * @inheritDoc + */ + public function isResetAllowed(): bool + { + return $this->canReset; + } + + /** + * @inheritdoc + */ + public function resetConfiguration(int $userId): void + { + $this->userConfigManager->setProviderConfig($userId, $this->getCode(), null); + } + + /** + * @inheritdoc + */ + public function isConfigured(int $userId): bool + { + return $this->getConfiguration($userId) !== null; + } + + /** + * Retrieve user's configuration + * @param int $userId + * @return array|null + * @throws NoSuchEntityException + */ + private function getConfiguration(int $userId): ?array + { + return $this->userConfigManager->getProviderConfig($userId, $this->getCode()); + } + + /** + * @inheritdoc + */ + public function isActive(int $userId): bool + { + return $this->userConfigManager->isProviderConfigurationActive($userId, $this->getCode()); + } + + /** + * @inheritdoc + */ + public function activate(int $userId): void + { + $this->userConfigManager->activateProviderConfiguration($userId, $this->getCode()); + } + + /** + * @inheritdoc + */ + public function getConfigureAction(): string + { + return $this->configureAction; + } + + /** + * @inheritdoc + */ + public function getAuthAction(): string + { + return $this->authAction; + } + + /** + * @inheritdoc + */ + public function getExtraActions(): array + { + return $this->extraActions; + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/Authy.php b/TwoFactorAuth/Model/Provider/Engine/Authy.php new file mode 100644 index 00000000..7487d381 --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/Authy.php @@ -0,0 +1,136 @@ +userConfigManager = $userConfigManager; + $this->curlFactory = $curlFactory; + $this->service = $service; + $this->scopeConfig = $scopeConfig; + $this->token = $token; + } + + /** + * Enroll in Authy + * @param UserInterface $user + * @return bool + * @throws LocalizedException + */ + public function enroll(UserInterface $user): bool + { + $providerInfo = $this->userConfigManager->getProviderConfig((int) $user->getId(), Authy::CODE); + if (!isset($providerInfo['country_code'])) { + throw new LocalizedException(__('Missing phone information')); + } + + $url = $this->service->getProtectedApiEndpoint('users/new'); + $curl = $this->curlFactory->create(); + + $curl->addHeader('X-Authy-API-Key', $this->service->getApiKey()); + $curl->post($url, [ + 'user[email]' => $user->getEmail(), + 'user[cellphone]' => $providerInfo['phone_number'], + 'user[country_code]' => $providerInfo['country_code'], + ]); + + $response = Json::decode($curl->getBody(), Json::TYPE_ARRAY); + + $errorMessage = $this->service->getErrorFromResponse($response); + if ($errorMessage) { + throw new LocalizedException(__($errorMessage)); + } + + $this->userConfigManager->addProviderConfig((int) $user->getId(), Authy::CODE, [ + 'user' => $response['user']['id'], + ]); + + $this->userConfigManager->activateProviderConfiguration((int) $user->getId(), Authy::CODE); + + return true; + } + + /** + * @inheritDoc + */ + public function isEnabled(): bool + { + try { + return !!$this->service->getApiKey(); + } catch (\TypeError $exception) { + //API key is empty, returned null instead of a string + return false; + } + } + + /** + * @inheritDoc + */ + public function verify(UserInterface $user, DataObject $request): bool + { + return $this->token->verify($user, $request); + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/Authy/OneTouch.php b/TwoFactorAuth/Model/Provider/Engine/Authy/OneTouch.php new file mode 100644 index 00000000..bb9a3ed6 --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/Authy/OneTouch.php @@ -0,0 +1,169 @@ +curlFactory = $curlFactory; + $this->userConfigManager = $userConfigManager; + $this->storeManager = $storeManager; + $this->service = $service; + $this->scopeConfig = $scopeConfig; + } + + /** + * Request one-touch + * @param UserInterface $user + * @throws LocalizedException + */ + public function request(UserInterface $user): void + { + $providerInfo = $this->userConfigManager->getProviderConfig((int) $user->getId(), Authy::CODE); + if (!isset($providerInfo['user'])) { + throw new LocalizedException(__('Missing user information')); + } + + $url = $this->service->getOneTouchApiEndpoint('users/' . $providerInfo['user'] . '/approval_requests'); + + $curl = $this->curlFactory->create(); + $curl->addHeader('X-Authy-API-Key', $this->service->getApiKey()); + $curl->post($url, [ + 'message' => $this->scopeConfig->getValue(self::XML_PATH_ONETOUCH_MESSAGE), + 'details[URL]' => $this->storeManager->getStore()->getBaseUrl(), + 'details[User]' => $user->getUserName(), + 'details[Email]' => $user->getEmail(), + 'seconds_to_expire' => 300, + ]); + + $response = Json::decode($curl->getBody(), Json::TYPE_ARRAY); + + $errorMessage = $this->service->getErrorFromResponse($response); + if ($errorMessage) { + throw new LocalizedException(__($errorMessage)); + } + + $this->userConfigManager->addProviderConfig((int) $user->getId(), Authy::CODE, [ + 'pending_approval' => $response['approval_request']['uuid'], + ]); + } + + /** + * Verify one-touch + * @param UserInterface $user + * @return string + * @throws LocalizedException + */ + public function verify(UserInterface $user): string + { + $providerInfo = $this->userConfigManager->getProviderConfig((int) $user->getId(), Authy::CODE); + if (!isset($providerInfo['user'])) { + throw new LocalizedException(__('Missing user information')); + } + + if (!isset($providerInfo['pending_approval'])) { + throw new LocalizedException(__('No approval requests for this user')); + } + + $approvalCode = $providerInfo['pending_approval']; + + if (!preg_match('/^\w[\w\-]+\w$/', $approvalCode)) { + throw new LocalizedException(__('Invalid approval code')); + } + + $url = $this->service->getOneTouchApiEndpoint('approval_requests/' . $approvalCode); + + $times = 10; + + for ($i=0; $i<$times; $i++) { + $curl = $this->curlFactory->create(); + $curl->addHeader('X-Authy-API-Key', $this->service->getApiKey()); + $curl->get($url); + + $response = Json::decode($curl->getBody(), Json::TYPE_ARRAY); + + $errorMessage = $this->service->getErrorFromResponse($response); + if ($errorMessage) { + throw new LocalizedException(__($errorMessage)); + } + + $status = $response['approval_request']['status']; + if ($status === 'pending') { + // @codingStandardsIgnoreStart + sleep(1); // I know... but it is the only option I have here + // @codingStandardsIgnoreEnd + continue; + } + + if ($status === 'approved') { + return $status; + } + + return $status; + } + + return 'retry'; + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/Authy/Service.php b/TwoFactorAuth/Model/Provider/Engine/Authy/Service.php new file mode 100644 index 00000000..38fafa3e --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/Authy/Service.php @@ -0,0 +1,86 @@ +scopeConfig = $scopeConfig; + } + + /** + * Get API key + * @return string + */ + public function getApiKey(): string + { + return (string) $this->scopeConfig->getValue(static::XML_PATH_API_KEY); + } + + /** + * Get authy API endpoint + * @param string $path + * @return string + */ + public function getProtectedApiEndpoint(string $path): string + { + return static::AUTHY_BASE_ENDPOINT . 'protected/json/' . $path; + } + + /** + * Get authy API endpoint + * @param string $path + * @return string + */ + public function getOneTouchApiEndpoint(string $path): string + { + return static::AUTHY_BASE_ENDPOINT . 'onetouch/json/' . $path; + } + + /** + * Get error from response + * @param array|boolean $response + * @return string|null + */ + public function getErrorFromResponse($response): ?string + { + if ($response === false) { + return 'Invalid authy webservice response'; + } + + if (!isset($response['success']) || !$response['success']) { + return $response['message']; + } + + return null; + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/Authy/Token.php b/TwoFactorAuth/Model/Provider/Engine/Authy/Token.php new file mode 100644 index 00000000..dd7d70ff --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/Authy/Token.php @@ -0,0 +1,118 @@ +userConfigManager = $userConfigManager; + $this->curlFactory = $curlFactory; + $this->service = $service; + } + + /** + * Request a token + * @param UserInterface $user + * @param string $via + * @throws LocalizedException + */ + public function request(UserInterface $user, string $via): void + { + if (!in_array($via, ['call', 'sms'])) { + throw new LocalizedException(__('Unsupported via method')); + } + + $providerInfo = $this->userConfigManager->getProviderConfig((int) $user->getId(), Authy::CODE); + if (!isset($providerInfo['user'])) { + throw new LocalizedException(__('Missing user information')); + } + + $url = $this->service->getProtectedApiEndpoint('' . $via . '/' . $providerInfo['user']) . '?force=true'; + + $curl = $this->curlFactory->create(); + $curl->addHeader('X-Authy-API-Key', $this->service->getApiKey()); + $curl->get($url); + + $response = Json::decode($curl->getBody(), Json::TYPE_ARRAY); + + $errorMessage = $this->service->getErrorFromResponse($response); + if ($errorMessage) { + throw new LocalizedException(__($errorMessage)); + } + } + + /** + * Return true on token validation + * @param UserInterface $user + * @param DataObject $request + * @return bool + * @throws LocalizedException + */ + public function verify(UserInterface $user, DataObject $request): bool + { + $code = $request->getData('tfa_code'); + if (!preg_match('/^\w+$/', $code)) { + throw new LocalizedException(__('Invalid code format')); + } + + $providerInfo = $this->userConfigManager->getProviderConfig((int) $user->getId(), Authy::CODE); + if (!isset($providerInfo['user'])) { + throw new LocalizedException(__('Missing user information')); + } + + $url = $this->service->getProtectedApiEndpoint('verify/' . $code . '/' . $providerInfo['user']); + + $curl = $this->curlFactory->create(); + $curl->addHeader('X-Authy-API-Key', $this->service->getApiKey()); + $curl->get($url); + + $response = Json::decode($curl->getBody(), Json::TYPE_ARRAY); + + $errorMessage = $this->service->getErrorFromResponse($response); + if ($errorMessage) { + throw new LocalizedException(__($errorMessage)); + } + + return true; + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/Authy/Verification.php b/TwoFactorAuth/Model/Provider/Engine/Authy/Verification.php new file mode 100644 index 00000000..5d46664e --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/Authy/Verification.php @@ -0,0 +1,145 @@ +curlFactory = $curlFactory; + $this->service = $service; + $this->userConfigManager = $userConfigManager; + $this->dateTime = $dateTime; + } + + /** + * Verify phone number + * @param UserInterface $user + * @param string $country + * @param string $phoneNumber + * @param string $method + * @param array &$response + * @throws LocalizedException + */ + public function request( + UserInterface $user, + string $country, + string $phoneNumber, + string $method, + array &$response + ): void { + $url = $this->service->getProtectedApiEndpoint('phones/verification/start'); + + $curl = $this->curlFactory->create(); + $curl->addHeader('X-Authy-API-Key', $this->service->getApiKey()); + $curl->post($url, [ + 'via' => $method, + 'country_code' => $country, + 'phone_number' => $phoneNumber + ]); + + $response = Json::decode($curl->getBody(), Json::TYPE_ARRAY); + + $errorMessage = $this->service->getErrorFromResponse($response); + if ($errorMessage) { + throw new LocalizedException(__($errorMessage)); + } + + $this->userConfigManager->addProviderConfig((int) $user->getId(), Authy::CODE, [ + 'country_code' => $country, + 'phone_number' => $phoneNumber, + 'carrier' => $response['carrier'], + 'mobile' => $response['is_cellphone'], + 'verify' => [ + 'uuid' => $response['uuid'], + 'via' => $method, + 'expires' => $this->dateTime->timestamp() + $response['seconds_to_expire'], + 'seconds_to_expire' => $response['seconds_to_expire'], + 'message' => $response['message'], + ], + 'phone_confirmed' => false, + ]); + } + + /** + * Verify phone number + * @param UserInterface $user + * @param string $verificationCode + * @throws LocalizedException + */ + public function verify(UserInterface $user, string $verificationCode): void + { + $providerInfo = $this->userConfigManager->getProviderConfig((int) $user->getId(), Authy::CODE); + if (!isset($providerInfo['country_code'])) { + throw new LocalizedException(__('Missing verify request information')); + } + + $url = $this->service->getProtectedApiEndpoint('phones/verification/check'); + + $curl = $this->curlFactory->create(); + $curl->addHeader('X-Authy-API-Key', $this->service->getApiKey()); + $curl->get($url . '?' . http_build_query([ + 'country_code' => $providerInfo['country_code'], + 'phone_number' => $providerInfo['phone_number'], + 'verification_code' => $verificationCode, + ])); + + $response = Json::decode($curl->getBody(), Json::TYPE_ARRAY); + + $errorMessage = $this->service->getErrorFromResponse($response); + if ($errorMessage) { + throw new LocalizedException(__($errorMessage)); + } + + $this->userConfigManager->addProviderConfig((int) $user->getId(), Authy::CODE, [ + 'phone_confirmed' => true, + ]); + $this->userConfigManager->activateProviderConfiguration((int) $user->getId(), Authy::CODE); + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/DuoSecurity.php b/TwoFactorAuth/Model/Provider/Engine/DuoSecurity.php new file mode 100644 index 00000000..1be0abe1 --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/DuoSecurity.php @@ -0,0 +1,252 @@ +scopeConfig = $scopeConfig; + } + + /** + * Get API hostname + * @return string + */ + public function getApiHostname(): string + { + return $this->scopeConfig->getValue(static::XML_PATH_API_HOSTNAME); + } + + /** + * Get application key + * @return string + */ + private function getApplicationKey(): string + { + return $this->scopeConfig->getValue(static::XML_PATH_APPLICATION_KEY); + } + + /** + * Get secret key + * @return string + */ + private function getSecretKey(): string + { + return $this->scopeConfig->getValue(static::XML_PATH_SECRET_KEY); + } + + /** + * Get integration key + * @return string + */ + private function getIntegrationKey(): string + { + return $this->scopeConfig->getValue(static::XML_PATH_INTEGRATION_KEY); + } + + /** + * Sign values + * @param string $key + * @param string $values + * @param string $prefix + * @param int $expire + * @param int $time + * @return string + */ + private function signValues(string $key, string $values, string $prefix, int $expire, int $time): string + { + $exp = $time + $expire; + $cookie = $prefix . '|' . base64_encode($values . '|' . $exp); + + $sig = hash_hmac('sha1', $cookie, $key); + return $cookie . '|' . $sig; + } + + /** + * Parse signed values and return username + * @param string $key + * @param string $val + * @param string $prefix + * @param int $time + * @return string|null + */ + private function parseValues(string $key, string $val, string $prefix, int $time): ?string + { + $integrationKey = $this->getIntegrationKey(); + + $timestamp = ($time ? $time : time()); + + $parts = explode('|', $val); + if (count($parts) !== 3) { + return null; + } + [$uPrefix, $uB64, $uSig] = $parts; + + $sig = hash_hmac('sha1', $uPrefix . '|' . $uB64, $key); + if (hash_hmac('sha1', $sig, $key) !== hash_hmac('sha1', $uSig, $key)) { + return null; + } + + if ($uPrefix !== $prefix) { + return null; + } + + // @codingStandardsIgnoreStart + $cookieParts = explode('|', base64_decode($uB64)); + // @codingStandardsIgnoreEnd + + if (count($cookieParts) !== 3) { + return null; + } + [$user, $uIkey, $exp] = $cookieParts; + + if ($uIkey !== $integrationKey) { + return null; + } + if ($timestamp >= (int) $exp) { + return null; + } + + return $user; + } + + /** + * Get request signature + * @param UserInterface $user + * @return string + */ + public function getRequestSignature(UserInterface $user): string + { + $time = time(); + + $values = $user->getUserName() . '|' . $this->getIntegrationKey(); + $duoSignature = $this->signValues( + $this->getSecretKey(), + $values, + static::DUO_PREFIX, + static::DUO_EXPIRE, + $time + ); + $appSignature = $this->signValues( + $this->getApplicationKey(), + $values, + static::APP_PREFIX, + static::APP_EXPIRE, + $time + ); + + return $duoSignature . ':' . $appSignature; + } + + /** + * @inheritDoc + */ + public function verify(UserInterface $user, DataObject $request): bool + { + $time = time(); + + $signatures = explode(':', (string)$request->getData('sig_response')); + if (count($signatures) !== 2) { + return false; + } + [$authSig, $appSig] = $signatures; + + $authUser = $this->parseValues($this->getSecretKey(), $authSig, static::AUTH_PREFIX, $time); + $appUser = $this->parseValues($this->getApplicationKey(), $appSig, static::APP_PREFIX, $time); + + return (($authUser === $appUser) && ($appUser === $user->getUserName())); + } + + /** + * @inheritDoc + */ + public function isEnabled(): bool + { + try { + return !!$this->getApiHostname() && + !!$this->getIntegrationKey() && + !!$this->getSecretKey(); + } catch (\TypeError $exception) { + //At least one of the methods returned null instead of a string + return false; + } + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/Google.php b/TwoFactorAuth/Model/Provider/Engine/Google.php new file mode 100644 index 00000000..cfa85474 --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/Google.php @@ -0,0 +1,182 @@ +configManager = $configManager; + $this->storeManager = $storeManager; + } + + /** + * Generate random secret + * @return string + * @throws Exception + */ + private function generateSecret(): string + { + $secret = random_bytes(128); + return preg_replace('/[^A-Za-z0-9]/', '', Base32::encode($secret)); + } + + /** + * Get TOTP object + * @param UserInterface $user + * @return TOTP + * @throws NoSuchEntityException + */ + private function getTotp(UserInterface $user): TOTP + { + $config = $this->configManager->getProviderConfig((int)$user->getId(), static::CODE); + if (!isset($config['secret'])) { + $config['secret'] = $this->getSecretCode($user); + } + if (!$config['secret']) { + throw new NoSuchEntityException(__('Secret for user with ID#%1 was not found', $user->getId())); + } + $totp = new TOTP($user->getEmail(), $config['secret']); + + return $totp; + } + + /** + * Get the secret code used for Google Authentication + * @param UserInterface $user + * @return string|null + * @throws NoSuchEntityException + * @author Konrad Skrzynski + */ + public function getSecretCode(UserInterface $user): ?string + { + $config = $this->configManager->getProviderConfig((int)$user->getId(), static::CODE); + + if (!isset($config['secret'])) { + $config['secret'] = $this->generateSecret(); + $this->configManager->setProviderConfig((int)$user->getId(), static::CODE, $config); + } + + return $config['secret'] ?? null; + } + + /** + * Get TFA provisioning URL + * @param UserInterface $user + * @return string + * @throws NoSuchEntityException + */ + private function getProvisioningUrl(UserInterface $user): string + { + $baseUrl = $this->storeManager->getStore()->getBaseUrl(); + + // @codingStandardsIgnoreStart + $issuer = parse_url($baseUrl, PHP_URL_HOST); + // @codingStandardsIgnoreEnd + + $totp = $this->getTotp($user); + $totp->setIssuer($issuer); + + return $totp->getProvisioningUri(); + } + + /** + * @inheritDoc + */ + public function verify(UserInterface $user, DataObject $request): bool + { + $token = $request->getData('tfa_code'); + if (!$token) { + return false; + } + + $totp = $this->getTotp($user); + $totp->now(); + + return $totp->verify($token); + } + + /** + * Render TFA QrCode + * @param UserInterface $user + * @return string + * @throws NoSuchEntityException + * @throws ValidationException + */ + public function getQrCodeAsPng(UserInterface $user): string + { + // @codingStandardsIgnoreStart + $qrCode = new QrCode($this->getProvisioningUrl($user)); + $qrCode->setSize(400); + $qrCode->setErrorCorrectionLevel('high'); + $qrCode->setForegroundColor(['r' => 0, 'g' => 0, 'b' => 0, 'a' => 0]); + $qrCode->setBackgroundColor(['r' => 255, 'g' => 255, 'b' => 255, 'a' => 0]); + $qrCode->setLabelFontSize(16); + $qrCode->setEncoding('UTF-8'); + + $writer = new PngWriter(); + $pngData = $writer->writeString($qrCode); + // @codingStandardsIgnoreEnd + + return $pngData; + } + + /** + * @inheritDoc + */ + public function isEnabled(): bool + { + return true; + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/U2fKey.php b/TwoFactorAuth/Model/Provider/Engine/U2fKey.php new file mode 100644 index 00000000..a6725caf --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/U2fKey.php @@ -0,0 +1,149 @@ +userConfigManager = $userConfigManager; + $this->storeManager = $storeManager; + $this->webAuthn = $webAuthn; + } + + /** + * @inheritDoc + */ + public function verify(UserInterface $user, DataObject $request): bool + { + $registration = $this->getRegistration($user); + if ($registration === null) { + throw new LocalizedException(__('Missing registration data')); + } + + $this->webAuthn->assertCredentialDataIsValid( + $request->getData('publicKeyCredential'), + $registration['public_keys'], + $request->getData('originalChallenge') + ); + + return true; + } + + /** + * Create the registration challenge + * + * @param UserInterface $user + * @return array + * @throws LocalizedException + */ + public function getRegisterData(UserInterface $user): array + { + return $this->webAuthn->getRegisterData($user); + } + + /** + * Get authenticate data + * + * @param UserInterface $user + * @return array + * @throws LocalizedException + */ + public function getAuthenticateData(UserInterface $user): array + { + return $this->webAuthn->getAuthenticateData($this->getRegistration($user)['public_keys']); + } + + /** + * Get registration information + * + * @param UserInterface $user + * @return array + * @throws NoSuchEntityException + */ + private function getRegistration(UserInterface $user): array + { + $providerConfig = $this->userConfigManager->getProviderConfig((int) $user->getId(), static::CODE); + + if (!isset($providerConfig['registration'])) { + return null; + } + + return $providerConfig['registration']; + } + + /** + * Register a new key + * + * @param UserInterface $user + * @param array $data + * @throws NoSuchEntityException + * @throws \Magento\Framework\Validation\ValidationException + */ + public function registerDevice(UserInterface $user, array $data): void + { + $publicKey = $this->webAuthn->getPublicKeyFromRegistrationData($data); + + $this->userConfigManager->addProviderConfig((int) $user->getId(), static::CODE, [ + 'registration' => [ + 'public_keys' => [$publicKey] + ] + ]); + $this->userConfigManager->activateProviderConfiguration((int) $user->getId(), static::CODE); + } + + /** + * @inheritDoc + */ + public function isEnabled(): bool + { + return true; + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/U2fKey/Session.php b/TwoFactorAuth/Model/Provider/Engine/U2fKey/Session.php new file mode 100644 index 00000000..8ec49a84 --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/U2fKey/Session.php @@ -0,0 +1,39 @@ +storage->getData(static::CHALLENGE_KEY); + } + + /** + * Set the current challenge data + * + * @param array|null $challenge + */ + public function setU2fChallenge(?array $challenge): void + { + $this->storage->setData(static::CHALLENGE_KEY, $challenge); + } +} diff --git a/TwoFactorAuth/Model/Provider/Engine/U2fKey/WebAuthn.php b/TwoFactorAuth/Model/Provider/Engine/U2fKey/WebAuthn.php new file mode 100644 index 00000000..53d0c7a7 --- /dev/null +++ b/TwoFactorAuth/Model/Provider/Engine/U2fKey/WebAuthn.php @@ -0,0 +1,400 @@ +storeManager = $storeManager; + } + + /** + * Analyze a PublicKeyCredential object and verify it is valid + * + * @param array $credentialData + * @param array $publicKeys + * @param array $originalChallenge + * @throws LocalizedException + */ + public function assertCredentialDataIsValid( + array $credentialData, + array $publicKeys, + array $originalChallenge + ): void { + // Verification process as defined by w3 https://www.w3.org/TR/webauthn/#verifying-assertion + + // Step 1-3 + $key = false; + foreach ($publicKeys as $registeredKey) { + if ($registeredKey['id'] === $credentialData['id']) { + $key = $registeredKey; + break; + } + } + + if (empty($key)) { + throw new LocalizedException(__('Invalid U2F key.')); + } + + $domain = $this->getDomainName(); + + // Steps 7-9 + if (rtrim(strtr(base64_encode($this->convertArrayToBytes($originalChallenge)), '+/', '-_'), '=') + !== $credentialData['response']['clientData']['challenge'] + || 'https://' . $domain !== $credentialData['response']['clientData']['origin'] + || $credentialData['response']['clientData']['type'] !== 'webauthn.get' + ) { + throw new LocalizedException(__('Invalid U2F key.')); + } + + // Step 10 not applicable + + // @see https://www.w3.org/TR/webauthn/#sec-authenticator-data + $authenticatorDataBytes = base64_decode($credentialData['response']['authenticatorData']); + $attestationObject = [ + 'rpIdHash' => substr($authenticatorDataBytes, 0, 32), + 'flags' => ord(substr($authenticatorDataBytes, 32, 1)), + 'counter' => substr($authenticatorDataBytes, 33, 4), + ]; + + // Steps 11-12 (skipping 13 due to some devices failing to set the flags correctly) + $hashId = hash('sha256', $domain, true); + if ($hashId !== $attestationObject['rpIdHash'] + || !($attestationObject['flags'] & 0b1) + ) { + throw new LocalizedException(__('Invalid U2F key.')); + } + + // Steps 15-16 + $clientDataSha256 = hash('sha256', $credentialData['response']['clientDataJSON'], true); + $isValidSignature = openssl_verify( + $authenticatorDataBytes . $clientDataSha256, + base64_decode($credentialData['response']['signature']), + $key['key'], + OPENSSL_ALGO_SHA256 + ); + if (!$isValidSignature) { + throw new LocalizedException(__('Invalid U2F key.')); + } + + // Skipping step 17 per the spec. This is sufficient proof for us at this point. + } + + /** + * Get all data needed for an authentication prompt + * + * @param array $publicKeys + * @return array + * @throws LocalizedException + */ + public function getAuthenticateData(array $publicKeys): array + { + try { + $challenge = random_bytes(16); + } catch (\Exception $e) { + throw new LocalizedException(__('There was an error during the U2F key process.')); + } + + $store = $this->storeManager->getStore(Store::ADMIN_CODE); + $allowedCredentials = []; + foreach ($publicKeys as $key) { + $allowedCredentials[] = [ + 'type' => 'public-key', + 'id' => $this->convertBytesToArray(base64_decode($key['id'])) + ]; + } + + $data = [ + 'credentialRequestOptions' => [ + 'challenge' => $this->convertBytesToArray($challenge), + 'timeout' => 60000, + 'allowCredentials' => $allowedCredentials, + 'userVerification' => 'discouraged', + 'extensions' => [ + 'txAuthSimple' => 'Authenticate with ' . $store->getName(), + ], + 'rpId' => $this->getDomainName(), + ] + ]; + + return $data; + } + + /** + * Generate the challenge for registration + * + * @param UserInterface $user + * @return array + * @throws LocalizedException + */ + public function getRegisterData(UserInterface $user): array + { + $domain = $this->getDomainName(); + + try { + $challenge = random_bytes(16); + } catch (\Exception $e) { + throw new LocalizedException(__('There was an error during the U2F key process.')); + } + $data = [ + 'publicKey' => [ + 'challenge' => $this->convertBytesToArray($challenge), + 'user' => [ + 'id' => $this->convertBytesToArray(sha1($user->getId())), + 'name' => $user->getUserName(), + 'displayName' => $user->getUserName() + ], + 'rp' => [ + 'name' => $domain, + 'id' => $domain, + ], + 'pubKeyCredParams' => [ + [ + 'alg' => self::ES256, + 'type' => 'public-key' + ], + ], + 'attestation' => 'indirect', + 'authenticatorSelection' => [ + 'authenticatorAttachment' => 'cross-platform', + 'requireResidentKey' => false, + 'userVerification' => 'discouraged' + ], + 'timeout' => 60000, + // Currently only one device may be registered at a time + 'excludeCredentials' => [], + 'extensions' => [ + 'exts' => true + ] + ] + ]; + + return $data; + } + + /** + * Convert registration data response into public key + * + * @param array $data + * @return array + * @throws ValidationException + */ + public function getPublicKeyFromRegistrationData(array $data): array + { + // Verification process as defined by w3 @see https://www.w3.org/TR/webauthn/#registering-a-new-credential + + $credentialData = $data['publicKeyCredential']; + $domain = $this->getDomainName(); + + if (rtrim(strtr(base64_encode($this->convertArrayToBytes($data['challenge'])), '+/', '-_'), '=') + !== $credentialData['response']['clientData']['challenge'] + || 'https://' . $domain !== $credentialData['response']['clientData']['origin'] + || $credentialData['response']['clientData']['type'] !== 'webauthn.create' + ) { + throw new LocalizedException(__('Invalid U2F key.')); + } + + if (empty($credentialData['response']['attestationObject']) || empty($credentialData['id'])) { + throw new ValidationException(__('Invalid U2F key data')); + } + $byteString = base64_decode($credentialData['response']['attestationObject']); + $attestationObject = CBOREncoder::decode($byteString); + if (empty($attestationObject['fmt']) + || empty($attestationObject['authData']) + ) { + throw new ValidationException(__('Invalid U2F key data')); + } + + $byteString = $attestationObject['authData']->get_byte_string(); + + // @see https://www.w3.org/TR/webauthn/#sec-authenticator-data + $attestationObject['rpIdHash'] = substr($byteString, 0, 32); + $attestationObject['flags'] = ord(substr($byteString, 32, 1)); + $attestationObject['counter'] = substr($byteString, 33, 4); + + $hashId = hash('sha256', $this->getDomainName(), true); + if ($hashId !== $attestationObject['rpIdHash']) { + throw new ValidationException(__('Invalid U2F key data')); + } + + // User presence, attestation data + if (!($attestationObject['flags'] & 0b1000001)) { + throw new ValidationException(__('Invalid U2F key data')); + } + + $attestationObject['attestationData'] = [ + 'aaguid' => substr($byteString, 37, 16), + 'credentialIdLength' => (ord($byteString[53]) << 8) + ord($byteString[54]), + ]; + $attestationObject['attestationData']['credId'] = substr( + $byteString, + 55, + $attestationObject['attestationData']['credentialIdLength'] + ); + $cborPublicKey = substr($byteString, 55 + $attestationObject['attestationData']['credentialIdLength']); + + $attestationObject['attestationData']['keyBytes'] = $this->COSEECDHAtoPKCS($cborPublicKey); + + if (empty($attestationObject['attestationData']['keyBytes']) + || $attestationObject['attestationData']['credId'] !== base64_decode($credentialData['id']) + ) { + throw new ValidationException(__('Invalid U2F key data')); + } + + return [ + 'key' => $attestationObject['attestationData']['keyBytes'], + 'id' => $credentialData['id'], + 'aaguid' => $attestationObject['attestationData']['aaguid'] ?? null + ]; + } + + /** + * Convert a binary string to an array of unsigned 8 bit integers + * + * @param string $byteString + * @return array + */ + private function convertBytesToArray(string $byteString): array + { + $result = []; + $numberOfBytes = strlen($byteString); + for ($i = 0; $i < $numberOfBytes; $i++) { + $result[] = ord($byteString[$i]); + } + return $result; + } + + /** + * Convert an array of unsigned 8 bit integers into a byte string + * + * @param array $bytes + * @return string + */ + private function convertArrayToBytes(array $bytes): string + { + $byteString = ''; + + foreach ($bytes as $byte) { + $byteString .= chr((int)$byte); + } + + return $byteString; + } + + /** + * Get the store domain but only if it's secure + * + * @return string + * @throws LocalizedException + */ + private function getDomainName(): string + { + $store = $this->storeManager->getStore(Store::ADMIN_CODE); + $baseUrl = $store->getBaseUrl(); + if (!preg_match('/^(https?:\/\/(?P.+?))\//', $baseUrl, $matches)) { + throw new LocalizedException(__('Could not determine secure domain name.')); + } + return $matches['domain']; + } + + /** + * Convert a CBOR encoded public key to PKCS format + * + * @param string $binary + * @return string|null + * @throws \Exception + */ + private function COSEECDHAtoPKCS(string $binary): ?string + { + $cosePubKey = CBOREncoder::decode($binary); + + // Sections 7.1 and 13.1.1 of @see https://tools.ietf.org/html/rfc8152 + if (!isset($cosePubKey[3]) + || $cosePubKey[3] !== self::ES256 + || !isset($cosePubKey[-1]) + || $cosePubKey[-1] != 1 + || !isset($cosePubKey[1]) + || $cosePubKey[1] != 2 + || !isset($cosePubKey[-2]) + || !isset($cosePubKey[-3]) + ) { + return null; + } + + $x = $cosePubKey[-2]->get_byte_string(); + $y = $cosePubKey[-3]->get_byte_string(); + if (strlen($x) != 32 || strlen($y) != 32) { + return null; + } + + $tag = "\x04"; + return $this->convertToPem($tag . $x . $y); + } + + /** + * Transform a WebAuthn public key to PEM format + * + * @param string $key + * @return string|null + * @see https://github.com/Yubico/php-u2flib-server/blob/master/src/u2flib_server/U2F.php + */ + private function convertToPem(string $key): ?string + { + if (strlen($key) !== self::PUBKEY_LEN || $key[0] !== "\x04") { + return null; + } + + /* + * Convert the public key to binary DER format first + * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480 + * + * SEQUENCE(2 elem) 30 59 + * SEQUENCE(2 elem) 30 13 + * OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01 + * OID1.2.840.10045.3.1.7 (secp256r1) 06 08 2a 86 48 ce 3d 03 01 07 + * BIT STRING(520 bit) 03 42 ..key.. + */ + $der = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"; + $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42"; + $der .= "\0".$key; + + $pem = "-----BEGIN PUBLIC KEY-----\r\n"; + $pem .= chunk_split(base64_encode($der), 64); + $pem .= "-----END PUBLIC KEY-----"; + + return $pem; + } +} diff --git a/TwoFactorAuth/Model/ProviderPool.php b/TwoFactorAuth/Model/ProviderPool.php new file mode 100644 index 00000000..b8502344 --- /dev/null +++ b/TwoFactorAuth/Model/ProviderPool.php @@ -0,0 +1,55 @@ +providers = $providers; + } + + /** + * @inheritDoc + */ + public function getProviders(): array + { + return $this->providers; + } + + /** + * @inheritDoc + */ + public function getProviderByCode(string $code): ProviderInterface + { + if ($code) { + $providers = $this->getProviders(); + if (isset($providers[$code])) { + return $providers[$code]; + } + } + + throw new NoSuchEntityException(__('Unknown provider %1', $code)); + } +} diff --git a/TwoFactorAuth/Model/ResourceModel/Country.php b/TwoFactorAuth/Model/ResourceModel/Country.php new file mode 100644 index 00000000..d2c7d0c6 --- /dev/null +++ b/TwoFactorAuth/Model/ResourceModel/Country.php @@ -0,0 +1,22 @@ +_init('tfa_country_codes', 'country_id'); + } +} diff --git a/TwoFactorAuth/Model/ResourceModel/Country/Collection.php b/TwoFactorAuth/Model/ResourceModel/Country/Collection.php new file mode 100644 index 00000000..e0576182 --- /dev/null +++ b/TwoFactorAuth/Model/ResourceModel/Country/Collection.php @@ -0,0 +1,33 @@ +_init( + \Magento\TwoFactorAuth\Model\Country::class, + Country::class + ); + } +} diff --git a/TwoFactorAuth/Model/ResourceModel/CountryRepository.php b/TwoFactorAuth/Model/ResourceModel/CountryRepository.php new file mode 100644 index 00000000..cc5b3b49 --- /dev/null +++ b/TwoFactorAuth/Model/ResourceModel/CountryRepository.php @@ -0,0 +1,209 @@ +searchResultsFactory = $searchResultsFactory; + $this->countryFactory = $countryFactory; + $this->resource = $resource; + $this->registry = $registry; + $this->extensibleDataObjectConverter = $extensibleDataObjectConverter; + $this->collectionProcessor = $collectionProcessor; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->collectionFactory = $collectionFactory; + } + + /** + * {@inheritdoc} + */ + public function save(CountryInterface $country): CountryInterface + { + $countryData = $this->extensibleDataObjectConverter->toNestedArray( + $country, + [], + CountryInterface::class + ); + + /** @var \Magento\TwoFactorAuth\Model\Country $countryModel */ + $countryModel = $this->countryFactory->create(['data' => $countryData]); + $countryModel->setDataChanges(true); + $this->resource->save($countryModel); + $country->setId($countryModel->getId()); + + $this->registry->push($countryModel); + + return $this->getById($countryModel->getId()); + } + + /** + * {@inheritdoc} + * @throws NoSuchEntityException + */ + public function getById(int $id): CountryInterface + { + $fromRegistry = $this->registry->retrieveById($id); + if ($fromRegistry === null) { + $country = $this->countryFactory->create(); + $this->resource->load($country, $id); + + if (!$country->getId()) { + throw new NoSuchEntityException(__('No such Country')); + } + + $this->registry->push($country); + } + + return $this->registry->retrieveById($id); + } + + /** + * {@inheritdoc} + * @throws NoSuchEntityException + */ + public function getByCode(string $value): CountryInterface + { + $fromRegistry = $this->registry->retrieveByCode($value); + if ($fromRegistry === null) { + $country = $this->countryFactory->create(); + $this->resource->load($country, $value, 'code'); + + if (!$country->getId()) { + throw new NoSuchEntityException(__('No such Country')); + } + + $this->registry->push($country); + } + + return $this->registry->retrieveByCode($value); + } + + /** + * {@inheritdoc} + */ + public function delete(CountryInterface $country): void + { + $countryData = $this->extensibleDataObjectConverter->toNestedArray( + $country, + [], + CountryInterface::class + ); + + /** @var \Magento\TwoFactorAuth\Model\Country $countryModel */ + $countryModel = $this->countryFactory->create(['data' => $countryData]); + $countryModel->setData($this->resource->getIdFieldName(), $country->getId()); + + $this->resource->delete($countryModel); + $this->registry->removeById($countryModel->getId()); + } + + /** + * {@inheritdoc} + */ + public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface + { + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + + if (null === $searchCriteria) { + $searchCriteria = $this->searchCriteriaBuilder->create(); + } else { + $this->collectionProcessor->process($searchCriteria, $collection); + } + + /** @var CountrySearchResultsInterface $searchResult */ + $searchResult = $this->searchResultsFactory->create(); + $searchResult->setItems($collection->getItems()); + $searchResult->setTotalCount($collection->getSize()); + $searchResult->setSearchCriteria($searchCriteria); + + return $searchResult; + } +} diff --git a/TwoFactorAuth/Model/ResourceModel/UserConfig.php b/TwoFactorAuth/Model/ResourceModel/UserConfig.php new file mode 100644 index 00000000..08eb44a6 --- /dev/null +++ b/TwoFactorAuth/Model/ResourceModel/UserConfig.php @@ -0,0 +1,110 @@ +encryptor = $encryptor ?: + ObjectManager::getInstance()->get(EncryptorInterface::class); + $this->serializer = $serializer ?: + ObjectManager::getInstance()->get(SerializerInterface::class); + } + + /** + * @inheritDoc + */ + protected function _construct() + { + $this->_init('tfa_user_config', 'config_id'); + } + + /** + * @param array $config + * @return string + */ + private function encodeConfig(array $config): string + { + return $this->encryptor->encrypt($this->serializer->serialize($config)); + } + + /** + * @param string $config + * @return array + */ + private function decodeConfig(string $config): array + { + // Support for legacy unencrypted configuration + try { + $config = $this->encryptor->decrypt($config); + } catch (Exception $e) { + unset($e); + } + + return $this->serializer->unserialize($config); + } + + public function _afterLoad(AbstractModel $object) + { + parent::_afterLoad($object); + + try { + $object->setData('config', $this->decodeConfig($object->getData('encoded_config') ?? '')); + } catch (Exception $e) { + $object->setData('config', []); + } + + try { + $object->setData('providers', $this->serializer->unserialize($object->getData('encoded_providers') ?? '')); + } catch (Exception $e) { + $object->setData('providers', []); + } + + return $this; + } + + public function _beforeSave(AbstractModel $object) + { + $object->setData('encoded_config', $this->encodeConfig($object->getData('config') ?? [])); + $object->setData('encoded_providers', $this->serializer->serialize($object->getData('providers') ?? [])); + + parent::_beforeSave($object); + } +} diff --git a/TwoFactorAuth/Model/ResourceModel/UserConfig/Collection.php b/TwoFactorAuth/Model/ResourceModel/UserConfig/Collection.php new file mode 100644 index 00000000..53f6e0e9 --- /dev/null +++ b/TwoFactorAuth/Model/ResourceModel/UserConfig/Collection.php @@ -0,0 +1,32 @@ +_init( + \Magento\TwoFactorAuth\Model\UserConfig::class, + UserConfig::class + ); + } +} diff --git a/TwoFactorAuth/Model/ResourceModel/UserConfigRepository.php b/TwoFactorAuth/Model/ResourceModel/UserConfigRepository.php new file mode 100644 index 00000000..d47d3263 --- /dev/null +++ b/TwoFactorAuth/Model/ResourceModel/UserConfigRepository.php @@ -0,0 +1,209 @@ +searchResultsFactory = $searchResultsFactory; + $this->userConfigFactory = $userConfigFactory; + $this->resource = $resource; + $this->registry = $registry; + $this->extensibleDataObjectConverter = $extensibleDataObjectConverter; + $this->collectionFactory = $collectionFactory; + $this->collectionProcessor = $collectionProcessor; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + } + + /** + * {@inheritdoc} + */ + public function save(UserConfigInterface $userConfig): UserConfigInterface + { + $userConfigData = $this->extensibleDataObjectConverter->toNestedArray( + $userConfig, + [], + UserConfigInterface::class + ); + + /** @var \Magento\TwoFactorAuth\Model\UserConfig $userConfigModel */ + $userConfigModel = $this->userConfigFactory->create(['data' => $userConfigData]); + $userConfigModel->setDataChanges(true); + $this->resource->save($userConfigModel); + $userConfig->setId($userConfigModel->getId()); + + $this->registry->push($userConfigModel); + + return $this->getById($userConfigModel->getId()); + } + + /** + * {@inheritdoc} + */ + public function getById(int $id): UserConfigInterface + { + $fromRegistry = $this->registry->retrieveById($id); + if ($fromRegistry === null) { + $userConfig = $this->userConfigFactory->create(); + $this->resource->load($userConfig, $id); + + if (!$userConfig->getId()) { + throw new NoSuchEntityException(__('No such UserConfig')); + } + + $this->registry->push($userConfig); + } + + return $this->registry->retrieveById($id); + } + + /** + * {@inheritdoc} + */ + public function getByUserId(int $value): UserConfigInterface + { + $fromRegistry = $this->registry->retrieveByUserId($value); + if ($fromRegistry === null) { + $userConfig = $this->userConfigFactory->create(); + $this->resource->load($userConfig, $value, 'user_id'); + + if (!$userConfig->getId()) { + throw new NoSuchEntityException(__('No such UserConfig')); + } + + $this->registry->push($userConfig); + } + + return $this->registry->retrieveByUserId($value); + } + + /** + * {@inheritdoc} + */ + public function delete(UserConfigInterface $userConfig): bool + { + $userConfigData = $this->extensibleDataObjectConverter->toNestedArray( + $userConfig, + [], + UserConfigInterface::class + ); + + /** @var \Magento\TwoFactorAuth\Model\UserConfig $userConfigModel */ + $userConfigModel = $this->userConfigFactory->create(['data' => $userConfigData]); + $userConfigModel->setData($this->resource->getIdFieldName(), $userConfig->getId()); + + $this->resource->delete($userConfigModel); + $this->registry->removeById($userConfigModel->getId()); + + return true; + } + + /** + * {@inheritdoc} + */ + public function getList(SearchCriteriaInterface $searchCriteria): SearchResultsInterface + { + /** @var Collection $collection */ + $collection = $this->collectionFactory->create(); + + if (null === $searchCriteria) { + $searchCriteria = $this->searchCriteriaBuilder->create(); + } else { + $this->collectionProcessor->process($searchCriteria, $collection); + } + + /** @var UserConfigSearchResultsInterface $searchResult */ + $searchResult = $this->searchResultsFactory->create(); + $searchResult->setItems($collection->getItems()); + $searchResult->setTotalCount($collection->getSize()); + $searchResult->setSearchCriteria($searchCriteria); + + return $searchResult; + } +} diff --git a/TwoFactorAuth/Model/Tfa.php b/TwoFactorAuth/Model/Tfa.php new file mode 100644 index 00000000..4bb945e1 --- /dev/null +++ b/TwoFactorAuth/Model/Tfa.php @@ -0,0 +1,290 @@ +scopeConfig = $scopeConfig; + $this->userConfigManager = $userConfigManager; + $this->searchCriteriaBuilder = $searchCriteriaBuilder; + $this->providerPool = $providerPool; + } + + /** + * @inheritdoc + */ + public function getAllProviders(): array + { + return array_values($this->providerPool->getProviders()); + } + + /** + * @inheritdoc + */ + public function getProviderByCode(string $code): ?ProviderInterface + { + if ($code) { + try { + return $this->providerPool->getProviderByCode($code); + } catch (NoSuchEntityException $e) { + return null; + } + } + + return null; + } + + /** + * @inheritdoc + */ + public function getAllEnabledProviders(): array + { + $enabledProviders = []; + $providers = $this->getAllProviders(); + foreach ($providers as $provider) { + if ($provider->isEnabled()) { + $enabledProviders[] = $provider; + } + } + + return $enabledProviders; + } + + /** + * @inheritdoc + */ + public function getProvider(string $providerCode, bool $onlyEnabled = true): ?ProviderInterface + { + $provider = $this->getProviderByCode($providerCode); + + if (!$provider) { + return null; + } + + if ($onlyEnabled && !$provider->isEnabled()) { + return null; + } + + return $provider; + } + + /** + * @inheritdoc + */ + public function getForcedProviders(): array + { + $forcedProviders = []; + + $configValue = $this->scopeConfig->getValue(TfaInterface::XML_PATH_FORCED_PROVIDERS); + if (!is_array($configValue) && $configValue) { + $forcedProvidersCodes = preg_split('/\s*,\s*/', $configValue); + } else { + $forcedProvidersCodes = $configValue; + } + + if ($forcedProvidersCodes) { + foreach ($forcedProvidersCodes as $forcedProviderCode) { + $provider = $this->getProvider($forcedProviderCode); + if ($provider) { + $forcedProviders[] = $provider; + } + } + } + + return $forcedProviders; + } + + /** + * @inheritdoc + */ + public function getUserProviders(int $userId): array + { + return $this->getForcedProviders(); + } + + /** + * @inheritdoc + */ + public function getAllowedUrls(): array + { + if ($this->allowedUrls === null) { + $this->allowedUrls = [ + 'adminhtml_auth_login', + 'adminhtml_auth_logout', + 'adminhtml_auth_forgotpassword', + 'tfa_tfa_accessdenied', + 'tfa_tfa_requestconfig', + 'tfa_tfa_configurelater', + 'tfa_tfa_configure', + 'tfa_tfa_configurepost', + 'tfa_tfa_index' + ]; + + $providers = $this->getAllProviders(); + foreach ($providers as $provider) { + $this->allowedUrls[] = str_replace('/', '_', $provider->getConfigureAction()); + $this->allowedUrls[] = str_replace('/', '_', $provider->getAuthAction()); + + foreach (array_values($provider->getExtraActions()) as $extraAction) { + $this->allowedUrls[] = str_replace('/', '_', $extraAction); + } + } + } + + return $this->allowedUrls; + } + + /** + * @inheritdoc + */ + public function getProvidersToActivate(int $userId): array + { + $providers = $this->getUserProviders($userId); + + $res = []; + foreach ($providers as $provider) { + if (!$provider->isActive($userId)) { + $res[] = $provider; + } + } + + return $res; + } + + /** + * @inheritdoc + */ + public function getProviderIsAllowed(int $userId, string $providerCode): bool + { + $providers = $this->getUserProviders($userId); + foreach ($providers as $provider) { + if ($provider->getCode() === $providerCode) { + return true; + } + } + + return false; + } + + /** + * @inheritdoc + */ + public function isEnabled(): bool + { + return true; + } + + /** + * Return true if a provider code is allowed + * @param int $userId + * @param string $providerCode + * @throws NoSuchEntityException + */ + private function assertAllowedProvider(int $userId, string $providerCode): void + { + if (!$this->getProviderIsAllowed($userId, $providerCode)) { + throw new NoSuchEntityException(__('Unknown or not enabled provider %1 for this user', $providerCode)); + } + } + + /** + * @inheritdoc + */ + public function getDefaultProviderCode(int $userId): string + { + return $this->userConfigManager->getDefaultProvider($userId); + } + + /** + * Set default provider code + * @param int $userId + * @param string $providerCode + * @return boolean + * @throws NoSuchEntityException + */ + public function setDefaultProviderCode(int $userId, string $providerCode): bool + { + $this->assertAllowedProvider($userId, $providerCode); + return $this->userConfigManager->setDefaultProvider($userId, $providerCode); + } + + /** + * @inheritdoc + */ + public function resetProviderConfig(int $userId, string $providerCode): bool + { + $this->assertAllowedProvider($userId, $providerCode); + return $this->userConfigManager->resetProviderConfig($userId, $providerCode); + } + + /** + * @inheritdoc + */ + public function setProvidersCodes(int $userId, string $providersCodes): bool + { + if (is_string($providersCodes)) { + $providersCodes = preg_split('/\s*,\s*/', $providersCodes); + } + + foreach ($providersCodes as $providerCode) { + $this->assertAllowedProvider($userId, $providerCode); + } + + return $this->userConfigManager->setProvidersCodes($userId, $providersCodes); + } +} diff --git a/TwoFactorAuth/Model/TfaSession.php b/TwoFactorAuth/Model/TfaSession.php new file mode 100644 index 00000000..2c6a7266 --- /dev/null +++ b/TwoFactorAuth/Model/TfaSession.php @@ -0,0 +1,53 @@ +storage->setData(TfaSessionInterface::KEY_PASSED, true); + } + + /** + * @inheritDoc + */ + public function isGranted(): bool + { + return (bool) $this->storage->getData(TfaSessionInterface::KEY_PASSED); + } + + /** + * @inheritDoc + * + * @return array + */ + public function getSkippedProviderConfig(): array + { + return $this->getData(static::SKIPPED_PROVIDERS_KEY) ?? []; + } + + /** + * @inheritDoc + */ + public function setSkippedProviderConfig(array $config): void + { + $this->storage->setData(static::SKIPPED_PROVIDERS_KEY, $config); + } +} diff --git a/TwoFactorAuth/Model/UserConfig.php b/TwoFactorAuth/Model/UserConfig.php new file mode 100644 index 00000000..47a9aef3 --- /dev/null +++ b/TwoFactorAuth/Model/UserConfig.php @@ -0,0 +1,89 @@ +dataObjectHelper = $dataObjectHelper; + $this->userConfigDataFactory = $userConfigDataFactory; + } + + /** + * @inheritDoc + */ + protected function _construct() + { + $this->_init(ResourceModel\UserConfig::class); + } + + /** + * Retrieve UserConfig model + * + * @return UserConfigInterface + */ + public function getDataModel(): UserConfigInterface + { + $userConfigData = $this->getData(); + + /** @var UserConfigInterface $userConfigDataObject */ + $userConfigDataObject = $this->userConfigDataFactory->create(); + + $this->dataObjectHelper->populateWithArray( + $userConfigDataObject, + $userConfigData, + UserConfigInterface::class + ); + $userConfigDataObject->setId($this->getId()); + + return $userConfigDataObject; + } +} diff --git a/TwoFactorAuth/Model/UserConfig/HtmlAreaTokenVerifier.php b/TwoFactorAuth/Model/UserConfig/HtmlAreaTokenVerifier.php new file mode 100644 index 00000000..e461adb4 --- /dev/null +++ b/TwoFactorAuth/Model/UserConfig/HtmlAreaTokenVerifier.php @@ -0,0 +1,118 @@ +request = $request; + $this->tokenManager = $tokenManager; + $this->cookies = $cookies; + $this->cookieMetadataFactory = $cookieMetadataFactory; + $this->session = $session; + $this->sessionManager = $sessionManager; + } + + /** + * Was config token provided by current user?. + * + * @return bool + */ + public function isConfigTokenProvided(): bool + { + return (bool)$this->readConfigToken(); + } + + /** + * Read configuration token provided by user. + * + * @return string|null + */ + public function readConfigToken(): ?string + { + $user = $this->session->getUser(); + if (!$user) { + return null; + } + $cookieToken = $this->cookies->getCookie('tfa-ct'); + $paramToken = $this->request->getParam('tfat'); + $cookieTokenValid = $cookieToken && $this->tokenManager->isValidFor((int)$user->getId(), $cookieToken); + $paramTokenValid = $paramToken && $this->tokenManager->isValidFor((int)$user->getId(), $paramToken); + + if (!$cookieTokenValid && !$paramTokenValid) { + return null; + } elseif ($paramTokenValid && !$cookieTokenValid) { + $metadata = $this->cookieMetadataFactory->createSensitiveCookieMetadata() + ->setPath($this->sessionManager->getCookiePath()); + $this->cookies->setSensitiveCookie('tfa-ct', $paramToken, $metadata); + return $paramToken; + } elseif (!$paramTokenValid && $cookieTokenValid) { + return $cookieToken; + } else { + return $cookieToken; + } + } +} diff --git a/TwoFactorAuth/Model/UserConfig/SignedTokenManager.php b/TwoFactorAuth/Model/UserConfig/SignedTokenManager.php new file mode 100644 index 00000000..d0d3e93c --- /dev/null +++ b/TwoFactorAuth/Model/UserConfig/SignedTokenManager.php @@ -0,0 +1,86 @@ +encryptor = $encryptor; + $this->json = $json; + $this->dateTime = $dateTime; + } + + /** + * @inheritDoc + */ + public function issueFor(int $userId): string + { + $data = ['user_id' => $userId, 'tfa_configuration' => true, 'iss' => $this->dateTime->timestamp()]; + $encodedData = $this->json->serialize($data); + $signature = base64_encode($this->encryptor->hash($encodedData)); + + return base64_encode($encodedData .'.' .$signature); + } + + /** + * @inheritDoc + */ + public function isValidFor(int $userId, string $token): bool + { + $isValid = false; + [$encodedData, $signatureProvided] = explode('.', base64_decode($token)); + try { + $data = $this->json->unserialize($encodedData); + if (array_key_exists('user_id', $data) + && array_key_exists('tfa_configuration', $data) + && array_key_exists('iss', $data) + && $data['user_id'] === $userId + && $data['tfa_configuration'] + && ($this->dateTime->timestamp() - (int)$data['iss']) < 3600 + && Security::compareStrings(base64_encode($this->encryptor->hash($encodedData)), $signatureProvided) + ) { + $isValid = true; + } + } catch (\Throwable $exception) { + $isValid = false; + } + + return $isValid; + } +} diff --git a/TwoFactorAuth/Model/UserConfig/UserConfigRequestManager.php b/TwoFactorAuth/Model/UserConfig/UserConfigRequestManager.php new file mode 100644 index 00000000..07f0460b --- /dev/null +++ b/TwoFactorAuth/Model/UserConfig/UserConfigRequestManager.php @@ -0,0 +1,88 @@ +tfa = $tfa; + $this->notifier = $notifier; + $this->tokenManager = $tokenManager; + $this->auth = $auth; + } + + /** + * @inheritDoc + */ + public function isConfigurationRequiredFor(int $userId): bool + { + return empty($this->tfa->getUserProviders($userId)) + || !empty($this->tfa->getProvidersToActivate($userId)); + } + + /** + * @inheritDoc + */ + public function sendConfigRequestTo(User $user): void + { + $userId = (int)$user->getId(); + if (empty($this->tfa->getUserProviders($userId))) { + //Application level configuration is required. + if (!$this->auth->isAllowed($user->getAclRole(), 'Magento_TwoFactorAuth::config')) { + throw new AuthorizationException(__('User is not authorized to edit 2FA configuration')); + } + $this->notifier->sendAppConfigRequestMessage($user, $this->tokenManager->issueFor($userId)); + } else { + //Personal provider config required. + $this->notifier->sendUserConfigRequestMessage($user, $this->tokenManager->issueFor($userId)); + } + } +} diff --git a/TwoFactorAuth/Model/UserConfigManager.php b/TwoFactorAuth/Model/UserConfigManager.php new file mode 100644 index 00000000..c3875747 --- /dev/null +++ b/TwoFactorAuth/Model/UserConfigManager.php @@ -0,0 +1,191 @@ +userConfigFactory = $userConfigFactory; + $this->userConfigResource = $userConfigResource; + } + + /** + * @inheritDoc + */ + public function getProviderConfig(int $userId, string $providerCode): ?array + { + $userConfig = $this->getUserConfiguration($userId); + $providersConfig = $userConfig->getData('config'); + + if (!isset($providersConfig[$providerCode])) { + return null; + } + + return $providersConfig[$providerCode]; + } + + /** + * @inheritdoc + */ + public function setProviderConfig(int $userId, string $providerCode, ?array $config=null): bool + { + $userConfig = $this->getUserConfiguration($userId); + $providersConfig = $userConfig->getData('config'); + + if ($config === null) { + if (isset($providersConfig[$providerCode])) { + unset($providersConfig[$providerCode]); + } + } else { + $providersConfig[$providerCode] = $config; + } + + $userConfig->setData('config', $providersConfig); + $this->userConfigResource->save($userConfig); + + return true; + } + + /** + * @inheritdoc + */ + public function addProviderConfig(int $userId, string $providerCode, ?array $config=null): bool + { + $userConfig = $this->getProviderConfig($userId, $providerCode); + if ($userConfig === null) { + $newConfig = $config; + } else { + $newConfig = array_merge($userConfig, $config); + } + + return $this->setProviderConfig($userId, $providerCode, $newConfig); + } + + /** + * @inheritdoc + */ + public function resetProviderConfig(int $userId, string $providerCode): bool + { + $this->setProviderConfig($userId, $providerCode, null); + return true; + } + + /** + * Get user TFA config + * @param int $userId + * @return UserConfig + */ + private function getUserConfiguration(int $userId): UserConfig + { + if (!isset($this->configurationRegistry[$userId])) { + /** @var $userConfig UserConfig */ + $userConfig = $this->userConfigFactory->create(); + $this->userConfigResource->load($userConfig, $userId, 'user_id'); + $userConfig->setData('user_id', $userId); + + $this->configurationRegistry[$userId] = $userConfig; + } + + return $this->configurationRegistry[$userId]; + } + + /** + * @inheritdoc + */ + public function setProvidersCodes(int $userId, $providersCodes): bool + { + if (is_string($providersCodes)) { + $providersCodes = preg_split('/\s*,\s*/', $providersCodes); + } + + $userConfig = $this->getUserConfiguration($userId); + $userConfig->setData('providers', $providersCodes); + $this->userConfigResource->save($userConfig); + + return true; + } + + /** + * @inheritdoc + */ + public function getProvidersCodes(int $userId): array + { + $userConfig = $this->getUserConfiguration($userId); + return $userConfig->getData('providers'); + } + + /** + * @inheritdoc + */ + public function activateProviderConfiguration(int $userId, string $providerCode): bool + { + return $this->addProviderConfig($userId, $providerCode, [ + UserConfigManagerInterface::ACTIVE_CONFIG_KEY => true + ]); + } + + /** + * @inheritdoc + */ + public function isProviderConfigurationActive(int $userId, string $providerCode): bool + { + $config = $this->getProviderConfig($userId, $providerCode); + return $config && + isset($config[UserConfigManagerInterface::ACTIVE_CONFIG_KEY]) && + $config[UserConfigManagerInterface::ACTIVE_CONFIG_KEY]; + } + + /** + * @inheritdoc + */ + public function setDefaultProvider(int $userId, string $providerCode): bool + { + $userConfig = $this->getUserConfiguration($userId); + $userConfig->setData('default_provider', $providerCode); + $this->userConfigResource->save($userConfig); + return true; + } + + /** + * @inheritdoc + */ + public function getDefaultProvider(int $userId): string + { + $userConfig = $this->getUserConfiguration($userId); + return $userConfig->getData('default_provider') ?: ''; + } +} diff --git a/TwoFactorAuth/Model/UserConfigRegistry.php b/TwoFactorAuth/Model/UserConfigRegistry.php new file mode 100644 index 00000000..f283ba48 --- /dev/null +++ b/TwoFactorAuth/Model/UserConfigRegistry.php @@ -0,0 +1,84 @@ + [], + ]; + + /** + * Remove registry entity by id + * @param int $id + */ + public function removeById(int $id): void + { + if (isset($this->registry[$id])) { + unset($this->registry[$id]); + } + + foreach (array_keys($this->registryByKey) as $key) { + $reverseMap = array_flip($this->registryByKey[$key]); + if (isset($reverseMap[$id])) { + unset($this->registryByKey[$key][$reverseMap[$id]]); + } + } + } + + /** + * Push one object into registry + * @param int $id + * @return UserConfigInterface|null + */ + public function retrieveById(int $id): ?UserConfigInterface + { + return $this->registry[$id] ?? null; + } + + /** + * Retrieve by UserId value + * @param int $value + * @return UserConfigInterface|null + */ + public function retrieveByUserId(int $value): ?UserConfigInterface + { + if (isset($this->registryByKey['user_id'][$value])) { + return $this->retrieveById($this->registryByKey['user_id'][$value]); + } + + return null; + } + + /** + * Push one object into registry + * @param UserConfig $userConfig + */ + public function push(UserConfig $userConfig): void + { + $this->registry[$userConfig->getId()] = $userConfig->getDataModel(); + foreach (array_keys($this->registryByKey) as $key) { + $this->registryByKey[$key][$userConfig->getData($key)] = $userConfig->getId(); + } + } +} diff --git a/TwoFactorAuth/Observer/AdminUserLoadAfter.php b/TwoFactorAuth/Observer/AdminUserLoadAfter.php new file mode 100644 index 00000000..151d53b3 --- /dev/null +++ b/TwoFactorAuth/Observer/AdminUserLoadAfter.php @@ -0,0 +1,39 @@ +userConfigManager = $userConfigManager; + } + + /** + * @param Observer $observer + * @return void + */ + public function execute(Observer $observer) + { + $user = $observer->getEvent()->getObject(); + $user->setData('tfa_providers', $this->userConfigManager->getProvidersCodes((int) $user->getId())); + } +} diff --git a/TwoFactorAuth/Observer/AdminUserSaveAfter.php b/TwoFactorAuth/Observer/AdminUserSaveAfter.php new file mode 100644 index 00000000..f5777997 --- /dev/null +++ b/TwoFactorAuth/Observer/AdminUserSaveAfter.php @@ -0,0 +1,59 @@ +userConfigManager = $userConfigManager; + $this->authorization = $authorization; + } + + /** + * @inheritDoc + */ + public function execute(Observer $observer) + { + if ($this->authorization->isAllowed('Magento_TwoFactorAuth::tfa')) { + $user = $observer->getEvent()->getObject(); + $data = $user->getData(); + + if (isset($data['tfa_providers'])) { + if (!is_array($data['tfa_providers'])) { + $data['tfa_providers'] = []; + } + $this->userConfigManager->setProvidersCodes((int) $user->getId(), $data['tfa_providers']); + } + } + } +} diff --git a/TwoFactorAuth/Observer/ControllerActionPredispatch.php b/TwoFactorAuth/Observer/ControllerActionPredispatch.php new file mode 100644 index 00000000..784a6fd4 --- /dev/null +++ b/TwoFactorAuth/Observer/ControllerActionPredispatch.php @@ -0,0 +1,162 @@ +tfa = $tfa; + $this->tfaSession = $tfaSession; + $this->configRequestManager = $configRequestManager; + $this->tokenManager = $tokenManager; + $this->actionFlag = $actionFlag; + $this->url = $url; + $this->authorization = $authorization; + $this->userContext = $userContext; + } + + /** + * Redirect user to given URL. + * + * @param string $url + * @return void + */ + private function redirect(string $url): void + { + $this->actionFlag->set('', Action::FLAG_NO_DISPATCH, true); + $this->action->getResponse()->setRedirect($this->url->getUrl($url)); + } + + /** + * @inheritDoc + */ + public function execute(Observer $observer) + { + /** @var $controllerAction AbstractAction */ + $controllerAction = $observer->getEvent()->getData('controller_action'); + $this->action = $controllerAction; + $fullActionName = $controllerAction->getRequest()->getFullActionName(); + $userId = $this->userContext->getUserId(); + + $this->tokenManager->readConfigToken(); + + if (in_array($fullActionName, $this->tfa->getAllowedUrls(), true)) { + //Actions that are used for 2FA must remain accessible. + return; + } + + if ($userId) { + $configurationStillRequired = $this->configRequestManager->isConfigurationRequiredFor($userId); + $toActivate = $this->tfa->getProvidersToActivate($userId); + $toActivateCodes = []; + foreach ($toActivate as $toActivateProvider) { + $toActivateCodes[] = $toActivateProvider->getCode(); + } + $accessGranted = $this->tfaSession->isGranted(); + + if (!$accessGranted && $configurationStillRequired) { + //User needs special link with a token to be allowed to configure 2FA + if ($this->authorization->isAllowed(Configure::ADMIN_RESOURCE)) { + $this->redirect('tfa/tfa/requestconfig'); + } else { + $this->redirect('tfa/tfa/accessdenied'); + } + } else { + if (!$accessGranted) { + if ($this->authorization->isAllowed(Index::ADMIN_RESOURCE)) { + $this->redirect('tfa/tfa/index'); + } else { + $this->redirect('tfa/tfa/accessdenied'); + } + } + } + } + } +} diff --git a/TwoFactorAuth/Plugin/AddTabToAdminUserEdit.php b/TwoFactorAuth/Plugin/AddTabToAdminUserEdit.php new file mode 100644 index 00000000..f788c95a --- /dev/null +++ b/TwoFactorAuth/Plugin/AddTabToAdminUserEdit.php @@ -0,0 +1,67 @@ +tfa = $tfa; + $this->authorization = $authorization; + } + + /** + * @param Tabs $subject + * @throws LocalizedException + */ + public function beforeToHtml(Tabs $subject) + { + if (empty($this->tfa->getAllEnabledProviders()) || + !$this->authorization->isAllowed('Magento_TwoFactorAuth::tfa') + ) { + return; + } + + $tfaForm = $subject->getLayout()->renderElement('tfa_edit_user_form'); + + $subject->addTabAfter( + 'twofactorauth', + [ + 'label' => __('2FA'), + 'title' => __('2FA'), + 'content' => $tfaForm, + 'active' => true + ], + 'roles_section' + ); + } +} diff --git a/TwoFactorAuth/Plugin/AvoidRecursionOnPasswordChange.php b/TwoFactorAuth/Plugin/AvoidRecursionOnPasswordChange.php new file mode 100644 index 00000000..597d9688 --- /dev/null +++ b/TwoFactorAuth/Plugin/AvoidRecursionOnPasswordChange.php @@ -0,0 +1,64 @@ +request = $request; + $this->tfa = $tfa; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @param ForceAdminPasswordChangeObserver $subject + * @param Closure $proceed + * @param EventObserver $observer + * @return void + */ + public function aroundExecute( + ForceAdminPasswordChangeObserver $subject, + Closure $proceed, + EventObserver $observer + ) { + /* + * We need to bypass ForceAdminPasswordChangeObserver::execute while authenticating 2FA + * to avoid a recursion loop caused by two different redirects + */ + $fullActionName = $this->request->getFullActionName(); + if (!in_array($fullActionName, $this->tfa->getAllowedUrls(), true)) { + $proceed($observer); + } + } +} diff --git a/TwoFactorAuth/README.md b/TwoFactorAuth/README.md new file mode 100644 index 00000000..05fa32be --- /dev/null +++ b/TwoFactorAuth/README.md @@ -0,0 +1,99 @@ +MSP TwoFactorAuth + +Two Factor Authentication module for maximum **backend access protection** in Magento 2. + +> Member of **MSP Security Suite** +> +> See: https://github.com/magespecialist/m2-MSP_Security_Suite + +Did you lock yourself out from Magento backend? click here. + +## Main features: + +* Providers: + * Google authenticator + * QR code enroll + * Authy + * SMS + * Call + * Token + * One touch + * U2F keys (Yubico and others) + * Duo Security + * SMS + * Push notification +* Central security suite events logging +* Per user configuration +* Forced global 2FA configuration + +## Installing on Magento2: + +**1. Install using composer** + +From command line: + +`composer require msp/twofactorauth` + +**2. Enable and configure from your Magento backend config** + +Enable from **Store > Config > SecuritySuite > Two Factor Authentication**. + + + +**3. Enable two factor authentication for your user** + +You can select among a set of different 2FA providers. **Multiple concurrent providers** are supported. + + + +**4. Subscribe / Configure your 2FA provider(s):** + +**4.1 Google Authenticator example** + + + +**4.2. Duo Security example** + + + +**4.3. U2F key (Yubico and others) example** + + + +**4.4. Authy example** + + + +## Emergency commandline disable: + +If you messed up with two factor authentication you can disable it from command-line: + +`php bin/magento msp:security:tfa:disable` + +This will disable two factor auth globally. + +## Emergency commandline reset: + +If you need to manually reset one single user configuration (so you can restart configuration / subscription), type: + +`php bin/magento msp:security:tfa:reset ` + +e.g.: + +`php bin/magento msp:security:tfa:reset admin google` + +`php bin/magento msp:security:tfa:reset admin u2fkey` + +`php bin/magento msp:security:tfa:reset admin authy` + +## Emergency of emergency and your house is on fire, your dog is lost and your wife doesn't love you anymore: + +**DO NOT ATTEMPT TO MODIFY ANY DB INFORMATION UNLESS YOU UNDERSTAND WHAT YOU ARE DOING** + +Table `core_config_data`: +* `msp/twofactorauth/enabled`: Set to zero to disable 2fa globally +* `msp/twofactorauth/force_providers`: Delete this entry to remove forced providers option + +Table `msp_tfa_user_config`: +* Delete one user row to reset user's 2FA preference and configuration + diff --git a/TwoFactorAuth/Setup/Patch/Data/CopyConfigFromOldModule.php b/TwoFactorAuth/Setup/Patch/Data/CopyConfigFromOldModule.php new file mode 100644 index 00000000..caeab859 --- /dev/null +++ b/TwoFactorAuth/Setup/Patch/Data/CopyConfigFromOldModule.php @@ -0,0 +1,106 @@ +moduleDataSetup = $moduleDataSetup; + $this->scopeConfig = $scopeConfig; + } + + /** + * Move config from srcPath to dstPath + * + * @param array $paths + */ + private function copyConfig(array $paths): void + { + $connection = $this->moduleDataSetup->getConnection(); + $configDataTable = $this->moduleDataSetup->getTable('core_config_data'); + + foreach ($paths as $srcPath => $dstPath) { + $value = $this->scopeConfig->getValue($srcPath); + if (is_array($value)) { + foreach (array_keys($value) as $v) { + $this->copyConfig([$srcPath . '/' . $v => $dstPath . '/' . $v]); + } + } else { + $sel = $connection->select() + ->from($configDataTable) + ->where('path = ?', $srcPath); + + $rows = $connection->fetchAll($sel); + foreach ($rows as $row) { + unset($row['config_id']); + $row['path'] = $dstPath; + + $connection->insert($configDataTable, $row); + } + } + } + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + + // Previous versions configuration + $this->copyConfig([ + 'msp_securitysuite_twofactorauth' => 'twofactorauth' + ]); + + $this->moduleDataSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/TwoFactorAuth/Setup/Patch/Data/EncryptConfiguration.php b/TwoFactorAuth/Setup/Patch/Data/EncryptConfiguration.php new file mode 100644 index 00000000..f2a16736 --- /dev/null +++ b/TwoFactorAuth/Setup/Patch/Data/EncryptConfiguration.php @@ -0,0 +1,85 @@ +moduleDataSetup = $moduleDataSetup; + $this->encryptor = $encryptor; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + + $tfaConfigTableName = $this->moduleDataSetup->getTable('tfa_user_config'); + $connection = $this->moduleDataSetup->getConnection(); + + $qry = $connection->select()->from($tfaConfigTableName); + $configurations = $connection->fetchAll($qry); + + foreach ($configurations as $configuration) { + if (!$this->encryptor->decrypt($configuration['encoded_config'])) { + $connection->update( + $tfaConfigTableName, + ['encoded_config' => $this->encryptor->encrypt($configuration['encoded_config'])], + $connection->quoteInto('config_id = ?', $configuration['config_id']) + ); + } + } + + $this->moduleDataSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/TwoFactorAuth/Setup/Patch/Data/EncryptSecrets.php b/TwoFactorAuth/Setup/Patch/Data/EncryptSecrets.php new file mode 100644 index 00000000..15ae99f9 --- /dev/null +++ b/TwoFactorAuth/Setup/Patch/Data/EncryptSecrets.php @@ -0,0 +1,93 @@ +moduleDataSetup = $moduleDataSetup; + $this->encryptor = $encryptor; + } + + /** + * @inheritdoc + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + + $configTable = $this->moduleDataSetup->getTable('core_config_data'); + $connection = $this->moduleDataSetup->getConnection(); + + $query = $connection->select() + ->from($configTable) + ->where( + 'path in (?)', + [ + DuoSecurity::XML_PATH_APPLICATION_KEY, + DuoSecurity::XML_PATH_SECRET_KEY, + Service::XML_PATH_API_KEY, + ] + ); + $configurations = $connection->fetchAll($query); + + foreach ($configurations as $configuration) { + if (preg_match('/[^\x00-\x7F]/', $this->encryptor->decrypt($configuration['value']))) { + $connection->update( + $configTable, + ['value' => $this->encryptor->encrypt($configuration['value'])], + $connection->quoteInto('config_id = ?', $configuration['config_id']) + ); + } + } + + $this->moduleDataSetup->endSetup(); + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return []; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/TwoFactorAuth/Setup/Patch/Data/GenerateDuoSecurityKey.php b/TwoFactorAuth/Setup/Patch/Data/GenerateDuoSecurityKey.php new file mode 100644 index 00000000..e92e2a7e --- /dev/null +++ b/TwoFactorAuth/Setup/Patch/Data/GenerateDuoSecurityKey.php @@ -0,0 +1,93 @@ +moduleDataSetup = $moduleDataSetup; + $this->config = $config; + $this->scopeConfig = $scopeConfig; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + + if (!$this->scopeConfig->getValue(DuoSecurity::XML_PATH_APPLICATION_KEY)) { + // Generate random duo security key + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + for ($i = 0; $i < 64; $i++) { + $randomString .= $characters[random_int(0, $charactersLength - 1)]; + } + + $this->config->saveConfig(DuoSecurity::XML_PATH_APPLICATION_KEY, $randomString, 'default', 0); + } + + $this->moduleDataSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return [ + CopyConfigFromOldModule::class + ]; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/TwoFactorAuth/Setup/Patch/Data/PopulateCountryTable.php b/TwoFactorAuth/Setup/Patch/Data/PopulateCountryTable.php new file mode 100644 index 00000000..fa5c6474 --- /dev/null +++ b/TwoFactorAuth/Setup/Patch/Data/PopulateCountryTable.php @@ -0,0 +1,104 @@ +moduleDataSetup = $moduleDataSetup; + $this->file = $file; + $this->moduleReader = $moduleReader; + $this->serializer = $serializer; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + $connection = $this->moduleDataSetup->getConnection(); + + $tableName = $this->moduleDataSetup->getTable('tfa_country_codes'); + + $countryCodesJsonFile = + $this->moduleReader->getModuleDir(false, 'Magento_TwoFactorAuth') . DIRECTORY_SEPARATOR . 'Setup' . + DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'country_codes.json'; + + $countryCodesJson = $this->file->read($countryCodesJsonFile); + + $countryCodes = $this->serializer->unserialize(trim($countryCodesJson)); + + // @codingStandardsIgnoreStart + foreach ($countryCodes as $countryCode) { + $connection->insertOnDuplicate($tableName, $countryCode, ['dial_code']); + } + // @codingStandardsIgnoreEnd + + $this->moduleDataSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/TwoFactorAuth/Setup/Patch/Data/ResetU2fConfig.php b/TwoFactorAuth/Setup/Patch/Data/ResetU2fConfig.php new file mode 100644 index 00000000..9b0f041a --- /dev/null +++ b/TwoFactorAuth/Setup/Patch/Data/ResetU2fConfig.php @@ -0,0 +1,91 @@ +moduleDataSetup = $moduleDataSetup; + $this->userCollectionFactory = $userCollectionFactory; + $this->userConfigManager = $userConfigManager; + } + + /** + * @inheritdoc + */ + public function apply() + { + $this->moduleDataSetup->startSetup(); + + /** @var \Magento\User\Model\ResourceModel\User\Collection $collection */ + $collection = $this->userCollectionFactory->create(); + + foreach ($collection as $user) { + /** @var $user User */ + + try { + $this->userConfigManager->setProviderConfig((int)$user->getId(), U2fKey::CODE, []); + } catch (NoSuchEntityException $e) { + continue; + } + } + + $this->moduleDataSetup->endSetup(); + } + + /** + * @inheritdoc + */ + public static function getDependencies() + { + return []; + } + + /** + * @inheritdoc + */ + public function getAliases() + { + return []; + } +} diff --git a/TwoFactorAuth/Setup/Patch/Schema/CopyTablesFromOldModule.php b/TwoFactorAuth/Setup/Patch/Schema/CopyTablesFromOldModule.php new file mode 100644 index 00000000..81ad6b10 --- /dev/null +++ b/TwoFactorAuth/Setup/Patch/Schema/CopyTablesFromOldModule.php @@ -0,0 +1,84 @@ +schemaSetup = $schemaSetup; + } + + /** + * {@inheritdoc} + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + public function apply() + { + $this->schemaSetup->startSetup(); + + $connection = $this->schemaSetup->getConnection(); + $sourceUserConfigTable = $this->schemaSetup->getTable('msp_tfa_user_config'); + $sourceTrustedDevicesTable = $this->schemaSetup->getTable('msp_tfa_trusted'); + + $userConfigTable = $this->schemaSetup->getTable('tfa_user_config'); + $trustedDevicesTable = $this->schemaSetup->getTable('tfa_trusted'); + + if ($connection->isTableExists($sourceUserConfigTable)) { + $cols = ['user_id', 'encoded_providers', 'encoded_config', 'default_provider']; + $connection->query($connection->insertFromSelect( + $connection->select()->from($sourceUserConfigTable, $cols), + $userConfigTable, + $cols + )); + } + + if ($connection->isTableExists($sourceTrustedDevicesTable)) { + $connection->dropTable($sourceTrustedDevicesTable); + } + if ($connection->isTableExists($trustedDevicesTable)) { + $connection->dropTable($trustedDevicesTable); + } + + $this->schemaSetup->endSetup(); + } + + /** + * {@inheritdoc} + */ + public static function getDependencies() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function getAliases() + { + return []; + } +} diff --git a/TwoFactorAuth/Setup/data/country_codes.json b/TwoFactorAuth/Setup/data/country_codes.json new file mode 100644 index 00000000..541a1745 --- /dev/null +++ b/TwoFactorAuth/Setup/data/country_codes.json @@ -0,0 +1,1207 @@ +[ + { + "name": "Israel", + "dial_code": "+972", + "code": "IL" + }, + { + "name": "Afghanistan", + "dial_code": "+93", + "code": "AF" + }, + { + "name": "Albania", + "dial_code": "+355", + "code": "AL" + }, + { + "name": "Algeria", + "dial_code": "+213", + "code": "DZ" + }, + { + "name": "AmericanSamoa", + "dial_code": "+1 684", + "code": "AS" + }, + { + "name": "Andorra", + "dial_code": "+376", + "code": "AD" + }, + { + "name": "Angola", + "dial_code": "+244", + "code": "AO" + }, + { + "name": "Anguilla", + "dial_code": "+1 264", + "code": "AI" + }, + { + "name": "Antigua and Barbuda", + "dial_code": "+1268", + "code": "AG" + }, + { + "name": "Argentina", + "dial_code": "+54", + "code": "AR" + }, + { + "name": "Armenia", + "dial_code": "+374", + "code": "AM" + }, + { + "name": "Aruba", + "dial_code": "+297", + "code": "AW" + }, + { + "name": "Australia", + "dial_code": "+61", + "code": "AU" + }, + { + "name": "Austria", + "dial_code": "+43", + "code": "AT" + }, + { + "name": "Azerbaijan", + "dial_code": "+994", + "code": "AZ" + }, + { + "name": "Bahamas", + "dial_code": "+1 242", + "code": "BS" + }, + { + "name": "Bahrain", + "dial_code": "+973", + "code": "BH" + }, + { + "name": "Bangladesh", + "dial_code": "+880", + "code": "BD" + }, + { + "name": "Barbados", + "dial_code": "+1 246", + "code": "BB" + }, + { + "name": "Belarus", + "dial_code": "+375", + "code": "BY" + }, + { + "name": "Belgium", + "dial_code": "+32", + "code": "BE" + }, + { + "name": "Belize", + "dial_code": "+501", + "code": "BZ" + }, + { + "name": "Benin", + "dial_code": "+229", + "code": "BJ" + }, + { + "name": "Bermuda", + "dial_code": "+1 441", + "code": "BM" + }, + { + "name": "Bhutan", + "dial_code": "+975", + "code": "BT" + }, + { + "name": "Bosnia and Herzegovina", + "dial_code": "+387", + "code": "BA" + }, + { + "name": "Botswana", + "dial_code": "+267", + "code": "BW" + }, + { + "name": "Brazil", + "dial_code": "+55", + "code": "BR" + }, + { + "name": "British Indian Ocean Territory", + "dial_code": "+246", + "code": "IO" + }, + { + "name": "Bulgaria", + "dial_code": "+359", + "code": "BG" + }, + { + "name": "Burkina Faso", + "dial_code": "+226", + "code": "BF" + }, + { + "name": "Burundi", + "dial_code": "+257", + "code": "BI" + }, + { + "name": "Cambodia", + "dial_code": "+855", + "code": "KH" + }, + { + "name": "Cameroon", + "dial_code": "+237", + "code": "CM" + }, + { + "name": "Canada", + "dial_code": "+1", + "code": "CA" + }, + { + "name": "Cape Verde", + "dial_code": "+238", + "code": "CV" + }, + { + "name": "Cayman Islands", + "dial_code": "+ 345", + "code": "KY" + }, + { + "name": "Central African Republic", + "dial_code": "+236", + "code": "CF" + }, + { + "name": "Chad", + "dial_code": "+235", + "code": "TD" + }, + { + "name": "Chile", + "dial_code": "+56", + "code": "CL" + }, + { + "name": "China", + "dial_code": "+86", + "code": "CN" + }, + { + "name": "Christmas Island", + "dial_code": "+61", + "code": "CX" + }, + { + "name": "Colombia", + "dial_code": "+57", + "code": "CO" + }, + { + "name": "Comoros", + "dial_code": "+269", + "code": "KM" + }, + { + "name": "Congo", + "dial_code": "+242", + "code": "CG" + }, + { + "name": "Cook Islands", + "dial_code": "+682", + "code": "CK" + }, + { + "name": "Costa Rica", + "dial_code": "+506", + "code": "CR" + }, + { + "name": "Croatia", + "dial_code": "+385", + "code": "HR" + }, + { + "name": "Cuba", + "dial_code": "+53", + "code": "CU" + }, + { + "name": "Cyprus", + "dial_code": "+537", + "code": "CY" + }, + { + "name": "Czech Republic", + "dial_code": "+420", + "code": "CZ" + }, + { + "name": "Denmark", + "dial_code": "+45", + "code": "DK" + }, + { + "name": "Djibouti", + "dial_code": "+253", + "code": "DJ" + }, + { + "name": "Dominica", + "dial_code": "+1 767", + "code": "DM" + }, + { + "name": "Dominican Republic", + "dial_code": "+1 849", + "code": "DO" + }, + { + "name": "Ecuador", + "dial_code": "+593", + "code": "EC" + }, + { + "name": "Egypt", + "dial_code": "+20", + "code": "EG" + }, + { + "name": "El Salvador", + "dial_code": "+503", + "code": "SV" + }, + { + "name": "Equatorial Guinea", + "dial_code": "+240", + "code": "GQ" + }, + { + "name": "Eritrea", + "dial_code": "+291", + "code": "ER" + }, + { + "name": "Estonia", + "dial_code": "+372", + "code": "EE" + }, + { + "name": "Ethiopia", + "dial_code": "+251", + "code": "ET" + }, + { + "name": "Faroe Islands", + "dial_code": "+298", + "code": "FO" + }, + { + "name": "Fiji", + "dial_code": "+679", + "code": "FJ" + }, + { + "name": "Finland", + "dial_code": "+358", + "code": "FI" + }, + { + "name": "France", + "dial_code": "+33", + "code": "FR" + }, + { + "name": "French Guiana", + "dial_code": "+594", + "code": "GF" + }, + { + "name": "French Polynesia", + "dial_code": "+689", + "code": "PF" + }, + { + "name": "Gabon", + "dial_code": "+241", + "code": "GA" + }, + { + "name": "Gambia", + "dial_code": "+220", + "code": "GM" + }, + { + "name": "Georgia", + "dial_code": "+995", + "code": "GE" + }, + { + "name": "Germany", + "dial_code": "+49", + "code": "DE" + }, + { + "name": "Ghana", + "dial_code": "+233", + "code": "GH" + }, + { + "name": "Gibraltar", + "dial_code": "+350", + "code": "GI" + }, + { + "name": "Greece", + "dial_code": "+30", + "code": "GR" + }, + { + "name": "Greenland", + "dial_code": "+299", + "code": "GL" + }, + { + "name": "Grenada", + "dial_code": "+1 473", + "code": "GD" + }, + { + "name": "Guadeloupe", + "dial_code": "+590", + "code": "GP" + }, + { + "name": "Guam", + "dial_code": "+1 671", + "code": "GU" + }, + { + "name": "Guatemala", + "dial_code": "+502", + "code": "GT" + }, + { + "name": "Guinea", + "dial_code": "+224", + "code": "GN" + }, + { + "name": "Guinea-Bissau", + "dial_code": "+245", + "code": "GW" + }, + { + "name": "Guyana", + "dial_code": "+595", + "code": "GY" + }, + { + "name": "Haiti", + "dial_code": "+509", + "code": "HT" + }, + { + "name": "Honduras", + "dial_code": "+504", + "code": "HN" + }, + { + "name": "Hungary", + "dial_code": "+36", + "code": "HU" + }, + { + "name": "Iceland", + "dial_code": "+354", + "code": "IS" + }, + { + "name": "India", + "dial_code": "+91", + "code": "IN" + }, + { + "name": "Indonesia", + "dial_code": "+62", + "code": "ID" + }, + { + "name": "Iraq", + "dial_code": "+964", + "code": "IQ" + }, + { + "name": "Ireland", + "dial_code": "+353", + "code": "IE" + }, + { + "name": "Israel", + "dial_code": "+972", + "code": "IL" + }, + { + "name": "Italy", + "dial_code": "+39", + "code": "IT" + }, + { + "name": "Jamaica", + "dial_code": "+1 876", + "code": "JM" + }, + { + "name": "Japan", + "dial_code": "+81", + "code": "JP" + }, + { + "name": "Jordan", + "dial_code": "+962", + "code": "JO" + }, + { + "name": "Kazakhstan", + "dial_code": "+7 7", + "code": "KZ" + }, + { + "name": "Kenya", + "dial_code": "+254", + "code": "KE" + }, + { + "name": "Kiribati", + "dial_code": "+686", + "code": "KI" + }, + { + "name": "Kuwait", + "dial_code": "+965", + "code": "KW" + }, + { + "name": "Kyrgyzstan", + "dial_code": "+996", + "code": "KG" + }, + { + "name": "Latvia", + "dial_code": "+371", + "code": "LV" + }, + { + "name": "Lebanon", + "dial_code": "+961", + "code": "LB" + }, + { + "name": "Lesotho", + "dial_code": "+266", + "code": "LS" + }, + { + "name": "Liberia", + "dial_code": "+231", + "code": "LR" + }, + { + "name": "Liechtenstein", + "dial_code": "+423", + "code": "LI" + }, + { + "name": "Lithuania", + "dial_code": "+370", + "code": "LT" + }, + { + "name": "Luxembourg", + "dial_code": "+352", + "code": "LU" + }, + { + "name": "Madagascar", + "dial_code": "+261", + "code": "MG" + }, + { + "name": "Malawi", + "dial_code": "+265", + "code": "MW" + }, + { + "name": "Malaysia", + "dial_code": "+60", + "code": "MY" + }, + { + "name": "Maldives", + "dial_code": "+960", + "code": "MV" + }, + { + "name": "Mali", + "dial_code": "+223", + "code": "ML" + }, + { + "name": "Malta", + "dial_code": "+356", + "code": "MT" + }, + { + "name": "Marshall Islands", + "dial_code": "+692", + "code": "MH" + }, + { + "name": "Martinique", + "dial_code": "+596", + "code": "MQ" + }, + { + "name": "Mauritania", + "dial_code": "+222", + "code": "MR" + }, + { + "name": "Mauritius", + "dial_code": "+230", + "code": "MU" + }, + { + "name": "Mayotte", + "dial_code": "+262", + "code": "YT" + }, + { + "name": "Mexico", + "dial_code": "+52", + "code": "MX" + }, + { + "name": "Monaco", + "dial_code": "+377", + "code": "MC" + }, + { + "name": "Mongolia", + "dial_code": "+976", + "code": "MN" + }, + { + "name": "Montenegro", + "dial_code": "+382", + "code": "ME" + }, + { + "name": "Montserrat", + "dial_code": "+1664", + "code": "MS" + }, + { + "name": "Morocco", + "dial_code": "+212", + "code": "MA" + }, + { + "name": "Myanmar", + "dial_code": "+95", + "code": "MM" + }, + { + "name": "Namibia", + "dial_code": "+264", + "code": "NA" + }, + { + "name": "Nauru", + "dial_code": "+674", + "code": "NR" + }, + { + "name": "Nepal", + "dial_code": "+977", + "code": "NP" + }, + { + "name": "Netherlands", + "dial_code": "+31", + "code": "NL" + }, + { + "name": "Netherlands Antilles", + "dial_code": "+599", + "code": "AN" + }, + { + "name": "New Caledonia", + "dial_code": "+687", + "code": "NC" + }, + { + "name": "New Zealand", + "dial_code": "+64", + "code": "NZ" + }, + { + "name": "Nicaragua", + "dial_code": "+505", + "code": "NI" + }, + { + "name": "Niger", + "dial_code": "+227", + "code": "NE" + }, + { + "name": "Nigeria", + "dial_code": "+234", + "code": "NG" + }, + { + "name": "Niue", + "dial_code": "+683", + "code": "NU" + }, + { + "name": "Norfolk Island", + "dial_code": "+672", + "code": "NF" + }, + { + "name": "Northern Mariana Islands", + "dial_code": "+1 670", + "code": "MP" + }, + { + "name": "Norway", + "dial_code": "+47", + "code": "NO" + }, + { + "name": "Oman", + "dial_code": "+968", + "code": "OM" + }, + { + "name": "Pakistan", + "dial_code": "+92", + "code": "PK" + }, + { + "name": "Palau", + "dial_code": "+680", + "code": "PW" + }, + { + "name": "Panama", + "dial_code": "+507", + "code": "PA" + }, + { + "name": "Papua New Guinea", + "dial_code": "+675", + "code": "PG" + }, + { + "name": "Paraguay", + "dial_code": "+595", + "code": "PY" + }, + { + "name": "Peru", + "dial_code": "+51", + "code": "PE" + }, + { + "name": "Philippines", + "dial_code": "+63", + "code": "PH" + }, + { + "name": "Poland", + "dial_code": "+48", + "code": "PL" + }, + { + "name": "Portugal", + "dial_code": "+351", + "code": "PT" + }, + { + "name": "Puerto Rico", + "dial_code": "+1 939", + "code": "PR" + }, + { + "name": "Qatar", + "dial_code": "+974", + "code": "QA" + }, + { + "name": "Romania", + "dial_code": "+40", + "code": "RO" + }, + { + "name": "Rwanda", + "dial_code": "+250", + "code": "RW" + }, + { + "name": "Samoa", + "dial_code": "+685", + "code": "WS" + }, + { + "name": "San Marino", + "dial_code": "+378", + "code": "SM" + }, + { + "name": "Saudi Arabia", + "dial_code": "+966", + "code": "SA" + }, + { + "name": "Senegal", + "dial_code": "+221", + "code": "SN" + }, + { + "name": "Serbia", + "dial_code": "+381", + "code": "RS" + }, + { + "name": "Seychelles", + "dial_code": "+248", + "code": "SC" + }, + { + "name": "Sierra Leone", + "dial_code": "+232", + "code": "SL" + }, + { + "name": "Singapore", + "dial_code": "+65", + "code": "SG" + }, + { + "name": "Slovakia", + "dial_code": "+421", + "code": "SK" + }, + { + "name": "Slovenia", + "dial_code": "+386", + "code": "SI" + }, + { + "name": "Solomon Islands", + "dial_code": "+677", + "code": "SB" + }, + { + "name": "South Africa", + "dial_code": "+27", + "code": "ZA" + }, + { + "name": "South Georgia and the South Sandwich Islands", + "dial_code": "+500", + "code": "GS" + }, + { + "name": "Spain", + "dial_code": "+34", + "code": "ES" + }, + { + "name": "Sri Lanka", + "dial_code": "+94", + "code": "LK" + }, + { + "name": "Sudan", + "dial_code": "+249", + "code": "SD" + }, + { + "name": "Suriname", + "dial_code": "+597", + "code": "SR" + }, + { + "name": "Swaziland", + "dial_code": "+268", + "code": "SZ" + }, + { + "name": "Sweden", + "dial_code": "+46", + "code": "SE" + }, + { + "name": "Switzerland", + "dial_code": "+41", + "code": "CH" + }, + { + "name": "Tajikistan", + "dial_code": "+992", + "code": "TJ" + }, + { + "name": "Thailand", + "dial_code": "+66", + "code": "TH" + }, + { + "name": "Togo", + "dial_code": "+228", + "code": "TG" + }, + { + "name": "Tokelau", + "dial_code": "+690", + "code": "TK" + }, + { + "name": "Tonga", + "dial_code": "+676", + "code": "TO" + }, + { + "name": "Trinidad and Tobago", + "dial_code": "+1 868", + "code": "TT" + }, + { + "name": "Tunisia", + "dial_code": "+216", + "code": "TN" + }, + { + "name": "Turkey", + "dial_code": "+90", + "code": "TR" + }, + { + "name": "Turkmenistan", + "dial_code": "+993", + "code": "TM" + }, + { + "name": "Turks and Caicos Islands", + "dial_code": "+1 649", + "code": "TC" + }, + { + "name": "Tuvalu", + "dial_code": "+688", + "code": "TV" + }, + { + "name": "Uganda", + "dial_code": "+256", + "code": "UG" + }, + { + "name": "Ukraine", + "dial_code": "+380", + "code": "UA" + }, + { + "name": "United Arab Emirates", + "dial_code": "+971", + "code": "AE" + }, + { + "name": "United Kingdom", + "dial_code": "+44", + "code": "GB" + }, + { + "name": "United States", + "dial_code": "+1", + "code": "US" + }, + { + "name": "Uruguay", + "dial_code": "+598", + "code": "UY" + }, + { + "name": "Uzbekistan", + "dial_code": "+998", + "code": "UZ" + }, + { + "name": "Vanuatu", + "dial_code": "+678", + "code": "VU" + }, + { + "name": "Wallis and Futuna", + "dial_code": "+681", + "code": "WF" + }, + { + "name": "Yemen", + "dial_code": "+967", + "code": "YE" + }, + { + "name": "Zambia", + "dial_code": "+260", + "code": "ZM" + }, + { + "name": "Zimbabwe", + "dial_code": "+263", + "code": "ZW" + }, + { + "name": "land Islands", + "dial_code": "", + "code": "AX" + }, + { + "name": "Bolivia, Plurinational State of", + "dial_code": "+591", + "code": "BO" + }, + { + "name": "Brunei Darussalam", + "dial_code": "+673", + "code": "BN" + }, + { + "name": "Cocos (Keeling) Islands", + "dial_code": "+61", + "code": "CC" + }, + { + "name": "Congo, The Democratic Republic of the", + "dial_code": "+243", + "code": "CD" + }, + { + "name": "Cote d'Ivoire", + "dial_code": "+225", + "code": "CI" + }, + { + "name": "Falkland Islands (Malvinas)", + "dial_code": "+500", + "code": "FK" + }, + { + "name": "Guernsey", + "dial_code": "+44", + "code": "GG" + }, + { + "name": "Holy See (Vatican City State)", + "dial_code": "+379", + "code": "VA" + }, + { + "name": "Hong Kong", + "dial_code": "+852", + "code": "HK" + }, + { + "name": "Iran, Islamic Republic of", + "dial_code": "+98", + "code": "IR" + }, + { + "name": "Isle of Man", + "dial_code": "+44", + "code": "IM" + }, + { + "name": "Jersey", + "dial_code": "+44", + "code": "JE" + }, + { + "name": "Korea, Democratic People's Republic of", + "dial_code": "+850", + "code": "KP" + }, + { + "name": "Korea, Republic of", + "dial_code": "+82", + "code": "KR" + }, + { + "name": "Lao People's Democratic Republic", + "dial_code": "+856", + "code": "LA" + }, + { + "name": "Libyan Arab Jamahiriya", + "dial_code": "+218", + "code": "LY" + }, + { + "name": "Macao", + "dial_code": "+853", + "code": "MO" + }, + { + "name": "Macedonia, The Former Yugoslav Republic of", + "dial_code": "+389", + "code": "MK" + }, + { + "name": "Micronesia, Federated States of", + "dial_code": "+691", + "code": "FM" + }, + { + "name": "Moldova, Republic of", + "dial_code": "+373", + "code": "MD" + }, + { + "name": "Mozambique", + "dial_code": "+258", + "code": "MZ" + }, + { + "name": "Palestinian Territory, Occupied", + "dial_code": "+970", + "code": "PS" + }, + { + "name": "Pitcairn", + "dial_code": "+872", + "code": "PN" + }, + { + "name": "Réunion", + "dial_code": "+262", + "code": "RE" + }, + { + "name": "Russia", + "dial_code": "+7", + "code": "RU" + }, + { + "name": "Saint Barthélemy", + "dial_code": "+590", + "code": "BL" + }, + { + "name": "Saint Helena, Ascension and Tristan Da Cunha", + "dial_code": "+290", + "code": "SH" + }, + { + "name": "Saint Kitts and Nevis", + "dial_code": "+1 869", + "code": "KN" + }, + { + "name": "Saint Lucia", + "dial_code": "+1 758", + "code": "LC" + }, + { + "name": "Saint Martin", + "dial_code": "+590", + "code": "MF" + }, + { + "name": "Saint Pierre and Miquelon", + "dial_code": "+508", + "code": "PM" + }, + { + "name": "Saint Vincent and the Grenadines", + "dial_code": "+1 784", + "code": "VC" + }, + { + "name": "Sao Tome and Principe", + "dial_code": "+239", + "code": "ST" + }, + { + "name": "Somalia", + "dial_code": "+252", + "code": "SO" + }, + { + "name": "Svalbard and Jan Mayen", + "dial_code": "+47", + "code": "SJ" + }, + { + "name": "Syrian Arab Republic", + "dial_code": "+963", + "code": "SY" + }, + { + "name": "Taiwan, Province of China", + "dial_code": "+886", + "code": "TW" + }, + { + "name": "Tanzania, United Republic of", + "dial_code": "+255", + "code": "TZ" + }, + { + "name": "Timor-Leste", + "dial_code": "+670", + "code": "TL" + }, + { + "name": "Venezuela, Bolivarian Republic of", + "dial_code": "+58", + "code": "VE" + }, + { + "name": "Viet Nam", + "dial_code": "+84", + "code": "VN" + }, + { + "name": "Virgin Islands, British", + "dial_code": "+1 284", + "code": "VG" + }, + { + "name": "Virgin Islands, U.S.", + "dial_code": "+1 340", + "code": "VI" + } +] \ No newline at end of file diff --git a/TwoFactorAuth/Test/Integration/Block/ChangeProviderTest.php b/TwoFactorAuth/Test/Integration/Block/ChangeProviderTest.php new file mode 100644 index 00000000..eb24c216 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Block/ChangeProviderTest.php @@ -0,0 +1,127 @@ +configure([ + CompositeUserContext::class => [ + 'arguments' => [ + 'userContexts' => [ + 'adminSessionUserContext' => [ + 'type' => ['instance' => AdminSessionUserContext::class], + 'sortOrder' => 10 + ] + ] + ] + ] + ]); + $auth = $objectManager->get(Auth::class); + $auth->login(TestFrameworkBootstrap::ADMIN_NAME, TestFrameworkBootstrap::ADMIN_PASSWORD); + $objectManager->get(AuthPlugin::class) + ->afterLogin($auth); + $this->session = $auth->getAuthStorage(); + $this->tfa = Bootstrap::getObjectManager()->get(TfaInterface::class); + $this->block = $objectManager->get(LayoutInterface::class) + ->createBlock(ChangeProvider::class); + $this->block->setData('area', 'adminhtml'); + $this->block->setTemplate('Magento_TwoFactorAuth::tfa/change_provider.phtml'); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers authy,duo_security + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testBlockRendersWithActiveProviders(): void + { + $userId = (int)$this->session->getUser()->getId(); + $this->tfa->getProvider(Authy::CODE)->activate($userId); + $jsLayout = json_decode($this->block->getJsLayout(), true); + $actualProviders = array_map( + function ($item) { + return $item['code']; + }, + $jsLayout['components']['tfa-change-provider']['providers'] + ); + + self::assertSame(['authy'], $actualProviders); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers authy,duo_security + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testBlockRendersWhenCurrentProviderIsActivated(): void + { + $userId = (int)$this->session->getUser()->getId(); + $this->tfa->getProvider(Authy::CODE)->activate($userId); + $this->block->setData('provider', 'authy'); + $html = $this->block->toHtml(); + + self::assertContains('id="tfa', $html); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers authy,duo_security + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testBlockRendersWhenCurrentProviderIsNotActivated(): void + { + $userId = (int)$this->session->getUser()->getId(); + $this->tfa->getProvider(Authy::CODE)->activate($userId); + $this->block->setData('provider', 'duo_security'); + $html = $this->block->toHtml(); + + self::assertSame('', $html); + } +} diff --git a/TwoFactorAuth/Test/Integration/Block/ConfigureLaterTest.php b/TwoFactorAuth/Test/Integration/Block/ConfigureLaterTest.php new file mode 100644 index 00000000..f76b25a7 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Block/ConfigureLaterTest.php @@ -0,0 +1,113 @@ +configure([ + CompositeUserContext::class => [ + 'arguments' => [ + 'userContexts' => [ + 'adminSessionUserContext' => [ + 'type' => ['instance' => AdminSessionUserContext::class], + 'sortOrder' => 10 + ] + ] + ] + ] + ]); + $auth = $objectManager->get(Auth::class); + $auth->login(TestFrameworkBootstrap::ADMIN_NAME, TestFrameworkBootstrap::ADMIN_PASSWORD); + $objectManager->get(AuthPlugin::class) + ->afterLogin($auth); + $this->session = $auth->getAuthStorage(); + $this->tfa = Bootstrap::getObjectManager()->get(TfaInterface::class); + $this->block = $objectManager->get(LayoutInterface::class) + ->createBlock(ConfigureLater::class); + $this->block->setData('area', 'adminhtml'); + $this->block->setTemplate('Magento_TwoFactorAuth::tfa/configure_later.phtml'); + } + + public function testGetPostData(): void + { + $this->block->setData('provider', 'provider1'); + + $data = json_decode($this->block->getPostData(), true); + + self::assertNotEmpty($data['action']); + self::assertNotEmpty($data['data']); + self::assertNotEmpty($data['data']['form_key']); + self::assertSame('provider1', $data['data']['provider']); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers authy,duo_security + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testBlockRendersWithCurrentInactiveAndOneOtherActive(): void + { + $userId = (int)$this->session->getUser()->getId(); + $this->tfa->getProvider(Authy::CODE)->activate($userId); + $this->block->setData('provider', 'duo_security'); + $html = $this->block->toHtml(); + + self::assertContains('id="tfa', $html); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers duo_security + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testBlockDoesntRenderWithCurrentInactiveAndNoOtherActive(): void + { + $this->block->setData('provider', 'duo_security'); + $html = $this->block->toHtml(); + + self::assertSame('', $html); + } +} diff --git a/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Authy/ConfigureTest.php b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Authy/ConfigureTest.php new file mode 100644 index 00000000..7bd4bd0f --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Authy/ConfigureTest.php @@ -0,0 +1,56 @@ +expectedNoAccessResponseCode = 302; + } + + /** + * @inheritDoc + * @magentoConfigFixture default/twofactorauth/general/force_providers duo_security + * @magentoConfigFixture default/twofactorauth/duo/integration_key duo_security + * @magentoConfigFixture default/twofactorauth/duo/secret_key duo_security + * @magentoConfigFixture default/twofactorauth/duo/api_hostname duo_security + * @magentoConfigFixture default/twofactorauth/duo/application_key duo_security + */ + public function testTokenAccess(): void + { + parent::testTokenAccess(); + //Redirect when isAllowed returns false + $this->assertRedirect($this->stringContains('auth/login')); + } + + /** + * @inheritDoc + * @magentoConfigFixture default/twofactorauth/general/force_providers duo_security + * @magentoConfigFixture default/twofactorauth/duo/integration_key duo_security + * @magentoConfigFixture default/twofactorauth/duo/secret_key duo_security + * @magentoConfigFixture default/twofactorauth/duo/api_hostname duo_security + * @magentoConfigFixture default/twofactorauth/duo/application_key duo_security + */ + public function testAclHasAccess() + { + $this->expectedNoAccessResponseCode = 200; + parent::testAclHasAccess(); + //Redirect that Authpost supplies when signatures is not provided in a request. + $this->assertRedirect($this->stringContains('duo/auth')); + } + + /** + * @inheritDoc + * @magentoConfigFixture default/twofactorauth/general/force_providers duo_security + * @magentoConfigFixture default/twofactorauth/duo/integration_key duo_security + * @magentoConfigFixture default/twofactorauth/duo/secret_key duo_security + * @magentoConfigFixture default/twofactorauth/duo/api_hostname duo_security + * @magentoConfigFixture default/twofactorauth/duo/application_key duo_security + */ + public function testAclNoAccess() + { + parent::testAclNoAccess(); + //Redirect when isAllowed returns false + $this->assertRedirect($this->stringContains('auth/login')); + } +} diff --git a/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Google/ConfigureTest.php b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Google/ConfigureTest.php new file mode 100644 index 00000000..17055633 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Google/ConfigureTest.php @@ -0,0 +1,53 @@ +tfa = $this->_objectManager->get(TfaInterface::class); + $this->tfaSession = $this->_objectManager->get(TfaSessionInterface::class); + $this->tokenManager = $this->_objectManager->get(UserConfigTokenManagerInterface::class); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers duo_security + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testNotAllowedWhenProviderAlreadyActivated(): void + { + $userId = (int)$this->_session->getUser()->getId(); + $this->tfa->getProvider(DuoSecurity::CODE)->activate($userId); + $this->getRequest() + ->setMethod('POST') + ->setQueryValue('tfat', $this->tokenManager->issueFor($userId)) + ->setPostValue('provider', 'duo_security'); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('auth/login')); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers duo_security + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testNotAllowedWhenProviderNotActivatedButIsTheOnlyProvider(): void + { + $userId = (int)$this->_session->getUser()->getId(); + $this->getRequest() + ->setMethod('POST') + ->setQueryValue('tfat', $this->tokenManager->issueFor($userId)) + ->setPostValue('provider', 'google'); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('auth/login')); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers google,duo_security,authy + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testSkippingAProvider(): void + { + $userId = (int)$this->_session->getUser()->getId(); + $this->getRequest() + ->setMethod('POST') + ->setQueryValue('tfat', $this->tokenManager->issueFor($userId)) + ->setPostValue('provider', 'authy'); + $this->dispatch($this->uri); + self::assertTrue($this->tfaSession->getSkippedProviderConfig()['authy']); + $this->assertRedirect($this->stringContains('tfa/index')); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers duo_security,authy + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + */ + public function testSkippingAllProvidersWhenThereAreNoneConfigured(): void + { + $userId = (int)$this->_session->getUser()->getId(); + $this->tfaSession->setSkippedProviderConfig( + [ + 'duo_security' => true, + 'already disabled provider' => true, + 'authy' => true, + ] + ); + $this->getRequest() + ->setMethod('POST') + ->setQueryValue('tfat', $this->tokenManager->issueFor($userId)) + ->setPostValue('provider', 'authy'); + $this->dispatch($this->uri); + $this->assertSessionMessages( + $this->equalTo(['At least one two-factor authentication provider must be configured.']) + ); + $this->assertRedirect($this->stringContains('tfa/index')); + } + + /** + * @inheritDoc + */ + public function testAclHasAccess() + { + $this->markTestSkipped('Tested with the tests above.'); + } + + /** + * @inheritDoc + */ + public function testAclNoAccess() + { + $this->markTestSkipped('Tested with the tests above.'); + } +} diff --git a/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/ConfigureTest.php b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/ConfigureTest.php new file mode 100644 index 00000000..fe0a6cb4 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/ConfigureTest.php @@ -0,0 +1,93 @@ +tokenManager = Bootstrap::getObjectManager()->get(UserConfigTokenManagerInterface::class); + } + + /** + * Verify that 2FA providers form is shown to users when 2FA for the app is not configured and token is present. + * + * @return void + */ + public function testList(): void + { + $this->getRequest() + ->setQueryValue('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + $this->dispatch($this->uri); + $this->assertRegExp('/google/', $this->getResponse()->getBody()); + } + + /** + * Verify that 2FA config request is displayed for users when 2FA is not configured for the user. + * + * @return void + */ + public function testWithoutToken(): void + { + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('requestconfig')); + } + + /** + * @inheritDoc + */ + public function testAclHasAccess() + { + $this->getRequest() + ->setQueryValue('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + parent::testAclHasAccess(); + } + + /** + * @inheritDoc + */ + public function testAclNoAccess() + { + $this->getRequest() + ->setQueryValue('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + parent::testAclNoAccess(); + $this->assertRedirect($this->stringContains('login')); + } +} diff --git a/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/ConfigurepostTest.php b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/ConfigurepostTest.php new file mode 100644 index 00000000..dd0ac7a6 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/ConfigurepostTest.php @@ -0,0 +1,154 @@ +tokenManager = Bootstrap::getObjectManager()->get(UserConfigTokenManagerInterface::class); + $this->tfa = Bootstrap::getObjectManager()->get(TfaInterface::class); + } + + /** + * Verify that 2FA providers are updated when a user submits the form. + * + * @return void + */ + public function testUpdated(): void + { + $providerCode = Google::CODE; + $this->getRequest()->setMethod(Request::METHOD_POST); + $this->getRequest() + ->setParam('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + $this->getRequest()->setPostValue([ + 'tfa_selected' => [$providerCode => 'on'] + ]); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('admin')); + $this->assertNotEmpty($providers = $this->tfa->getForcedProviders()); + /** @var ProviderInterface $provider */ + $provider = array_pop($providers); + $this->assertEquals($providerCode, $provider->getCode()); + } + + /** + * Verify that token is required to proceed. + * + * @return void + */ + public function testWithoutToken(): void + { + $this->getRequest()->setMethod(Request::METHOD_POST); + $this->getRequest()->setPostValue([ + 'tfa_selected' => [Google::CODE => 'on'] + ]); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('login')); + $this->assertEmpty($this->tfa->getForcedProviders()); + } + + /** + * Verify that token is required to proceed even if providers area already configured. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + */ + public function testConfiguredWithoutToken(): void + { + $this->getRequest()->setMethod(Request::METHOD_POST); + $this->getRequest()->setPostValue([ + 'tfa_selected' => ['nonExisting' => 'on'] + ]); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('login')); + } + + /** + * Verify that 2FA providers are validated + * + * @return void + */ + public function testValidated(): void + { + $this->getRequest()->setMethod(Request::METHOD_POST); + $this->getRequest() + ->setQueryValue('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + $this->getRequest()->setPostValue([ + 'tfa_selected' => ['nonExisting' => 'on'] + ]); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('configure')); + $this->assertEmpty($this->tfa->getForcedProviders()); + $this->assertSessionMessages($this->contains(__('Please select valid providers.')->render())); + } + + /** + * @inheritDoc + */ + public function testAclHasAccess() + { + $this->markTestSkipped('Subsequently tested with the tests above.'); + } + + /** + * @inheritDoc + */ + public function testAclNoAccess() + { + $this->getRequest() + ->setQueryValue('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + parent::testAclNoAccess(); + $this->assertRedirect($this->stringContains('login')); + } +} diff --git a/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/IndexTest.php b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/IndexTest.php new file mode 100644 index 00000000..d0c05429 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/IndexTest.php @@ -0,0 +1,166 @@ +tfaSession = $objectManager->get(TfaSessionInterface::class); + $this->userManager = $objectManager->get(UserConfigManagerInterface::class); + $this->tfa = $objectManager->get(TfaInterface::class); + } + + /** + * Verify that user is taken to Config Request page when 2FA is not configured. + * + * @return void + */ + public function testNoneConfigured(): void + { + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('requestconfig')); + } + + /** + * Verify that user is taken to provider's configuration page when only personal 2FA is not configured. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + */ + public function testUserNotConfigured(): void + { + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('google/configure')); + } + + /** + * Verify that user is taken to configured provider's challenge page. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + * @magentoDbIsolation enabled + */ + public function testConfigured(): void + { + //Activating a provider for the user. + $this->tfa->getProvider(Google::CODE)->activate((int)$this->_session->getUser()->getId()); + + $this->dispatch($this->uri); + //Taken to the provider's challenge page. + $this->assertRedirect($this->stringContains('google/auth')); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers google,authy + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoDbIsolation enabled + */ + public function testNotConfiguredWithSkipped(): void + { + $this->tfaSession->setSkippedProviderConfig(['google' => true]); + + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('authy/configure')); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers google,authy,duo_security + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + * @magentoDbIsolation enabled + */ + public function testDefaultProviderIsUsedForAuth(): void + { + $userId = (int)$this->_session->getUser()->getId(); + $this->tfa->getProvider(Google::CODE)->activate($userId); + $this->tfa->getProvider(Authy::CODE)->activate($userId); + $this->tfa->getProvider(DuoSecurity::CODE)->activate($userId); + $this->userManager->setDefaultProvider($userId, Authy::CODE); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('authy/auth')); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers google,authy,duo_security + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + * @magentoDbIsolation enabled + */ + public function testFirstProviderIsUsedForAuthWithoutADefault(): void + { + $userId = (int)$this->_session->getUser()->getId(); + $this->userManager->setDefaultProvider($userId, ''); + $this->tfa->getProvider(Google::CODE)->activate($userId); + $this->tfa->getProvider(Authy::CODE)->activate($userId); + $this->tfa->getProvider(DuoSecurity::CODE)->activate($userId); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('google/auth')); + } + + /** + * @magentoConfigFixture default/twofactorauth/general/force_providers google,authy,duo_security + * @magentoConfigFixture default/twofactorauth/authy/api_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/integration_key abc123 + * @magentoConfigFixture default/twofactorauth/duo/api_hostname abc123 + * @magentoConfigFixture default/twofactorauth/duo/secret_key abc123 + * @magentoDbIsolation enabled + */ + public function testFirstProviderIsUsedForAuthWhenDefaultIsInvalid(): void + { + $userId = (int)$this->_session->getUser()->getId(); + $this->userManager->setDefaultProvider($userId, 'foobar'); + $this->tfa->getProvider(Google::CODE)->activate($userId); + $this->tfa->getProvider(Authy::CODE)->activate($userId); + $this->tfa->getProvider(DuoSecurity::CODE)->activate($userId); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('google/auth')); + } +} diff --git a/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/RequestconfigTest.php b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/RequestconfigTest.php new file mode 100644 index 00000000..e6c66799 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/Tfa/RequestconfigTest.php @@ -0,0 +1,111 @@ +tfa = Bootstrap::getObjectManager()->get(TfaInterface::class); + $this->tokenManager = Bootstrap::getObjectManager()->get(UserConfigTokenManagerInterface::class); + } + + /** + * Verify that 2FA config request is display for users when 2FA is not configured for the app. + * + * @return void + */ + public function testAppConfigRequested(): void + { + $this->dispatch($this->uri); + $this->assertRegExp('/You need to configure Two\-Factor Authorization/', $this->getResponse()->getBody()); + } + + /** + * Verify that 2FA config request is display for users when 2FA is not configured for the user. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + */ + public function testUserConfigRequested(): void + { + $this->dispatch($this->uri); + $this->assertRegExp('/You need to configure Two\-Factor Authorization/', $this->getResponse()->getBody()); + } + + + /** + * Verify that 2FA config is not requested when 2FA is configured. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + * @magentoDbIsolation enabled + * @expectedException \Magento\Framework\Exception\AuthorizationException + */ + public function testNotRequested(): void + { + $this->tfa->getProvider(Google::CODE)->activate((int)$this->_session->getUser()->getId()); + $this->dispatch($this->uri); + } + + /** + * Verify that users with valid tokens get redirected to the app 2FA config page. + * + * @return void + */ + public function testRedirectToAppConfig(): void + { + $this->getRequest() + ->setQueryValue('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('tfa/configure')); + } + + /** + * Verify that users with valid tokens get redirected to the user 2FA config page. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + */ + public function testRedirectToUserConfig(): void + { + $this->getRequest() + ->setQueryValue('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('tfa/index')); + } +} diff --git a/TwoFactorAuth/Test/Integration/Controller/Adminhtml/U2f/ConfigureTest.php b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/U2f/ConfigureTest.php new file mode 100644 index 00000000..bbf35b4b --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Controller/Adminhtml/U2f/ConfigureTest.php @@ -0,0 +1,53 @@ +cookieReader = Bootstrap::getObjectManager()->get(CookieReaderInterface::class); + $this->tokenManager = Bootstrap::getObjectManager()->get(UserConfigTokenManagerInterface::class); + $this->tfaSession = Bootstrap::getObjectManager()->get(TfaSessionInterface::class); + $this->tfa = Bootstrap::getObjectManager()->get(TfaInterface::class); + } + + /** + * Verify that users with configured 2FA and 2FA completed can proceed to desired page. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + * @magentoDbIsolation enabled + * @magentoAppIsolation enabled + */ + public function testTfaCompleted(): void + { + //Configuring 2FA for the user and completing 2FA. + $this->tfa->getProvider(Google::CODE)->activate((int)$this->_session->getUser()->getId()); + $this->tfaSession->grantAccess(); + //Accessing a page in adminhtml area + $this->dispatch('backend/admin/user/'); + //Authenticated user with 2FA configured and completed is taken to the Users page as requested. + $this->assertRegExp('/' .$this->_session->getUser()->getUserName() .'/i', $this->getResponse()->getBody()); + } + + /** + * Verify that unauthenticated user is redirected to login page. + * + * @return void + * @magentoAppIsolation enabled + */ + public function testUnauthenticated(): void + { + $this->logout(); + $this->dispatch('backend/admin/index/index'); + //Login controller redirects to full start-up URL + $this->assertRedirect($this->stringContains('index')); + $properUrl = $this->getResponse()->getHeader('Location')->getFieldValue(); + + //Login page must be rendered without redirects + $this->getRequest()->setDispatched(false); + $this->getRequest()->setUri($properUrl); + $this->dispatch($properUrl); + $this->assertContains('Welcome, please sign in', $this->getResponse()->getBody()); + } + + /** + * Verify that users would be redirected to "2FA Config Request" page when 2FA is not configured for the app. + * + * @magentoConfigFixture default/twofactorauth/general/force_providers google,duo_security + * @return void + */ + public function testConfigRequested(): void + { + $this->tfa->getProvider(Google::CODE)->resetConfiguration((int)$this->_session->getUser()->getId()); + + //Accessing a page in adminhtml area + $this->dispatch('backend/admin/user/'); + //Authenticated user gets a redirect to 2FA configuration page since none is configured. + $this->assertRedirect($this->stringContains('requestconfig')); + } + + /** + * Verify that users would be redirected to "2FA Config Request" page when 2FA is not configured for the user. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + */ + public function testUserConfigRequested(): void + { + //Accessing a page in adminhtml area + $this->dispatch('backend/admin/user/'); + //Authenticated user gets a redirect to 2FA configuration page since none is configured for the user. + $this->assertRedirect($this->stringContains('requestconfig')); + } + + /** + * Verify that users returning with a token from the E-mail get a new cookie with it. + * + * @magentoConfigFixture default/twofactorauth/general/force_providers google,duo_security + * @return void + */ + public function testCookieSet(): void + { + $this->tfa->getProvider(Google::CODE)->resetConfiguration((int)$this->_session->getUser()->getId()); + + //Accessing a page in adminhtml area with a valid token. + $this->getRequest() + ->setQueryValue('tfat', $token = $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + $this->dispatch('backend/admin/user/'); + //Authenticated user gets a redirect to 2FA configuration page since none is configured. + $this->assertRedirect($this->stringContains('requestconfig')); + $this->assertNotEmpty($cookie = $this->cookieReader->getCookie('tfa-ct')); + $this->assertEquals($token, $cookie); + } + + /** + * Verify that users returning with a valid token from the E-mail and 2FA configured get redirected to 2FA form. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + * @magentoDbIsolation enabled + */ + public function testTfaChallenged(): void + { + $this->tfa->getProvider(Google::CODE)->activate((int)$this->_session->getUser()->getId()); + //Accessing a page in adminhtml area + $this->dispatch('backend/admin/user/'); + //Authenticated user with 2FA configured is redirected to 2FA code form. + $this->assertRedirect($this->stringContains('tfa/tfa/index')); + } +} diff --git a/TwoFactorAuth/Test/Integration/Model/Provider/Engine/U2fKey/SessionTest.php b/TwoFactorAuth/Test/Integration/Model/Provider/Engine/U2fKey/SessionTest.php new file mode 100644 index 00000000..b58b1ce6 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Model/Provider/Engine/U2fKey/SessionTest.php @@ -0,0 +1,38 @@ +session = ObjectManager::getInstance()->get(Session::class); + } + + public function testU2fChallenge() + { + self::assertNull($this->session->getU2fChallenge()); + $this->session->setU2fChallenge([123, 456]); + self::assertSame([123, 456], $this->session->getU2fChallenge()); + $this->session->setU2fChallenge([345, 678]); + self::assertSame([345, 678], $this->session->getU2fChallenge()); + $this->session->setU2fChallenge(null); + self::assertNull($this->session->getU2fChallenge()); + $this->session->setU2fChallenge([]); + } +} diff --git a/TwoFactorAuth/Test/Integration/Model/TfaSessionTest.php b/TwoFactorAuth/Test/Integration/Model/TfaSessionTest.php new file mode 100644 index 00000000..8b1c20ca --- /dev/null +++ b/TwoFactorAuth/Test/Integration/Model/TfaSessionTest.php @@ -0,0 +1,37 @@ +session = ObjectManager::getInstance()->get(TfaSession::class); + } + + public function testSkipProvider() + { + self::assertSame([], $this->session->getSkippedProviderConfig()); + $this->session->setSkippedProviderConfig(['foo' => true]); + self::assertSame(['foo' => true], $this->session->getSkippedProviderConfig()); + $this->session->setSkippedProviderConfig(['foo' => true, 'bar' => true]); + self::assertSame(['foo' => true, 'bar' => true], $this->session->getSkippedProviderConfig()); + $this->session->setSkippedProviderConfig([]); + self::assertSame([], $this->session->getSkippedProviderConfig()); + } +} diff --git a/TwoFactorAuth/Test/Integration/UserConfigManagerTest.php b/TwoFactorAuth/Test/Integration/UserConfigManagerTest.php new file mode 100644 index 00000000..2c0f4dc0 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/UserConfigManagerTest.php @@ -0,0 +1,268 @@ +userConfigManager = Bootstrap::getObjectManager()->get(UserConfigManagerInterface::class); + $this->serializer = Bootstrap::getObjectManager()->get(SerializerInterface::class); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldSetAndGetProviderConfiguration(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + $configPayload = ['a' => 1, 'b' => 2]; + + $this->userConfigManager->setProviderConfig( + (int)$dummyUser->getId(), + 'test_provider', + $configPayload + ); + + $this->assertSame( + $configPayload, + $this->userConfigManager->getProviderConfig((int)$dummyUser->getId(), 'test_provider') + ); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldSetAndGetConfiguredProviders(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + $providers = ['test_provider1', 'test_provider2']; + + $this->userConfigManager->setProvidersCodes((int)$dummyUser->getId(), $providers); + + $this->assertSame( + $providers, + $this->userConfigManager->getProvidersCodes((int)$dummyUser->getId()) + ); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldSetAndGetDefaultProvider(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + $provider = 'test_provider'; + + $this->userConfigManager->setDefaultProvider((int)$dummyUser->getId(), $provider); + + $this->assertSame( + $provider, + $this->userConfigManager->getDefaultProvider((int)$dummyUser->getId()) + ); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldResetProviderConfiguration(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + $configPayload = ['a' => 1, 'b' => 2]; + + $this->userConfigManager->setProviderConfig( + (int)$dummyUser->getId(), + 'test_provider', + $configPayload + ); + $this->userConfigManager->resetProviderConfig((int)$dummyUser->getId(), 'test_provider'); + + $this->assertNull( + $this->userConfigManager->getProviderConfig((int)$dummyUser->getId(), 'test_provider') + ); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldActivateProvider(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + $configPayload = ['a' => 1, 'b' => 2]; + $this->userConfigManager->setProviderConfig( + (int)$dummyUser->getId(), + 'test_provider', + $configPayload + ); + + // Check precondition + $this->assertFalse( + $this->userConfigManager->isProviderConfigurationActive((int)$dummyUser->getId(), 'test_provider') + ); + + $this->userConfigManager->activateProviderConfiguration((int)$dummyUser->getId(), 'test_provider'); + + $this->assertTrue( + $this->userConfigManager->isProviderConfigurationActive((int)$dummyUser->getId(), 'test_provider') + ); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldEncryptConfiguration(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + /** @var EncryptorInterface $encryptor */ + $encryptor = Bootstrap::getObjectManager()->create(EncryptorInterface::class); + + /** @var ResourceConnection $resourceConnection */ + $connection = Bootstrap::getObjectManager()->get(ResourceConnection::class) + ->getConnection(ResourceConnection::DEFAULT_CONNECTION); + + $configPayload = ['a' => 1, 'b' => 2]; + + $this->userConfigManager->setProviderConfig( + (int)$dummyUser->getId(), + 'test_provider', + $configPayload + ); + + $qry = $connection->select() + ->from('tfa_user_config', 'encoded_config') + ->where('user_id = ?', (int)$dummyUser->getId()); + + $res = $connection->fetchOne($qry); + $this->assertSame( + ['test_provider' => $configPayload], + $this->serializer->unserialize($encryptor->decrypt($res)) + ); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldGetLegacyNonEncryptedProviderConfiguration(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + + /** @var ResourceConnection $resourceConnection */ + $resourceConnection = Bootstrap::getObjectManager()->get(ResourceConnection::class); + $connection = $resourceConnection->getConnection(ResourceConnection::DEFAULT_CONNECTION); + + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + $tfaUserConfig = $resourceConnection->getTableName('tfa_user_config'); + $connection = $resourceConnection->getConnection(); + + $configPayload = ['a' => 1, 'b' => 2]; + $connection->insertOnDuplicate( + $tfaUserConfig, + [ + 'encoded_config' => $this->serializer->serialize(['test_provider' => $configPayload]), + 'default_provider' => 'test_provider', + 'encoded_providers' => $this->serializer->serialize(['test_Provider']), + 'user_id' => (int)$dummyUser->getId() + ], + [ + 'encoded_config', + 'default_provider', + 'encoded_providers' + ] + ); + + $this->assertSame( + $configPayload, + $this->userConfigManager->getProviderConfig((int)$dummyUser->getId(), 'test_provider') + ); + } + + /** + * @magentoDataFixture Magento/User/_files/dummy_user.php + */ + public function testShouldAddProviderConfiguration(): void + { + /** @var AdminUserCollection $dummyUserCollection */ + $dummyUserCollection = Bootstrap::getObjectManager()->create(AdminUserCollection::class); + + $dummyUserCollection->addFieldToFilter('username', 'dummy_username'); + $dummyUser = $dummyUserCollection->getFirstItem(); + + $configPayload1 = ['a' => 1, 'b' => 2]; + $configPayload2 = ['c' => 1, 'd' => 2]; + $this->userConfigManager->addProviderConfig( + (int)$dummyUser->getId(), + 'test_provider1', + $configPayload1 + ); + $this->userConfigManager->addProviderConfig( + (int)$dummyUser->getId(), + 'test_provider2', + $configPayload2 + ); + + $this->assertSame( + $configPayload1, + $this->userConfigManager->getProviderConfig((int)$dummyUser->getId(), 'test_provider1') + ); + $this->assertSame( + $configPayload2, + $this->userConfigManager->getProviderConfig((int)$dummyUser->getId(), 'test_provider2') + ); + } +} diff --git a/TwoFactorAuth/Test/Integration/UserConfigRequestManagerTest.php b/TwoFactorAuth/Test/Integration/UserConfigRequestManagerTest.php new file mode 100644 index 00000000..4e576ac6 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/UserConfigRequestManagerTest.php @@ -0,0 +1,174 @@ +create(User::class); + $user->loadByUsername(\Magento\TestFramework\Bootstrap::ADMIN_NAME); + $this->user = $user; + $this->tfa = Bootstrap::getObjectManager()->get(TfaInterface::class); + $this->transportBuilderMock = Bootstrap::getObjectManager()->get(TransportBuilderMock::class); + $this->tokenManager = Bootstrap::getObjectManager()->get(UserConfigTokenManagerInterface::class); + $this->aclBuilder = Bootstrap::getObjectManager()->get(Builder::class); + + $this->manager = Bootstrap::getObjectManager()->get(UserConfigRequestManagerInterface::class); + } + + /** + * Check that config is required when no providers are enabled for the app. + * + * @return void + */ + public function testIsRequiredWithoutAppProviders(): void + { + $this->assertTrue($this->manager->isConfigurationRequiredFor((int)$this->user->getId())); + } + + /** + * Check that config is required when personal provider config is empty. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + */ + public function testIsRequiredWithoutUserProviders(): void + { + $this->assertTrue($this->manager->isConfigurationRequiredFor((int)$this->user->getId())); + } + + /** + * Check that config is not required when both app and personal provider config is present. + * + * @return void + * @magentoConfigFixture default/twofactorauth/general/force_providers google + * @magentoDbIsolation enabled + */ + public function testIsRequiredWithConfig(): void + { + $this->tfa->getProvider(Google::CODE)->activate((int)$this->user->getId()); + $this->assertFalse($this->manager->isConfigurationRequiredFor((int)$this->user->getId())); + } + + /** + * Check that app config request E-mail is NOT sent for a user that does not posses proper rights. + * + * @return void + * @expectedException \Magento\Framework\Exception\AuthorizationException + * @throws \Throwable + * @magentoAppArea adminhtml + * @magentoAppIsolation enabled + */ + public function testFailAppConfigRequest(): void + { + $this->aclBuilder->getAcl()->deny(null, 'Magento_TwoFactorAuth::config'); + $this->manager->sendConfigRequestTo($this->user); + } + + /** + * Check that app config request E-mail is sent for a user that posseses proper rights. + * + * @return void + * @throws \Throwable + * @magentoAppArea adminhtml + */ + public function testSendAppConfigRequest(): void + { + $this->manager->sendConfigRequestTo($this->user); + + $this->assertNotEmpty($message = $this->transportBuilderMock->getSentMessage()); + $messageHtml = $message->getBody()->getParts()[0]->getRawContent(); + $this->assertContains( + 'You are required to configure website-wide and personal Two-Factor Authorization in order to login to', + $messageHtml + ); + $this->assertThat( + $messageHtml, + $this->matchesRegularExpression( + '/\/s' + ) + ); + preg_match('/\/tfat\/([^\/]+)/s', $messageHtml, $tokenMatches); + $this->assertNotEmpty($tokenMatches[1]); + $token = urldecode($tokenMatches[1]); + $this->assertTrue($this->tokenManager->isValidFor((int)$this->user->getId(), $token)); + } + + /** + * Check that personal 2FA config request E-mail is sent for users. + * + * @return void + * @throws \Throwable + * @magentoAppArea adminhtml + * @magentoConfigFixture default/twofactorauth/general/force_providers google + */ + public function testSendUserConfigRequest(): void + { + $this->manager->sendConfigRequestTo($this->user); + + $this->assertNotEmpty($message = $this->transportBuilderMock->getSentMessage()); + $messageHtml = $message->getBody()->getParts()[0]->getRawContent(); + $this->assertContains( + 'You are required to configure personal Two-Factor Authorization in order to login to', + $messageHtml + ); + $this->assertThat( + $messageHtml, + $this->matchesRegularExpression( + '/\/s' + ) + ); + preg_match('/\/tfat\/([^\/]+)/s', $messageHtml, $tokenMatches); + $this->assertNotEmpty($tokenMatches[1]); + $token = urldecode($tokenMatches[1]); + $this->assertTrue($this->tokenManager->isValidFor((int)$this->user->getId(), $token)); + } +} diff --git a/TwoFactorAuth/Test/Integration/UserConfigTokenManagerTest.php b/TwoFactorAuth/Test/Integration/UserConfigTokenManagerTest.php new file mode 100644 index 00000000..901b4594 --- /dev/null +++ b/TwoFactorAuth/Test/Integration/UserConfigTokenManagerTest.php @@ -0,0 +1,87 @@ +dateTimeMock = $this->getMockBuilder(DateTime::class)->disableOriginalConstructor()->getMock(); + $this->userFactory = Bootstrap::getObjectManager()->get(UserFactory::class); + $this->tokenManager = Bootstrap::getObjectManager()->create( + UserConfigTokenManagerInterface::class, + ['dateTime' => $this->dateTimeMock] + ); + } + + /** + * Test that issued tokens are valid for specific users and can expire. + * + * @return void + * @magentoDataFixture Magento/User/_files/user_with_role.php + */ + public function testToken(): void + { + $time = time(); + $this->dateTimeMock->method('timestamp') + ->willReturnCallback( + function () use (&$time) { + return $time; + } + ); + /** @var \Magento\User\Model\User $user1 */ + $user1 = $this->userFactory->create(); + $user1->loadByUsername(\Magento\TestFramework\Bootstrap::ADMIN_NAME); + /** @var \Magento\User\Model\User $user2 */ + $user2 = $this->userFactory->create(); + $user2->loadByUsername('adminUser'); + $this->assertNotEmpty($user1->getId()); + $this->assertNotEmpty($user2->getId()); + + //Checking that tokens for different users are different even when issued at the same time. + $token1 = $this->tokenManager->issueFor((int)$user1->getId()); + $token2 = $this->tokenManager->issueFor((int)$user2->getId()); + $this->assertNotEquals($token1, $token2); + + //Checking that valid tokens are only valid for corresponding users. + $time += 5; + $this->assertTrue($this->tokenManager->isValidFor((int)$user1->getId(), $token1)); + $this->assertTrue($this->tokenManager->isValidFor((int)$user2->getId(), $token2)); + $this->assertFalse($this->tokenManager->isValidFor((int)$user1->getId(), $token2)); + $this->assertFalse($this->tokenManager->isValidFor((int)$user2->getId(), $token1)); + + //Checking that tokens do expire + $time += 3601; + $this->assertFalse($this->tokenManager->isValidFor((int)$user1->getId(), $token1)); + $this->assertFalse($this->tokenManager->isValidFor((int)$user2->getId(), $token2)); + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/Config/Backend/Duo/ApiHostnameTest.php b/TwoFactorAuth/Test/Unit/Model/Config/Backend/Duo/ApiHostnameTest.php new file mode 100644 index 00000000..df640d54 --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/Config/Backend/Duo/ApiHostnameTest.php @@ -0,0 +1,57 @@ +model = $objectManager->getObject(ApiHostname::class); + } + + /** + * @dataProvider valuesDataProvider + */ + public function testBefore($value, $isValid): void + { + if (!$isValid) { + $this->expectException(ValidatorException::class); + } + $this->model->setValue($value); + $this->model->beforeSave(); + } + + public function valuesDataProvider() + { + return [ + ['', true], + ['foo', false], + ['123', false], + ['http://google.com', false], + ['http://duosecurity.com', false], + ['http://foo.duosecurity.com', false], + ['http://foo.duosecurity.com', false], + ['foo.duosecurity.com', true], + ['abc123-123dc.duosecurity.com', true], + ['abc123-123dc.duosecurity.com.foo', false], + ['abc123/123dc.duosecurity.com.foo', false], + ['abc123/123dc.duosecurity.com/foo', false], + ['abc123-123dc.duosecurity.com/foo', false], + ]; + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/Config/Backend/ForceProvidersTest.php b/TwoFactorAuth/Test/Unit/Model/Config/Backend/ForceProvidersTest.php new file mode 100644 index 00000000..467a6391 --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/Config/Backend/ForceProvidersTest.php @@ -0,0 +1,75 @@ +tfa = $this->createMock(TfaInterface::class); + + $this->model = $objectManager->getObject( + ForceProviders::class, + [ + 'tfa' => $this->tfa + ] + ); + } + + /** + * Check that beforeSave validates values. + * + * @return void + * @expectedException \Magento\Framework\Exception\ValidatorException + */ + public function testBeforeSaveInvalid(): void + { + $this->model->setValue(''); + $this->model->beforeSave(); + } + + /** + * Check that beforeSave validates values. + * + * @return void + */ + public function testBeforeSaveValid(): void + { + $provider1 = $this->createMock(ProviderInterface::class); + $provider1->method('getCode') + ->willReturn('provider1'); + $provider2 = $this->createMock(ProviderInterface::class); + $provider2->method('getCode') + ->willReturn('provider2'); + $provider3 = $this->createMock(ProviderInterface::class); + $provider3->method('getCode') + ->willReturn('provider3'); + $this->tfa->method('getAllProviders') + ->willReturn([$provider1, $provider2, $provider3]); + $this->model->setValue(['provider1', 'ignoreme', 'provider2']); + $this->model->beforeSave(); + self::assertSame(['provider1', 'provider2'], $this->model->getValue()); + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/Provider/Engine/AuthyTest.php b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/AuthyTest.php new file mode 100644 index 00000000..f36e6bf8 --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/AuthyTest.php @@ -0,0 +1,67 @@ +serviceMock = $this->getMockBuilder(Authy\Service::class)->disableOriginalConstructor()->getMock(); + + $this->model = $objectManager->getObject(Authy::class, ['service' => $this->serviceMock]); + } + + /** + * Enabled test dataset. + * + * @return array + */ + public function getIsEnabledTestDataSet(): array + { + return [ + 'api key present' => [ + 'api-key', + true + ], + 'api key not configured' => [ + null, + false + ] + ]; + } + + /** + * Check that the provider is available based on configuration. + * + * @param string|null $apiKey + * @param bool $expected + * @return void + * @dataProvider getIsEnabledTestDataSet + */ + public function testIsEnabled(?string $apiKey, bool $expected): void + { + $this->serviceMock->method('getApiKey')->willReturn($apiKey); + + $this->assertEquals($expected, $this->model->isEnabled()); + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/Provider/Engine/DuoSecurityTest.php b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/DuoSecurityTest.php new file mode 100644 index 00000000..6eef7bad --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/DuoSecurityTest.php @@ -0,0 +1,117 @@ +configMock = $this->getMockBuilder(ScopeConfigInterface::class)->disableOriginalConstructor()->getMock(); + + $this->model = $objectManager->getObject(DuoSecurity::class, ['scopeConfig' => $this->configMock]); + } + + /** + * Enabled test dataset. + * + * @return array + */ + public function getIsEnabledTestDataSet(): array + { + return [ + [ + 'value', + 'value', + 'value', + 'value', + true + ], + [ + null, + null, + null, + null, + false + ], + [ + 'value', + null, + null, + null, + false + ], + [ + null, + 'value', + null, + null, + false + ], + [ + null, + null, + 'value', + null, + false + ], + [ + null, + null, + null, + 'value', + false + ] + ]; + } + + /** + * Check that the provider is available based on configuration. + * + * @param string|null $apiHostname + * @param string|null $appKey + * @param string|null $secretKey + * @param string|null $integrationKey + * @param bool $expected + * @return void + * @dataProvider getIsEnabledTestDataSet + */ + public function testIsEnabled( + ?string $apiHostname, + ?string $appKey, + ?string $secretKey, + ?string $integrationKey, + bool $expected + ): void { + $this->configMock->method('getValue')->willReturnMap( + [ + [DuoSecurity::XML_PATH_API_HOSTNAME, 'default', null, $apiHostname], + [DuoSecurity::XML_PATH_APPLICATION_KEY, 'default', null, $appKey], + [DuoSecurity::XML_PATH_SECRET_KEY, 'default', null, $secretKey], + [DuoSecurity::XML_PATH_INTEGRATION_KEY, 'default', null, $integrationKey] + ] + ); + + $this->assertEquals($expected, $this->model->isEnabled()); + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/Provider/Engine/GoogleTest.php b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/GoogleTest.php new file mode 100644 index 00000000..9f45e801 --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/GoogleTest.php @@ -0,0 +1,35 @@ +model = $objectManager->getObject(Google::class); + } + + /** + * Check that the provider is available based on configuration. + * + * @return void + */ + public function testIsEnabled(): void { + $this->assertTrue($this->model->isEnabled()); + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/Provider/Engine/U2fKeyTest.php b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/U2fKeyTest.php new file mode 100644 index 00000000..c7e6b18c --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/Provider/Engine/U2fKeyTest.php @@ -0,0 +1,35 @@ +model = $objectManager->getObject(U2fKey::class); + } + + /** + * Check that the provider is available based on configuration. + * + * @return void + */ + public function testIsEnabled(): void { + $this->assertTrue($this->model->isEnabled()); + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/TfaTest.php b/TwoFactorAuth/Test/Unit/Model/TfaTest.php new file mode 100644 index 00000000..46ae8050 --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/TfaTest.php @@ -0,0 +1,226 @@ +pool = $this->getMockForAbstractClass(ProviderPoolInterface::class); + $this->pool->method('getProviders') + ->willReturnCallback( + function (): array { + return $this->providersMockList; + } + ); + $this->pool->method('getProviderByCode') + ->willReturnCallback( + function (string $code): ProviderInterface { + if (array_key_exists($code, $this->providersMockList)) { + return $this->providersMockList[$code]; + } + throw new NoSuchEntityException(); + } + ); + $this->configMock = $this->getMockForAbstractClass(ScopeConfigInterface::class); + + $this->model = $objectManager->getObject( + Tfa::class, + ['providerPool' => $this->pool, 'scopeConfig' => $this->configMock] + ); + } + + /** + * Define existing providers. + * + * @param array $providersData Keys - codes, values - {enabled: bool}. + * @return void + */ + private function defineProvidersList(array $providersData): void + { + $providers = []; + foreach ($providersData as $code => $providerData) { + $provider = $this->getMockForAbstractClass(ProviderInterface::class); + $provider->method('getCode')->willReturn($code); + $provider->method('isEnabled')->willReturn($providerData['enabled']); + $providers[$code] = $provider; + } + + $this->providersMockList = $providers; + } + + /** + * Extract codes from a providers list. + * + * @param ProviderInterface[] $providers + * @return string[] + */ + private function extractProviderCodes(array $providers): array + { + return array_map( + function (ProviderInterface $provider): string { + return $provider->getCode(); + }, + $providers + ); + } + + /** + * Check that enabled providers list updates when the pool or providers update. + * + * @return void + */ + public function testAllEnabledProvidersUpdates(): void + { + $providersData = ['provider1' => ['enabled' => true], 'provider2' => ['enabled' => true]]; + $this->defineProvidersList($providersData); + $this->assertEquals( + ['provider1', 'provider2'], + $this->extractProviderCodes($this->model->getAllEnabledProviders()) + ); + + $providersData = ['provider1' => ['enabled' => true], 'provider2' => ['enabled' => false]]; + $this->defineProvidersList($providersData); + $this->assertEquals( + ['provider1'], + $this->extractProviderCodes($this->model->getAllEnabledProviders()) + ); + + $providersData = [ + 'provider1' => ['enabled' => true], + 'provider2' => ['enabled' => true], + 'provider3' => ['enabled' => true] + ]; + $this->defineProvidersList($providersData); + $this->assertEquals( + ['provider1', 'provider2', 'provider3'], + $this->extractProviderCodes($this->model->getAllEnabledProviders()) + ); + } + + /** + * Data set for the forcedProviders test. + * + * @return array + */ + public function getForcedProvidersDataSet(): array + { + return [ + 'not defined' => [ + null, + ['provider1' => ['enabled' => true]], + [] + ], + 'not defined string' => [ + '', + ['provider1' => ['enabled' => true]], + [] + ], + 'not defined array' => [ + [], + ['provider1' => ['enabled' => true]], + [] + ], + 'valid array' => [ + ['provider1'], + ['provider1' => ['enabled' => true], 'provider2' => ['enabled' => true]], + ['provider1'] + ], + 'valid string' => [ + 'provider1, provider2', + ['provider1' => ['enabled' => true], 'provider2' => ['enabled' => true]], + ['provider1', 'provider2'] + ], + 'invalid code' => [ + 'nonExistingProvider', + ['provider1' => ['enabled' => true], 'provider2' => ['enabled' => true]], + [] + ], + 'disabledProvider' => [ + 'provider1', + ['provider1' => ['enabled' => false], 'provider2' => ['enabled' => true]], + [] + ] + ]; + } + + /** + * Test getForcedProviders method. + * + * @param string|null|array $configValue + * @param array $providersList + * @param array $expected + * @return void + * @dataProvider getForcedProvidersDataSet + */ + public function testGetForcedProviders($configValue, array $providersList, $expected): void + { + $this->configMock->method('getValue')->willReturn($configValue); + $this->defineProvidersList($providersList); + + $result = $this->model->getForcedProviders(); + + $this->assertEquals($expected, $this->extractProviderCodes($result)); + } + + /** + * Check that user providers = forced providers. + * + * @return void + */ + public function testGetUserProviders(): void + { + $this->configMock->method('getValue')->willReturnReference($configValue); + $this->defineProvidersList(['provider1' => ['enabled' => true]]); + + $configValue = 'provider1'; + $this->assertEquals(['provider1'], $this->extractProviderCodes($this->model->getUserProviders(1))); + + $configValue = ''; + $this->assertEmpty($this->model->getUserProviders(1)); + } + + /** + * Verify that 2FA is always enabled + * + * @return void + */ + public function testIsEnabled(): void + { + $this->assertTrue($this->model->isEnabled()); + } +} diff --git a/TwoFactorAuth/Test/Unit/Model/UserConfig/HtmlAreaTokenVerifierTest.php b/TwoFactorAuth/Test/Unit/Model/UserConfig/HtmlAreaTokenVerifierTest.php new file mode 100644 index 00000000..501e60a5 --- /dev/null +++ b/TwoFactorAuth/Test/Unit/Model/UserConfig/HtmlAreaTokenVerifierTest.php @@ -0,0 +1,200 @@ +requestMock = $this->getMockForAbstractClass(RequestInterface::class); + $this->tokenManagerMock = $this->getMockForAbstractClass(UserConfigTokenManagerInterface::class); + $this->cookiesMock = $this->getMockForAbstractClass(CookieManagerInterface::class); + $this->cookiesMetaFactoryMock = $this->getMockBuilder(CookieMetadataFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->sessionMock = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->getMock(); + $this->sessionManagerMock = $this->getMockBuilder(SessionManager::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->model = $objectManager->getObject( + HtmlAreaTokenVerifier::class, + [ + 'request' => $this->requestMock, + 'tokenManager' => $this->tokenManagerMock, + 'cookies' => $this->cookiesMock, + 'cookieMetadataFactory' => $this->cookiesMetaFactoryMock, + 'session' => $this->sessionMock, + 'sessionManager' => $this->sessionManagerMock + ] + ); + } + + /** + * Request/cookies data sets. + * + * @return array + */ + public function getTokenRequestData(): array + { + return [ + 'token in query' => [ + true, + 'token', + null, + true, + true, + 'token' + ], + 'token in cookies' => [ + true, + null, + 'token', + true, + false, + 'token' + ], + 'token is absent' => [ + true, + null, + null, + false, + false, + null + ], + 'invalid token' => [ + true, + 'token', + null, + false, + false, + null + ], + 'invalid token from cookies' => [ + true, + null, + 'token', + false, + false, + null + ], + 'token in both' => [ + true, + 'token', + 'token', + true, + false, + 'token' + ], + 'no user' => [ + false, + 'token', + 'token', + true, + false, + null + ] + ]; + } + + /** + * Test "readConfigToken" method with different variation of request/cookie parameters provided. + * + * @param bool $userPresent + * @param string|null $fromRequest + * @param string|null $fromCookies + * @param bool $isValid + * @param bool $cookieSet + * @param string|null $expected + * @return void + * @dataProvider getTokenRequestData + */ + public function testReadConfigToken( + bool $userPresent, + ?string $fromRequest, + ?string $fromCookies, + bool $isValid, + bool $cookieSet, + ?string $expected + ): void { + $this->sessionMock->method('__call') + ->willReturn( + $userPresent ? $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock() : null + ); + $this->requestMock->method('getParam')->with('tfat')->willReturn($fromRequest); + $this->cookiesMock->method('getCookie')->with('tfa-ct')->willReturn($fromCookies); + $this->tokenManagerMock->method('isValidFor')->willReturn($isValid); + $this->sessionManagerMock->method('getCookiePath')->willReturn('admin_path'); + $this->cookiesMetaFactoryMock->method('createSensitiveCookieMetadata') + ->willReturn( + $metaMock = $this->getMockBuilder(SensitiveCookieMetadata::class) + ->disableOriginalConstructor() + ->getMock() + ); + if ($cookieSet) { + $metaMock->expects($this->atLeastOnce())->method('setPath')->with('admin_path')->willReturn($metaMock); + $this->cookiesMock->expects($this->once()) + ->method('setSensitiveCookie') + ->with('tfa-ct', $fromRequest, $metaMock); + } else { + $this->cookiesMock->expects($this->never())->method('setSensitiveCookie'); + } + + $this->assertEquals($expected, $this->model->readConfigToken()); + } +} diff --git a/TwoFactorAuth/TestFramework/ControllerActionPredispatch.php b/TwoFactorAuth/TestFramework/ControllerActionPredispatch.php new file mode 100644 index 00000000..dc4353e4 --- /dev/null +++ b/TwoFactorAuth/TestFramework/ControllerActionPredispatch.php @@ -0,0 +1,32 @@ +getEvent()->getData('controller_action'); + if (class_exists('Magento\TestFramework\Request') + && $controllerAction->getRequest() instanceof \Magento\TestFramework\Request + && !$controllerAction->getRequest()->getParam('tfa_enabled') + ) { + //Hack that allows integration controller tests that are not aware of 2FA to run + return; + } + + parent::execute($observer); + } +} diff --git a/TwoFactorAuth/TestFramework/TestCase/AbstractBackendController.php b/TwoFactorAuth/TestFramework/TestCase/AbstractBackendController.php new file mode 100644 index 00000000..05a91a43 --- /dev/null +++ b/TwoFactorAuth/TestFramework/TestCase/AbstractBackendController.php @@ -0,0 +1,63 @@ +_objectManager->get(\Magento\Backend\Model\UrlInterface::class)->turnOffSecretKey(); + $this->_auth = $this->_objectManager->get(\Magento\Backend\Model\Auth::class); + $this->_session = $this->_auth->getAuthStorage(); + $this->login(); + } + + /** + * Perform logout + * + * @return void + */ + protected function logout(): void + { + $this->_auth->getAuthStorage()->destroy(['send_expire_cookie' => false]); + } + + /** + * Login as default admin user. + * + * @return void + */ + protected function login(): void + { + $credentials = $this->_getAdminCredentials(); + $this->_auth->login($credentials['user'], $credentials['password']); + /** @var \Magento\Security\Model\Plugin\Auth $authPlugin */ + $authPlugin = $this->_objectManager->get(\Magento\Security\Model\Plugin\Auth::class); + $authPlugin->afterLogin($this->_auth); + } + + /** + * @inheritDoc + */ + public function dispatch($uri) + { + if ($this->getRequest()->getParam('tfa_enabled') === null) { + $this->getRequest()->setParam('tfa_enabled', true); + } + + parent::dispatch($uri); + } +} diff --git a/TwoFactorAuth/TestFramework/TestCase/AbstractConfigureBackendController.php b/TwoFactorAuth/TestFramework/TestCase/AbstractConfigureBackendController.php new file mode 100644 index 00000000..040f58b0 --- /dev/null +++ b/TwoFactorAuth/TestFramework/TestCase/AbstractConfigureBackendController.php @@ -0,0 +1,72 @@ +tokenManager = Bootstrap::getObjectManager()->get(UserConfigTokenManagerInterface::class); + } + + /** + * Verify that even with ACL an admin user needs token to access the page. + * + * @return void + */ + public function testTokenAccess(): void + { + $this->getRequest()->setMethod($this->httpMethod); + $this->dispatch($this->uri); + $this->assertRedirect($this->stringContains('login')); + } + + /** + * Check that a user with proper token and rights can access the page. + */ + public function testAclHasAccess() + { + $this->getRequest() + ->setParam('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + + parent::testAclHasAccess(); + } + + /** + * Check that a user with proper token but without rights cannot access the page. + */ + public function testAclNoAccess() + { + $this->getRequest() + ->setParam('tfat', $this->tokenManager->issueFor((int)$this->_session->getUser()->getId())); + + parent::testAclNoAccess(); + } +} diff --git a/TwoFactorAuth/Ui/Component/Form/User/DataProvider.php b/TwoFactorAuth/Ui/Component/Form/User/DataProvider.php new file mode 100644 index 00000000..a43cfaa5 --- /dev/null +++ b/TwoFactorAuth/Ui/Component/Form/User/DataProvider.php @@ -0,0 +1,158 @@ +collection = $collectionFactory->create(); + $this->tfa = $tfa; + $this->enabledProvider = $enabledProvider; + $this->userConfigManager = $userConfigManager; + $this->url = $url; + } + + /** + * Get a list of forced providers + * @return array + */ + private function getForcedProviders() + { + $names = []; + $forcedProviders = $this->tfa->getForcedProviders(); + if (!empty($forcedProviders)) { + foreach ($forcedProviders as $forcedProvider) { + $names[] = $forcedProvider->getName(); + } + } + + return $names; + } + + /** + * Get reset provider urls + * @param User $user + * @return array + */ + private function getResetProviderUrls(User $user) + { + $providers = $this->tfa->getAllEnabledProviders(); + + $resetProviders = []; + foreach ($providers as $provider) { + if ($provider->isConfigured((int) $user->getId()) && $provider->isResetAllowed()) { + $resetProviders[] = [ + 'value' => $provider->getCode(), + 'label' => __('Reset %1', $provider->getName()), + 'url' => $this->url->getUrl('tfa/tfa/reset', [ + 'id' => (int) $user->getId(), + 'provider' => $provider->getCode(), + ]), + ]; + } + } + + return $resetProviders; + } + + /** + * @inheritdoc + */ + public function getMeta() + { + $meta = parent::getMeta(); + + $meta['base_fieldset']['children']['tfa_providers']['arguments']['data']['config']['forced_providers'] = + $this->getForcedProviders(); + $meta['base_fieldset']['children']['tfa_providers']['arguments']['data']['config']['enabled_providers'] = + $this->enabledProvider->toOptionArray(); + + return $meta; + } + + /** + * @inheritdoc + */ + public function getData() + { + if ($this->loadedData === null) { + $this->loadedData = []; + $items = $this->collection->getItems(); + + /** @var User $user */ + foreach ($items as $user) { + $providerCodes = $this->userConfigManager->getProvidersCodes((int) $user->getId()); + $resetProviders = $this->getResetProviderUrls($user); + + $data = [ + 'reset_providers' => $resetProviders, + 'tfa_providers' => $providerCodes, + ]; + $this->loadedData[(int) $user->getId()] = $data; + } + } + + return $this->loadedData; + } +} diff --git a/TwoFactorAuth/composer.json b/TwoFactorAuth/composer.json new file mode 100644 index 00000000..366dcff4 --- /dev/null +++ b/TwoFactorAuth/composer.json @@ -0,0 +1,40 @@ +{ + "name": "magento/module-two-factor-auth", + "version": "5.0.0", + "description": "Two Factor Authentication module for Magento2", + "require": { + "php": "~7.1.3||~7.2.0||~7.3.0", + "magento/magento-composer-installer": "*", + "magento/module-backend": "*", + "magento/module-config": "*", + "magento/module-store": "*", + "magento/module-ui": "*", + "magento/module-user": "*", + "christian-riesen/base32": "^1.3", + "spomky-labs/otphp": "~8.3", + "endroid/qr-code": "^2.5", + "donatj/phpuseragentparser": "~0.7", + "2tvenom/cborencode": "^1.0", + "phpseclib/phpseclib": "~2.0" + }, + "authors": [ + { + "name": "Riccardo Tempesta", + "email": "riccardo.tempesta@magespecialist.it" + }, + { + "name": "Giacomo Mirabassi", + "email": "giacomo.mirabassi@magespecialist.it" + } + ], + "type": "magento2-module", + "license": "OSL-3.0", + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Magento\\TwoFactorAuth\\": "" + } + } +} diff --git a/TwoFactorAuth/etc/acl.xml b/TwoFactorAuth/etc/acl.xml new file mode 100644 index 00000000..0ff1cdeb --- /dev/null +++ b/TwoFactorAuth/etc/acl.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TwoFactorAuth/etc/adminhtml/di.xml b/TwoFactorAuth/etc/adminhtml/di.xml new file mode 100644 index 00000000..2c57e43f --- /dev/null +++ b/TwoFactorAuth/etc/adminhtml/di.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/TwoFactorAuth/etc/adminhtml/events.xml b/TwoFactorAuth/etc/adminhtml/events.xml new file mode 100644 index 00000000..0d5c1f23 --- /dev/null +++ b/TwoFactorAuth/etc/adminhtml/events.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/TwoFactorAuth/etc/adminhtml/routes.xml b/TwoFactorAuth/etc/adminhtml/routes.xml new file mode 100644 index 00000000..bbc7e5c6 --- /dev/null +++ b/TwoFactorAuth/etc/adminhtml/routes.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/TwoFactorAuth/etc/adminhtml/system.xml b/TwoFactorAuth/etc/adminhtml/system.xml new file mode 100644 index 00000000..94180a24 --- /dev/null +++ b/TwoFactorAuth/etc/adminhtml/system.xml @@ -0,0 +1,69 @@ + + + + + + + + +
+ separator-top + + security + Magento_TwoFactorAuth::config + + + + + + Magento\TwoFactorAuth\Model\Config\Source\Provider + Magento\TwoFactorAuth\Block\Adminhtml\System\Config\Providers + Two-factor authorization providers for admin users to use during login + Magento\TwoFactorAuth\Model\Config\Backend\ForceProviders + + + + + + + + + + + Magento\Config\Model\Config\Backend\Encrypted + + + + Magento\TwoFactorAuth\Model\Config\Backend\Duo\ApiHostname + + + + + + + + Magento\Config\Model\Config\Backend\Encrypted + + + + + +
+
+
diff --git a/TwoFactorAuth/etc/config.xml b/TwoFactorAuth/etc/config.xml new file mode 100644 index 00000000..9dba4e2b --- /dev/null +++ b/TwoFactorAuth/etc/config.xml @@ -0,0 +1,25 @@ + + + + + + + + + + Login request to your Magento Admin + + + + + + + + + diff --git a/TwoFactorAuth/etc/db_schema.xml b/TwoFactorAuth/etc/db_schema.xml new file mode 100644 index 00000000..711a24c2 --- /dev/null +++ b/TwoFactorAuth/etc/db_schema.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+
diff --git a/TwoFactorAuth/etc/db_schema_whitelist.json b/TwoFactorAuth/etc/db_schema_whitelist.json new file mode 100644 index 00000000..c81eca73 --- /dev/null +++ b/TwoFactorAuth/etc/db_schema_whitelist.json @@ -0,0 +1,16 @@ +{ + "tfa_user_config": { + "constraint": { + "PRIMARY": true, + "TFA_USER_CONFIG_USER_ID_ADMIN_USER_USER_ID": true + } + }, + "tfa_country_codes": { + "constraint": { + "PRIMARY": true + }, + "index": { + "TFA_COUNTRY_CODES_CODE": true + } + } +} diff --git a/TwoFactorAuth/etc/di.xml b/TwoFactorAuth/etc/di.xml new file mode 100644 index 00000000..3f80d9fa --- /dev/null +++ b/TwoFactorAuth/etc/di.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + Magento\TwoFactorAuth\Command\TfaReset + Magento\TwoFactorAuth\Command\TfaProviders + + + + + + + + + 1 + 1 + 1 + 1 + + + 1 + 1 + 1 + 1 + + + + + + + + + + Magento\TwoFactorAuth\Model\Provider\Google + Magento\TwoFactorAuth\Model\Provider\DuoSecurity + Magento\TwoFactorAuth\Model\Provider\Authy + Magento\TwoFactorAuth\Model\Provider\U2fKey + + + + + + + Magento\TwoFactorAuth\Model\Provider\Engine\Google + google + Google Authenticator + Magento_TwoFactorAuth::images/providers/google.png + tfa/google/configure + tfa/google/auth + + tfa/google/qr + tfa/google/configurepost + tfa/google/authpost + + true + + + + + + Magento\TwoFactorAuth\Model\Provider\Engine\DuoSecurity + duo_security + Duo Security + Magento_TwoFactorAuth::images/providers/duo_security.png + tfa/duo/configure + tfa/duo/auth + + tfa/duo/authpost + + false + + + + + + Magento\TwoFactorAuth\Model\Provider\Engine\Authy + authy + Authy + Magento_TwoFactorAuth::images/providers/authy.png + tfa/authy/configure + tfa/authy/auth + + tfa/authy/configurepost + tfa/authy/configureverifypost + tfa/authy/verify + tfa/authy/authpost + tfa/authy/token + tfa/authy/onetouch + tfa/authy/verifyonetouch + + true + + + + + + Magento\TwoFactorAuth\Model\Provider\Engine\U2fKey + u2fkey + U2F (Yubikey and others) + Magento_TwoFactorAuth::images/providers/u2fkey.png + tfa/u2f/configure + tfa/u2f/auth + + tfa/u2f/configurepost + tfa/u2f/authpost + + true + + + + + + + + diff --git a/TwoFactorAuth/etc/email_templates.xml b/TwoFactorAuth/etc/email_templates.xml new file mode 100644 index 00000000..addfd7bd --- /dev/null +++ b/TwoFactorAuth/etc/email_templates.xml @@ -0,0 +1,11 @@ + + + +