<

PHP Client Registration, Login, Logout and Easy Access Control

Published on
255,252 Points
68,352 Views
59 Endorsements
Last Modified:
Awarded
Community Pick
Foreword
In the years since this article was written, numerous hacking attacks have targeted password-protected web sites.  The storage of client passwords has become a subject of much discussion, some of it useful and some of it misguided.  Of course nobody would store client passwords in clear-text, but many of the attempted solutions (message digests, encryption, etc) are little better than clear-text.  A companion article about password storage is available here: Password Hashing in PHP.  The discussion and article segment below, An Afterword: About Storing Passwords, is obsolete and should not be used as the basis for your applications.

Introduction
A frequent design pattern question for new PHP developers goes something like this, "How do I handle client registration and login?"  It's done in every framework and CMS, and all of them use a similar pattern.  This article builds the pattern step-by-step so you can see what is going on at each part of the code.

For this example, we rely on the PHP session handler to tell us if the client is logged in.  We also use cookies so that we can "remember" that a client is logged in, and we employ a data base table that contains our client information.  

Our implementation of this design pattern gives us the ability to password-protect a web page with a single line of PHP code like this:
access_control();

Open in new window

Furthermore, we can test for a client login (without actually requiring a login) with this:

if (access_control(TRUE)) { /* CLIENT IS ALREADY LOGGED IN */ }

Open in new window


There are some notes and afterwords at the end of this article.  If you've got time, you might want to scroll down and read those now.  They go deeper into issues that you might encounter as you try to use this design.  Then come back and follow along.

Conventions and Standards
1. We agree to the convention that we use session_start() on all pages, no exceptions.  You probably want to add session_start() to the top of a common script that gets included at the top of all your page scripts, so that each page of your web site starts something like this:
<?php 
require_once('RAY_EE_config.php'); 
// PHP AND HTML CODE FOLLOWS BELOW

Open in new window

The "config" script is also a good place to have your data base connection and selection code, as well as your define() statements and your local variables, classes and functions  You probably already knew that you want to have those common elements in a single, easy to find, script.

2. We agree to the convention that we add the access_control() statement to the VERY TOP of every page you want to protect.  Why the top?  Because we may need to use the header() function and it is a protocol restriction of HTTP that all headers must be presented and completed before any browser output.  The header() statement will fail, and your script will fail if this protocol is violated.  There are ways to comply with this protocol while also creating browser output.  The PHP function ob_start() can help with this.  But ob_start() is not necessary for our tasks here -- we process the data in the correct order and produce browser output, if any, only after we have finished with all the header commands.  With those understandings in place, let's begin building the framework for our access control system.  

Common Elements - the Config Script
The first step will be to create the "config" script that contains the common elements needed by all our web page scripts.  You will need to make some customization of these examples.  In particular, you will want to add your own data base credentials near lines 15-20 of the script below.  With that information in place, you should be able to install each of these code snippets on your own server and run them to see the "moving parts" in action.

With our data base credentials in place, we connect to the DB server and select our data base (lines 21-34).

Have a look at the access_control() function that is defined on line 36.  The first thing it does is save the client's entry point so we can return to the right page after client authentication.  We use the REQUEST_URI string instead of PHP_SELF because REQUEST_URI contains not only our URL address, but also the URL arguments.  PHP_SELF has only the URL address.  Next, we test the session UID variable.  If this is set, the client is already logged in, so no further processing is needed, and we return TRUE.  We only get to the next lines of this function if the client is not logged in.  If the client is not logged in, we look at the $test argument.  By default it is set to FALSE, but if the calling script has set it to TRUE, we will return an indicator that the client is not logged in.  If the client is not logged in, and this is NOT a test, we must protect the page.  So we redirect the client browser to our login page and exit the script.

We need one more function to complete our work -- the function that sets the "remember me" cookie.  This definition begins on line 53.  We name the cookie "uuk" for "unique user key" and we make its lifetime dependent on the definition of the constant REMEMBER that we defined on line 10.  More information on setcookie() is available in the PHP online manual, here: http://php.net/manual/en/function.setcookie.php

With the "config" framework in place, we can begin every web page by executing the common code found starting on line 80.  The first thing we test for is the presence of the "uid" in the session array.  If we have that, we have an authenticated client and no other processing is needed.  But if $_SESSION["uid"] is not set we still may be able to remember this client from an earlier visit, via the information in the "uuk" cookie.  If that is cookie is set, we may be able to look up the client in our data base and accomplish the login automatically, thus fulfilling our "remember me" promise.

Because the $_COOKIE array contains data stored on the client computer, it must be considered "tainted" external data.  Therefore a minimum sanity check is to run the data through mysqli::real_escape_string() before using it in our query.  With the data thus prepared, we can query the user table and try to find the UID that matches this unique user key.  If we find that UID in the data base, we copy it into the session array (line 101) and the client is now logged in.  Our last step is to call the remember_me() function on line 104, extending our memory of this client.  By calling this function here, a client who has asked to be remembered and who visits the site frequently will be remembered repeatedly, perhaps forever.  Here is the common "config" script:
<?php // RAY_EE_config.php

