Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/recipe-fiddle.git
<?php
namespace com\whitemagicsoftware;

require "constants.php";
require "class.BaseController.php";
require "class.Mail.php";

/**
 * Authenticates the user using an e-mail based authorization mechanism.
 */
class Login extends BaseController {
  const AUTH_LOGIN_EMAIL_LOG_IN  = "AUTH_LOGIN_EMAIL_LOG_IN";
  const AUTH_LOGIN_EMAIL_INVALID = "AUTH_LOGIN_EMAIL_INVALID";
  const AUTH_LOGIN_EMAIL_PENDING = "AUTH_LOGIN_EMAIL_PENDING";
  const AUTH_LOGIN_EMAIL_ERROR   = "AUTH_LOGIN_EMAIL_ERROR";
  const AUTH_LOGIN_TOKEN_INVALID = "AUTH_LOGIN_TOKEN_INVALID";

  // TODO: These should come from the database...
  private $auth_status_map = array(
    Login::AUTH_LOGIN_EMAIL_LOG_IN  => "Log in using your email address.",
    Login::AUTH_LOGIN_EMAIL_INVALID => "Use a different email address.",
    Login::AUTH_LOGIN_EMAIL_PENDING => "Log in link sent; check your email.",
    Login::AUTH_LOGIN_EMAIL_ERROR   => "Error sending confirmation email.",
    Login::AUTH_LOGIN_TOKEN_INVALID => "Expired link; try again.",
  );

  // Prompt the user to log in.
  private $auth_status = Login::AUTH_LOGIN_EMAIL_LOG_IN;

  function __construct() {
    parent::__construct();
  }

  /**
   * Checks to see if the given account ID exists.
   *
   * @param id The identifier for an existing account.
   * @return true if the given recipe ID has data.
   */
  protected function exists( $id ) {
    return $this->isTrue( $this->call( "is_existing_account", "exists", $id ) );
  }

  /**
   * Returns the name of the parameter used to obtain the identifier for
   * the object to edit.
   *
   * @return "account-id"
   */
  protected function getParameterIdName() {
    return "account-id";
  }

  /**
   * Returns the name of the database function to call to verify that this
   * authentication token is allowed to modify a given account. Note that
   * the authentication token (ID) is associated with the browser cookie.
   *
   * @return true iff the account ID can be modified by the authentication ID.
   */
  protected function getAuthorizationFunctionName() {
    return "is_authorized_account";
  }

  /** 
   * Overrides the default behaviour (return 0) to return the account ID.
   * This should only get called when all other means to determine the ID
   * for this request have failed.
   *
   * @return $this->getAccountId()
   * @see BaseController::getAccountId
   */
  protected function getLastResortId() {
    return $this->getAccountId();
  }

  /**
   * Returns the name of the stylesheet to use for transforming the
   * recipe from XML to XHTML.
   *
   * @return "xsl/recipe.xsl"
   */
  protected function getStylesheetName() {
    return "xsl/login.xsl";
  }

  /**
   * Returns the list of recipes for an account in XML format.
   */
  private function getXml() {
    $result = $this->call( "generate_account_xml", "x", $this->getId() );

    return isset( $result ) ?
      $result[0]["x"] : $this->getErrorXml( "account" );
  }

  /**
   * Returns account information in XHTML format.
   */
  protected function getXhtml() {
    $xslt = $this->getXsltEngine();
    $xslt->setXml( $this->getXml() );
    $xslt->setStylesheet( $this->getStylesheetName() );

    $xslt->setParameter( $this->getParameterIdName(), $this->getId() );
    $xslt->setParameter( "status", $this->getAuthStatus()  );
    $xslt->setParameter( "message", $this->getAuthStatusMessage()  );

    return $xslt->transform();
  }

