Приглашаем посетить
Черный Саша (cherny-sasha.lit-info.ru)

Implementing Our Own Authentication

Previous
Table of Contents
Next

Implementing Our Own Authentication

Although the preceding authentication and login mechanisms are straightforward to implement, they are less suitable when we want to implement an access control system against a set of users over the Internet. In this situation, we are likely to want to implement our own authentication system, which is matched up with your tables of users and system administrators to whom we want to grant access to our web application.

We will now look at writing such a system. Pay attention to the details of configuring the database, processing user login details, and requiring and verifying logins for pages that require authorization.

Please note that in the interest of brevity, we can just use calls to session_start instead of implementing something with more robust security properties. (See the "Session Security" section in Chapter 19.) Part V, "Sample Projects and Further Ideas," shows implementations of more robust session-handling code.

Configuring the Database to Handle Logins

We have to make two changes to our message board example to enable us to manage user authentication in the same database. First, we must add a password field to the Users table, creating it as follows:

CREATE TABLE Users
(
  user_id INT AUTO_INCREMENT PRIMARY KEY,
  user_name VARCHAR(50) NOT NULL,
  password VARCHAR(50) NOT NULL,
  full_name VARCHAR(150),
  user_email VARCHAR(200) NOT NULL,
  birthdate DATE,
  INDEX (user_name)
);


Next, we must create a new table called LoggedInUsers, which contains a user ID, a session ID, and the last user information verification date. The last field is there so that we can expire or invalidate login sessions that have been idle for too long.

CREATE TABLE LoggedInUsers
(
  sessionid VARCHAR(100) PRIMARY KEY,
  user_id INT NOT NULL,
  last_update DATETIME NOT NULL
);


Every time a user is authenticated successfully in this new table, we can insert an entry into it. This entry tells us the session associated with the user, in addition to the last time there was any activity in the session. The next time we receive a page request from this user, we will see that there is an entry in this table and will not make them go through the login process again.

It is critical to protect the session ID from hijacking. (See the "Session Security" section in Chapter 19.) We now trust the session ID to come in with a request to our server accurately and to reliably tell us that the user is who we thought he was when we logged him in.

Finally, we should assure any worried readers that there is nothing about this login system that is specific to our message board system. As long as you have a list of user IDs and passwords, you can include the equivalent of the LoggedInUsers table and manage the information in a manner similar to what is shown in this chapter.

Adding New Users

Creating and adding users is one of the requirements of our user management and authentication system.

Showing a Form for User Creation

To create new accounts, we first provide the user with a form to present his user information:

<?php session_start(); ?>

<html>
<head>
  <title>New User Form</title>
</head>
<body>
  <p><b> Please Enter your User Information: </b></p>
  <form action='create_user.php' method='post'>
    <table align='center' width='100%' border='0'>
    <tr>
      <td width='30%'>User Name:</td>
      <td>
        <input type='text' size='30' name='username'/>
      </td>
    </tr>
    <tr>
      <td width='30%'>Password:</td>
      <td>
        <input type='password' size='30' name='password1'/>
      </td>
    </tr>
    <tr>
      <td width='30%'>Password (confirm):</td>
      <td>
        <input type='password' size='30' name='password2'/>
      </td>
    </tr>
    <tr>
      <td width='30%'>Full Name:</td>
      <td>
        <input type='text' size='30' name='fullname'/>
      </td>
    </tr>
    <tr>
      <td width='30%'>Email Address:</td>
      <td>
        <input type='text' size='30' name='emailaddr'/>
      </td>
    </tr>
    <tr>
      <td width='30%'>Birth Date:</td>
      <td>
        Year: <select name='year'>
          <option value='--'> --
          <option value='1999'>1999
          <option value='1998'>1998
          <!-- etc. ... one for each year! -->
          <option value='1931'>1931
          <option value='1930'>1930
        </select>
        Month: <select name='month'>
          <option value='--'> --
          <option value='01'>01
          <option value='02'>02
          <!-- etc. ... one for each month! -->
          <option value='12'>12
        </select>
        Day: <select name='day'>
          <option value='--'> --
          <option value='01'>01
          <option value='02'>02
          <!-- etc. ... one for each day! -->
          <option value='31'>31
        </select>
      </td>
    </tr>
    </table>
    <p align='center'>
      <input type='submit' value='Create Account'/>
    </p>
  </form>