// WHEN WE ARE DEBUGGING OUR CODE, WE WANT TO SEE ALL THE ERRORS!
error_reporting(E_ALL);

// REQUIRED FOR PHP 5.1+
date_default_timezone_set('America/Chicago');

// THE LIFE OF THE "REMEMBER ME" COOKIE
define('REMEMBER', 60*60*24*7); // ONE WEEK IN SECONDS

// WE WANT TO START THE SESSION ON EVERY PAGE
session_start();

// CONNECTION AND SELECTION VARIABLES FOR THE DATABASE
$db_host = "localhost"; // PROBABLY THIS IS OK
$db_name = "??";        // GET THESE FROM YOUR HOSTING COMPANY
$db_user = "??";
$db_word = "??";

// OPEN A CONNECTION TO THE DATA BASE SERVER AND SELECT THE DB
$mysqli = new mysqli($db_host, $db_user, $db_word, $db_name);

// DID THE CONNECT/SELECT WORK OR FAIL?
if ($mysqli->connect_errno)
{
    $err
    = "CONNECT FAIL: "
    . $mysqli->connect_errno
    . ' '
    . $mysqli->connect_error
    ;
    trigger_error($err, E_USER_ERROR);
}

// DEFINE THE ACCESS CONTROL FUNCTION
function access_control($test=FALSE)
{
    // REMEMBER HOW WE GOT HERE
    $_SESSION["entry_uri"] = $_SERVER["REQUEST_URI"];

    // IF THE UID IS SET, WE ARE LOGGED IN
    if (isset($_SESSION["uid"])) return $_SESSION["uid"];

    // IF WE ARE NOT LOGGED IN - RESPOND TO THE TEST REQUEST
    if ($test) return FALSE;

    // IF THIS IS NOT A TEST, REDIRECT TO CALL FOR A LOGIN
    header("Location: RAY_EE_login.php");
    exit;
}

// DEFINE THE "REMEMBER ME" COOKIE FUNCTION
function remember_me($uuk)
{
    // CONSTRUCT A "REMEMBER ME" COOKIE WITH THE UNIQUE USER KEY
    $cookie_name    = 'uuk';
    $cookie_value   = $uuk;
    $cookie_expires = time() + date('Z') + REMEMBER;
    $cookie_path    = '/';
    $cookie_domain  = NULL;
    $cookie_secure  = FALSE;
    $cookie_http    = TRUE; // HIDE COOKIE FROM JAVASCRIPT (PHP 5.2+)

    // SEE http://php.net/manual/en/function.setcookie.php
    setcookie
    ( $cookie_name
    , $cookie_value
    , $cookie_expires
    , $cookie_path
    , $cookie_domain
    , $cookie_secure
    , $cookie_http
    )
    ;
}


// DETERMINE IF THE CLIENT IS ALREADY LOGGED IN BECAUSE OF THE SESSION ARRAY
if (!isset($_SESSION["uid"]))
{

    // DETERMINE IF THE CLIENT IS ALREADY LOGGED IN BECAUSE OF "REMEMBER ME" FEATURE
    if (isset($_COOKIE["uuk"]))
    {
        $uuk = $mysqli->real_escape_string($_COOKIE["uuk"]);
        $sql = "SELECT uid FROM EE_userTable WHERE uuk = '$uuk' LIMIT 1";
        $res = $mysqli->query($sql);

        // IF THE QUERY SUCCEEDED
        if ($res)
        {
            // THERE SHOULD BE ONE ROW
            $num = $res->num_rows;
            if ($num)
            {
                // RETRIEVE THE ROW FROM THE QUERY RESULTS SET
                $row = $res->fetch_assoc();

                // STORE THE USER-ID IN THE SESSION ARRAY
                $_SESSION["uid"] = $row["uid"];

                // EXTEND THE "REMEMBER ME" COOKIE
                remember_me($uuk);
            }
        }
    }
}

Open in new window


The DB Table - Our Client Data Model
Now that we have created our common "config" script, it is time to create the data base table that will facilitate the access control functionality.  This code in the "create" snippet, below, should do it nicely.  The EE_userTable contains three columns.  These are the user identity in the "uid" column, the user password in the "pwd" column and the user's unique key in the "uuk" column.  In real life we would never store the password in clear text; we would store an abstraction or encrypted version of the password. (Please see the Afterword at the end of this article).  But for this example, it makes the design easier to understand if we process the password in clear text.  And in real life we would probably have many other fields associated with the client record - perhaps an email address and a DATETIME field indicating when the client registered, etc.
<?php // RAY_EE_create.php
require_once('RAY_EE_config.php');