  /**
   * This is called to associate the email, token, and cookie in the
   * database. When the user clicks the link in the email the token
   * is passed back to this class. The token is used to reload the
   * user's cookie based on the email address.
   *
   * @param $email The email address to authenticate.
   * @param $nonce The token that should be associated with the email address.
   */
  private function updateAuthenticationNonce( $email, $nonce ) {
    $cookie = $this->getCookieToken();

    // In the situation where a previous account was logged in, the
    // current cookie will belong to previous account. In these situations,
    // we want to reassign a new cookie when the email address differs
    // from the email address associated with the current cookie.
    // If the email and the cookie are already associated, then there
    // is no issue.

    if( !$this->isTrue( $this->call(
        "is_authenticated", "exists", $email, $cookie ) ) ) {

      $this->log( "Before generate cookie: $cookie" );
      $this->generateCookieToken();

      $cookie = $this->getCookieToken();
      $this->log( "After generate cookie: $cookie" );
    }

    // Associate the email, current cookie, and one-time security token.
    $this->call( "authentication_nonce_upsert", "", $email, $nonce, $cookie );
  }

  /**
   * If the e-mail is valid, then this will transmit a token.
   */
  private function transmitNonce() {
    global $AUTH_LOGIN_EMAIL_SUBJECT;

    $email = $this->getParameter( "email" );
    $mail = new Mail();

    if( $mail->validate( $email ) === true ) {
      $nonce = $this->generateLoginNonce();
      $message = $this->getLoginMessage( $nonce );

      if( $mail->send( $email, $AUTH_LOGIN_EMAIL_SUBJECT, $message ) ) {
        // If the email address already has a cookie, then clicking the
        // log in hyperlink will reassign the existing cookie to the user,
        // thereby re-assocating the account with that browser.
        $this->updateAuthenticationNonce( $email, $nonce );

        $this->setAuthStatus( Login::AUTH_LOGIN_EMAIL_PENDING );
      }
      else {
        // The mail server could not be reached.
        $this->setAuthStatus( Login::AUTH_LOGIN_EMAIL_ERROR );
      }
    }
    else {
      // The email address could not be validated against the mail server.
      $this->setAuthStatus( Login::AUTH_LOGIN_EMAIL_INVALID );

      // TODO: Add the "did you mean" result returned from validation.
    }
  }

  /**
   * Returns true to indicate that the given security token is valid. A
   * value of false means that the token is invalid and the authentication
   * should be denied.
   *
   * As a side-effect this will delete all nonces that are older than the
   * grace period. This makes nonce validation a bit more atomic, but perhaps
   * that is not necessary.
   *
   * @param $nonce One-time use security token.
   */
  private function isValidNonce( $nonce ) {
    return $this->isTrue( $this->call( "is_valid_nonce", "exists", $nonce ) );
  }

  /**
   * Returns true if the email address associated with the nonce has
   * been previously validated.
   *
   * @return true The email has a non-NULL validation date.
   */
  private function isEmailValidated( $nonce ) {
    return $this->isTrue( $this->call( "is_valid_email", "exists", $nonce ) );
  }

  private function setAccountId( $cookie ) {
    //$this->log( "Set account ID for: $cookie" );

    $id = $this->call( "get_account_id_cookie", "id", $cookie );

    // Causes the browser to redirect to the account page associated with
    // the current cookie value.
    if( isset( $id[0] ) ) {
      $id = $id[0]["id"];
      //$this->log( "Set account ID: $id" );

      if( $id > 0 ) {
        $this->setId( $id );
      }
    }
  }

