diff --git a/Module.php b/Module.php index d1b2743..695d566 100644 --- a/Module.php +++ b/Module.php @@ -102,11 +102,13 @@ public function getServiceConfig() 'lmcuser_user_mapper' => Factory\Mapper\User::class, 'lmcuser_login_form' => Factory\Form\Login::class, + 'lmcuser_otp_form' => Factory\Form\Otp::class, 'lmcuser_register_form' => Factory\Form\Register::class, 'lmcuser_change_password_form' => Factory\Form\ChangePassword::class, 'lmcuser_change_email_form' => Factory\Form\ChangeEmail::class, Authentication\Adapter\Db::class => Factory\Authentication\Adapter\DbFactory::class, + Authentication\Adapter\OtpMail::class => Factory\Authentication\Adapter\OtpMailFactory::class, Authentication\Storage\Db::class => Factory\Authentication\Storage\DbFactory::class, 'lmcuser_user_service' => Factory\Service\UserFactory::class, diff --git a/config/module.config.php b/config/module.config.php index 3f5518d..d61162e 100644 --- a/config/module.config.php +++ b/config/module.config.php @@ -44,6 +44,16 @@ ], ], ], + 'otp' => [ + 'type' => Literal::class, + 'options' => [ + 'route' => '/otp', + 'defaults' => [ + 'controller' => 'lmcuser', + 'action' => 'otp', + ], + ], + ], 'logout' => [ 'type' => Literal::class, 'options' => [ diff --git a/src/LmcUser/Authentication/Adapter/OtpMail.php b/src/LmcUser/Authentication/Adapter/OtpMail.php new file mode 100644 index 0000000..c9b5c08 --- /dev/null +++ b/src/LmcUser/Authentication/Adapter/OtpMail.php @@ -0,0 +1,249 @@ +getStorage()->clear(); + } + + /** + * @param AdapterChainEvent $e + * @return bool + */ + public function authenticate(AdapterChainEvent $e) + { + $storage = $this->getStorage()->read(); + if (!$this->isSatisfied()) { + $e->setCode(AuthenticationResult::FAILURE_IDENTITY_NOT_FOUND) + ->setMessages(array('A record with the supplied identity could not be found.')); + return; + } + + /** + * + * @var UserOtpInterface|null $userObject + */ + $userObject = $this->getMapper()->findById($storage['identity']); + if (!$userObject) { + $e->setCode(AuthenticationResult::FAILURE_IDENTITY_NOT_FOUND) + ->setMessages(array('A record with the supplied identity could not be found.')); + $this->setSatisfied(false); + return false; + } + + if (((isset($storage['is_otp_satisfied']) && true === $storage['is_otp_satisfied'])) || false === $userObject->getUseOtp()) { + $storage = $this->getStorage()->read(); + $e->setIdentity($storage['identity']) + ->setCode(AuthenticationResult::SUCCESS) + ->setMessages(array('Authentication successful.')); + return; + } + + $code = $e->getRequest()->getPost()->get('code'); + + if (!$code) { + $randCode = rand(100000, 999999); + $userObject->setOtp(strval($randCode)); + $userObject->setOtpTimeout(time() + 60 * 5); + + try { + $mail = $this->createMail($userObject, $randCode); + $transport = new Mail\Transport\Sendmail(); + $transport->send($mail); + } catch (\Exception $error) { + // Handle error if needed + } + $this->getMapper()->update($userObject); + $router = $this->serviceManager->get('Router'); + $url = $router->assemble([], [ + 'name' => 'lmcuser/otp' + ]); + $response = new Response(); + $response->getHeaders()->addHeaderLine('Location', $url); + $response->setStatusCode(302); + return $response; + } + + if ($userObject->getOtp() != $code) { + $e->setCode(AuthenticationResult::FAILURE_CREDENTIAL_INVALID) + ->setMessages(array('Supplied credential is invalid.')); + $this->setSatisfied(false); + return false; + } + + if ($userObject->getOtpTimeout('unix') < time()) { + $e->setCode(AuthenticationResult::FAILURE_CREDENTIAL_INVALID) + ->setMessages(array('Supplied credential is invalid.')); + $this->setSatisfied(false); + return false; + } + + // regen the id + $session = new SessionContainer($this->getStorage()->getNameSpace()); + $session->getManager()->regenerateId(); + + // Success! + $e->setIdentity($userObject->getId()); + // Update user's password hash if the cost parameter has changed + $storage = $this->getStorage()->read(); + $storage['is_otp_satisfied'] = true; + $storage['identity'] = $e->getIdentity(); + $this->getStorage()->write($storage); + $e->setCode(AuthenticationResult::SUCCESS) + ->setMessages(array('Authentication successful.')); + } + + /** + * + * @param User $user + * @param string $code + * @return Mail\Message + */ + private function createMail($user, $code) + { + $mail_message = $code; + $mail = new Mail\Message(); + $mail->setEncoding("UTF-8"); + $mail->setFrom('test@example.com'); + $mail->addTo($user->getEmail()); + $mail->setSubject('OTP'); + $html = new MimePart($mail_message); + $html->type = 'text/html'; + $body = new MimeMessage(); + $body->setParts([$html]); + $mail->setBody($body); + return $mail; + } + + /** + * getMapper + * + * @return UserMapperInterface + */ + public function getMapper() + { + if (null === $this->mapper) { + $this->mapper = $this->getServiceManager()->get('lmcuser_user_mapper'); + } + + return $this->mapper; + } + + /** + * setMapper + * + * @param UserMapperInterface $mapper + * @return Db + */ + public function setMapper(UserMapperInterface $mapper) + { + $this->mapper = $mapper; + + return $this; + } + + /** + * Get credentialPreprocessor. + * + * @return callable + */ + public function getCredentialPreprocessor() + { + return $this->credentialPreprocessor; + } + + /** + * Set credentialPreprocessor. + * + * @param callable $credentialPreprocessor + * @return $this + */ + public function setCredentialPreprocessor($credentialPreprocessor) + { + $this->credentialPreprocessor = $credentialPreprocessor; + return $this; + } + + /** + * Retrieve service manager instance + * + * @return ServiceManager + */ + public function getServiceManager() + { + return $this->serviceManager; + } + + /** + * Set service manager instance + * + * @param ContainerInterface $serviceManager + */ + public function setServiceManager(ContainerInterface $serviceManager) + { + $this->serviceManager = $serviceManager; + } + + /** + * @param ModuleOptions $options + */ + public function setOptions(ModuleOptions $options) + { + $this->options = $options; + } + + /** + * @return ModuleOptions + */ + public function getOptions() + { + if ($this->options === null) { + $this->setOptions($this->getServiceManager()->get('lmcuser_module_options')); + } + + return $this->options; + } +} diff --git a/src/LmcUser/Controller/RedirectCallback.php b/src/LmcUser/Controller/RedirectCallback.php index 43b1d1c..9408837 100644 --- a/src/LmcUser/Controller/RedirectCallback.php +++ b/src/LmcUser/Controller/RedirectCallback.php @@ -122,6 +122,7 @@ private function getRedirect($currentRoute, $redirect = false) switch ($currentRoute) { case 'lmcuser/register': case 'lmcuser/login': + case 'lmcuser/otp': case 'lmcuser/authenticate': if ($redirect && $routeMatched) { return $redirect; diff --git a/src/LmcUser/Controller/UserController.php b/src/LmcUser/Controller/UserController.php index 6ea7101..446a725 100644 --- a/src/LmcUser/Controller/UserController.php +++ b/src/LmcUser/Controller/UserController.php @@ -17,7 +17,7 @@ class UserController extends AbstractActionController const ROUTE_LOGIN = 'lmcuser/login'; const ROUTE_REGISTER = 'lmcuser/register'; const ROUTE_CHANGEEMAIL = 'lmcuser/changeemail'; - + const ROUTE_OTP = 'lmcuser/otp'; const CONTROLLER_NAME = 'lmcuser'; /** @@ -30,6 +30,11 @@ class UserController extends AbstractActionController */ protected $loginForm; + /** + * @var FormInterface + */ + protected $otpForm; + /** * @var FormInterface */ @@ -177,6 +182,38 @@ public function authenticateAction() return $redirect(); } + public function otpAction() + { + if ($this->lmcUserAuthentication()->hasIdentity()) { + return $this->redirect()->toRoute($this->getOptions()->getLoginRedirectRoute()); + } + + $request = $this->getRequest(); + $form = $this->getOtpForm(); + + if ($this->getOptions()->getUseRedirectParameterIfPresent() && $request->getQuery()->get('redirect')) { + $redirect = $request->getQuery()->get('redirect'); + } else { + $redirect = false; + } + + if (!$request->isPost()) { + return array( + 'otpForm' => $form, + 'redirect' => $redirect + ); + } + + $form->setData($request->getPost()); + + if (!$form->isValid()) { + $this->flashMessenger()->setNamespace('lmcuser-login-form')->addMessage($this->failedLoginMessage); + return $this->redirect()->toUrl($this->url()->fromRoute(static::ROUTE_LOGIN).($redirect ? '?redirect='. rawurlencode($redirect) : '')); + } + + return $this->forward()->dispatch(static::CONTROLLER_NAME, array('action' => 'authenticate')); + } + /** * Register new user */ @@ -392,6 +429,20 @@ public function setLoginForm(FormInterface $loginForm) return $this; } + public function getOtpForm() + { + if (!$this->otpForm) { + $this->setOtpForm($this->serviceLocator->get('lmcuser_otp_form')); + } + return $this->otpForm; + } + + public function setOtpForm(FormInterface $otpForm) + { + $this->otpForm = $otpForm; + return $this; + } + public function getChangePasswordForm() { if (!$this->changePasswordForm) { diff --git a/src/LmcUser/Entity/UserOtpInterface.php b/src/LmcUser/Entity/UserOtpInterface.php new file mode 100644 index 0000000..a822bb6 --- /dev/null +++ b/src/LmcUser/Entity/UserOtpInterface.php @@ -0,0 +1,111 @@ +setServiceManager($serviceLocator); + + return $mail; + } + + /** + * Create service + * + * @param ServiceLocatorInterface $serviceLocator + * @return mixed + */ + public function createService(ServiceLocatorInterface $serviceLocator) + { + return $this->__invoke($serviceLocator, null); + } +} diff --git a/src/LmcUser/Factory/Form/Otp.php b/src/LmcUser/Factory/Form/Otp.php new file mode 100644 index 0000000..a0458d7 --- /dev/null +++ b/src/LmcUser/Factory/Form/Otp.php @@ -0,0 +1,20 @@ +get('lmcuser_module_options'); + $form = new Form\Otp(null, $options); + + $form->setInputFilter(new Form\OtpFilter($options)); + + return $form; + } +} diff --git a/src/LmcUser/Form/Otp.php b/src/LmcUser/Form/Otp.php new file mode 100644 index 0000000..62d642e --- /dev/null +++ b/src/LmcUser/Form/Otp.php @@ -0,0 +1,83 @@ +setAuthenticationOptions($options); + + parent::__construct($name); + + $this->add( + array( + 'name' => 'code', + 'options' => array( + 'label' => '', + ), + 'attributes' => array( + 'type' => 'text' + ), + ) + ); + if ($this->getAuthenticationOptions()->getUseLoginFormCsrf()) { + $this->add([ + 'type' => '\Laminas\Form\Element\Csrf', + 'name' => 'security', + 'options' => [ + 'csrf_options' => [ + 'timeout' => $this->getAuthenticationOptions()->getLoginFormTimeout() + ] + ] + ]); + } + + $submitElement = new Element\Button('submit'); + $submitElement + ->setLabel('Sign In') + ->setAttributes( + array( + 'type' => 'submit', + ) + ); + + $this->add( + $submitElement, + array( + 'priority' => -100, + ) + ); + } + + /** + * Set Authentication-related Options + * + * @param AuthenticationOptionsInterface $authOptions + * @return Login + */ + public function setAuthenticationOptions(AuthenticationOptionsInterface $authOptions) + { + $this->authOptions = $authOptions; + + return $this; + } + + /** + * Get Authentication-related Options + * + * @return AuthenticationOptionsInterface + */ + public function getAuthenticationOptions() + { + return $this->authOptions; + } +} diff --git a/src/LmcUser/Form/OtpFilter.php b/src/LmcUser/Form/OtpFilter.php new file mode 100644 index 0000000..080f347 --- /dev/null +++ b/src/LmcUser/Form/OtpFilter.php @@ -0,0 +1,21 @@ + 'code', + 'required' => true, + 'validators' => array(), + 'filters' => array( + array('name' => 'StringTrim'), + ) + ); + } +} diff --git a/view/lmc-user/user/otp.phtml b/view/lmc-user/user/otp.phtml new file mode 100644 index 0000000..51d147a --- /dev/null +++ b/view/lmc-user/user/otp.phtml @@ -0,0 +1,11 @@ +

translate('Sign In'); ?>

+ +otpForm; +$form->prepare(); +$form->setAttribute('action', $this->url('lmcuser/otp')); +$form->setAttribute('method', 'post'); +$form->setAttribute('autocomplete', 'off'); +?> + +partial('_form.phtml', ['form' => $form]); ?>