// ACTIVATE THIS TO DROP THE OLD EE_userTable
// $mysqli->query("DROP TABLE EE_userTable");

$sql
= "CREATE TABLE EE_userTable
( _key INT         NOT NULL AUTO_INCREMENT
, uid  VARCHAR(16) NOT NULL DEFAULT '?'
, pwd  VARCHAR(16) NOT NULL DEFAULT '?'
, uuk  VARCHAR(32) NOT NULL DEFAULT '?'
, PRIMARY KEY (_key)
)
"
;
if (!$res = $mysqli->query($sql)) trigger_error( $mysqli->error, E_USER_ERROR );

Open in new window


The Tools for Testing
At this point it would be a good idea to create a test bed for our new scripts and our new data base table.  We will want two scripts to test with.  One will be completely access-controlled.  The other will be a public page that simply tests for access control and uses the response to determine what kind of output to create.  These two scripts meet those requirements.
<?php // RAY_EE_controlled.php
require_once('RAY_EE_config.php');

// ACCESS TO THIS PAGE IS CONTROLLED
$uid = access_control();

echo "<br/>HELLO $uid AND WELCOME TO THE ACCESS CONTROLLED PAGE";

Open in new window


<?php // RAY_EE_public.php
require_once('RAY_EE_config.php');

// ACCESS TO THIS PAGE IS TESTED BUT NOT CONTROLLED
if ($uid = access_control(TRUE))
{
    echo "<br/>HELLO $uid AND WELCOME TO THE PUBLIC PAGE";
}
else
{
    echo "<br/>HELLO STRANGER.";
    echo "<br/>YOU MIGHT WANT TO <a href=\"RAY_EE_register.php\">REGISTER</a> ON THIS SITE";
    echo "<br/>IF YOU ARE ALREADY REGISTERED, YOU CAN <a href=\"RAY_EE_login.php\">LOG IN HERE</a>";
}

Open in new window


Convenience for Clients - the Registration Page
If we only have a few users, we could register all our users by hand with phpMyAdmin, but if we have a public-facing web site that would be a lot of work.  Instead we want to create a page so our users can register themselves.  The page below page will do the registration work for us.  Sidebar note: If you want to extend this idea a little bit, the EE article here might be helpful to you.
http://www.experts-exchange.com/Web_Development/Web_Languages-Standards/PHP/A_3939-Registration-and-Email-Confirmation-in-PHP.html

Like all our scripts, this one starts with the PHP command to load our "config" script, require_once('RAY_EE_config.php'), on line 2.  We use the require form of the include() function because our scripts cannot run without our config file.  We use the once form of the include() function because the config file contains function definitions, and any attempt to redefine a PHP function results in a fatal error.  

To start our registration process, we optimistically set the $err variable to NULL (line 5), assuming that there are no errors.  We can check it later to see if we had any errors.   Then we look at the $_POST array to see if we have all the information needed to process the registration.  If we do not have the registration information yet, the script will fall to line 53, where it will present the registration form.  If we have all the registration information from the form we can try to create the client record.

Our first step in creating the client record is to make some effort to sanitize the external input.  We do not do anything beyond the basics here; in real life, there might be much more extensive filtering and testing.  But for this example, we simply protect our data base by escaping the three text fields that we received from the form inputs.  

Our first important test is to see if the two password fields match.  We do this because the client is typing into a form input field of type="password" and the browser will obscure the input as it is typed.  So to be nice to our client, we ask her to type the password twice and we check for a match.  If she typed her password twice the same way, we can be fairly sure that a typographical error will not cause a bogus password to be assigned to her account.   If there is a mismatch, we append an error message to the $err variable.

