The provides Email based OTP authentication on default login form. When enabled, this module overrides the default submit on the login form and registers its own ajax callback which generates OTP and sends in email to user who is logging in. User then taken to OTP form where they enter the OTP and the module validates and authenticates the user. The generated OTP is valid for 5 minutes only. No configurations needed just install the module and enable it. I did not find any similar module for Drupal 9 as of now so thought to build one for the community so here it is.

Project link

https://www.drupal.org/project/email_login_otp

Git instructions

git clone --branch '1.0.x' https://git.drupalcode.org/project/email_login_otp.git

PAreview checklist

http://pareview.net/r/424

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

    Comments

    Ahmed.Raza created an issue. See original summary.

    avpaderno’s picture

    Issue summary: View changes
    Status: Needs review » Needs work
    • What follows is a quick review of the project; it doesn't mean to be complete
    • For every point, the review doesn't make a complete list of lines that should be fixed, but an example of what is wrong in the code
    • A review is about code that doesn't follow the coding standards, contains possible security issue, or doesn't correctly use the Drupal API; if a point isn't about that, it makes it clear
      public function loginRedirect(GetResponseEvent $event) {
        if (\Drupal::service('path.current')->getPath() == '/login-otp' && \Drupal::currentUser()->isAuthenticated()) {
          $redirect = new RedirectResponse('/user');
          return $redirect->send();
        }
      }
    

    A service dependencies are defined in the .service.yml file. Services don't use \Drupal methods.

      protected $messenger;
      protected $loggerFactory;
    

    Those properties aren't used from the class, but they aren't necessary, as the parent class defines similar properties.

        $form['otp'] = [
          '#type' => 'textfield',
          '#title' => $this->t('OTP'),
          '#description' => $this->t('Enter the OTP you received in email.'),
          '#weight' => '0',
          '#required' => TRUE
        ];
    

    To use $this->t(), create() should contain lines similar to the following ones.

      $form = new static();
      $form->setStringTranslation($container->get('string_translation'));
      return $form;
    
    <?php
    
    namespace Drupal\email_login_otp;
    
    class OTP {
      private $username;
      private $tempStorageFactory;
    
      public function __construct() {
        $this->tempStorageFactory = \Drupal::service('tempstore.private');
      }
    
      public function generateOTP($username) {
        $this->username = $username;
        $uid = $this->getField('uid');
        $this->tempStorageFactory->get('email_login_otp')->set('uid', $uid);
    
        if ($this->exists($uid)) {
          return $this->update($uid);
        }
        return $this->new($uid);
      }
    
      public function sendOTP($otp) {
        $mail_manager = \Drupal::service('plugin.manager.mail');
    
        $to = $this->getField('mail');
        $langcode = \Drupal::languageManager()->getCurrentLanguage()->getId();
        $params['message'] = t('Hello, @username <br> This is the OTP you will use to login: @otp',
                            [
                              '@username' => $this->username,
                              '@otp' => $otp
                            ]);
        return $mail_manager->mail('email_login_otp', 'email_login_otp_mail', $to, $langcode, $params, NULL, TRUE);
      }
    
      public function check($uid, $otp) {
        if ($this->exists($uid)) {
          $database = \Drupal::database();
          $select = $database->select('email_login_otp', 'u')
                    ->fields('u', ['otp', 'expiration'])
                    ->condition('uid', $uid, '=')
                    ->execute()
                    ->fetchAssoc();
          if ($select['expiration'] >= time() && \Drupal::service('password')->check($otp, $select['otp'])) {
            return true;
          }
          return false;
        }
        return false;
      }
    
      public function expire($uid) {
        $database = \Drupal::database();
        $delete = $database->delete('email_login_otp')
                  ->condition('uid', $uid)
                  ->execute();
        return $delete;
      }
    
      private function getField($field) {
        $database = \Drupal::database();
        $query = $database->select('users_field_data', 'u')
                  ->fields('u', [$field])
                  ->condition('name', $this->username, '=')
                  ->execute()
                  ->fetchAssoc();
        return $query[$field];
      }
    
      private function exists($uid) {
        $database = \Drupal::database();
        $exists = $database->select('email_login_otp', 'u')
                  ->fields('u')
                  ->condition('uid', $uid, '=')
                  ->execute()
                  ->fetchAssoc();
        return $exists ?? true;
      }
    
      private function new($uid) {
        $human_readable_otp = rand(100000, 999999);
        $database = \Drupal::database();
        $insert_otp_info = $database->insert('email_login_otp')->fields([
          'uid' => $uid,
          'otp' => \Drupal::service('password')->hash($human_readable_otp),
          'expiration' => strtotime("+5 minutes",time())
        ])->execute();
        return $human_readable_otp;
      }
    
      private function update($uid) {
        $human_readable_otp = rand(100000, 999999);
        $database = \Drupal::database();
        $update_otp_info = $database->update('email_login_otp')
                  ->fields([
                    'otp' => \Drupal::service('password')->hash($human_readable_otp),
                    'expiration' => strtotime("+5 minutes",time())
                  ])
                  ->condition('uid', $uid, '=')
                  ->execute();
        return $human_readable_otp;
      }
    }
    

    This class should be made a service, since it depends on Drupal core services.

    ahmed.raza’s picture

    Thanks for the review @apaderno, I have made changes you noted and also refactored my code.

      public function loginRedirect(GetResponseEvent $event) {
        if (\Drupal::service('path.current')->getPath() == '/login-otp' && \Drupal::currentUser()->isAuthenticated()) {
          $redirect = new RedirectResponse('/user');
          return $redirect->send();
        }
      }
    

    Using services via proper dependency injection.

      protected $messenger;
      protected $loggerFactory;
    

    Removed unused properties.

      $form = new static();
      $form->setStringTranslation($container->get('string_translation'));
      return $form;
    

    Added above lines.

    <?php
    
    namespace Drupal\email_login_otp;
    
    class OTP {
      private $username;
      private $tempStorageFactory;
    
      public function __construct() {
        $this->tempStorageFactory = \Drupal::service('tempstore.private');
      }
    
      public function generateOTP($username) {
        $this->username = $username;
        $uid = $this->getField('uid');
        $this->tempStorageFactory->get('email_login_otp')->set('uid', $uid);
    
        if ($this->exists($uid)) {
          return $this->update($uid);
        }
        return $this->new($uid);
      }
    
      public function sendOTP($otp) {
        $mail_manager = \Drupal::service('plugin.manager.mail');
    
        $to = $this->getField('mail');
        $langcode = \Drupal::languageManager()->getCurrentLanguage()->getId();
        $params['message'] = t('Hello, @username <br> This is the OTP you will use to login: @otp',
                            [
                              '@username' => $this->username,
                              '@otp' => $otp
                            ]);
        return $mail_manager->mail('email_login_otp', 'email_login_otp_mail', $to, $langcode, $params, NULL, TRUE);
      }
    
      public function check($uid, $otp) {
        if ($this->exists($uid)) {
          $database = \Drupal::database();
          $select = $database->select('email_login_otp', 'u')
                    ->fields('u', ['otp', 'expiration'])
                    ->condition('uid', $uid, '=')
                    ->execute()
                    ->fetchAssoc();
          if ($select['expiration'] >= time() && \Drupal::service('password')->check($otp, $select['otp'])) {
            return true;
          }
          return false;
        }
        return false;
      }
    
      public function expire($uid) {
        $database = \Drupal::database();
        $delete = $database->delete('email_login_otp')
                  ->condition('uid', $uid)
                  ->execute();
        return $delete;
      }
    
      private function getField($field) {
        $database = \Drupal::database();
        $query = $database->select('users_field_data', 'u')
                  ->fields('u', [$field])
                  ->condition('name', $this->username, '=')
                  ->execute()
                  ->fetchAssoc();
        return $query[$field];
      }
    
      private function exists($uid) {
        $database = \Drupal::database();
        $exists = $database->select('email_login_otp', 'u')
                  ->fields('u')
                  ->condition('uid', $uid, '=')
                  ->execute()
                  ->fetchAssoc();
        return $exists ?? true;
      }
    
      private function new($uid) {
        $human_readable_otp = rand(100000, 999999);
        $database = \Drupal::database();
        $insert_otp_info = $database->insert('email_login_otp')->fields([
          'uid' => $uid,
          'otp' => \Drupal::service('password')->hash($human_readable_otp),
          'expiration' => strtotime("+5 minutes",time())
        ])->execute();
        return $human_readable_otp;
      }
    
      private function update($uid) {
        $human_readable_otp = rand(100000, 999999);
        $database = \Drupal::database();
        $update_otp_info = $database->update('email_login_otp')
                  ->fields([
                    'otp' => \Drupal::service('password')->hash($human_readable_otp),
                    'expiration' => strtotime("+5 minutes",time())
                  ])
                  ->condition('uid', $uid, '=')
                  ->execute();
        return $human_readable_otp;
      }
    }
    

    Made above class a service.

    Changes are pushed to branch: 1.0.x

    ahmed.raza’s picture

    Status: Needs work » Needs review
    avpaderno’s picture

    Assigned: Unassigned » avpaderno
    Status: Needs review » Fixed

    Thank you for your contribution! I am going to update your account.

    These are some recommended readings to help with excellent maintainership:

    You can find more contributors chatting on the IRC #drupal-contribute channel. So, come hang out and stay involved.
    Thank you, also, for your patience with the review process.
    Anyone is welcome to participate in the review process. Please consider reviewing other projects that are pending review. I encourage you to learn more about that process and join the group of reviewers.

    ahmed.raza’s picture

    StatusFileSize
    new86.34 KB

    Thank you @apaderno, please guide me how can I show download information like this in the attached screenshot on my project page?
    Screenshot

    avpaderno’s picture

    StatusFileSize
    new20.18 KB

    You need to wait until September 25 before being able to change the value of the Security advisory coverage field in https://www.drupal.org/node/3233129/edit. That is a limit set by the module handing projects on drupal.org. I cannot even edit that field before September 25.

    screenshot

    Status: Fixed » Closed (fixed)

    Automatically closed - issue fixed for 2 weeks with no activity.