</body>
</html>

(Some of the dates have been trimmed to make the code print on less than 10 pages). This form appears something like the one shown in Figure 20-7, and lets the user select his birth date in a way that allows us to not have to parse through complicated date values when we get the data from him (for example, 4/5/94 or 1935-6-3).

Figure 20-7. A new user input form.

Implementing Our Own Authentication


This form is submitted to create_user.php, which validates the data sent to it and creates an entry in the database for the new user.

Creating the User Account

Using the create_user.php script, we can create a user account in basically four steps, outlined as follows:

1.
Start a session if one is not already started (to have a valid session ID).

2.
Validate the input, making sure that all of the mandatory information was provided, and that the passwords match correctly.

3.
Create the user account in the database and verify that such an account name does not already exist.

4.
Redirect the user to the login page so that he can test his new account. Some implementations may decide to skip this step and automatically log users in for the first time after they create an account.

The first step is done with the following code:

<?php

require_once('user_manager.inc');
require_once('errors.inc');

//
// 1. in the interest of brevity, we're going
//    to omit a few of the security features suggested in
//    Chapter 19.
//
session_start();

Next, we validate user input. This involves the following:

  • Make sure that all mandatory values (username, password, and e-mail address) were provided.

  • Make sure that all values are "sane." (Names do not have illegal characters, birth date values are reasonable, and so on.)

  • Verify that the specified username does not already exist. You can actually do this later as part of the attempt to create the account to save database connections.

The code for the three actions is as follows:

//
// 2. Validate all input.
//
$uname = isset($_POST['username']) ? $_POST['username'] : '';
$pw1 =  isset($_POST['password1']) ? $_POST['password1'] : '';
$pw2 =  isset($_POST['password2']) ? $_POST['password2'] : '';
$fname = isset($_POST['fullname']) ? $_POST['fullname'] : '';
$email = isset($_POST['emailaddr']) ? $_POST['emailaddr'] : '';
$year = isset($_POST['year']) ? intval($_POST['year']) : 0;
$month = isset($_POST['month']) ? intval($_POST['month']) : 0;
$day = isset($_POST['day']) ? intval($_POST['day']) : 0;

//
// a. mandatory values.
//
if ($uname == '' or $email == '' or $pw1 == '' or $pw2 == '')
  throw new InvalidInputException();

//
// b. values are sane.
//
$usermgr = new UserManager();
$result = $usermgr->isValidUserName($uname);
if ($result !== TRUE)
  throw new InvalidInputException();

//
// c. are passwords the same?
//
if ($pw1 != $pw2)
  throw new InvalidInputException();

//
// d. is date sane enough? (values of 0 are okay)
//
if (!checkdate($month, $day, $year))
  throw new InvalidInputException();

A couple of things about the preceding code merits further discussion:

  • The code for the UserManager object is shown next, and is included through user_manager.inc. This object is in our middle tier. It is responsible for the management of users in the database and their use of the application.

  • We are using PHP exception handling to handle errors in the various scripts. This chapter focuses on user management and not worrying about how to deal with exceptions when you throw them. Part V, which focuses on building complete web applications, investigates strategies for handling these errors.

You may be asking why we have to check the values of the birth date components (year, month, and day) when we have given the user a form with "restricted" values. The answer is that (as we saw in Chapter 13, "Web Applications and the Internet") HTTP is simply a text protocol, and even though we can prime a form with suggested values, there is nothing that prevents someone from sending us different ones. Therefore, we must always check any incoming values from a form.

Next, we create the user account by using the new UserManager object (whose details are shown soon):

//
// 3. Create the Account
//
$usermgr->createAccount($uname, $pw1, $fname, $email,
                        $year, $month, $day);

After the account has successfully been created, we redirect the user to the login page so that he can take his new account for a spin.

//
// 4. redirect user to login page.
//
header('Location: login.php');


We will now show the UserManager class, which does much of the work for us.