Next we check the data base to see if the UID has already been taken.  If this is the case, we will consider it to be an error, and we will set the error indicator in the same way we set the error indicator for a password mismatch - by adding the error message to the end of the $err variable.  (You might consider marking the uid column in the MySQL table with UNIQUE.  MySQL will throw error #1062 if an attempt is made to put duplicate information into a UNIQUE column).

Once our edits are complete (line 25) we test the $err value.  Since a NULL string will return FALSE when tested with the PHP if() statement, we can simply check to see if $err remains empty. We use the PHP "not" expression, the exclamation point.  If we had errors, this test will fail and the code will fall to line 48 where we show the error message(s) and present the registration form again.  If there are no errors that prevent registration, we can continue our processing on line 28.  

We use the combination of the user id, password and a random number to create a completely unique and unpredictable value.  This is the "unique user key" that we can use in cookies.  Why not just use the auto_increment key from the data base?  Because a predictable value in the cookie would invite easy hacking.  If a hacker saw that his cookie value was, for example, "123" he might be tempted to try changing his cookie to "122" and see what happens.  When we use the md5() hash of the unique data combination we make it somewhat harder to guess what value might work in a hacked cookie.  Our query puts the user id, password, and unique user key into the table and our registration is complete.  

Our client can log in now.  But why make them log in separately after they register?  With a single line of code (line 33) we can complete their login at the time of registration.  We always want to be nice to our clients!

Next we turn our attention to the question of whether the client wants us to remember the logged-in status.  If the "rme" checkbox was checked, it will be set in the $POST array, and we can test it to see if we need to remember the client.  (Unlike empty input fields of type="text", type="checkbox" fields that are not checked do not appear in the $_POST array at all).  So if "rme" is there, we call the remember_me() function and pass it the unique user key.  If not, we skip this step and no cookie will be set.  With our registration and login work now completed, we welcome the client on line 42 and conclude the script on line 44.  Here is the registration script:
<?php // RAY_EE_register.php
require_once('RAY_EE_config.php');

// WE ASSUME NO ERRORS OCCURRED
$err = NULL;

// WAS EVERYTHING WE NEED POSTED TO THIS SCRIPT?
if ( (!empty($_POST["uid"])) && (!empty($_POST["pwd"])) && (!empty($_POST["vwd"])) )
{
    // YES, WE HAVE THE POSTED DATA. ESCAPE IT FOR USE IN A QUERY
    $uid = $mysqli->real_escape_string($_POST["uid"]);
    $pwd = $mysqli->real_escape_string($_POST["pwd"]);
    $vwd = $mysqli->real_escape_string($_POST["vwd"]);

    // DO THE PASSWORDS MATCH?
    if ($pwd != $vwd) $err .= "<br/>FAIL: CHOOSE AND VERIFY PASSWORDS DO NOT MATCH";

    // DOES THE UID ALREADY EXIST?
    $sql = "SELECT uid FROM EE_userTable WHERE uid = '$uid' LIMIT 1";
    if (!$res = $mysqli->query($sql)) trigger_error( $mysqli->error, E_USER_ERROR );
    $num = $res->num_rows;
    if ($num) $err .= "<br/>FAIL: UID $uid IS ALREADY TAKEN.  CHOOSE ANOTHER";

    // IF THERE WERE NO ERRORS THAT PREVENT REGISTRATION
    if (!$err)
    {
        // MAKE THE UNIQUE USER KEY
        $uuk = md5($uid . $pwd . rand());
        $sql = "INSERT INTO EE_userTable (uid, pwd, uuk) VALUES ('$uid', '$pwd', '$uuk')";
        if (!$res = $mysqli->query($sql)) trigger_error( $mysqli->error, E_USER_ERROR );

        // STORE THE USER-ID IN THE SESSION ARRAY
        $_SESSION["uid"] = $uid;

        // IS THE "REMEMBER ME" CHECKBOX SET?
        if (isset($_POST["rme"]))
        {
            remember_me($uuk);
        }

        // REGISTRATION AND LOGIN COMPLETE
        echo "<br/>WELCOME $uid. REGISTRATION COMPLETE.  YOU ARE LOGGED IN.";
        echo "<br/>CLICK <a href=\"/\">HERE</a> TO GO TO THE HOME PAGE";
        die();
    }

    // IF THERE WERE ERRORS
    else
    {
        echo $err;
        echo "<br/>SORRY, REGISTRATION FAILED";
    }
} // END OF FORM PROCESSING - PUT UP THE FORM
?>
<form method="post">
PLEASE REGISTER
<br/>CHOOSE USERNAME: <input name="uid" />
<br/>CHOOSE PASSWORD: <input name="pwd" type="password" />
<br/>VERIFY PASSWORD: <input name="vwd" type="password" />
<br/><input type="checkbox" name="rme" />KEEP ME LOGGED IN (DO NOT CHECK THIS ON A PUBLIC COMPUTER)
<br/><input type="submit" value="REGISTER" />
</form>

Open in new window


Client Authentication - the Login Page
Now that we can register our users, and we have test pages that let us see the registrations in action, we need to create login and logout pages.  

The login page uses a structure similar to the registration page.  We require our "config" page, then we test to see if the necessary credentials have been provided (line 5).  We filter and sanitize the external inputs (line 8-9), then we query the data base, looking for exactly one match on the UID and PWD fields (lines 12-20).  If we do not find a row that matches on both UID and PWD, the if() statement on line 20 will fail and the script will fall to line 46, where we can tell the client that authentication failed, and we can present the login form again.  If we did find the one row we were looking for, we retrieve the row (line 23) and copy the UID value into the session array (line 26) to show that the client is now logged in.  Our next step is to see if the client checked the "remember me" box.  We test for that checkbox (line 29) and if it is set, we call the remember_me() function, passing the unique user key that we created at the time of registration.  The remember_me() function sets a long-life cookie on the browser.  Without this cookie, only the session cookie will be used to remember the client.  And session cookies expire when the browser window is closed.  

Our final step in login processing is to determine where the client wants to go next.  We do this by testing the "entry_uri" in the session array.  If it was set by the access_control() function in the config script, we can use that address in the header() command to take the client back to the original page.  If it was not set, which would occur if the client came directly to the login page, we can redirect to the home page instead.

Perhaps it should go without saying, but I'll say it anyway: do not put the access_control() function in your login script, or else your code may cause a loop on the server!  Here is the login script:
<?php // RAY_EE_login.php
require_once('RAY_EE_config.php');

// WAS EVERYTHING WE NEED POSTED TO THIS SCRIPT?
if ( (!empty($_POST["uid"])) && (!empty($_POST["pwd"])) )
{
    // YES, WE HAVE THE POSTED DATA. ESCAPE IT FOR USE IN A QUERY
    $uid = $mysqli->real_escape_string($_POST["uid"]);
    $pwd = $mysqli->real_escape_string($_POST["pwd"]);

    // CONSTRUCT AND EXECUTE THE QUERY - COUNT THE NUMBER OF ROWS RETURNED
    $sql = "SELECT uid, uuk FROM EE_userTable WHERE uid = '$uid' AND pwd = '$pwd' LIMIT 1";
    $res = $mysqli->query($sql);

    // IF THE QUERY FAILED, GIVE UP
    if (!$res) trigger_error( $mysqli->error, E_USER_ERROR );

    // THERE SHOULD BE ONE ROW IF THE VALIDATION WAS PROCESSED SUCCESSFULLY
    $num = $res->num_rows;
    if ($num)
    {
        // RETRIEVE THE ROW FROM THE QUERY RESULTS SET
        $row = $res->fetch_assoc();

        // STORE THE USER-ID IN THE SESSION ARRAY
        $_SESSION["uid"] = $row["uid"];

        // IS THE "REMEMBER ME" CHECKBOX SET?
        if (isset($_POST["rme"]))
        {
            remember_me($row["uuk"]);
        }

        // REDIRECT TO THE ENTRY PAGE OR TO THE HOME PAGE
        if (isset($_SESSION["entry_uri"]))
        {
            header("Location: {$_SESSION["entry_uri"]}");
            exit;
        }
        else
        {
            header("Location: /");
            exit;
        }
    } // END OF SUCCESSFUL VALIDATION
    else
    {
        echo "SORRY, VALIDATION FAILED USING $uid AND $pwd \n";
    }
} // END OF FORM PROCESSING - PUT UP THE LOGIN FORM
?>
<form method="post">
PLEASE LOG IN
<br/>UID: <input name="uid" />
<br/>PWD: <input name="pwd" type="password" />
<br/><input type="checkbox" name="rme" />KEEP ME LOGGED IN (DO NOT CHECK THIS ON A PUBLIC COMPUTER)
<br/><input type="submit" value="LOGIN" />
</form>

Open in new window


Client Un-Authentication - the Logout Page
If our client logs in, and does not check the "remember me" box, he will be automatically logged out when his browser window closes, or when the session garbage collection routines detect a long period of inactivity (usually about 24 minutes).  But our client may want to deliberately log out sooner, or may be at a public computer where a logout is a prudent thing to do.  So we must also provide a logout script, shown below.  

As usual, our first step is to load our "config" script.  Next we collect the UID from the session array, or set an alternative value.  We are going to use this in the "goodbye" message, so we try to choose a data string that makes sense even if the client goes to the logout script twice in a row, or somehow manages to go there when she is not logged in.  That's done in the ternary operator statement (line 5).  

We dispose of the "remember me" cookie, if any (lines 7-12).  

Our next step is to clear the session array (line 15).  That may appear a little ham-fisted, and you might consider whether complete elimination of the session makes sense.  If you have other information in the session that you want to keep even after the client logs out, you might just unset($_SESSION["uid"]) on line 15 and skip the rest of the code.  However you should never use unset($_SESSION) to clear the array.  See the cautionary note here:
http://php.net/manual/en/session.examples.basic.php

Finally, you can say "goodbye" using the data string we created on line 5.  Or you can eliminate the browser output and instead activate the header("location: /") command sequence to redirect to the home page.  

Take a look at the "exit;" statement on the last line.  Although it is not strictly needed here, the use of "exit" after a header("Location") statement is a good habit to cultivate.  Why?  Because your script keeps right on running after it has sent the header() and will run for an unpredictable period of time -- until the browser receives the header and stops your script by redirecting.  There are lots of appropriate header() statements that may be used inline as part of a complete script, but when you use one that is intended to be the last statement in a script, you need to take the additional step of ensuring that it is, in fact, the last statement that gets executed.  Here is the logout script:
<?php // RAY_EE_logout.php
require_once('RAY_EE_config.php');

// GRAB THE UID OR A CONSTANT FOR THE GOODBYE MESSAGE
$uid = (isset($_SESSION["uid"])) ? ', ' . $_SESSION["uid"] : ' NOW';

// IF THE "REMEMBER ME" COOKIE IS SET, FORCE IT TO EXPIRE
$cookie_expires	= time() - date('Z') - REMEMBER;
if (isset($_COOKIE["uuk"]))
{
   setcookie("uuk", '', $cookie_expires, '/');
}

// CLEAR THE INFORMATION FROM THE $_SESSION ARRAY
$_SESSION = array();

// IF THE SESSION IS KEPT IN COOKIE, FORCE SESSION COOKIE TO EXPIRE
if (isset($_COOKIE[session_name()]))
{
   setcookie(session_name(), '', $cookie_expires, '/');
}

// TELL PHP TO ELIMINATE THE SESSION
session_destroy();
session_write_close();

// SAY GOODBYE...
echo "YOU ARE LOGGED OUT$uid.  GOODBYE.";

// OR REMOVE THE GOODBYE MESSAGE AND ACTIVATE THESE LINES TO REDIRECT TO THE HOME PAGE
// header("Location: /");
// exit;

Open in new window


Client Security - the Password Page
We all know that we should change our passwords from time to time.  And it is very easy to give our clients this capability.  Similar in design to the registration page, the password page takes in a pair of new passwords, checks for a match and updates the data base to store the new password.  But there are a few important differences.

Whenever we change something in the data model, we need to re-verify our client's password.  Why?  Because we are all human and we might accidentally leave our computer logged in.  A sneaky person might try to change our password (or other information) while we were not looking.  To reduce this risk, we use a design pattern that is similar to one used at modern ATM consoles.  Before each withdrawal of funds, you must re-enter your PIN.  It is a small inconvenience, and a powerful security measure.  So we will do the same thing in our change-the-password script.  We would use this design pattern whenever we change something important in the client records, like the email address or shipping address.

Our first step is to load the "config" script, and call the access control function to retrieve the client uid.  We assume that no errors have occurred (line 8).  We require three pieces of information from the client - the old password and the new password, typed twice to be sure the new password is typed correctly.  If we have all three of these (line 11) we can begin to process the request.

We sanitize the values for use in a query (line 14-17).  Next we test to see if the two password fields match.  We do this because the client is typing into a form input field of type="password" and the browser will obscure the input as it is typed.  So to be nice to our client, we ask her to type the password twice and we check for a match (line 20).  If there is a mismatch, we append an error message to the $err variable.

Next we check the data base to be sure that the UID record exists in combination with the old password.  There should be exactly one such row in the user table.  If this is not the case, we will consider it to be an error, and we will set the error indicator (line 26) in the same way we set the error indicator for a password mismatch - by adding the error message to the end of the $err variable.

Once our edits are complete (line 29) we test the $err value.  If we had errors, this test will fail and the code will fall to line 42 where we show the error message(s) and present the password form again.  If there are no errors that prevent the password change, we can continue our processing on line 32.  

Notice the WHERE clause in the UPDATE query on line 32 - it is exactly the same WHERE clause that we used in the SELECT query on line 23.  We already know that there is one and only one row that satisfies this WHERE clause.  Thus we are sure that the correct row will be updated with the new password.  As a nod to MySQL performance we tell the data base that there is a LIMIT of one row.  This avoids a table scan.  MySQL will stop, knowing its work is complete, as soon as it has updated the matching row.

The password change is finished and we give the client a success message (line 36).  If your real-world implementation had the client's email address in the user table (and it almost certainly would) you might extend this to send her an email message about the password change.  Good security practices would preclude you from sending the actual password.
<?php // RAY_EE_password.php
require_once('RAY_EE_config.php');

// ACCESS TO THIS PAGE IS CONTROLLED
$uid = access_control();

// WE ASSUME NO ERRORS OCCURRED
$err = NULL;

// WAS EVERYTHING WE NEED POSTED TO THIS SCRIPT?
if ( (!empty($_POST["old"])) && (!empty($_POST["pwd"])) && (!empty($_POST["vwd"])) )
{
    // YES, WE HAVE THE NEEDED DATA. ESCAPE IT FOR USE IN A QUERY
    $uid = $mysqli->real_escape_string($uid);
    $old = $mysqli->real_escape_string($_POST["old"]);
    $pwd = $mysqli->real_escape_string($_POST["pwd"]);
    $vwd = $mysqli->real_escape_string($_POST["vwd"]);

    // DO THE PASSWORDS MATCH?
    if ($pwd != $vwd) $err .= "<br/>FAIL: CHOOSE AND VERIFY PASSWORDS DO NOT MATCH";

    // DOES THE UID AND OLD PASSWORD COMBINATION EXIST?
    $sql = "SELECT uid FROM EE_userTable WHERE uid = '$uid' AND pwd = '$old' LIMIT 1";
    if (!$res = $mysqli->query($sql)) trigger_error( $mysqli->error, E_USER_ERROR );
    $num = $res->num_rows;
    if ($num != 1) $err .= "<br/>FAIL: $uid DOES NOT HAVE PASSWORD $old";

    // IF THERE WERE NO ERRORS TO PREVENT THE PASSWORD CHANGE
    if (!$err)
    {
        // UPDATE THE TABLE TO CHANGE THE PASSWORD
        $sql = "UPDATE EE_userTable SET pwd = '$pwd' WHERE uid = '$uid' AND pwd = '$old' LIMIT 1";
        if (!$res = $mysqli->query($sql)) trigger_error( $mysqli->error, E_USER_ERROR );

        // PASSWORD CHANGE IS COMPLETE
        echo "<br/>THANK YOU, $uid. PASSWORD CHANGE IS COMPLETE.";
        echo "<br/>CLICK <a href=\"/\">HERE</a> TO GO TO THE HOME PAGE";
        die();
    }

    // IF THERE WERE ERRORS
    else
    {
        echo $err;
        echo "<br/>SORRY, PASSWORD CHANGE FAILED";
    }
} // END OF FORM PROCESSING - PUT UP THE FORM
?>
<form method="post">
CHANGE YOUR PASSWORD
<br/>FORMER PASSWORD: <input name="old" type="password" />
<br/>CHOOSE PASSWORD: <input name="pwd" type="password" />
<br/>VERIFY PASSWORD: <input name="vwd" type="password" />
<br/><input type="submit" value="CHANGE" />
</form>

Open in new window


Summary - Putting it into Practice
This is everything it takes to password protect your web pages using basic PHP authentication.  Once you have this structure in place, you can present public registration and login pages, and build your site from a combination of public, protected, and partially protected web pages.  Most importantly, you can make the authentication tests with a single line of PHP code.  The scripts use the PHP session to identify the logged-in clients.  The clients can ask your site to remember their status, and you can do them the favor.  They can log out at any time, or can log out automatically after a period of inactivity.   They can protect their account information by changing their passwords whenever they want.

The comments and code here will work correctly in most PHP installations, but they are intended to be teaching examples and are not intended for use "as is" in a production environment - so please feel free to copy and modify them to suit your particular needs.

An Afterword: Understanding PHP Sessions
While these scripts use the PHP session in a way that makes sense, I've found that it is easy to overthink the way PHP sessions work.  They are much easier to use than you might expect!  You might want to read these two articles for a better understanding of the underlying technologies that our PHP client authentication depends upon.  

Article about PHP Sessions.
Article about  HTTP Client/Server Protocols.

An Afterword: Preventing Automated Registrations
Ever wonder how online forums get so many spammy Viagra ads?  The ads are placed by attack 'bot scripts that find the registration form and sign up for an account, then use the account to post unwanted material.  There are two techniques that, when taken together, will reduce the risk of this kind of intrusion.  The first is the use of a CAPTCHA test on the registration form.  The second is the use of a "handshake" which requires an additional step to confirm the registration via instructions in email.  Either of these can be effective alone; taken together they are even more effective.

An Afterword: MySQL and MySQLi  (Spring 2014)
This article was originally written several years ago, when PHP supported the MySQL database extension.  That has since changed, and if you used the older version of this article for guidance you may need to change your scripts.  Fortunately the changes are very easy to accomplish if you choose object-oriented MySQLi.  To learn why PHP is doing away with MySQL support, and what you must do to keep your scripts running, please see this article that teaches how to convert procedural MySQL code into MySQLi or PDO.

An Afterword: PHP session_unregister() (Fall 2014)
Some obsolete code sets contain "Logout" examples that use the session_unregister() function.  PHP deprecated this function many years ago and removed it more recently.  Unfortunately, PHP code examples do not come with expiration dates, so you may encounter obsolete code without any warning labels.  If you have a script that uses session_unregister(), you should replace the function name with unset(), using the same function call arguments.  Going forward, do not use session_unregister(), session_register(), or session_is_registered().  To understand the rest of this article, you may want to refresh your memory of how PHP sessions work.

An Afterword: About Storing Passwords
In practice, you would not store client passwords in clear text.  You can use the PHP md5() function to encode passwords.  MD5 is an abbreviation for "message digest." Please read the critique, here.  I disagree with the notion that md5() hashing is inadequate for most use cases if you understand what you're doing and use md5() correctly.  However many experts favor password hashing.  Or at least encryption.  Here is my thinking on the subject of md5().

1. Let's say you have foolishly stored the client password in clear text.  Advantage: Your scripts can send the client password when the client forgets the password.  Disadvantage: If your DB is compromised, all of the client passwords are exposed.

2. Let's say you have not stored clear-text passwords, but have instead foolishly stored the md5() digest of the password.  It looks something like this...

5f4dcc3b5aa765d61d8327deb882cf99

...which is the md5() for "password."  You can't readily tell that this md5() string matches "password" just by looking at it, but the problem with md5() digests is that they are programmatically idempotent.  No matter how many times you hash "password" you will always get the same md5() string.  If two people choose the same password, the md5() string will be the same.  Hackers know the md5() strings of the most popular passwords, so if your DB is compromised, many of the client passwords are exposed.  How many of the common passwords are know to hackers?  Would you believe millions?  You better believe it!

3. If you let your clients choose their own passwords and one of them chooses "password," it is all the more likely that a hacker can decipher the other common passwords.  A brute-force approach that matches the md5() strings of the popular passwords is computationally trivial.  It would take at most a few seconds to match every dictionary word with its md5() digest.

4. In an effort to make the passwords harder to crack, a sub-industry has sprung up full of all sorts of voodoo and nonsense about cryptography.  You can avoid the nonsense and learn more about this from the OWASP project.  You can use password hashing to make your password safety stronger.

5. Because md5() is idempotent, security demands that something must be done to break the direct links between, eg, "password" and 5f4dcc3b5aa765d61d8327deb882cf99.  That something is a "salt" added to the password during the encoding process.  With a salt string appended to the password, the idempotent relationship requires not only the password, but also the salt.  So if a client chooses "password" the server stores the md5 string of something like "passwordXYZ" and the resulting digest is cceef54fde042f058f571084338e2c40.  Compare these md5 strings and see if you can see a relationship.  I can't.

5f4dcc3b5aa765d61d8327deb882cf99
cceef54fde042f058f571084338e2c40

6. Your salt does not have to be easy to guess.  But it has to be stored somewhere that makes it programmatically available to PHP during the execution of your scripts.  You would want to guard it carefully.  If the salting string(s) and algorithm were compromised, your client's passwords are potentially exposed.  Maybe you want to put this into a PHP script that is stored above the WWW root and brought into the scope of the web root scripts via the include() function.

7. You can salt both ends of the password (salt and pepper).  You can use very long and arbitrary strings for the salt and pepper.  As long as you add the same salt and pepper to whatever the client types into the password box, your md5() algorithm will create the idempotent message digest.  And you can match the password with a simple SQL query.

8. The likelihood of md5() collision (two different input strings matching the same message digest) is about the same as the likelihood that you will meet someone else with your DNA sequence.  This is theoretically possible, but don't bet on it.

9. How sturdy is a salted password?  I will buy anyone a beer who can tell me the original input string that created this md5() digest:  

e0f1299ed629d3c8826e2dd2be4780cf

To facilitate your search for the original input string, here is a link to the explanation of the md5() algorithm.
http://www.faqs.org/rfcs/rfc1321.html

And I have installed this easy-to-use script on my server.  Experiment here:
http://www.iconoun.com/demo/md5.php

10. If you choose good salt and pepper strings and keep them secret, your md5() digest will be adequate to protect client passwords in most cases.  But you can't fix stupid.  If a client chooses "password" and your login process requires only an email address and a password, there isn't much protecting that client from exposure.  It doesn't matter how you encode "password" internally.  Anyone who knows the email addresses in the site can try pairing each one with "password" or the other common passwords to see if the pairing is effective.  No doubt, if your population is big enough, there is somebody in your client community using "password" and she is going to be among the first victims of any attack.

11. For reason 10, some password selection processes require you to use a combination of letters and numbers, upper and lower, etc.  I find these annoying and tend to prefer the idea of a multi-word pass-phrase instead of a single password.  I'm sure there will be popular and common phrases, and you would be wise to choose random words in the phrase, and include some things that are not even words at all.  And a well-salted phrase is at least as good as a well-salted password.  

12. The md5() string is always a 32 character hexadecimal number, no matter what the input string contains.  It follows that data base storage must have a 32-byte column width.

This article from ArsTechnica gives an insider's view of how hackers might go about attacking your encoded passwords.  Ultimately your passwords are like the doors of a fire safe.  They are rated on the basis of time and temperature.  Stronger doors mean you have more time before the contents are incinerated.  Unfortunately, each exercise like the one conducted by ArsTechnica adds to the basic dictionary of passwords and pass-phrases, and the dictionary attacks are the fastest to succeed.

And it matters what you're trying to protect.  If you have bowling scores, or purchase histories, or medical records, or financial details, or nuclear launch codes the security measures are likely to be different.

Further Reading on Security (updated periodically)
PHP Security (required reading for anyone who writes even one line of PHP code)
Password Hashing in PHP (better than message digests or encryption)
SitePoint (the original is old, but updated recently)
The danger of Register Globals
OWASP is worth joining
http://xkcd.com/327/
CodingHorror

Please give us your feedback!
If you found this article helpful, please click the "thumb's up" button below. Doing so lets the E-E community know what is valuable for E-E members and helps provide direction for future articles.  If you have questions or comments, please add them.  Thanks!
 
59
Author:Ray Paseur
Ask questions about what you read
If you have a question about something within an article, you can receive help directly from the article author. Experts Exchange article authors are available to answer questions and further the discussion.
Get 7 days free