Delayed Requests | Stylesheets

The JITBrain login process is still missing an essential component: password recovery. First, let us review the high-level sequence of events on the user side:

  1. The user is on the login page. They notice that they have forgotten their password, so they click on the “recover password” link. Alternatively, they try to sign up using an already registered mail.
  2. This leads the user to a page where they enter their e-mail address (the field is pre-filled if the user already entered an address on the login page) and are told that this is going to send them a confirmation e-mail.
  3. The user clicks “send” and receives an e-mail containing a confirm link.
  4. By following the link, they end up on a page which contains a twin password field, where they can enter their new password.
  5. Once they enter a new password correctly, they are redirected to whatever they were trying to access.

This process is secure: only the owner of the e-mail address can ever get the confirm link (so we must make sure it is unpredictable enough) and follow it.

The technical solution I choose for the above steps is the following:

  1. Whichever part of the system wishes to let the user change his password directs him actively or passively to GET /reset-password, or GET /reset-password?for={address}. This controller also accepts the next={uid} convention for delayed requests (which lets a request resume after the password is reset).
  2. The password reset controller notices it has been contacted without a confirm key, so it provides a form for entering the address, and a description. The form is sent to POST /do-reset-password, which also accepts the next={uid} convention.
  3. The do-reset-password controller notices it has been contacted without a confirm key, so it generates one, stores it in the session, and also sends a link to GET /reset-password?key={key} by mail to the specified address. The next={uid}, if any, is also included.
  4. The user follows the link, and the password reset controller notices it has been contacted with a valid confirm key, so it displays a form for entering the new password. The form is sent to POST /do-reset-password?key={key}.
  5. The do-reset-password controller notices it has been contacted with a valid confirm key, invalidates it, and resets the password. It then redirects the user accordingly.

A technical difficulty appears: the validity of a confirm key is checked by comparing it with the key saved in the session. However, there’s no reason for the user to click on the link in the e-mail they received and automatically retrieve the session which sent the e-mail! Sure, if they happen to be using the same browser on the same computer and the session was not disabled yet, they will probably get the same. But what if that isn’t the case?

The good news is, PHP lets us pass the session identifier as a GET parameter. So, we can actually store the session identifier in the link we send in the mail, so that it works automatically. The bad news is, if we send together an SID, a confirm key, and a resumed operation, it will end up looking like:

http://jitbrain.nicollet.net/reset-password?SID=abcdef0123456789&next=12345678abcd&key=12345678abcd

I deem this acceptable. The alternative would be adding another controller and designing an ad hoc compression scheme (plus database storage) with the benefit of marginally improving the life of the few people who will reset their password with a mail program that does not let them click links, which ends up being quite tiny in the grand scheme of things.

On to the implementation. First, the GET controller:

<?php // controllers/reset-password.php

  $get = RequestUtils::Read(array('next', 'for', 'key'), $_GET);

  $session = SessionUtils::Read('reset-password',
                                array('key', 'expires', 'error', 'mail'));

  if ($get['key'] && $session['key'] === $get['key'] &&
      $session['expires'] > time() && $session['mail'])
  {
      // We have received a valid password reset key!
      ResetPasswordView::RenderPassword($get['key'], $session['mail'],
                                        $session['error'], $get['next']);
  }

  else {
      // No valid password reset key!
      ResetPasswordView::RenderInitial($get['for'], $session['error'], $get['next']);
  }

Then, there is the POST controller which performs the actual work:

<?php // controllers/do-reset-password.php

  $get     = RequestUtils::Read(array('next', 'for', 'key'), $_GET);
  $post    = RequestUtils::Read(array('mail', 'pass', 'pass2'), $_POST);
  $session = SessionUtils::Read('reset-password',
                                array('key', 'expires', 'mail'));

  function on_error($get, $error)
  {
      SessionUtils::Write('reset-password', array('error' => $error));
      $url = DomainConfig::Url('/reset-password', $get);
      RedirectUtils::SeeOther($url);
  }

  if ($get['key'] && $session['key'] === $get['key'] &&
      $session['expires'] > time())
  {
      // We have received a valid password reset key!
      $pass = $post['pass'];
      $mail = $session['mail'];

      // Check the submitted passwords...
      if (!$pass)
          on_error($get, 'Please enter a password.');

      if (strlen($pass) < 6)
          on_error($get, 'Password must be at least 6 characters long.');

      if ($pass != $post['pass2'])
          on_error($get, 'Passwords do not match.');

      // Apply modification and register.
      AuthenticationModel::ResetPassword($mail, $pass);

      $token = AuthenticationModel::Login($mail, $pass, true);

      if ($token === null) {
          // For some reason, there's no associated user...
          $url = DomainConfig::Url('/reset-password',
                                   array('for' => $mail,
                                         'next' => $get['next']));
          RedirectUtils::SeeOther($url);
      }

      SessionUtils::Write('authentication',
                          array('token' => $token));

      SessionUtils::Delete('reset-password',
                           array('key', 'expires', 'error', 'mail'));

      // Done. Redirect...
      $url = $get['next']
           ? DomainConfig::Url('/confirm', array('id' => $get['next']))
           : DomainConfig::Url('/');

      RedirectUtils::SeeOther($url);
  }

  else {
      // We did not receive a valid key

      $mail = $post['mail'];

      if (AuthenticationModel::isMailAvailable($mail))
          on_error(array('next' => $get['next'], 'for' => $mail),
                   "Unknown e-mail.");

      $data = array(
        'key'     => md5(uniqid()),
        'expires' => time() + 3600 * 3, 
        'mail'    => $mail
      );

      list($sidk, $sidv) = SID ? explode('=', SID) : array('', null);
      $target = DomainConfig::Url('/reset-password',
                                  array($sidk  => $sidv,
                                        'next' => $get['next'],
                                        'key'  => $data['key']));

      $mail_to   = $mail;
      $mail_from = DomainConfig::NoReply();
      $mail_subj = "Password reset";
      $mail_body = ResetPasswordView::ResetMail($mail, $target);

      $success = mail($mail_to, $mail_subj, $mail_body,
                      "From: $mail_from");

      if (!$success)
          on_error(array('next' => $get['next'], 'for' => $mail),
                   "Could not send confirmation e-mail.");

      SessionUtils::Write('reset-password', $data);
      ResetPasswordView::RenderMailSent($mail);
  }