The UserManager Object

The PHP class we can use to manage the users in your system, create new accounts, and perform any logins is called the UserManager. The class declaration and constructor look as follows:

<?php

// Database, username, password
require_once('dbconn.inc');
require_once('errors.inc');

class UserManager
{
  function __construct()
  {
    // we don't have initialization just yet
  }

}

We have a few methods to write on the UserManager object. The first is the isValidUserName method, which sees if a given username is valid or not:

//
// verifies that this username doesn't have invalid
// characters in it.  please see Chapter 22, "Data
// Validation with Regular Expressions," for a discussion
// of the ereg function.
//
public function isValidUserName($in_user_name)
{
  if ($in_user_name == ''
      or ereg('[^[:alnum:] _-]', $in_user_name) === TRUE)
    return FALSE;
  else
    return TRUE;
}

We have made a call to the ereg function in the previous code to make sure that the given username does not contain characters we do not want it to. (We want only letters, numbers, spaces, underscores, and dashes.) This is covered more fully in Chapter 22.

Next, we need to write the method that actually creates a user accountcreateAccount:

  //
  // - get connection
  // - make sure the username does not already exist.
  // - add record to users table.
  //
  public function createAccount
  (
    $in_uname,
    $in_pw,
    $in_fname,
    $in_email,
    $in_year,
    $in_month,
    $in_day
  )
  {
    // 0. quick input validation
    if ($in_pw == '' or $in_fname == ''
        or !$this->isValidUserName($in_uname))
    {
      throw new InvalidArgumentException();
    }

    // 1. get a database connection with which to work.
    //    throws on failure.
    $conn = $this->getConnection();

    try
    {
      // 2. make sure username doesn't already exist.
      $exists = FALSE;
      $exists = $this->userNameExists($in_uname, $in_conn);
      if ($exists === TRUE)
        throw new UserAlreadyExistsException();

      // 3a. make sure the parameters are safe for insertion,
      //      and encrypt the password for storage.
      $uname = $this->super_escape_string($in_uname, $conn);
      $fname = $this->super_escape_string($in_fname, $conn);
      $email = $this->super_escape_string($in_email, $conn);
      $pw = md5($in_pw);

      // 3b. create query to insert new user.  we can be sure
      //     the date values are SQL safe, or the checkdate
      //     function call would have failed.
      $qstr = <<<EOQUERY
INSERT INTO Users
      (user_name,password,full_name,user_email,birthdate)
     VALUES ('$uname', '$pw', '$fname', '$email',
             '$in_year-$in_month-$in_day')
EOQUERY;

      // 3c. insert new user
      $results = @$conn->query($qstr);
      if ($results === FALSE)
        throw new DatabaseErrorException($conn->error);

      // we want to return the newly created user ID.
      $user_id = $conn->insert_id;
    }
    catch (Exception $e)
    {
      if (isset($conn))
        $conn->close();
      throw $e;
    }

    // clean up and exit
    $conn->close();
    return $user_id;
  }

The operation of this function is easy to follow. After it has checked that the parameters are valid, it fetches a database connection:

private function getConnection()
{
  $conn = new mysqli(DB_SERVER, DB_USERNAME, DB_PW, DB_DB);
  if (mysqli_connect_errno() !== 0)
    throw new DatabaseErrorException(mysqli_connect_error());
  return $conn;
}

