├── Api └── RequestLoginRepositoryInterface.php ├── Block └── Adminhtml │ └── System │ └── Config │ └── Button.php ├── Controller ├── Account │ ├── CreatePost.php │ ├── Edit.php │ ├── ForgotPassword.php │ └── ForgotPasswordPost.php ├── Adminhtml │ └── Pwl │ │ ├── ProcessLogin.php │ │ └── RequestLogin.php └── Pwl │ ├── ProcessLogin.php │ └── RequestLogin.php ├── Enum └── Config.php ├── Exception └── RequestException.php ├── Helper └── JwtHelper.php ├── LICENCE ├── Model ├── Admin │ ├── User.php │ └── User │ │ └── Save.php ├── LoginRequest.php ├── LoginRequestRepository.php ├── ResourceModel │ ├── Admin │ │ └── User.php │ ├── LoginRequest.php │ └── LoginRequest │ │ └── Collection.php └── Template │ └── Manager.php ├── Plugin ├── AdobeImsReAuth │ ├── Block │ │ ├── System │ │ │ └── Account │ │ │ │ └── Edit │ │ │ │ └── RemoveReAuthVerification.php │ │ └── User │ │ │ ├── Edit │ │ │ └── Tab │ │ │ │ └── RemoveReAuthVerification.php │ │ │ └── Role │ │ │ └── Tab │ │ │ └── RemoveReAuthVerification.php │ └── PerformIdentityCheckMessagePlugin.php ├── Model │ └── AccountManagement.php └── Webapi │ └── Controller │ └── Rest │ └── DisableApi.php ├── Processor └── EmailProcessor.php ├── README.md ├── Service ├── Admin │ ├── Login.php │ └── PasswordVerification.php ├── Customer │ └── Login.php ├── JwtManager.php ├── Password.php ├── Queue.php ├── Request.php └── Validation.php ├── Test └── Unit │ ├── Plugin │ ├── Model │ │ └── AccountManagementTest.php │ └── Webapi │ │ └── Controller │ │ └── Rest │ │ └── DisableApiTest.php │ ├── Processor │ └── EmailProcessorTest.php │ ├── Service │ ├── Customer │ │ └── LoginTest.php │ ├── JwtManagerTest.php │ ├── PasswordTest.php │ ├── QueueTest.php │ ├── RequestTest.php │ └── ValidationTest.php │ └── ViewModel │ └── LoginTest.php ├── ViewModel └── Request │ └── Login.php ├── composer.json ├── etc ├── adminhtml │ ├── di.xml │ ├── routes.xml │ └── system.xml ├── config.xml ├── db_schema.xml ├── db_schema_whitelist.json ├── di.xml ├── email_templates.xml ├── frontend │ ├── di.xml │ └── routes.xml └── module.xml ├── registration.php └── view ├── adminhtml ├── email │ └── login.html ├── layout │ └── adminhtml_auth_login.xml ├── templates │ └── admin │ │ └── login.phtml └── web │ └── css │ └── source │ └── _module.less └── frontend ├── email └── login.html ├── layout ├── customer_account_create.xml ├── customer_account_edit.xml ├── customer_account_index.xml └── customer_account_login.xml ├── templates ├── account │ └── dashboard │ │ └── info.phtml └── form │ ├── edit.phtml │ ├── login.phtml │ └── register.phtml └── web └── css └── source └── _module.less /Api/RequestLoginRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | _scopeConfig->getValue(Config::XML_PATH_HOODOOR_SECRET_KEY->value); 20 | $element->setType('password')->setValue($value)->setReadonly(true); 21 | $html = parent::_getElementHtml($element); 22 | return $html . $this->getButtonHtml() . $this->getJs($element); 23 | } 24 | 25 | private function getButtonHtml(): string 26 | { 27 | $button = $this->getLayout()->createBlock(WidgetButton::class) 28 | ->setData([ 29 | 'id' => 'generate_secret_key', 30 | 'label' => __('Generate Secret Key'), 31 | 'class' => 'generate-secret-key-button' 32 | ]); 33 | 34 | return $button->toHtml(); 35 | } 36 | 37 | private function getJs(AbstractElement $element): string 38 | { 39 | return ' 40 | 47 | '; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Controller/Account/CreatePost.php: -------------------------------------------------------------------------------- 1 | password) { 95 | $this->password = $this->passwordService->generate(); 96 | } 97 | $this->getRequest()->setParams([ 98 | 'password' => $this->password, 99 | 'password_confirmation' => $this->password 100 | ]); 101 | return parent::execute(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Controller/Account/Edit.php: -------------------------------------------------------------------------------- 1 | getRequest()->getParam('changepass')) { 18 | return $this->_redirect('customer/account'); 19 | } 20 | return parent::execute(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Controller/Account/ForgotPassword.php: -------------------------------------------------------------------------------- 1 | messageManager->addErrorMessage(__('Access denied.')); 17 | return $this->_redirect('customer/account'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Controller/Account/ForgotPasswordPost.php: -------------------------------------------------------------------------------- 1 | messageManager->addErrorMessage(__('Access denied.')); 17 | return $this->_redirect('customer/account'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Controller/Adminhtml/Pwl/ProcessLogin.php: -------------------------------------------------------------------------------- 1 | redirectFactory->create(); 37 | $params = $this->request->getParams(); 38 | if ($params) { 39 | try { 40 | if (isset($params['request'])) { 41 | $decryptedData = $this->jwtManager->validateToken($params['request']); 42 | if ($decryptedData) { 43 | $params = [ 44 | 'email' => $decryptedData->email, 45 | 'token' => $decryptedData->token 46 | ]; 47 | } 48 | if (isset($params['email']) && isset($params['token'])) { 49 | try { 50 | $loginRequest = $this->loginRequestRepository->get($params['email']); 51 | if ($loginRequest->getToken() === $params['token']) { 52 | if ($loginRequest->hasBeenUsed() || $loginRequest->hasExpired()) { 53 | $this->messageManager->addErrorMessage( 54 | __('Unable to execute the request. Please try again.') 55 | ); 56 | return $redirect->setPath('*'); 57 | } 58 | $this->loginRequestRepository->lock($loginRequest); 59 | $this->request->setParams(['email' => $params['email']]); 60 | $this->adminLoginService->perform($this->request); 61 | $this->loginRequestRepository->delete($loginRequest); 62 | } 63 | } catch (NoSuchEntityException) { 64 | throw new RequestException(_('Invalid request. Please try again.')); 65 | } 66 | } else { 67 | throw new RequestException( 68 | _('Invalid request. Please try again.') 69 | ); 70 | } 71 | } else { 72 | throw new RequestException( 73 | _('Invalid request. Please try again.') 74 | ); 75 | } 76 | } catch (\Exception $e) { 77 | $this->messageManager->addErrorMessage($e->getMessage()); 78 | } 79 | } 80 | return $redirect->setPath('*'); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Controller/Adminhtml/Pwl/RequestLogin.php: -------------------------------------------------------------------------------- 1 | redirectFactory->create(); 39 | 40 | $params = $this->request->getParams(); 41 | if ($params) { 42 | $isFormKey = $this->formKey->isEmpty(); 43 | if ($isFormKey) { 44 | $this->messageManager->addErrorMessage(__('Invalid Form Key. Please refresh the page.')); 45 | return $redirect->setPath('*/*'); 46 | } 47 | if (!isset($params['login']['username'])) { 48 | $this->messageManager->addErrorMessage( 49 | __('You must enter a valid email address.') 50 | ); 51 | return $redirect->setPath('*/*'); 52 | } else { 53 | try { 54 | $this->queueService->add($params, 'admin'); 55 | $this->messageManager->addSuccessMessage( 56 | __('If an account exists, you will receive an email to proceed with your request.') 57 | ); 58 | } catch (\Exception $e) { 59 | $this->messageManager->addErrorMessage($e->getMessage()); 60 | return $redirect->setPath('*/*'); 61 | } 62 | } 63 | } 64 | 65 | return $redirect->setPath('*/*'); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Controller/Pwl/ProcessLogin.php: -------------------------------------------------------------------------------- 1 | redirectFactory->create(); 37 | $params = $this->request->getParams(); 38 | if ($params) { 39 | try { 40 | if (isset($params['request'])) { 41 | $decryptedData = $this->jwtManager->validateToken($params['request']); 42 | if ($decryptedData) { 43 | $params = [ 44 | 'email' => $decryptedData->email, 45 | 'token' => $decryptedData->token 46 | ]; 47 | } 48 | if (isset($params['email']) && isset($params['token'])) { 49 | try { 50 | $request = $this->loginRequestRepository->get($params['email']); 51 | if ($request->getToken() === $params['token']) { 52 | if ($request->hasBeenUsed() || $request->hasExpired()) { 53 | $this->messageManager->addErrorMessage( 54 | __('Unable to execute request. Please try again.') 55 | ); 56 | return $redirect->setPath('*/account/login'); 57 | } 58 | $this->loginRequestRepository->lock($request); 59 | $this->loginService->perform($params); 60 | $this->loginRequestRepository->delete($request); 61 | } 62 | } catch (NoSuchEntityException) { 63 | throw new RequestException(_('Invalid request. Please try again.')); 64 | } 65 | } else { 66 | throw new RequestException(_('Invalid request. Please try again.')); 67 | } 68 | } else { 69 | throw new RequestException(_('Invalid request. Please try again.')); 70 | } 71 | } catch (RequestException $e) { 72 | $this->messageManager->addErrorMessage($e->getMessage()); 73 | } 74 | } 75 | return $redirect->setPath('*/account'); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Controller/Pwl/RequestLogin.php: -------------------------------------------------------------------------------- 1 | redirectFactory->create(); 36 | 37 | $params = $this->request->getParams(); 38 | if ($params) { 39 | $isFormKey = $this->formKey->isPresent(); 40 | if (!$isFormKey) { 41 | $this->messageManager->addErrorMessage( 42 | __('Invalid Form Key. Please refresh the page.') 43 | ); 44 | return $redirect->setPath('*/*/login'); 45 | } 46 | if (!isset($params['login']['username'])) { 47 | $this->messageManager->addErrorMessage( 48 | __('You must enter a valid email address.') 49 | ); 50 | return $redirect->setPath('*/*/login'); 51 | } else { 52 | try { 53 | $this->queueService->add($params, 'customer'); 54 | $this->messageManager->addSuccessMessage( 55 | __('If a customer account exists, you will receive an email to proceed with your request.') 56 | ); 57 | } catch (\Exception $e) { 58 | $this->messageManager->addErrorMessage($e->getMessage()); 59 | return $redirect->setPath('*/*/login'); 60 | } 61 | } 62 | } 63 | 64 | return $redirect->setPath('customer/account'); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Enum/Config.php: -------------------------------------------------------------------------------- 1 | resourceModel->loadByEmail($email); 62 | if ($data !== false) { 63 | $this->setData($data); 64 | $this->setOrigData(); 65 | } 66 | return $this; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Model/Admin/User/Save.php: -------------------------------------------------------------------------------- 1 | isEnabled()) { 36 | parent::execute(); 37 | return; 38 | } 39 | 40 | $userId = (int)$this->getRequest()->getParam('user_id'); 41 | $data = $this->getRequest()->getPostValue(); 42 | if (array_key_exists('form_key', $data)) { 43 | unset($data['form_key']); 44 | } 45 | if (!$data) { 46 | $this->_redirect('adminhtml/*/'); 47 | return; 48 | } 49 | 50 | $model = $this->_userFactory->create()->load($userId); 51 | if ($userId && $model->isObjectNew()) { 52 | $this->messageManager->addError(__('This user no longer exists.')); 53 | $this->_redirect('adminhtml/*/'); 54 | return; 55 | } 56 | $model->setData($this->_getAdminUserData($data)); 57 | $userRoles = $this->getRequest()->getParam('roles', []); 58 | if (count($userRoles)) { 59 | $model->setRoleId($userRoles[0]); 60 | } 61 | 62 | /** @var $currentUser User */ 63 | $currentUser = $this->_objectManager->get(\Magento\Backend\Model\Auth\Session::class)->getUser(); 64 | if ($userId == $currentUser->getId() 65 | && $this->_objectManager->get(\Magento\Framework\Validator\Locale::class) 66 | ->isValid($data['interface_locale']) 67 | ) { 68 | $this->_objectManager->get( 69 | \Magento\Backend\Model\Locale\Manager::class 70 | )->switchBackendInterfaceLocale( 71 | $data['interface_locale'] 72 | ); 73 | } 74 | 75 | try { 76 | $model->save(); 77 | $this->messageManager->addSuccess(__('You saved the user.')); 78 | $this->_getSession()->setUserData(false); 79 | $this->_redirect('adminhtml/*/'); 80 | 81 | $model->sendNotificationEmailsIfRequired(); 82 | } catch (UserLockedException $e) { 83 | $this->_auth->logout(); 84 | $this->getSecurityCookie()->setLogoutReasonCookie( 85 | \Magento\Security\Model\AdminSessionsManager::LOGOUT_REASON_USER_LOCKED 86 | ); 87 | $this->_redirect('*'); 88 | } catch (NotificationExceptionInterface $exception) { 89 | $this->messageManager->addErrorMessage($exception->getMessage()); 90 | } catch (\Magento\Framework\Validator\Exception $e) { 91 | $messages = $e->getMessages(); 92 | $this->messageManager->addMessages($messages); 93 | $this->redirectToEdit($model, $data); 94 | } catch (\Magento\Framework\Exception\LocalizedException $e) { 95 | if ($e->getMessage()) { 96 | $this->messageManager->addError($e->getMessage()); 97 | } 98 | $this->redirectToEdit($model, $data); 99 | } 100 | } 101 | 102 | private function getSecurityCookie() 103 | { 104 | if (!($this->securityCookie instanceof SecurityCookie)) { 105 | return \Magento\Framework\App\ObjectManager::getInstance()->get(SecurityCookie::class); 106 | } else { 107 | return $this->securityCookie; 108 | } 109 | } 110 | 111 | private function isEnabled(): bool 112 | { 113 | return $this->scopeConfig->isSetFlag(self::XML_PATH_ENABLE_ADMIN); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Model/LoginRequest.php: -------------------------------------------------------------------------------- 1 | _init(ResourceModel\LoginRequest::class); 17 | } 18 | 19 | public function setEmail(string $email): LoginRequest 20 | { 21 | return $this->setData('email', $email); 22 | } 23 | 24 | public function setType(string $type): LoginRequest 25 | { 26 | return $this->setData('type', $type); 27 | } 28 | 29 | public function setToken(string $token): LoginRequest 30 | { 31 | return $this->setData('token', $token); 32 | } 33 | 34 | public function setIsUsed(int $isUsed): LoginRequest 35 | { 36 | return $this->setData('is_used', $isUsed); 37 | } 38 | 39 | public function setExpiresAt(\DateTime $expiresAt): LoginRequest 40 | { 41 | return $this->setData('expires_at', $expiresAt); 42 | } 43 | 44 | public function getEmail(): ?string 45 | { 46 | return $this->getData('email'); 47 | } 48 | 49 | public function getType(): ?string 50 | { 51 | return $this->getData('type'); 52 | } 53 | 54 | public function getToken(): ?string 55 | { 56 | return $this->getData('token'); 57 | } 58 | 59 | public function getIsUsed(): ?string 60 | { 61 | return $this->getData('is_used'); 62 | } 63 | 64 | public function getExpiresAt(): ?string 65 | { 66 | return $this->getData('expires_at'); 67 | } 68 | 69 | public function hasExpired(): bool 70 | { 71 | $now = new \DateTime; 72 | return $now->getTimestamp() > $this->getExpiresAt(); 73 | } 74 | 75 | public function hasBeenUsed(): bool 76 | { 77 | return $this->getIsUsed() !== "0"; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Model/LoginRequestRepository.php: -------------------------------------------------------------------------------- 1 | loginRequestFactory->create(); 27 | $this->loginRequestResource->load($model, $email, 'email'); 28 | if (!$model->getId()) { 29 | throw new NoSuchEntityException(__('Request with email "%1" does not exist.', $email)); 30 | } 31 | return $model; 32 | } 33 | 34 | public function getById(string $id): LoginRequest 35 | { 36 | $model = $this->loginRequestFactory->create(); 37 | $this->loginRequestResource->load($model, $id); 38 | if (!$model->getId()) { 39 | throw new NoSuchEntityException(__('Request with id "%1" does not exist.', $id)); 40 | } 41 | return $model; 42 | } 43 | 44 | public function save(LoginRequest $model): bool 45 | { 46 | try { 47 | $this->loginRequestResource->save($model); 48 | } catch (\Exception $e) { 49 | throw new CouldNotSaveException(__( 50 | 'Could not save the request: %1', 51 | $e->getMessage() 52 | )); 53 | } 54 | return true; 55 | } 56 | 57 | public function delete(LoginRequest $model): bool 58 | { 59 | try { 60 | $this->loginRequestResource->delete($model); 61 | } catch (\Exception $e) { 62 | throw new CouldNotDeleteException(__( 63 | 'Could not delete the request: %1', 64 | $e->getMessage() 65 | )); 66 | } 67 | return true; 68 | } 69 | 70 | public function lock(LoginRequest $model): bool 71 | { 72 | try { 73 | $model->setIsUsed(1); 74 | $this->save($model); 75 | } catch (\Exception $e) { 76 | throw new (__( 77 | 'Could not lock the request: %1', 78 | $e->getMessage() 79 | )); 80 | } 81 | return true; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Model/ResourceModel/Admin/User.php: -------------------------------------------------------------------------------- 1 | getConnection(); 15 | 16 | $select = $connection->select()->from($this->getMainTable())->where('email=:email'); 17 | 18 | $binds = ['email' => $email]; 19 | 20 | return $connection->fetchRow($select, $binds); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Model/ResourceModel/LoginRequest.php: -------------------------------------------------------------------------------- 1 | _init('login_request_queue', 'entity_id'); 18 | } 19 | 20 | public function load(AbstractModel $object, $value, $field = null): LoginRequest|static 21 | { 22 | if ($field === 'email') { 23 | $object->beforeLoad($value, $field); 24 | $connection = $this->getConnection(); 25 | if ($connection && $value !== null) { 26 | $select = $connection->select() 27 | ->from($this->getMainTable()) 28 | ->where('email = (?)', $value) 29 | ->order('entity_id DESC'); 30 | $data = $connection->fetchRow($select); 31 | if ($data) { 32 | $object->setData($data); 33 | } 34 | } 35 | 36 | $this->unserializeFields($object); 37 | $this->_afterLoad($object); 38 | $object->afterLoad(); 39 | $object->setOrigData(); 40 | $object->setHasDataChanges(false); 41 | 42 | return $this; 43 | } 44 | 45 | return parent::load($object, $value, $field); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Model/ResourceModel/LoginRequest/Collection.php: -------------------------------------------------------------------------------- 1 | _init( 19 | \Opengento\Hoodoor\Model\LoginRequest::class, 20 | \Opengento\Hoodoor\Model\ResourceModel\LoginRequest::class 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Model/Template/Manager.php: -------------------------------------------------------------------------------- 1 | scopeConfig->isSetFlag(Config::XML_PATH_HOODOOR_ENABLE_FRONTEND->value); 23 | if ($type === 'admin') { 24 | $enable = $this->scopeConfig->isSetFlag(Config::XML_PATH_HOODOOR_ENABLE_ADMIN->value); 25 | } 26 | return $enable ? $override : $default; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Plugin/AdobeImsReAuth/Block/System/Account/Edit/RemoveReAuthVerification.php: -------------------------------------------------------------------------------- 1 | passwordVerification->remove($subject); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Plugin/AdobeImsReAuth/Block/User/Edit/Tab/RemoveReAuthVerification.php: -------------------------------------------------------------------------------- 1 | passwordVerification->remove($subject); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Plugin/AdobeImsReAuth/Block/User/Role/Tab/RemoveReAuthVerification.php: -------------------------------------------------------------------------------- 1 | passwordVerification->remove($subject); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Plugin/AdobeImsReAuth/PerformIdentityCheckMessagePlugin.php: -------------------------------------------------------------------------------- 1 | scopeConfig->isSetFlag(self::XML_PATH_HOODOOR_ENABLE_ADMIN); 32 | if ($hoodoorAdminIsEnable) { 33 | return true; 34 | } 35 | 36 | if ($this->adminImsConfig->enabled() === false) { 37 | return $proceed($passwordString); 38 | } 39 | 40 | try { 41 | return $proceed($passwordString); 42 | } catch (AuthenticationException $exception) { 43 | throw new AuthenticationException( 44 | __('Please perform the AdobeIms reAuth and try again.') 45 | ); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Plugin/Model/AccountManagement.php: -------------------------------------------------------------------------------- 1 | getPathInfo(); 25 | 26 | foreach ($blockedRoutes as $route) { 27 | if (str_contains($currentPath, $route)) { 28 | throw new AuthorizationException(__('Access to this API is disabled.')); 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Processor/EmailProcessor.php: -------------------------------------------------------------------------------- 1 | accountData = null; 37 | } 38 | 39 | public function sendMail(string $to, string $type): void 40 | { 41 | try { 42 | $templateId = $this->scopeConfig->getValue(Config::XML_PATH_HOODOOR_TEMPLATE_ID->value); 43 | $fromEmail = $this->scopeConfig->getValue(Config::XML_PATH_HOODOOR_SENDER_EMAIL->value); 44 | $fromName = $this->scopeConfig->getValue(Config::XML_PATH_HOODOOR_SENDER_NAME->value); 45 | 46 | $accountData = $this->getAccountDataByEmail($to); 47 | $requestEmail = $accountData->getEmail(); 48 | $requestToken = $accountData->getToken(); 49 | 50 | $templateVars = [ 51 | 'type' => $type, 52 | 'request' => $this->jwtManager->generateToken([ 53 | 'email' => $requestEmail, 54 | 'token' => $requestToken 55 | ], 900) 56 | ]; 57 | 58 | $this->inlineTranslation->suspend(); 59 | 60 | $storeScope = ScopeInterface::SCOPE_STORE; 61 | 62 | $transport = $this->transportBuilder 63 | ->setTemplateIdentifier($templateId) 64 | ->setTemplateModel($type === 'admin' ? BackendTemplate::class : Template::class) 65 | ->setTemplateOptions([ 66 | 'area' => $type === 'admin' ? Area::AREA_ADMINHTML : Area::AREA_FRONTEND, 67 | 'store' => Store::DEFAULT_STORE_ID 68 | ]) 69 | ->setTemplateVars($templateVars) 70 | ->setFromByScope( 71 | [ 72 | 'email' => $fromEmail, 73 | 'name' => $fromName 74 | ], 75 | $storeScope 76 | ) 77 | ->addTo($to) 78 | ->getTransport(); 79 | 80 | $transport->sendMessage(); 81 | 82 | $this->inlineTranslation->resume(); 83 | 84 | } catch (\Exception $e) { 85 | $this->logger->debug($e->getMessage()); 86 | } 87 | } 88 | 89 | private function getAccountDataByEmail(string $email): LoginRequest 90 | { 91 | if (!$this->accountData) { 92 | $this->accountData = $this->loginRequestRepository->get($email); 93 | } 94 | return $this->accountData; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Module for Magento 2 2 | 3 | [![Latest Stable Version](https://img.shields.io/packagist/v/opengento/module-hoodoor.svg?style=flat-square)](https://packagist.org/packages/opengento/module-hoodoor) 4 | [![License: MIT](https://img.shields.io/github/license/opengento/magento2-hoodoor.svg?style=flat-square)](./LICENSE) 5 | [![Packagist](https://img.shields.io/packagist/dt/opengento/module-hoodoor.svg?style=flat-square)](https://packagist.org/packages/opengento/module-hoodoor/stats) 6 | [![Packagist](https://img.shields.io/packagist/dm/opengento/module-hoodoor.svg?style=flat-square)](https://packagist.org/packages/opengento/module-hoodoor/stats) 7 | 8 | This module provides a top-notch security for your customers' accounts by adopting a passwordless approach, effectively removing the vulnerability of weak passwords from your database. This instills a sense of confidence and reliability in your platform among your customers. 9 | 10 | - [Setup](#setup) 11 | - [Composer installation](#composer-installation) 12 | - [Setup the module](#setup-the-module) 13 | - [Settings](#settings) 14 | - [Documentation](#documentation) 15 | - [Support](#support) 16 | - [Authors](#authors) 17 | - [License](#license) 18 | 19 | ## Setup 20 | 21 | Magento 2 Open Source or Commerce edition is required. 22 | 23 | ### Composer installation 24 | 25 | Run the following composer command: 26 | 27 | ``` 28 | composer require opengento/module-hoodoor 29 | ``` 30 | 31 | ### Setup the module 32 | 33 | Run the following magento command: 34 | 35 | ``` 36 | bin/magento setup:upgrade 37 | ``` 38 | 39 | **If you are in production mode, do not forget to recompile and redeploy the static resources.** 40 | 41 | ## Settings 42 | 43 | The configuration for this module is available in `Stores > Configuration > OpenGento > Hoodoor`. 44 | 45 | Make sure you have generated a secret key. 46 | 47 | ## Documentation 48 | 49 | ### Compatibility and Activation: 50 | 51 | This module is compatible with Magento 2 version 2.4.6-p4. 52 | 53 | You have the flexibility to enable its functionality either on the Magento frontend or backend. To activate either option, adjust the corresponding values in the config settings. 54 | 55 | ### Token Expiration and Customization: 56 | 57 | By default, the authentication token remains valid for 15 minutes after the email is sent. However, you have the option to customize this duration according to your requirements. Refer to the PHP documentation on how to modify the datetime value. 58 | 59 | ### Enhanced Security Measures: 60 | 61 | We have implemented a robust security layer to ensure a high level of protection for the data transmitted via the HTTP protocol. 62 | 63 | ### Private Key Generation: 64 | 65 | To process requests securely, it is essential to generate a private key in the settings. This private key serves as a crucial component for decrypting and authenticating requests. Failure to provide this key may hinder the ability to decipher and establish connections effectively. 66 | 67 | ## Support 68 | 69 | Raise a new [request](https://github.com/opengento/magento2-hoodoor-login/issues) to the issue tracker. 70 | 71 | ## Authors 72 | 73 | - **Opengento Community** - *Lead* - [![Twitter Follow](https://img.shields.io/twitter/follow/opengento.svg?style=social)](https://twitter.com/opengento) 74 | - **Ronan Guérin** - *Maintainer* - [![GitHub followers](https://img.shields.io/github/followers/ronangr1.svg?style=social)](https://github.com/ronangr1) 75 | - **Contributors** - *Contributor* - [![GitHub contributors](https://img.shields.io/github/contributors/opengento/magento2-hoodoor.svg?style=flat-square)](https://github.com/opengento/magento2-store-path-url/graphs/contributors) 76 | 77 | ## License 78 | 79 | This project is licensed under the MIT License - see the [LICENSE](./LICENSE) details. 80 | 81 | ***That's all folks!*** 82 | -------------------------------------------------------------------------------- /Service/Admin/Login.php: -------------------------------------------------------------------------------- 1 | getParams(); 39 | $backendUser = $this->getBackendUser($params); 40 | if ($backendUser) { 41 | $password = $this->random->getUniqueHash(); 42 | // Set new password each time you need to login 43 | $backendUser->setPassword($password); 44 | $backendUser->save(); 45 | $request = $request->setPostValue('login', [ 46 | 'username' => $backendUser->getUserName(), 47 | 'password' => $password 48 | ]); 49 | // Now login 50 | $this->processNotLoggedInUser($request); 51 | } 52 | } 53 | 54 | private function getBackendUser(array $data): ?User 55 | { 56 | try { 57 | $user = $this->user->loadByEmail($data['email']); 58 | if ($user->getId()) { 59 | return $user; 60 | } 61 | } catch (\Exception $e) { 62 | $this->logger->debug($e->getMessage()); 63 | } 64 | return null; 65 | } 66 | 67 | private function processNotLoggedInUser(RequestInterface $request): void 68 | { 69 | $isRedirectNeeded = false; 70 | if ($request->getPost('login')) { 71 | if ($this->performLogin($request)) { 72 | $isRedirectNeeded = $this->redirectIfNeededAfterLogin($request); 73 | } 74 | } 75 | if (!$isRedirectNeeded && !$request->isForwarded()) { 76 | if ($request->getParam('isIframe')) { 77 | $request->setForwarded(true) 78 | ->setRouteName('adminhtml') 79 | ->setControllerName('auth') 80 | ->setActionName('deniedIframe') 81 | ->setDispatched(false); 82 | } elseif ($request->getParam('isAjax')) { 83 | $request->setForwarded(true) 84 | ->setRouteName('adminhtml') 85 | ->setControllerName('auth') 86 | ->setActionName('deniedJson') 87 | ->setDispatched(false); 88 | } else { 89 | $request->setForwarded(true) 90 | ->setRouteName('adminhtml') 91 | ->setControllerName('auth') 92 | ->setActionName('login') 93 | ->setDispatched(false); 94 | } 95 | } 96 | } 97 | 98 | private function performLogin(RequestInterface $request): bool 99 | { 100 | $outputValue = true; 101 | $postLogin = $request->getPost('login'); 102 | $username = $postLogin['username'] ?? ''; 103 | $password = $postLogin['password'] ?? ''; 104 | $request->setPostValue('login', null); 105 | 106 | try { 107 | $this->auth->login($username, $password); 108 | } catch (AuthenticationException $e) { 109 | if (!$request->getParam('messageSent')) { 110 | $this->messageManager->addErrorMessage($e->getMessage()); 111 | $request->setParam('messageSent', true); 112 | $outputValue = false; 113 | } 114 | } 115 | return $outputValue; 116 | } 117 | 118 | private function redirectIfNeededAfterLogin(RequestInterface $request): bool 119 | { 120 | $requestUri = null; 121 | 122 | // Checks, whether secret key is required for admin access or request uri is explicitly set 123 | if ($this->url->useSecretKey()) { 124 | // The requested URL has an invalid secret key and therefore redirecting to this URL 125 | // will cause a security vulnerability. 126 | $requestUri = $this->url->getUrl($this->url->getStartupPageUrl()); 127 | } elseif ($request) { 128 | $requestUri = $request->getRequestUri(); 129 | } 130 | 131 | if (!$requestUri) { 132 | return false; 133 | } 134 | 135 | $this->response->setRedirect($requestUri); 136 | $this->actionFlag->set('', ActionInterface::FLAG_NO_DISPATCH, true); 137 | return true; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Service/Admin/PasswordVerification.php: -------------------------------------------------------------------------------- 1 | isEnabled()) { 31 | return; 32 | } 33 | 34 | $form = $subject->getForm(); 35 | if (!$form instanceof DataForm) { 36 | return; 37 | } 38 | 39 | $this->removePasswordFields($form); 40 | $this->removeVerificationFieldset($form); 41 | 42 | $subject->setForm($form); 43 | } 44 | 45 | private function isEnabled(): bool 46 | { 47 | return $this->scopeConfig->isSetFlag(self::XML_PATH_ENABLE_ADMIN); 48 | } 49 | 50 | private function removePasswordFields(DataForm $form): void 51 | { 52 | /** @var Fieldset|null $baseFs */ 53 | $baseFs = $form->getElement('base_fieldset'); 54 | if (!$baseFs instanceof Fieldset) { 55 | return; 56 | } 57 | $this->unsetByIds( 58 | $baseFs->getElements(), 59 | ['password', 'confirmation'] 60 | ); 61 | } 62 | 63 | private function removeVerificationFieldset(DataForm $form): void 64 | { 65 | $elements = $form->getElements(); 66 | $this->unsetByIds( 67 | $elements, 68 | ['current_user_verification_fieldset'] 69 | ); 70 | $form->setElements($elements); 71 | } 72 | 73 | private function unsetByIds(ElementCollection $collection, array $ids): void 74 | { 75 | foreach ($collection as $key => $elem) { 76 | if (in_array((string)$elem->getId(), $ids, true)) { 77 | unset($collection[$key]); 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Service/Customer/Login.php: -------------------------------------------------------------------------------- 1 | customerRepository->get($data['email']); 45 | $this->credentialsValidator->checkPasswordDifferentFromEmail($data['email'], $data['token']); 46 | $customerSecure = $this->customerRegistry->retrieveSecureData($customer->getId()); 47 | $customerSecure->setPasswordHash($this->createPasswordHash($data['token'])); 48 | $this->sessionCleaner->clearFor((int)$customer->getId()); 49 | $this->customerRepository->save($customer); 50 | // Now login 51 | $customer = $this->customerAccountManagement->authenticate($data['email'], $data['token']); 52 | $this->session->setCustomerDataAsLoggedIn($customer); 53 | if ($this->getCookieManager()->getCookie('mage-cache-sessid')) { 54 | $metadata = $this->getCookieMetadataFactory()->createCookieMetadata(); 55 | $metadata->setPath('/'); 56 | $this->getCookieManager()->deleteCookie('mage-cache-sessid', $metadata); 57 | } 58 | return true; 59 | } catch (InputException|InputMismatchException|LocalizedException $e) { 60 | $this->logger->debug($e->getMessage()); 61 | } 62 | return false; 63 | } 64 | 65 | private function createPasswordHash(string $password): string 66 | { 67 | return $this->encryptor->getHash($password, true); 68 | } 69 | 70 | private function getCookieManager(): PhpCookieManager 71 | { 72 | return $this->cookieMetadataManager; 73 | } 74 | 75 | private function getCookieMetadataFactory(): CookieMetadataFactory 76 | { 77 | return $this->cookieMetadataFactory; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Service/JwtManager.php: -------------------------------------------------------------------------------- 1 | secretKey = $this->scopeConfig->getValue(Config::XML_PATH_HOODOOR_SECRET_KEY->value); 25 | } 26 | 27 | public function generateToken(array $payload, int $expirationInSeconds): string 28 | { 29 | $issuedAt = time(); 30 | $expire = $issuedAt + $expirationInSeconds; 31 | 32 | $tokenPayload = array_merge($payload, [ 33 | 'iat' => $issuedAt, 34 | 'exp' => $expire, 35 | ]); 36 | 37 | return $this->jwtHelper->encode($tokenPayload, $this->secretKey, 'HS256'); 38 | } 39 | 40 | public function validateToken(string $token): bool|\stdClass 41 | { 42 | try { 43 | return $this->jwtHelper->decode($token, new Key($this->secretKey, 'HS256')); 44 | } catch (\Exception $e) { 45 | $this->logger->critical($e->getMessage()); 46 | } 47 | return false; 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Service/Password.php: -------------------------------------------------------------------------------- 1 | random->getUniqueHash(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Service/Queue.php: -------------------------------------------------------------------------------- 1 | requestService->create($params['login']['username'], $type); 23 | if ($success) { 24 | $this->emailProcessor->sendMail($params['login']['username'], $type); 25 | return true; 26 | } 27 | return false; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Service/Request.php: -------------------------------------------------------------------------------- 1 | validationService->validate($email, $type); 33 | if ($isValid) { 34 | $dateTime = new \DateTime(); 35 | $maxTimeExpiration = $this->scopeConfig 36 | ->getValue(Config::XML_PATH_HOODOOR_MAX_TIME_EXPIRATION->value); 37 | $loginRequest = $this->loginRequestFactory->create(); 38 | $loginRequest->setEmail($email) 39 | ->setType($type) 40 | ->setToken($this->random->getUniqueHash()) 41 | ->setExpiresAt($dateTime->modify($maxTimeExpiration)); 42 | $this->loginRequestRepository->save($loginRequest); 43 | return true; 44 | } 45 | } catch (\Exception $e) { 46 | $this->logger->debug($e->getMessage()); 47 | } 48 | return false; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Service/Validation.php: -------------------------------------------------------------------------------- 1 | user->loadByEmail($email); 29 | return $user && $user->getIsActive(); 30 | } 31 | return $this->customerRepository->get($email)->getId() !== null; 32 | } catch (NoSuchEntityException $e) { 33 | $this->logger->debug($e->getMessage()); 34 | } 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Test/Unit/Plugin/Model/AccountManagementTest.php: -------------------------------------------------------------------------------- 1 | plugin = new AccountManagement(); 21 | } 22 | 23 | public function testBeforeChangePasswordThrowsException() 24 | { 25 | $this->expectException(\Exception::class); 26 | $this->expectExceptionMessage('Access denied.'); 27 | 28 | $this->plugin->beforeChangePassword( 29 | $this->createMock(SubjectAccountManagement::class), 30 | 'customer@example.com', 31 | 'current_password', 32 | 'new_password' 33 | ); 34 | } 35 | 36 | public function testBeforeChangePasswordByIdThrowsException() 37 | { 38 | $this->expectException(\Exception::class); 39 | $this->expectExceptionMessage('Access denied.'); 40 | 41 | $this->plugin->beforeChangePasswordById( 42 | $this->createMock(SubjectAccountManagement::class), 43 | 123, 44 | 'current_password', 45 | 'new_password' 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Test/Unit/Plugin/Webapi/Controller/Rest/DisableApiTest.php: -------------------------------------------------------------------------------- 1 | plugin = new DisableApi(); 21 | $this->restControllerMock = $this->createMock(Rest::class); 22 | } 23 | 24 | public function testBeforeDispatchThrowsAuthorizationExceptionForBlockedRoutes() 25 | { 26 | $blockedRoutes = [ 27 | 'https://magento.test/rest/V1/customers', 28 | 'https://magento.test/rest/all/V1/customers', 29 | 'https://magento.test/rest/default/V1/customers' 30 | ]; 31 | 32 | foreach ($blockedRoutes as $blockedRoute) { 33 | $requestMock = $this->getMockBuilder(HttpRequest::class) 34 | ->disableOriginalConstructor() 35 | ->onlyMethods(['getPathInfo']) 36 | ->getMock(); 37 | 38 | $requestMock->expects($this->once()) 39 | ->method('getPathInfo') 40 | ->willReturn($blockedRoute); 41 | 42 | $this->expectException(AuthorizationException::class); 43 | $this->expectExceptionMessage('Access to this API is disabled.'); 44 | 45 | $this->plugin->beforeDispatch($this->restControllerMock, $requestMock); 46 | } 47 | } 48 | 49 | public function testBeforeDispatchDoesNotThrowExceptionForAllowedRoutes() 50 | { 51 | $allowedRoutes = [ 52 | 'https://magento.test/one/allowed/route', 53 | 'https://magento.test/any/allowed/route', 54 | 'https://magento.test/another/allowed/route' 55 | ]; 56 | 57 | foreach ($allowedRoutes as $allowedRoute) { 58 | $requestMock = $this->getMockBuilder(HttpRequest::class) 59 | ->disableOriginalConstructor() 60 | ->onlyMethods(['getPathInfo']) 61 | ->getMock(); 62 | 63 | $requestMock->expects($this->once()) 64 | ->method('getPathInfo') 65 | ->willReturn($allowedRoute); 66 | 67 | $this->plugin->beforeDispatch($this->restControllerMock, $requestMock); 68 | $this->addToAssertionCount(1); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Test/Unit/Processor/EmailProcessorTest.php: -------------------------------------------------------------------------------- 1 | loginRequestRepositoryMock = $this->createMock(RequestLoginRepositoryInterface::class); 46 | $this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class); 47 | $this->transportBuilderMock = $this->createMock(TransportBuilder::class); 48 | $this->inlineTranslationMock = $this->createMock(StateInterface::class); 49 | $this->loggerMock = $this->createMock(LoggerInterface::class); 50 | $this->jwtManagerMock = $this->createMock(JwtManager::class); 51 | 52 | $this->emailProcessor = new EmailProcessor( 53 | $this->loginRequestRepositoryMock, 54 | $this->scopeConfigMock, 55 | $this->transportBuilderMock, 56 | $this->inlineTranslationMock, 57 | $this->loggerMock, 58 | $this->jwtManagerMock 59 | ); 60 | } 61 | 62 | public function testSendMailSuccessfully() 63 | { 64 | $to = 'customer@magento.test'; 65 | $type = 'frontend'; 66 | 67 | $loginRequestMock = $this->createMock(LoginRequest::class); 68 | $loginRequestMock->expects($this->once())->method('getEmail')->willReturn($to); 69 | $loginRequestMock->expects($this->once())->method('getToken')->willReturn('test_token'); 70 | 71 | $this->loginRequestRepositoryMock->expects($this->once()) 72 | ->method('get') 73 | ->with($to) 74 | ->willReturn($loginRequestMock); 75 | 76 | $this->scopeConfigMock->method('getValue') 77 | ->willReturnMap([ 78 | [Config::XML_PATH_HOODOOR_TEMPLATE_ID->value, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, 'template_id'], 79 | [Config::XML_PATH_HOODOOR_SENDER_EMAIL->value, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, 'sender@magento.test'], 80 | [Config::XML_PATH_HOODOOR_SENDER_NAME->value, ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null, 'Sender Name'] 81 | ]); 82 | 83 | $this->jwtManagerMock->expects($this->once()) 84 | ->method('generateToken') 85 | ->with(['email' => $to, 'token' => 'test_token'], 900) 86 | ->willReturn('jwt_token'); 87 | 88 | $this->inlineTranslationMock->expects($this->once())->method('suspend'); 89 | 90 | $this->transportBuilderMock->expects($this->once()) 91 | ->method('setTemplateIdentifier') 92 | ->with('template_id') 93 | ->willReturnSelf(); 94 | 95 | $this->transportBuilderMock->expects($this->once()) 96 | ->method('setTemplateModel') 97 | ->with(Template::class) 98 | ->willReturnSelf(); 99 | 100 | $this->transportBuilderMock->expects($this->once()) 101 | ->method('setTemplateOptions') 102 | ->with([ 103 | 'area' => Area::AREA_FRONTEND, 104 | 'store' => Store::DEFAULT_STORE_ID 105 | ]) 106 | ->willReturnSelf(); 107 | 108 | $this->transportBuilderMock->expects($this->once()) 109 | ->method('setTemplateVars') 110 | ->with(['type' => 'frontend', 'request' => 'jwt_token']) 111 | ->willReturnSelf(); 112 | 113 | $this->transportBuilderMock->expects($this->once()) 114 | ->method('setFromByScope') 115 | ->with(['email' => 'sender@magento.test', 'name' => 'Sender Name'], ScopeInterface::SCOPE_STORE) 116 | ->willReturnSelf(); 117 | 118 | $this->transportBuilderMock->expects($this->once()) 119 | ->method('addTo') 120 | ->with($to) 121 | ->willReturnSelf(); 122 | 123 | $transportMock = $this->createMock(TransportInterface::class); 124 | $this->transportBuilderMock->expects($this->once()) 125 | ->method('getTransport') 126 | ->willReturn($transportMock); 127 | 128 | $transportMock->expects($this->once())->method('sendMessage'); 129 | 130 | $this->inlineTranslationMock->expects($this->once())->method('resume'); 131 | 132 | $this->emailProcessor->sendMail($to, $type); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Test/Unit/Service/Customer/LoginTest.php: -------------------------------------------------------------------------------- 1 | customerRepositoryMock = $this->createMock(CustomerRepositoryInterface::class); 54 | $this->customerAccountManagementMock = $this->createMock(AccountManagementInterface::class); 55 | $this->credentialsValidatorMock = $this->createMock(CredentialsValidator::class); 56 | $this->customerRegistryMock = $this->createMock(CustomerRegistry::class); 57 | $this->encryptorMock = $this->createMock(Encryptor::class); 58 | $this->sessionCleanerMock = $this->createMock(SessionCleanerInterface::class); 59 | $this->sessionMock = $this->createMock(Session::class); 60 | $this->cookieMetadataFactoryMock = $this->createMock(CookieMetadataFactory::class); 61 | $this->cookieMetadataManagerMock = $this->createMock(PhpCookieManager::class); 62 | $this->loggerMock = $this->createMock(LoggerInterface::class); 63 | 64 | $this->loginService = new Login( 65 | $this->customerRepositoryMock, 66 | $this->customerAccountManagementMock, 67 | $this->credentialsValidatorMock, 68 | $this->customerRegistryMock, 69 | $this->encryptorMock, 70 | $this->sessionCleanerMock, 71 | $this->sessionMock, 72 | $this->cookieMetadataFactoryMock, 73 | $this->cookieMetadataManagerMock, 74 | $this->loggerMock 75 | ); 76 | } 77 | 78 | public function testPerformReturnsTrueOnSuccessfulLogin() 79 | { 80 | $data = ['email' => 'customer@magento.test', 'token' => 'secure_token']; 81 | 82 | $customerMock = $this->createMock(CustomerInterface::class); 83 | $customerMock->method('getId')->willReturn(123); 84 | 85 | $customerSecureMock = $this->getMockBuilder(CustomerSecure::class) 86 | ->disableOriginalConstructor() 87 | ->addMethods(['setPasswordHash']) 88 | ->getMock(); 89 | 90 | $this->customerRepositoryMock->expects($this->once()) 91 | ->method('get') 92 | ->with($data['email']) 93 | ->willReturn($customerMock); 94 | 95 | $this->credentialsValidatorMock->expects($this->once()) 96 | ->method('checkPasswordDifferentFromEmail') 97 | ->with($data['email'], $data['token']); 98 | 99 | $this->customerRegistryMock->expects($this->once()) 100 | ->method('retrieveSecureData') 101 | ->with(123) 102 | ->willReturn($customerSecureMock); 103 | 104 | $this->encryptorMock->expects($this->once()) 105 | ->method('getHash') 106 | ->willReturn('hashed_password'); 107 | 108 | $customerSecureMock->expects($this->once()) 109 | ->method('setPasswordHash') 110 | ->with('hashed_password'); 111 | 112 | $this->sessionCleanerMock->expects($this->once()) 113 | ->method('clearFor') 114 | ->with(123); 115 | 116 | $this->customerRepositoryMock->expects($this->once()) 117 | ->method('save') 118 | ->with($customerMock); 119 | 120 | $this->customerAccountManagementMock->expects($this->once()) 121 | ->method('authenticate') 122 | ->with($data['email'], $data['token']) 123 | ->willReturn($customerMock); 124 | 125 | $this->sessionMock->expects($this->once()) 126 | ->method('setCustomerDataAsLoggedIn') 127 | ->with($customerMock); 128 | 129 | $this->assertTrue($this->loginService->perform($data)); 130 | } 131 | 132 | public function testPerformReturnsFalseOnException() 133 | { 134 | $data = ['email' => 'invalid@magento.test', 'token' => 'secure_token']; 135 | 136 | $this->customerRepositoryMock->expects($this->once()) 137 | ->method('get') 138 | ->with($data['email']) 139 | ->willThrowException(new NoSuchEntityException(__('Invalid request'))); 140 | 141 | $this->loggerMock->expects($this->once()) 142 | ->method('debug') 143 | ->with('Invalid request'); 144 | 145 | $this->assertFalse($this->loginService->perform($data)); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Test/Unit/Service/JwtManagerTest.php: -------------------------------------------------------------------------------- 1 | jwtHelperMock = $this->createMock(JwtHelper::class); 34 | $this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class); 35 | $this->loggerMock = $this->createMock(LoggerInterface::class); 36 | 37 | $this->scopeConfigMock->method('getValue') 38 | ->with(Config::XML_PATH_HOODOOR_SECRET_KEY->value) 39 | ->willReturn($this->secretKey); 40 | 41 | $this->jwtManager = new JwtManager( 42 | $this->jwtHelperMock, 43 | $this->scopeConfigMock, 44 | $this->loggerMock 45 | ); 46 | } 47 | 48 | public function testGenerateToken() 49 | { 50 | $payload = ['user_id' => 1]; 51 | $expirationInSeconds = 3600; 52 | $issuedAt = time(); 53 | $expectedExpire = $issuedAt + $expirationInSeconds; 54 | 55 | $expectedTokenPayload = array_merge($payload, [ 56 | 'iat' => $issuedAt, 57 | 'exp' => $expectedExpire, 58 | ]); 59 | 60 | $this->jwtHelperMock->expects($this->once()) 61 | ->method('encode') 62 | ->with($expectedTokenPayload, $this->secretKey, 'HS256') 63 | ->willReturn('generated_token'); 64 | 65 | $generatedToken = $this->jwtManager->generateToken($payload, $expirationInSeconds); 66 | 67 | $this->assertEquals('generated_token', $generatedToken); 68 | } 69 | 70 | public function testValidateTokenReturnsDecodedPayload() 71 | { 72 | $token = 'valid_token'; 73 | $decodedPayload = (object) ['user_id' => 1, 'iat' => time(), 'exp' => time() + 3600]; 74 | 75 | $this->jwtHelperMock->expects($this->once()) 76 | ->method('decode') 77 | ->with($token, new Key($this->secretKey, 'HS256')) 78 | ->willReturn($decodedPayload); 79 | 80 | $this->assertEquals($decodedPayload, $this->jwtManager->validateToken($token)); 81 | } 82 | 83 | public function testValidateTokenReturnsFalseOnInvalidToken() 84 | { 85 | $token = 'invalid_token'; 86 | 87 | $this->jwtHelperMock->expects($this->once()) 88 | ->method('decode') 89 | ->with($token, new Key($this->secretKey, 'HS256')) 90 | ->willThrowException(new \Exception('Invalid token')); 91 | 92 | $this->loggerMock->expects($this->once()) 93 | ->method('critical') 94 | ->with('Invalid token'); 95 | 96 | $this->assertFalse($this->jwtManager->validateToken($token)); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Test/Unit/Service/PasswordTest.php: -------------------------------------------------------------------------------- 1 | randomMock = $this->createMock(Random::class); 24 | 25 | $this->passwordService = new Password($this->randomMock); 26 | } 27 | 28 | public function testGenerateReturnsUniqueHash() 29 | { 30 | $this->randomMock 31 | ->expects($this->once()) 32 | ->method('getUniqueHash') 33 | ->willReturn('uniqueRandomHash'); 34 | 35 | $this->assertEquals('uniqueRandomHash', $this->passwordService->generate()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Test/Unit/Service/QueueTest.php: -------------------------------------------------------------------------------- 1 | requestServiceMock = $this->createMock(Request::class); 27 | $this->emailProcessorMock = $this->createMock(EmailProcessor::class); 28 | 29 | $this->queueService = new Queue( 30 | $this->requestServiceMock, 31 | $this->emailProcessorMock 32 | ); 33 | } 34 | 35 | public function testAddReturnsTrueWhenRequestCreationSucceeds() 36 | { 37 | $params = [ 38 | 'login' => [ 39 | 'username' => 'test@magento.test' 40 | ] 41 | ]; 42 | 43 | $type = 'login'; 44 | 45 | $this->requestServiceMock->expects($this->once()) 46 | ->method('create') 47 | ->with('test@magento.test', $type) 48 | ->willReturn(true); 49 | 50 | $this->emailProcessorMock->expects($this->once()) 51 | ->method('sendMail') 52 | ->with('test@magento.test', $type); 53 | 54 | $this->assertTrue($this->queueService->add($params, $type)); 55 | } 56 | 57 | public function testAddReturnsFalseWhenRequestCreationFails() 58 | { 59 | $params = [ 60 | 'login' => [ 61 | 'username' => 'test@magento.test' 62 | ] 63 | ]; 64 | $type = 'login'; 65 | 66 | $this->requestServiceMock->expects($this->once()) 67 | ->method('create') 68 | ->with('test@magento.test', $type) 69 | ->willReturn(false); 70 | 71 | $this->emailProcessorMock->expects($this->never()) 72 | ->method('sendMail'); 73 | 74 | $this->assertFalse($this->queueService->add($params, $type)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Test/Unit/Service/RequestTest.php: -------------------------------------------------------------------------------- 1 | scopeConfigMock = $this->createMock(ScopeConfigInterface::class); 43 | $this->loginRequestFactoryMock = $this->createMock(LoginRequestFactory::class); 44 | $this->loginRequestRepositoryMock = $this->createMock(LoginRequestRepository::class); 45 | $this->validationServiceMock = $this->createMock(Validation::class); 46 | $this->randomMock = $this->createMock(Random::class); 47 | $this->loggerMock = $this->createMock(LoggerInterface::class); 48 | 49 | $this->requestService = new Request( 50 | $this->scopeConfigMock, 51 | $this->loginRequestFactoryMock, 52 | $this->loginRequestRepositoryMock, 53 | $this->validationServiceMock, 54 | $this->randomMock, 55 | $this->loggerMock 56 | ); 57 | } 58 | 59 | public function testCreateReturnsTrueWhenRequestIsValid() 60 | { 61 | $email = 'customer@magento.test'; 62 | $type = 'login'; 63 | $uniqueHash = 'uniqueHash'; 64 | $maxTimeExpiration = '+1 hour'; 65 | 66 | $this->validationServiceMock->expects($this->once()) 67 | ->method('validate') 68 | ->with($email, $type) 69 | ->willReturn(true); 70 | 71 | $this->scopeConfigMock->expects($this->once()) 72 | ->method('getValue') 73 | ->with(Config::XML_PATH_HOODOOR_MAX_TIME_EXPIRATION->value) 74 | ->willReturn($maxTimeExpiration); 75 | 76 | $this->randomMock->expects($this->once()) 77 | ->method('getUniqueHash') 78 | ->willReturn($uniqueHash); 79 | 80 | $loginRequestMock = $this->createMock(LoginRequest::class); 81 | 82 | $this->loginRequestFactoryMock->expects($this->once()) 83 | ->method('create') 84 | ->willReturn($loginRequestMock); 85 | 86 | $loginRequestMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); 87 | $loginRequestMock->expects($this->once())->method('setType')->with($type)->willReturnSelf(); 88 | $loginRequestMock->expects($this->once())->method('setToken')->with($uniqueHash)->willReturnSelf(); 89 | $loginRequestMock->expects($this->once())->method('setExpiresAt')->with($this->isInstanceOf(DateTime::class))->willReturnSelf(); 90 | 91 | $this->loginRequestRepositoryMock->expects($this->once()) 92 | ->method('save') 93 | ->with($loginRequestMock); 94 | 95 | $this->assertTrue($this->requestService->create($email, $type)); 96 | } 97 | 98 | public function testCreateReturnsFalseWhenValidationFails() 99 | { 100 | $email = 'invalid@magento.test'; 101 | $type = 'login'; 102 | 103 | $this->validationServiceMock->expects($this->once()) 104 | ->method('validate') 105 | ->with($email, $type) 106 | ->willReturn(false); 107 | 108 | $this->scopeConfigMock->expects($this->never())->method('getValue'); 109 | $this->loginRequestFactoryMock->expects($this->never())->method('create'); 110 | $this->loginRequestRepositoryMock->expects($this->never())->method('save'); 111 | 112 | $this->assertFalse($this->requestService->create($email, $type)); 113 | } 114 | 115 | public function testCreateReturnsFalseAndLogsErrorWhenExceptionIsThrown() 116 | { 117 | $email = 'customer@magento.test'; 118 | $type = 'login'; 119 | $exceptionMessage = 'Something went wrong while processing your request.'; 120 | 121 | $this->validationServiceMock->expects($this->once()) 122 | ->method('validate') 123 | ->with($email, $type) 124 | ->willReturn(true); 125 | 126 | $this->scopeConfigMock->expects($this->once()) 127 | ->method('getValue') 128 | ->willThrowException(new RequestException($exceptionMessage)); 129 | 130 | $this->loggerMock->expects($this->once()) 131 | ->method('debug') 132 | ->with($exceptionMessage); 133 | 134 | $this->assertFalse($this->requestService->create($email, $type)); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Test/Unit/Service/ValidationTest.php: -------------------------------------------------------------------------------- 1 | customerRepositoryMock = $this->createMock(CustomerRepositoryInterface::class); 32 | $this->loggerMock = $this->createMock(LoggerInterface::class); 33 | $this->userMock = $this->createMock(User::class); 34 | 35 | $this->validationService = new Validation( 36 | $this->customerRepositoryMock, 37 | $this->loggerMock, 38 | $this->userMock 39 | ); 40 | } 41 | 42 | public function testValidateReturnsTrueForActiveAdmin() 43 | { 44 | $email = 'admin@magento.test'; 45 | $type = 'admin'; 46 | 47 | $this->userMock->expects($this->once()) 48 | ->method('loadByEmail') 49 | ->with($email) 50 | ->willReturnSelf(); 51 | 52 | $this->userMock->expects($this->once()) 53 | ->method('getIsActive') 54 | ->willReturn(true); 55 | 56 | $this->assertTrue($this->validationService->validate($email, $type)); 57 | } 58 | 59 | public function testValidateReturnsFalseForInactiveAdmin() 60 | { 61 | $email = 'inactive_admin@magento.test'; 62 | $type = 'admin'; 63 | 64 | $this->userMock->expects($this->once()) 65 | ->method('loadByEmail') 66 | ->with($email) 67 | ->willReturnSelf(); 68 | 69 | $this->userMock->expects($this->once()) 70 | ->method('getIsActive') 71 | ->willReturn(false); 72 | 73 | $this->assertFalse($this->validationService->validate($email, $type)); 74 | } 75 | 76 | public function testValidateReturnsTrueForExistingCustomer() 77 | { 78 | $email = 'customer@magento.test'; 79 | $type = 'customer'; 80 | 81 | $customerMock = $this->createMock(CustomerInterface::class); 82 | $customerMock->method('getId')->willReturn(123); 83 | 84 | $this->customerRepositoryMock->expects($this->once()) 85 | ->method('get') 86 | ->with($email) 87 | ->willReturn($customerMock); 88 | 89 | $this->assertTrue($this->validationService->validate($email, $type)); 90 | } 91 | 92 | public function testValidateReturnsFalseForNonExistingCustomer() 93 | { 94 | $email = 'nonexisting@magento.test'; 95 | $type = 'customer'; 96 | 97 | $this->customerRepositoryMock->expects($this->once()) 98 | ->method('get') 99 | ->with($email) 100 | ->willThrowException(new NoSuchEntityException(__('No such entity'))); 101 | 102 | $this->loggerMock->expects($this->once()) 103 | ->method('debug') 104 | ->with('No such entity'); 105 | 106 | $this->assertFalse($this->validationService->validate($email, $type)); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Test/Unit/ViewModel/LoginTest.php: -------------------------------------------------------------------------------- 1 | urlBackendMock = $this->createMock(BackendUrlInterface::class); 31 | $this->urlFrontendMock = $this->createMock(FrontendUrlInterface::class); 32 | $this->stateMock = $this->createMock(State::class); 33 | 34 | $this->loginViewModel = new Login( 35 | $this->urlFrontendMock, 36 | $this->urlBackendMock, 37 | $this->stateMock 38 | ); 39 | } 40 | 41 | public function testGetPostActionUrlReturnsAdminUrlIfAdminArea() 42 | { 43 | $this->stateMock 44 | ->expects($this->once()) 45 | ->method('getAreaCode') 46 | ->willReturn(Area::AREA_ADMINHTML); 47 | 48 | $this->urlBackendMock 49 | ->expects($this->once()) 50 | ->method('getUrl') 51 | ->with('admin/pwl/requestlogin') 52 | ->willReturn('https://magento.test/admin/pwl/requestlogin'); 53 | 54 | $this->assertEquals( 55 | 'https://magento.test/admin/pwl/requestlogin', 56 | $this->loginViewModel->getPostActionUrl() 57 | ); 58 | } 59 | 60 | public function testGetPostActionUrlReturnsFrontendUrlIfFrontendArea() 61 | { 62 | $this->stateMock 63 | ->expects($this->once()) 64 | ->method('getAreaCode') 65 | ->willReturn(Area::AREA_FRONTEND); 66 | 67 | $this->urlFrontendMock 68 | ->expects($this->once()) 69 | ->method('getUrl') 70 | ->with('customer/pwl/requestlogin') 71 | ->willReturn('https://magento.test/customer/pwl/requestlogin'); 72 | 73 | $this->assertEquals( 74 | 'https://magento.test/customer/pwl/requestlogin', 75 | $this->loginViewModel->getPostActionUrl() 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /ViewModel/Request/Login.php: -------------------------------------------------------------------------------- 1 | state->getAreaCode(); 28 | 29 | if ($areaCode === Area::AREA_ADMINHTML) { 30 | return $this->urlBackend->getUrl('admin/pwl/requestlogin'); 31 | } 32 | 33 | return $this->urlFrontend->getUrl('customer/pwl/requestlogin'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opengento/module-hoodoor", 3 | "description": "This module enables you to log in without a password and without relying on a third-party service.", 4 | "keywords": [ 5 | "php", 6 | "magento", 7 | "magento2", 8 | "module", 9 | "extension", 10 | "free" 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "psr/log": "*", 15 | "magento/framework": "*", 16 | "firebase/php-jwt": "^6.10" 17 | }, 18 | "require-dev": { 19 | "magento/magento-coding-standard": "^33", 20 | "roave/security-advisories": "dev-latest" 21 | }, 22 | "type": "magento2-module", 23 | "license": [ 24 | "MIT" 25 | ], 26 | "homepage": "https://github.com/opengento/magento2-hoodoor", 27 | "authors": [ 28 | { 29 | "name": "Opengento Team", 30 | "email": "opengento@gmail.com", 31 | "homepage": "https://opengento.fr/", 32 | "role": "lead" 33 | } 34 | ], 35 | "support": { 36 | "source": "https://github.com/opengento/magento2-hoodoor", 37 | "issues": "https://github.com/opengento/magento2-hoodoor/issues" 38 | }, 39 | "autoload": { 40 | "files": [ 41 | "registration.php" 42 | ], 43 | "psr-4": { 44 | "Opengento\\Hoodoor\\": "" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /etc/adminhtml/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 13 | 14 | 15 | 17 | 18 | 19 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /etc/adminhtml/routes.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /etc/adminhtml/system.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 13 | 14 |
15 | separator-top 16 | 17 | opengento 18 | Opengento_Hoodoor::config 19 | 20 | 21 | 23 | 24 | Magento\Config\Model\Config\Source\Yesno 25 | 26 | 28 | 29 | Magento\Config\Model\Config\Source\Yesno 30 | 31 | 32 | 33 | 34 | 36 | 37 | 38 | 40 | 41 | 42 | 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | Opengento\Hoodoor\Block\Adminhtml\System\Config\Button 53 | Magento\Config\Model\Config\Backend\Serialized\ArraySerialized 54 | 55 | 57 | 58 | Set the max. time before the request expires. (E.g: +15 minutes) 59 | 60 | 61 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /etc/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 12 | 13 | login 14 | admin@opengento.fr 15 | Opengento 16 | 17 | 18 | +15 minutes 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /etc/db_schema.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 |
23 |
24 | -------------------------------------------------------------------------------- /etc/db_schema_whitelist.json: -------------------------------------------------------------------------------- 1 | { 2 | "login_request_queue": { 3 | "column": { 4 | "entity_id": true, 5 | "email": true, 6 | "type": true, 7 | "token": true, 8 | "is_used": true, 9 | "expires_at": true 10 | }, 11 | "constraint": { 12 | "PRIMARY": true 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 13 | 15 | 16 | 18 | 20 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /etc/email_templates.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 |