The corresponding ResetPasswordView class:

<?php // views/resetpassword.php

  class ResetPasswordView
  {
      public static function RenderPassword($key, $mail, $error, $next)
      {
          $lazy = new LazyObj('ResetPasswordView::RenderPasswordContent', 
                              $key, $mail, $error, $next);

          PlainPageView::Render('Reset Password', 'password-reset-check', $lazy);
      }

      public static function RenderInitial($mail, $error, $next)
      {
          $lazy = new LazyObj('ResetPasswordView::RenderInitialContent', 
                              $mail, $error, $next);

          PlainPageView::Render('Reset Password', 'password-reset', $lazy);
      }

      public static function RenderMailSent($mail)
      {
          $lazy = new LazyObj('ResetPasswordView::RenderMailSentContent', $mail);
          PlainPageView::Render('Mail Sent', 'password-reset-sent', $lazy);
      }

      public static function RenderInitialContent($mail, $error, $next)
      {
          if ($error) {
              $error = htmlspecialchars($error);
?><p class="error"><?=$error?></p><?php
          }
?><p><strong>To retrieve your account password, please enter your
     account e-mail address below.</strong></p>
  <p>You will receive a confirmation e-mail at that address containing
     a link. Follow that link to change your account password.</p><?php
          $c = new TwoColFormView();
          $fields = array(
            $c->Input("mail", "E-mail", $mail), 
            $c->Submit("Send e-mail")
          );

          $target = DomainConfig::Url("/do-reset-password",
                                      array('next' => $next));

          $c->Render($target, 'mail-form', $fields);
      }

      public static function RenderPasswordContent($key, $mail, $error, $next)
      {
          if ($error) {
              $error = htmlspecialchars($error);
?><p class="error"><?=$error?></p><?php
          }

          $mail = htmlspecialchars($mail);
?><p><strong>Welcome, <?=$mail?>.</strong></p>
  <p>Please enter your new password below.</p><?php
          $c = new TwoColFormView();
          $fields = array(
            $c->Password("pass", "Password"), 
            $c->Password("pass2", "(again)"), 
            $c->Submit("Change password")
          );

          $target = DomainConfig::Url("/do-reset-password",
                                      array('next' => $next, 'key' => $key));

          $c->Render($target, 'pass-form', $fields);
      }

      public static function RenderMailSentContent($mail)
      {
          $mail = htmlspecialchars($mail);
?><p><strong>Confirmation e-mail sent.</strong></p>
  <p>Please check address <?=$mail?>, it should arrive
     within a few minutes.</p><?php
      }

      public static function ResetMail($mail, $target)
      {
          $mail   = strtr($mail, "\n\r", '  ');
          $target = strtr($target, "\n\r", '  ');

          return 
'Hello,

You have requested a password change on the JITBrain website
for the address ' . $mail . '

To proceed, please follow this link by clicking on it or
copy-pasting it to your browser\'s address bar:

' . $target . '

If you did not request a password change, please ignore this
mail. The above link will become invalid within a few hours.

The JITBrain team.'
          ;
      }
  }

And the AuthenticationModel::ResetPassword function:

  public static function ResetPassword($mail, $pass)
  {
      $query =
      '`authentication`
       SET `auth_pass` = MD5(CONCAT(`auth_salt`, ?))
       WHERE `auth_mail` = ?';

      DBUtils::Update(DBConfig::Master(), $query, 'ss', $pass, $mail);
  }

Of particular interest is the new shape of the AuthenticationModel::Login function. The previous version always queries the slave database, which is the expected behavior since you don’t want to bother the master every time a user logs in (and the passwords and usernames change very rarely). However, when you change the user password, you have the issue that the new password is not instantly propagated to the slave, which means that your login attempt may well end up being incorrect because the slave still remembers the old password.

The solution is to let the programmer specify whether the login function should use the slave or the master database. In our password reset controller, we query the master in order to get the latest password value, and in our login controller we query the slave because we can expect the password to propagate soon enough.

I leave the implementation of DomainConfig::NoReply to you, mine returns “no-reply@jitbrain.nicollet.net”, and don’t forget to use RequestUtils::AllowFrom inside the POST controller.

Delayed Requests | Stylesheets

0 Responses to “12. Password Recovery”


  1. No Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

*

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>



1150 feed subscribers
(readers who polled a feed this week)