function super_escape_string
(
  $in_string,
  $in_conn,
  $in_removePct = FALSE
)
{
  $str = $in_conn->real_escape_string($in_string);
  if ($in_removePct)
    $str = ereg_replace('(%)', "\\\1', $str);
  return $str;
}

Why do we put such simple code in a separate routine? This is done so that we can change and expand upon this code later if we want without having to search through a thousand places, where we might have to change code and might introduce bugs. By marking this routine as private, it is internal to your class but is still easily used.

You also see in the previous code that we have provided a new version of real_escape_string called super_escape_string, which knows how to handle other dangerous and invalid characters in our input. We should use this wherever possible to be safe.

Next, we use a method called userNameExists to see if a user with the given name already exists:

  //
  // - validate input
  // - get connection
  // - execute query
  // - see if we found an existing record.
  // - clean up connection if necessary.
  //
  public function userNameExists
  (
    $in_uname,
    $in_db_conn = NULL
  )
  {
    // 0. simple validation.
    if ($in_uname == '')
      throw new InvalidArgumentException();
    // 1. make sure we have a database connection.
    if ($in_db_conn === NULL)
      $conn = $this->getConnection();
    else
      $conn = $in_db_conn;

    try
    {
      // 2. prepare and execute query.
      $name = $this->super_escape_string($in_uname, $conn);
      $qstr = <<<EOQUERY
SELECT user_name FROM Users WHERE user_name = '$name'
EOQUERY;

      $results = @$conn->query($qstr);
      if ($results === FALSE)
        throw new DatabaseErrorException($conn->error);

      // 3. see if we found an existing record
      $user_exists = FALSE;
      while (($row = @$results->fetch_assoc()) !== NULL)
      {
        if ($row['user_name'] == $in_uname)
        {
          $user_exists = TRUE;
          break;
        }
      }

    }
    catch (Exception $e)
    {
      // clean up and re-throw the exception.
      if ($in_db_conn === NULL and isset($conn))
        $conn->close();
      throw $e;
    }

    // only clean up what we allocated.
    $results->close();
    if ($in_db_conn === NULL)
      $conn->close();
    return $user_exists;
  }

One clever feature we have added to your method is the optional third parameter, $in_db_conn = NULL. Instead of creating a new connection to the database, the method can use one that is passed to it. If it allocates its own, it properly closes it when done; otherwise, it leaves the connection alone and returns to the caller. We do this for efficiency since we have already created the connection from a function that is calling us. We can expect this to be a common usage pattern.

Finally, the createAccount method creates the query to insert the row in the database and executes it. The user ID of the newly created user is put in if everything is executed without trouble.

Logging In Users

Now that we have the ability to create users and insert them into our database, the next step is to learn how to log a user into the system.

The Form

For pages where we show a login form or login page, we need to send this information to a script to process the information. The following shows the code for a basic login form:

<html>
<head>
  <title>Please Log In to the System</title>
</head>

<body>
  <form align='center' action='process_login.php' method='POST'>
  <p align='center'> Message Board Login<br/></p>
  <table align='center' width='50%' border='0'>
  <tr>
    <td width='40%' align='right'>User Name:</td>
    <td>
      <input type='text' name='username' size='20'/>
    </td>
  </tr>
  <tr>
    <td width='40%' align='right'>Password:</td>
    <td>
      <input type='password' name='userpass' size='20'/>
    </td>
  </tr>
  </table>
  <p align='center'><input type='submit' value='Login'/></p>
  </form>
</body>
</html>

We can include a login form as part of a larger page instead of having it as its own separate login page. This is commonly seen in web sites that contain a small login form in a corner of the page through which users can elect to log in.

Processing the Login

The form is submitted to process_login.php when the user clicks the Login button, which performs the following steps:

1.
Starts a session if one is not already started (so that we have a valid session ID).

2.
Validates the input and makes sure that both a username and password are specified.

3.
Logs the user into the system and validates the username and password along the way.

4.
Redirects the user to the appropriate page, such as a welcome page or his personalized "home page."

The first two steps can be taken care of with the following code:

<?php

require_once('user_manager.inc');
require_once('errors.inc');

// 1. in the interest of brevity, we're going
//    to omit a few of the security features suggested in
//    Chapter 19.
session_start();
// 2. verify that we have all the input we need.
if (!isset($_POST['user_name']) || $_POST['user_name'] == ''
    || !isset($_POST['password'] || $_POST['password'] == '')
{
  throw new InvalidInputException();
}
else
{
  $user_name = $_POST['user_name'];
  $user_pass = $_POST['password'];
}

In the preceding code, we make sure that the input is not empty. Our middle tier, in the UserManager object, does security checks on these strings. It checks for SQL injection attacks and other inappropriate values.

Next, we have the UserManager object process the data and log the user into the system:

//
// 3.  Have user manager process login.
//
$usermgr = new UserManager();
$usermgr->processLogin($user_name, $user_pass);

The processLogin method on the UserManager object throws an exception if the username or password is invalid. It simply returns with the user being logged in to your system if it is successful.

Finally, the script redirects the user to a welcome page, as follows:

//
// 4. redirect the user to some page to confirm success!
//
header("Location: userhomepage.php");

?>

So far, we have skipped the meat of this operationthe code to the processLogin method.

The processLogin Method

The next big method we write on the UserManager object is the one that performs system logins. This method performs the following actions:

1.
Gets a connection to the database with which we are working. Since we are not using persistent connections, we have to establish a connection any time we need to connect to the database. Fortunately, this only has to be done once per script.

2.
Verifies that the username and password are valid and safe. We certainly want to protect ourselves against SQL injection attacks.

3.
Clears out any existing login information for the user. This occurs if the user logs in to your system from a different web browser or computer and never log off. If he tries to log in again, he will have a new session ID, but an entry for him may still exist in the LoggedInUsers table. Thus, you must be sure to always delete all information for a user before logging him in.

4.
Adds the username and session ID to the LoggedInUsers table and makes sure to set the time that the login was established so that we can eliminate stale logins.

The code for this function is

  //
  // - get db connection
  // - verify that username and password are valid
  // - clear out existing login information for user. (if any)
  // - log user into table (associate SID with user name).
  //
  public function processLogin($in_user_name, $in_user_passwd)
  {
    // 1. internal arg checking.
    if ($in_user_name == '' || $in_user_passwd == '')
      throw new InvalidArgumentException();
    // 2. get a database connection with which to work.
    $conn = $this->getConnection();

    try
    {
      // 3. we will merge these two steps into one function
      // (and one query) so that we will not help people learn
      // whether it was the username or password that was the
      // problem failure.
      //
      // Note that this function also validates that the
      // username and password are secure and are not
      // attempts at SQL injection attacks ...
      //
      // This function throws an InvalidLoginException if
      // the username or password is not valid.
      $userid = $this->confirmUserNamePasswd($in_user_name,
                                             $in_user_passwd,
                                             $conn);

      $sessionid = session_id();

      // 4. clear out existing entries in the login table.
      $this->clearLoginEntriesForUser($userid);

      // 5. log the user into the table.
      $query = <<<EOQUERY
INSERT INTO LoggedInUsers(user_id, session_id, last_access)
     VALUES('$userid', '$session_id', NOW())
EOQUERY;

      $result = @$conn->query($query);
      if ($result === FALSE)
        throw new DatabaseErrorException($conn->error);
    }
    catch (Exception $e)
    {
      if (isset($conn))
        $conn->close();
      throw $e;
    }
    // our work here is done.  clean up and exit.
    $conn->close();
  }

After glancing through the preceding code, you might ask yourself why we checked the arguments again in step 0 since we already did this in the login page. The answer lies in the fact that classes are extremely powerful and lend themselves heavily to reuse and sharing between projects.

While we know that the values that are being passed in to this routine right now have been checked, we cannot guarantee that somebody who decides to use this class in his project one year from now will check the parameter values before passing them to our public method. Therefore, we must always take extra care to prevent silly errors, particularly on member functions marked as public. A few extra machine instructions (and microseconds to execute them) are worth the stability and predictability of your web application.

After we have checked that the parameters are valid, we fetch a database connection with the getConnection method.

Next, we make sure the username and password are valid. Note that we chose to do this in one step (in the confirmUserNamePassword method) instead of writing code, such as the following:

  if (!$this->confirmUserName($in_user_name, $conn))
    throw new InvalidUserNameException();

  if (!$this->validatePassword($in_user_name, $password, $conn))
    throw new InvalidPasswordException();

There are two reasons we should do this. First, we would have one less database query to execute. Second, it can help us foil attackers. If we let attackers know whether a username is valid, they can keep trying until they get a valid username before they work on the password. On the other hand, if we do not tell them whether the username is valid, they have no idea if the username, password, or both are incorrect.

The confirmUserNamePassword method looks as follows on the UserManager object:

  //
  // - internal arg checking
  // - get a connection
  // - get the record for the username.
  // - verify the password
  //
  private function confirmUserNamePasswd
  (
    $in_uname,
    $in_user_passwd,
    $in_db_conn = NULL
  )
  {
    // 1. make sure we have a database connection.
    if ($in_db_conn == NULL)
      $conn = $this->getConnection();
    else
      $conn = $in_db_conn;

    try
    {
      // 2. make sure incoming username is safe for queries.
      $uname = $this->super_escape_string($in_uname, $conn);

      // 3. get the record with this username
      $querystr = <<<EOQUERY
SELECT * FROM Users
 WHERE user_name = '$uname'
EOQUERY;

      $results = @$conn->query($querystr);
      if ($results === FALSE)
        throw new DatabaseErrorException($conn->error);

      // 4. re-confirm the name and the passwords match
      $login_ok = FALSE;
      while (($row = @$results->fetch_assoc()) !== NULL)
      {
        if (strcasecmp($db_name, $in_user_name) == 0)
        {
          // good, name matched.  does password?
          if (md5($in_user_passwd) == $row['password'])
          {
            $login_ok = TRUE;
            $userid = $row['user_id'];
          }
          else
            $login_ok = FALSE;
          break;
        }
      }
      $results->close();

    }
    catch (Exception $e)
    {
      if ($in_db_conn === NULL and isset($conn))
        $conn->close();
      throw $e;
    }

    // only clean up what we allocated.
    if ($in_db_conn === NULL)
      $conn->close();

    // throw on failure, or return the user ID on success.
    if ($login_ok === FALSE)
      throw new InvalidLoginException();

    return $userid;
  }

This method appears to do a lot of things, but it only performs a couple major tasks. After getting a connection to the database, it tries to find a record in the Users table with the given user_name. When it gets this, it makes sure that the passwords are the same and then returns the user's ID on success. Otherwise, it throws an InvalidLoginException exception indicating that the information was invalid.

When there is a database error, the previous code throws an exception of type DatabaseErrorException. This is an internal error that is your problem, not the user's. The user does not understand or care why the database failed to execute a given query. (He might not even know what a database is!)

We have again added the optional third parameter, $in_db_conn = NULL, to your method; this permits it to reuse an existing database connection passed to it instead of creating a new one. If it allocates its own, it closes it when it is done. Otherwise, it leaves the connection alone and returns to the caller.

The final routine we have to write for your login process is clearLoginEntriesForUser, which removes any existing entries for the given username.

  //
  // - get connection
  // - delete any rows for this user ID
  //
  private function clearLoginEntriesForUser
  (
    $in_userid,
    $in_db_conn = NULL
  )
  {
    // 0. internal arg checking
    if (!is_int($in_userid))
      throw new InvalidArgumentException();

    // 1. make sure we have a database connection.
    if ($in_db_conn == NULL)
      $conn = $this->getConnection();
    else
      $conn = $in_db_conn;

    try
    {
      // 2. delete any rows for this user in LoggedInUsers
      $querystr = <<<EOQUERY
DELETE FROM LoggedInUsers WHERE user_id = $in_userid
EOQUERY;

      $results = @$conn->query($querystr);
      if ($results === FALSE)
        throw new DatabaseErrorException($conn->error);
    }
    catch (Exception $e)
    {
      if ($in_db_conn === NULL and isset($conn))
        $conn->close();
      throw $e;
    }

    // clean up and return.
    if ($in_db_conn === NULL)
      $conn->close();
  }

Like confirmUserNamePassword, this routine creates a database connection if it is not already given one; otherwise, it just deletes any entries in the LoggedInUsers table with the name of the user who is about to be logged in.

Updating Pages That Require a Logged In User

Now that we have a system for logging users into your system, we need to see the code for the pages that require a logged in user to access the content. Your pages must complete the following steps to ensure a properly logged in user:

1.
Start a session.

2.
Have the user manager see if the session ID associated with the current client is logged in to your system.

3.
Continue processing the page if successful, or direct the user to the login page or to an error page if it fails.

The code for this kind of page looks as follows:

<?php

// this includes errors.inc for us.
require_once('user_manager.inc');

// we'll just start up a simple session
session_start();

$usermgr = new UserManager();

$user_name = NULL;
$userid = $usermgr->sessionLoggedIn(session_id());
if ($userid === -1)
{
  //
  // we're not logged in; go to login page
  //
  header("Location: login_form.html");
  exit;
}

// otherwise, continue as normal -- we have the username, so
// we can continue processing with that information ...

?>

The new routine we have written for our UserManager object is the sessionLoggedIn method, which executes a SQL query to find records in the LoggedInUsers table with the given session ID:

  //
  // - get a db connection
  // - look for the session id in LoggedInUsers
  // - if found, update last access time (user is active)
  // - return user id or -1 if not logged in.
  //
  public function sessionLoggedIn($in_sid)
  {
    // 0. internal arg checking.
    if ($in_sid == '')
      throw new InvalidArgumentException();

    // 1. get a database connection with which to work.
    $conn = $this->getConnection();
    try
    {
      // 2. execute a query to find the given session ID
      $sess_id = $this->super_escape_string($in_sid, $conn);
      $query = <<<EOQUERY
SELECT * FROM LoggedInUsers WHERE session_id = '$sess_id'
EOQUERY;

      $result = @$conn->query($query);
      if ($result === FALSE)
      {
        throw new DatabaseErrorException($conn->error);
      }
      else
      {
        // 2a. look through results for the given session ID
        $user_id = -1;
        while (($row = @$results->fetch_assoc()) !== NULL)
        {
          if ($row['session_id'] == $in_sess_id)
          {
            // 3. update last access time for logged in user
            $this->updateSessionActivity($in_sess_id, $conn);
            $_SESSION['user_name'] = $row['user_name'];
            $user_id = $row['user_id'];
            break;
          }
        }
      }
    }
    catch (Exception $e)
    {
      if (isset($conn))
        $conn->close();
      throw $e;
    }

    // our work here is done.  clean up and exit.
    $result->close();
    $conn->close();
    return $user_id;
  }

This method behaves like a lot of the methods we have written so far. It makes sure that it has a valid set of parameters and fetches a connection to the database. It then executes a query to return the records in the LoggedInUsers table with the given session ID. However, one interesting thing we do in the preceding code is go through the results and make sure that there is a row with the given session ID.

Another idea we can use in your code is the ability to cache the username in the session data after the user has logged in. This lets us have another test that we could perform in the sesssionLoggedIn function. In addition to seeing whether the session is logged in, you also make sure it is still associated with the correct user. This serves as an additional defense against session ID spoofing.

This serves two purposes: It lets us make sure beyond a doubt that there is a record with the given session ID, and it lets us fetch the username for the logged in user. We will find that in a lot of pages, we want the username to display on the page the user is visiting ("You are currently logged in as 'Bobo the Clown'"), or we want to use this to fetch more information from the Users table.

This method returns the user ID if the user is logged in, or 1 if nobody is currently logged in. It throws an exception if there is an error.

The updateSessionActivity function makes sure that the last_update field in the LoggedInUsers table is set to the current time so that the current user's login does not "expire":

  private function updateSessionActivity
  (
    $in_sessid,
    $in_db_conn
  )
  {
    // make sure we have a database connection.
    if ($in_db_conn == NULL)
      $conn = $this->getConnection();
    else
      $conn = $in_db_conn;

    try
    {
      // update the row for this session.
      $sessid = $this->super_escape_string($in_sessid, $conn);
      $querystr = <<<EOQUERY
UPDATE LoggedInUsers SET last_update = NOW()
  WHERE session_id = $sessid
EOQUERY;

      $results = @$conn->query($querystr);
      if ($results === FALSE)
        throw new DatabaseErrorException($conn->error);
    }
    catch (Exception $e)
    {
      if ($in_db_conn === NULL and isset($conn))
        $conn->close();
      throw $e;
    }

    // clean up and return.
    if ($in_db_conn === NULL)
      $conn->close();
  }

With the last piece of the puzzle, we now have a complete system for logging users in to your system. We can add the call to the sessionLoggedIn method in pages where we need a valid login to continue processing, and our UserManager object takes care of all the details. If we change anything in our database, the pages do not need to change. The UserManager accommodates these changes.

Logging Out Users

To be complete, we need to provide a way for users to log out; otherwise, we are guaranteed to always have stale login entries in your LoggedInUsers table. We can log a user out of your system by simply deleting the entry from your table. We add a method to the UserManager object called processLogout to parallel the processLogin method we added earlier (consistent object design goes a long way to help people learn how to use our classes).

public function processLogout()
{
  $this->clearLoginEntriesForSessionID(session_id());
}

The processLogout method deletes all of the entries in the LoggedInUsers table with the current session ID by calling the clearLoginEntriesForSessionID function:

  //
  // - get connection
  // - delete record(s)
  //
  private function clearLoginEntriesForSessionId
  (
    $in_sid,
    $in_db_conn = NULL
  )
  {
    // 1. make sure we have a database connection.
    if ($in_db_conn == NULL)
      $conn = $this->getConnection();
    else
      $conn = $in_db_conn;

    // 2. Create and execute the query to do the cleanup!
    try
    {
      $sessid = $this->super_escape_string($in_sid, $conn);
      $query = <<<EOQ
DELETE FROM LoggedInUsers WHERE session_id ='$sessid'
EOQ;
      $results = @$conn->query($query);
      if ($results === FALSE or $results === NULL)
        throw new DatabaseErrorException($conn->error);
    }
    catch (Exception $e)
    {
      if ($in_db_conn === NULL and isset($conn))
        $conn->close();
      throw $e;
    }

    // clean up and return.
    if ($in_db_conn === NULL)
      $conn->close();
  }

The full code for a logout.php page might look as follows then:

<?php

// this includes errors.inc for us.
require_once('user_manager.inc');

// we'll just start up a simple session
session_start();

$usermgr = new UserManager();
$user_name = NULL;
$userid = $usermgr->sessionLoggedIn(session_id());
if ($userid === -1)
{
  echo <<<EOT
<p align='center'>
  <b>Sorry, you cannot be logged out if you are
     not logged in!</b>
</p>
EOT;
  exit;
}

// log the user out of the system.
$usermgr->processLogout();
echo <<<EOM
  <p align='center'>
    You have been successfully logged out of the system. Please
    click <a href='login_form.html'>Here</a> to log back in or
    click <a href='/'>Here</a> to visit the home page.
  </p>
EOM;

?>

We might ask why to bother verifying that the user is logged in if he is just going to be logged out. This is because it is an inconsistent state. A user should not see Logout links in any of his pages if he is not logged in, which implies he is calling this page directly (either by typing it in the address bar of his client browser or by playing with other programs). In that case, we want to make it clear to the user that this is not the intended usage pattern for the page.

Deleting Users

If a user ever decided to terminate his account, we can use the code in the UserManager object to delete him from the system:

//
// - check args
// - get database connection
// - log out user if he's logged in
// - delete account.
//
public function deleteAccount($in_userid)
{
  // 0. verify parameters
  if (!is_int($in_userid))
    throw new InvalidArgumentException();

  // 1. get a database connection with which to work.
  $conn = $this->getConnection();
  try
  {
    // 2. make sure user is logged out.
    $this->clearLoginEntriesForSessionID(session_id());

    // 3. create query to delete given user and execute!
    $qstr = "DELETE FROM Users WHERE user_id = $in_userid";
    $result = @$conn->query($qstr);
    if ($result === FALSE)
      throw new DatabaseErrorException($conn->error);
  }
  catch (Exception $e)
  {
    if (isset($conn))
      $conn->close();
    throw $e;
  }

  // clean up and go home!
  $conn->close();
}

The new thing this method does is make sure that the user is no longer logged in to our system, which is done with the clearLoginEntriesForSessionID call on your UserManager.

Users might have other data in our database that is associated with them. Perhaps they wrote a message or some comments in our system that are still around. Depending on how we have the database schema set up, the database server might cascade and delete those entries when we delete a user account (see the "Foreign Keys and Cascading Deletes" section in Chapter 9, "Designing and Creating Your Database"), or we might end up with an inconsistent data state that we must clean up ourselves at a later time.


Previous
Table of Contents
Next