Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi factor authentication (OTP = One time password) #30

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions config/module.config.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
],
],
],
'otp' => [
'type' => Literal::class,
'options' => [
'route' => '/otp',
'defaults' => [
'controller' => 'lmcuser',
'action' => 'otp',
],
],
],
'logout' => [
'type' => Literal::class,
'options' => [
Expand Down
249 changes: 249 additions & 0 deletions src/LmcUser/Authentication/Adapter/OtpMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
<?php

namespace LmcUser\Authentication\Adapter;

use Interop\Container\ContainerInterface;
use Laminas\Authentication\Result as AuthenticationResult;
use Laminas\EventManager\EventInterface;
use Laminas\ServiceManager\ServiceManager;
use Laminas\Crypt\Password\Bcrypt;
use Laminas\Session\Container as SessionContainer;
use LmcUser\Entity\UserOtpInterface;
use LmcUser\Mapper\UserInterface as UserMapperInterface;
use LmcUser\Options\ModuleOptions;
use LmcUser\Entity\User;
use Laminas\Mail;
use Laminas\Mime\Message as MimeMessage;
use Laminas\Mime\Part as MimePart;
use Laminas\Http\Response;

class OtpMail extends AbstractAdapter
{
/**
* @var UserMapperInterface
*/
protected $mapper;

/**
* @var callable
*/
protected $credentialPreprocessor;

/**
* @var ServiceManager
*/
protected $serviceManager;

/**
* @var ModuleOptions
*/
protected $options;

/**
* Called when user id logged out
*
* @param AdapterChainEvent $e
*/
public function logout(AdapterChainEvent $e)
{
$this->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('[email protected]');
$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;
}
}
1 change: 1 addition & 0 deletions src/LmcUser/Controller/RedirectCallback.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 52 additions & 1 deletion src/LmcUser/Controller/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -30,6 +30,11 @@ class UserController extends AbstractActionController
*/
protected $loginForm;

/**
* @var FormInterface
*/
protected $otpForm;

/**
* @var FormInterface
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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) {
Expand Down
Loading