  /**
   * Sets the appropriate cookie when given a nonce. The nonce is validated
   * in this method. The algorithm follows:
   *
   * 1. If the email address associated with the nonce has been validated,
   * then add the browser's current cookie to the account's authentication
   * list. This prevents data loss should users edit recipes before they
   * log in.
   *
   * 2. If the email address is not registered, then associate the email
   * with the account and set the email validation flag.
   *
   * In both cases the browser's cookie need not change.
   *
   * @param $nonce The security identifier.
   */
  private function authenticate( $nonce ) {
    if( $this->isValidNonce( $nonce ) ) {
      if( $this->isEmailValidated( $nonce ) ) {
        global $AUTH_COOKIE_NAME;
        $token = $this->getCookieToken();

        $this->log( "Cookie Token: $token" );
        $this->log( "Nonce: $nonce" );

        // "c" is for cookie, that's good enough for me!
        $cookie = $this->call( "authenticate_nonce", "c", $nonce, $token );
        $cookie = isset( $cookie[0] ) ? $cookie[0]["c"] : $token;

        $this->log( "Resolved cookie: $cookie" );

        $this->setCookieToken( $AUTH_COOKIE_NAME, $cookie );
        $this->setAccountId( $cookie );
      }
      else {
        // Add the authentication token to the account bound to the nonce.
        $this->call( "authenticate_account", "", $nonce );
      }
    }
  }

  /**
   * Ensures that the token parameter is valid. A token can fail when it:
   *
   * - has been deleted (tokens are deleted after first use); and
   * - is too old.
   */
  private function authenticateNonce() {
    global $AUTH_LOGIN_TOKEN_NAME;

    $result = false;
    $nonce = $this->getParameter( $AUTH_LOGIN_TOKEN_NAME );

    // If a token is set make sure it is valid.
    if( isset( $nonce ) ) {
      // Assume token validation failed.
      $this->setAuthStatus( Login::AUTH_LOGIN_TOKEN_INVALID );

      // Grab the cookie associated with the token.
      $this->authenticate( $nonce );

      $result = true;
    }

    return $result;
  }

  /**
   * Returns the text used for the message prompting the user to log in
   * to the web site.
   *
   * @param $nonce The one-time security token.
   */
  private function getLoginMessage( $nonce ) {
    global $AUTH_LOGIN_SERVICE;
    global $DEFAULT_APP_TITLE;

    $path = realpath( dirname( __FILE__ ) );

    // Open up the e-mail file
    // TODO: use database
    $html = file_get_contents( "$path/email/login.html" );

    // TODO: Map these...
    $html = str_replace( '${AUTH_LOGIN_SERVICE}', $AUTH_LOGIN_SERVICE, $html );
    $html = str_replace( '${AUTH_LOGIN_TOKEN}', $nonce, $html );
    $html = str_replace( '${DEFAULT_APP_TITLE}', $DEFAULT_APP_TITLE, $html );

    return $html;
  }

  /**
   * Returns a cryptographcially secure token to use for logging in.
   *
   * @see http://security.stackexchange.com/a/40315/22184
   */
  private function generateLoginNonce() {
    return bin2hex( openssl_random_pseudo_bytes( 16 ) );
  }

  /**
   * Returns the authentication status for logging into the system.
   *
   * @return A string.
   */
  private function getAuthStatus() {
    return $this->auth_status;
  }

  /**
   * Changes the state of the log in process.
   */
  private function setAuthStatus( $status ) {
    $this->auth_status = $status;
  }

  /**
   */
  private function getAuthStatusMessage() {
    return $this->auth_status_map[ $this->getAuthStatus() ];
  }

  /**
   * Commands are only parsed if the content is editable. This appears to
   * be an incorrect (and tight) coupling between edit status of pages and
   * commands.
   */
  protected function isEditable() {
    // Allow the login command to be parsed.
    return true;
  }

  /**
   * Executes database transactions depending on the supplied commands.
   */
  protected function handleRequest() {
    $command = $this->getCommand();
    $subcommand = $this->getSubCommand();

    if( $command === "login" && $subcommand === "transmit" ) {
      $this->transmitNonce();
    }
    else {
      if( $this->authenticateNonce() ) {
        global $BASE_ACCOUNT;

        if( $this->redirect( $BASE_ACCOUNT, $this->getId() ) ) {
          return;
        }
      }
    }

    // The false avoids POST-redirect-GET, which would otherwise lose 
    // the log in status.
    $this->render( false );
  }
}