facebook twitter
Webucator's Free PHP Tutorial

Lesson: Authentication with PHP and SQL

Welcome to our free PHP tutorial. This tutorial is based on Webucator's Introduction to PHP Training course.

In this lesson, you will learn how to create a secure and user-friendly registration and authentication process.

Lesson Goals

  • To safely store passwords and pass phrases.
  • To create and work with tokens.
  • To manage session variables.
  • To manage cookies.
  • To create a login form.
  • To create a logout page.
  • To create a registration form.
  • To create a pass phrase reset form.

The Registration Process

A good registration process works like this:

  1. User registers choosing a username and password, which they must enter twice to make sure they didn't mistype it.
  2. If registration is valid, the user's data gets stored in the database, but is not marked as confirmed.
  3. An email is sent to the user asking them to confirm their registration. This is to prevent people from registering others without their knowledge.
  4. The user clicks on a link in the email confirming their registration. This marks the user confirmed in the database and brings the user to a page with a login form or a link to a login page.
  5. The user can then log in with their username and password.
  6. If the user doesn't remember their password, they can click a link to reset it. This will generate an email with a password reset link, which leads to a form for resetting the password.
  7. The user can also update their data, including their password, on their "my account" page.

Passwords and Pass Phrases

The users table in our poetree database looks like this:Poetree ER Diagram - UsersNotice that, instead of a traditional password, we are using a pass phrase (pass_phrase). Long pass phrases can be easier for users to remember and more difficult for hackers to discover than shorter complex passwords.

Look at the data in the database table and you'll see that the pass_phrase field contains values like: $2y$10$GTRg3OMHAX5KFXdcVwDaS.oCHHrhiU6BqFF0h5JTpyycDhojjguqW

While that may be difficult for a hacker to figure out, it would also be difficult for a user to remember! Luckily, that isn't the user's actual pass phrase. Rather, it is created using PHP's built-in password_hash() function, which generally takes two arguments:

  1. The password (or phrase) to be hashed.
  2. An integer representing the hashing algorithm. PHP provides several constants holding valid integers. You will most likely use the PASSWORD_DEFAULT algorithm, but you can check out other options at https://www.php.net/password_hash.

The following demo shows how the password_hash() function works.

Code Sample:

Authentication/Demos/password-hash.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../static/styles/normalize.css">
<link rel="stylesheet" href="../../static/styles/styles.css">
<title>password_hash()</title>
</head>
<body>
<main>
  <h1>password_hash()</h1>
  <?php
    $passPhrase = $_POST['pass-phrase'] ?? '';
    if ($passPhrase) {
      $hashedPhrase = password_hash($passPhrase, PASSWORD_DEFAULT);
      
      echo "<p>The entered pass phrase is:<br>
            <code>$passPhrase</code></p>";
      echo "<p>The hashed pass phrase is:<br>
            <code>$hashedPhrase</code></p>";
    }
  ?>
  <form method="post">
    <label for="pass-phrase">Pass phrase:</label>
    <input name="pass-phrase" id="pass-phrase"
      value="<?= $passPhrase ?>">
    <button class="wide">Submit</button>
  </form>
</main>
</body>
</html>

Code Explanation

Visit http://localhost:8888/Webucator/php/Authentication/Demos/password-hash.php and try submitting some phrases. You will see the original phrase and the hashed phrase returned by the password_hash() function:password_hash()

Passwords and Security

For security reasons, you should never return the password to the browser, send it by email, or even store it in a database or a file in its raw form.

Registration with Tokens

In the registration process we laid out above, we included:

  1. If registration is valid, the user's data gets stored in the database, but is not marked as confirmed.
  2. An email is sent to the user asking them to confirm their registration.
  3. The user clicks on a link in the email confirming their registration. This marks the user confirmed in the database.

Here we will look more deeply at the confirmation process, which is done with tokens, which are stored in the database. The diagram below show the relationship between users and tokens:Poetree ER Diagram - Users

Things to note:

  1. A token is associated with a user through the user_id field. The token_expires field will hold the date and time that the token expires.
  2. The token field has a unique constraint, so we can be sure that searching for a specific token will only return 0 or 1 records.
  3. The users table includes a registration_confirmed field, which will default to 0, indicating that the user has not confirmed their registration.

The confirmation process works as follows:

  1. When the user registers, the user data is entered into the users table and a random token is generated and stored in the tokens table with the user_id set to the new user's user_id and the token_expires field set to a date and time by which the user must confirm the registration.
  2. An email is sent to the user with a link to a URL like the one below:  registration-confirm.php?token=823814931337503d622eb2967a7311f3db177bd6afa2db73508535e8fdec1d10
  3. When the user clicks on that link, the page will run the following query:
    UPDATE users
    SET registration_confirmed = 1
    WHERE user_id = (SELECT user_id
              FROM tokens
              WHERE token = ?
              AND token_expires > now() );
    1. The ? will be replaced with the token passed in on the URL.
    2. The subquery gets the user_id from the tokens table for the record with that token that has not yet expired, if such a record exists.
  4. We will redirect the user to login.php?just-registered=1 if the query successfully updates a record, and use the just-registered flag to output a success message:phppoetry.com Login after confirming registration.

Creating a Registration Form

Duration: 45 to 60 minutes.

In this exercise, you will process a registration form. First, let's take a look at the files involved:

  1. utilities.php - This contains our utility functions. We have added a few:
    1. isAuthenticated() - We will look at this soon.
    2. generateToken() - This generates a random string of a specified even length defaulting to 64.
    3. getFullPath() - This is a utility function that creates a full absolute URL from a relative path.
    4. logout() - We will look at this soon.
  2. registration-confirm.php - This is the page the user visits to confirm their registration. It has been done for you. Review it thoroughly.
  3. register.php - This is the page you will be working on.

The three pages are shown below:

Code Sample:

Authentication/Exercises/phppoetry.com/includes/utilities.php
---- C O D E   O M I T T E D ----
  function isAuthenticated() {
    return isset( $_SESSION['user-id']);
  }
---- C O D E   O M I T T E D ----
  function generateToken($length = 64) {
    // generate random token
    if ($length % 2 !== 0) {
      throw new Exception('$length must be even.');
      return false;
    }
    return bin2hex(random_bytes($length/2));
  }
  
  function getFullPath($relativePath) {
    $protocol = ( !empty($_SERVER['HTTPS']) &&
      $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443
                ) ? "https://" : "http://";
    $domainName = $_SERVER['HTTP_HOST'];
    $relPathSplit = explode('/', $relativePath);
    $pathFromHost = dirname($_SERVER['REQUEST_URI']);
    $pathFromHostSplit = explode('/', $pathFromHost);
    while ($relPathSplit[0] === '..') {
      array_shift($relPathSplit);
      array_pop($pathFromHostSplit);
    }
    return $protocol.$domainName . implode('/', $pathFromHostSplit) .
            '/' . implode('/', $relPathSplit);
  }


---- C O D E   O M I T T E D ----
  function logout() {
    unset($_SESSION['user-id']);
    unset($_COOKIE['token']); // unset on server
    setcookie('token', '', 0); // unset on client
  }
?>

Next is the page that the user is directed to to confirm their registration. The code is already complete. Review it.

Code Sample:

Authentication/Exercises/phppoetry.com/registration-confirm.php
<?php
  if (!isset($_GET['token'])) {
    // How did you get here?
    header("Location: index.php");
  }

  $pageTitle = 'Registration Confirmation';
  require 'includes/header.php';
  logout(); // In case a different user has logged in

  $token = $_GET['token'];
?>

<?php
  $qUpdate = "UPDATE users
  SET registration_confirmed = 1
  WHERE user_id = (SELECT user_id
    FROM tokens
    WHERE token = ?
    AND token_expires > now() );";

  try {
    $stmtUpdate = $db->prepare($qUpdate);
  
    if (!$stmtUpdate->execute( [$token] )) {
      // Query failed to execute
      logError($stmtUpdate->errorInfo()[2], true);
    } elseif ($stmtUpdate->rowCount()) {
      // Redirect user to login page
      header("Location: login.php?just-registered=1");
    } // Else no rows were updated. Continue to error message.
  } catch (PDOException $e) {
    logError($e);
  }
?>
<!-- Won't get her unless something went wrong -->
<main class="narrow">
  <h1><?= $pageTitle ?></h1>
  <article class='poem error'>
    <?= nl2br(POEM_INVALID_TOKEN_REGISTRATION) ?>
  </article>
</main>
<?php
  require 'includes/footer.php';
?>

Code Explanation

Things to notice:

  1. The first thing it does is check for the existence of $_GET['token']. The only proper way to get to this page is through the confirmation email we sent, so if the token doesn't exist, it means the user got here improperly, so we redirect to the home page.
  2. After setting $pageTitle and including header.php, we call logout() to force a logout. This is a precaution. It's possible that between the time a visitor registers on the site and confirms the registration, another user has logged in. We will look at the logout() function soon.
  3. We then assign $_GET['token'] to $token.
  4. We then run the update:
    1. If $stmtUpdate->execute([$token]) returns false, the query didn't execute properly (maybe a syntax error?). In this case, we log the error and redirect to the error page.
    2. If $stmtUpdate->rowCount() returns 1 (true), we redirect to the login page, passing the just-registered=1 flag.
    3. Otherwise, no rows were updated, meaning the token either didn't exist or was expired. In this case, we remain on the page and output an error letting them know that something went wrong:phppoetry.com Registration Confirmation Error.

Finally, we have the file in which you will be doing your work: the registration page:

Code Sample:

Authentication/Exercises/phppoetry.com/register.php
---- C O D E   O M I T T E D ----
    // Check if username exists
    $qUsernameCheck = "SELECT user_id
      FROM users
      WHERE username = ?";

    try {
      $stmtUsername = $db->prepare($qUsernameCheck);
      $stmtUsername->execute([ $f['username'] ]);

      if ($stmtUsername->fetch()) {
        $errors[] = 'That username is already taken.<br>
          Please try a different one.';
      }
    } catch (PDOException $e) {
      logError($e);
      $errors[] = 'Oops! Our bad. We cannot register you right now.';
    }

    // TODO: Check if email exists

    if (!$errors) {
      // TODO: Prepare to insert user by creating the $hashedPhrase,
      //       $token, and $qInserts variables
        
      try {
        // TODO: Insert the user
      } catch (PDOException $e) {
        logError($e);
        $errors[] = 'Registration failed. Please try again.';
      }
---- C O D E   O M I T T E D ----

Code Explanation

This is where you will do your work.

Follow the instructions below to complete the exercise:

  1. Open Authentication/Exercises/phppoetry.com/register.php in your editor.
  2. Much of this code is already done for you. Study the code and make sure you understand everything that is already done.
  3. When the user submits the form, we check to make sure the data entered is valid and add error messages to the $errors array as appropriate.
  4. We then check to see if the username is already taken. If it is, we add an error message to the $errors array.
  5. Below the username check, write code checking to see if a user with that email already exists. If one does, add the following error to the $errors array:
    We recognize that email.<br>
    Did you <a href="pass-phrase-reset.php">forget your pass phrase</a>?
  6. If, after validating the data, there are no errors, insert the user and a user token into the database:
    1. Set $hashedPhrase to the hashed user's pass phrase.
    2. Create a 64-character token using the generateToken() method in utilities.php. Name the variable $token.
    3. Set $qInserts to:
      INSERT INTO users
      (first_name, last_name, email, username, pass_phrase)
      VALUES (:first_name, :last_name, :email,
          :username, '$hashedPhrase');
      
      INSERT INTO tokens
      (token, user_id, token_expires)
      VALUES (:token, LAST_INSERT_ID(), 
          DATE_ADD(now(), INTERVAL 1 HOUR));
  7. Within the try block:
    1. Prepare a PDOStatement using $qInserts and assign the result to $stmtInserts.
    2. Use the bindParam() method of $stmtInserts to bind variables to the named parameters. The first one will look like this:
      $stmtInserts->bindParam(':first_name', $f['first-name']);
    3. Attempt to execute $stmtInserts. If it fails, log the error and add the following error to $errors:
      Registration failed. Please try again.
  8. The rest of the code is written.
  9. If there are still no errors:
    1. We build a query string using PHP's built-in http_build_query() function. This function will encode the query string to make it safe for URLs.
    2. With that query string and the getFullPath() function from utilities.php, we create the URL we will send to the user to confirm the registration.
    3. We attempt to send the user the email.
  10. If the email was sent, we output a success message: "We have sent you an email with instructions. Check your email."
  11. If it was not, we show any errors followed by the form.

Note that the first time this page is visited, $registrationMailSent and $errors will both be empty, because those variables are created within the if block that checks if the form has been submitted. Because they are both empty, only the registration form will show up on the page.

To test your solution:

  1. Visit http://localhost:8888/Webucator/php/Authentication/Exercises/phppoetry.com/register.php.
  2. Register for a new account. Remember your pass phrase!
  3. You should get an email asking you to confirm your registration. Click the link and confirm the registration.
  4. Look at the users table in PHPMyAdmin. Are you in there?
  5. If you get any errors, go back and fix your code.
  6. If the registration succeeds, try registering again, but intentionally enter invalid data. Do you get helpful errors?

Solution:

Authentication/Solutions/phppoetry.com/register.php
---- C O D E   O M I T T E D ----
    // Check if email exists
    $qEmailCheck = "SELECT user_id
      FROM users
      WHERE email = ?";

    try {
      $stmtEmail = $db->prepare($qEmailCheck);
      $stmtEmail->execute([ $f['email'] ]); 
    
      if ($stmtEmail->fetch()) {
        $errors[] = 'We recognize that email.<br>
          Did you <a href="pass-phrase-reset.php">forget your
          pass phrase</a>?';
      }
    } catch (PDOException $e) {
      logError($e);
      $errors[] = 'Oops! Our bad. We cannot register you right now.';
    }

    if (!$errors) {
      // Insert user
      $hashedPhrase = password_hash($passPhrase1, PASSWORD_DEFAULT);
      $token = generateToken();
      $qInserts = "INSERT INTO users
      (first_name, last_name, email, username, pass_phrase)
      VALUES (:first_name, :last_name, :email,
        :username, '$hashedPhrase');
        
      INSERT INTO tokens
      (token, user_id, token_expires)
      VALUES (:token, LAST_INSERT_ID(), 
        DATE_ADD(now(), INTERVAL 1 HOUR));";
        
      try {
        $stmtInserts = $db->prepare($qInserts);
        $stmtInserts->bindParam(':first_name', $f['first-name']);
        $stmtInserts->bindParam(':last_name', $f['last-name']);
        $stmtInserts->bindParam(':email', $f['email']);
        $stmtInserts->bindParam(':username', $f['username']);
        $stmtInserts->bindParam(':token', $token);
        if (!$stmtInserts->execute()) {
          logError($stmtInserts->errorInfo()[2]);
          $errors[] = 'Registration failed. Please try again.';
        }
      } catch (PDOException $e) {
        logError($e);
        $errors[] = 'Registration failed. Please try again.';
      }
---- C O D E   O M I T T E D ----

Sessions

When a user logs into a website, you need to keep track of them throughout their visit. This is done using sessions.

A session begins when a visiting client identifies itself to the web server. The web server assigns the client a unique session id, which the client uses to re-identify itself as it moves from page to page on the website. Most of the time, these unique ids are stored in session cookies that expire after the client hasn't interacted with the server for some amount of time. The amount of time varies depending on the web application. For example, an online investment site might have very short sessions, so that if a user leaves their computer without logging out, another user who sits down at the same computer several minutes later cannot continue with the first user's session.

The default session length in PHP is 1440 seconds (24 minutes), but you can change this using the session.gc_maxlifetime variable in the php.ini file.

Session Variables

Before setting, reading, or unsetting session variables, you need to start the session. This is done using the session_start() function. If a session has already been started on a previously visited page, session_start() will continue with that session.

The page below illustrates how session variables are used. Notice the following:

  1. Pages that are part of the session should begin with a call to session_start().
  2. Session variables are created in the $_SESSION superglobal array.
  3. Session variables are deleted in the same way as other variables - using the unset() function.

Code Sample:

Authentication/Demos/session-variables.php
<?php
  ini_set('display_errors', '1');

  session_start();
  $_SESSION['greeting'] = 'Hello';
  $_SESSION['name'] = 'world';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../static/styles/normalize.css">
<link rel="stylesheet" href="../../static/styles/styles.css">
<title>Session Variables</title>
</head>
<body>
<main>
  <h3>Session Variables</h3>
  <p>Two session variables created:</p>
  <ol>
    <li><?= $_SESSION['greeting'] ?></li>
    <li><?= $_SESSION['name'] ?></li>
  </ol>
  <p>Unset greeting session variable.</p>
  <?php
    unset($_SESSION['greeting']);
  ?>
  <ol>
    <li><?= $_SESSION['greeting'] ?></li>
    <li><?= $_SESSION['name'] ?></li>
  </ol>
</main>
</body>
</html>

Code Explanation

Visit http://localhost:8888/Webucator/php/Authentication/Demos/session-variables.php to see this file in the browser.

In this file, we start the session, create and display two session variables, and then unset one of those variables:Session Variables

session_unset() and session_destroy()

There are two session functions that may be tempting to use, but should be avoided: session_unset() and session_destroy(). It is better and safer to clean up unneeded or unwanted session variables using unset(). For example:

unset($_SESSION['foo']);

Cookies

When a user leaves your website and then returns at a later time or date, you may want to remember them from their previous visit. This is done using cookies.

Cookies are stored by the browser on the client machine. Web pages with the right permissions can set, read, and delete cookies. Cookies are generally used to track user information between visits.

In PHP, cookies are set with the setcookie() function, which can take several parameters including:

  1. The cookie's name (required).
  2. The cookie's value.
  3. The cookie's expiration date (if this isn't set, the cookie will expire when the browser window is closed).
  4. The directory path on the server that can read the cookie.
  5. The domain name that can read the cookie.
  6. A flag indicating whether the cookie should only be read over HTTPS.

The following code will set a cookie that expires in one week.

setcookie('flavor','chocolate chip', time()+60*60*24*7);

There is no deletecookie() function. To delete a cookie on the client, set the value to an empty string and set the expiration date to sometime in the past, like this (0 is the epoch):

setcookie('flavor','', 0);

Note that setting the cookie's expiration date to a date in the past only deletes the cookie on the client. It will not delete it on the server.

Cookies are set in the HTTP header, so they must be set before any HTML code is passed back to the browser.

Together, the files below illustrate how cookies work:

Code Sample:

Authentication/Demos/set-cookie.php
<?php
  ini_set('display_errors', '1');

  setcookie('flavor','chocolate chip', time()+60*60*24*7);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../static/styles/normalize.css">
<link rel="stylesheet" href="../../static/styles/styles.css">
<title>Cookie</title>
</head>
<body>
<main>
  <h1>Cookie</h1>
  <p>Flavor Cookie: <?= $_COOKIE['flavor'] ?></p>
  <a href="unset-cookie.php">Unset Cookie</a>
</main>
</body>
</html>

Code Explanation

Visit http://localhost:8888/Webucator/php/Authentication/Demos/set-cookie.php to open this page in your browser.

This sets the cookie, but note that it won't be available on the page in which it is initially set. That's because the $_COOKIE array is populated with cookies sent from the client with the page request. The setcookie() method does not itself add an item to the $_COOKIE array. So, you won't be able to read a cookie that you just set. The screenshot below shows that the cookie is set in Google Chrome, but the page did not have access to it at the time the page was created:Set cookie - First Visit

Refresh the page and the page can now read $_COOKIE['flavor']. That's because the browser sent the cookie with the page request:Set cookie - Subsequent Visits

As shown in the screenshots above, you can see the cookies for a page in Google Chrome DevTools. To do so:

  1. Open Chrome DevTools.
  2. Click the Application tab.
  3. On the left side under Storage, expand Cookies and click on the URL.
  4. On the right, you'll see the flavor cookie. Notice the expiration date. The screenshot is from December 18. We set the cookie to expire in 7 days.

Click the Unset Cookie link to move to the next demo.

Code Sample:

Authentication/Demos/unset-cookie.php
<?php
  ini_set('display_errors', '1');

  unset($_COOKIE['flavor']);
?>
---- C O D E   O M I T T E D ----

Code Explanation

This unsets the cookie on the server, but does not delete it on the client. When we try to read $_COOKIE['flavor'] after unsetting it, we get a notice saying the index is undefined. Notice, however, that the expiration date for the cookie has not changed.Unset cookie

Click the Delete Cookie link to move to the next demo.

Code Sample:

Authentication/Demos/delete-cookie.php
<?php
  ini_set('display_errors', '1');

  setcookie('flavor','', 0);
?>
---- C O D E   O M I T T E D ----

Code Explanation

This deletes the cookie on the client, but not from the $_COOKIES array: Delete cookie

To delete the cookie on both the client and the server, navigate to http://localhost:8888/Webucator/php/Authentication/Demos/delete-cookie-fully.php:

Code Sample:

Authentication/Demos/delete-cookie-fully.php
<?php
  ini_set('display_errors', '1');

  unset($_COOKIE['flavor']);
  setcookie('flavor','', 0);
?>
---- C O D E   O M I T T E D ----

Code Explanation

This deletes the cookie on the client and on the server in the $_COOKIES array: Fully Delete cookie

Logging in

Duration: 30 to 40 minutes.

In this exercise, you will create a page for logging the user in. A couple of things to note:

  1. We will use a session variable that holds the user's user id to keep track of a logged-in user during a session. This is safe to do, because the user id is never shared with the browser.
  2. We will use a cookie that holds a token to keep track of a user between sessions. We use a token instead of the user's id because this value is transferred between the server and client. For security reasons, we don't want to send the user's id to the client.

Code Sample:

Authentication/Exercises/phppoetry.com/login.php
<?php
  $pageTitle = 'Login';
  require 'includes/header.php';
  logout(); // In case a different user has logged in

  // TODO: Set $username and $passPhrase
  //    These should be set to the values posted in the form
  //    If the form has not been posted, they should default to
  //      empty strings.

  if ($username && $passPhrase) {
    
    $qLogin = "SELECT pass_phrase, registration_confirmed, user_id
      FROM users
      WHERE username = ?";

    try {
      // TODO: Prepare and execute the $qLogin query
      
      if (!$row = $stmt->fetch()) {
        // username doesn't exist
        $loginFailed = true;
        $failureMessage = nl2br(POEM_LOGIN_FAILED);
      } elseif ( !$row['registration_confirmed']) {
        // user never confirmed registration
        $loginFailed = true;
        $failureMessage = nl2br(POEM_REGISTRATION_UNCONFIRMED);
      } elseif (password_verify($passPhrase, $row['pass_phrase'])) {
        // TODO: log user in and redirect to home page
        //
        //    Set 'user-id' session variable to user_id returned by
        //      query.

        //    If the user checked the remember-me checkbox, create a
        //      'token' cookie for 30 days (in minutes).
        //      To do so, follow these steps:
        //        Use the generateToken() utility function to
        //          generate the token.
        //        Insert a new row into the tokens table with values
        //          for token, user_id, and token_expires set to the
        //          generated token, the current user id, and a date
        //          30 days (in minutes) in the future.
        //        If the insert statement fails to execute, log the
        //          error and redirect to the error page.
        //        If the insert fails due to a PDOException, log
        //          the error and fail silently.
        //        If the insert is successful, attempt to set a
        //          'token' cookie that expires in 30
        //          days (in seconds). If this fails (i.e.,
        //          set_cookie() returns false), log the error.
        //
        //    After (and separate from) the cookie code,
        //      redirect to the home page
      } else {
        // bad password
        $loginFailed = true;
        $failureMessage = nl2br(POEM_LOGIN_FAILED);
      }
    } catch (PDOException $e) {
      logError($e->getMessage());
      $loginFailed = true;
      $failureMessage = nl2br(POEM_GENERIC_ERROR);
    }
  }
?>
<main class="narrow">
  <h1><?= $pageTitle ?></h1>
  <?php
    if (isset($loginFailed)) {
      echo "<article class='poem error'>$failureMessage</article>";
    }

    if (isset($_GET['just-registered'])) {
      echo '<article class="poem success">' .
          nl2br(POEM_REGISTRATION_SUCCESS) .
        '</article>';
    } else {
      echo '<p>Need an account? 
        <a href="register.php">Register</a></p>';
    }
  ?>
  <!-- novalidate set so that PHP validation can be tested -->
  <form method="post" action="login.php" novalidate>
    <label for="username">Username:</label>
    <input name="username" id="username" required
      value="<?= $username ?>"> 
    <label for="pass-phrase">Pass Phrase:</label>
    <input type="password" name="pass-phrase" id="pass-phrase"
      required>
    <input type="checkbox" name="remember-me" id="remember-me">
    <label for="remember-me" class="inline">Remember me</label>
    <button>Login</button>
  </form>
  <p class="clear">
    <a href="pass-phrase-reset.php">Forgot your pass phrase?</a>
  </p>
</main>
<?php
  require 'includes/footer.php';
?>

To complete the login page:

  1. Open Authentication/Exercises/phppoetry.com/login.php in your editor.
  2. Much of this code is already done for you. Study the code and make sure you understand everything that is already done.
  3. Beneath the call to logout(), set $username and $passPhrase.
    1. If the form has been posted, these should be set to the values posted in the form.
    2. If the form has not been posted, they should default to empty strings.
  4. Within the try block after $qLogin is set, prepare and execute the $qLogin query.
  5. Carefully review the if and first elseif blocks.
  6. The second elseif statement checks if the password is correct. If it is, you will log the user in and redirect to the home page:
    1. Set a 'user-id' session variable to the user id returned by the $qLogin query.
    2. If the user checked the remember-me checkbox, create a 'token' cookie for 30 days (in minutes). To do so, follow these steps:
      1. Use the generateToken() utility function to generate the token.
      2. Insert a new row into the tokens table with values for token, user_id, and token_expires set to the generated token, the current user id, and a date 30 days (in minutes) in the future. The query should look like this:
        INSERT INTO tokens
        (token, user_id, token_expires)
        VALUES ( '$token', ?, DATE_ADD(now(),
        INTERVAL $interval MINUTE) )
        1. If the insert statement fails to execute, log the error and redirect to the error page.
        2. If the insert fails due to a PDOException, log the error and fail silently.
        3. If the insert is successful, attempt to set a 'token' cookie that expires in 30 days (in seconds). If this fails (i.e., set_cookie() returns false), log the error.
  7. After (and separate from) the cookie code, redirect to the home page.

Code Sample:

Authentication/Exercises/phppoetry.com/includes/header.php
<?php
  // TODO: Start the session
  require_once 'config.php';
  require_once 'utilities.php';
  require_once 'constants.php';
  if ( isDebugMode()) {
    ini_set('display_errors', '1');
  }

  // If $db isn't already set, set it.
  if ( !isset($db)) {
    $db = dbConnect();
  }

  // TODO: Set a variable called $currentUserId, which
  //    will either contain the logged-in user's id or 0 (if no user
  //    is logged in)

  // TODO: If 'user-id' doesn't exist in $_SESSION, check if there
  //    is a token cookie.
  //        If there is one, look for an unexpired match in the
  //        tokens table.
  //            If there is a match, set $_SESSION['user-id'] and
  //            $currentUserId to the user_id associated with that
  //            token.
  //    Don't forget to wrap the code connecting to the database in
  //      a try / catch block.
  
  $pageTitleTag = empty($pageTitle)
              ? 'The Poet Tree Club'
              : $pageTitle . ' | The Poet Tree Club';
?>
---- C O D E   O M I T T E D ----

<header>
  <nav id="main-nav">
    <!-- Bar icon for mobile menu -->
    <div id="mobile-menu-icon">
      <i class="fa fa-bars"></i>
    </div>
    <ul>
      <li><a href="index.php">Home</a></li>
      <li><a href="poems.php">Poems</a></li>
      <li><a href="poem-submit.php">Submit Poem</a></li>
      <!-- TODO: If the user is logged in, include this link: -->
        <li><a href="my-account.php">My Account</a></li>
      <!-- OTHERWISE include this link: -->
        <li><a href="login.php">Log in / Register</a></li>
      <li><a href="contact.php">Contact us</a></li>
    </ul>
  </nav>
  <h1>
    <a href="index.php">The Poet Tree Club</a>
  </h1>
  <h2>Set your poems free...</h2>
</header>

To update header.php to keep track of logged-in users:

  1. Open Authentication/Exercises/phppoetry.com/includes/header.php in your editor.
  2. Much of this code is already done for you. Study the code and make sure you understand everything that is already done.
  3. At the beginning of the file, write code to start or continue the session. As header.php is included in all of our files, we only have to do this in this one file.
  4. Below the code that connects to the database, set a variable called $currentUserId, which will contain 0 if the user is not logged in and will contain the logged-in user's id if 'user-id' is set in $_SESSION. We will be able to use this throughout the site to check if the user is logged in.
  5. If 'user-id' doesn't exist in $_SESSION, check if there is a token cookie.
    1. If there is a token cookie, look for an unexpired match in the tokens table.
      1. If there is a match, set $_SESSION['user-id'] and $currentUserId to the user_id associated with that token.
    2. Don't forget to wrap the code connecting to the database in a try / catch block.

Code Sample:

Authentication/Exercises/phppoetry.com/includes/footer.php
<footer>
  <span>Copyright &copy; 2019 The Poet Tree Club.</span>
  <nav>
    <!--TODO: Add the following link if the user is logged in:-->
    <a href="logout.php">Log out</a>
    
    <a href="admin/index.php">Admin</a>
    <a href="about-us.php">About us</a>
  </nav>
</footer>
</body>
</html>

To update the logout link in footer.php:

  1. Open Authentication/Exercises/phppoetry.com/includes/footer.php in your editor.
  2. Add the following link if the user is logged in:
    <a href="logout.php">Log out</a>

To test your solution:

  1. Navigate to http://localhost:8888/Webucator/php/Authentication/Exercises/phppoetry.com/login.php and log in with the wrong pass phrase. Do you get an appropriate error?
  2. Now log in with the correct username and pass phrase. You should be redirected to the home page. The header should have a My Account link instead of Log in / Register link and the footer should have a logout link.

Solution:

Authentication/Solutions/phppoetry.com/login.php
<?php
  $pageTitle = 'Login';
  require 'includes/header.php';
  logout(); // In case a different user has logged in

  $username = trim($_POST['username'] ?? '');
  $passPhrase = $_POST['pass-phrase'] ?? '';

  if ($username && $passPhrase) {
    
    $qLogin = "SELECT pass_phrase, registration_confirmed, user_id
      FROM users
      WHERE username = ?";

    try {
      $stmt = $db->prepare($qLogin);
      $stmt->execute([$username]);
      
      if (!$row = $stmt->fetch()) {
        // username doesn't exist
        $loginFailed = true;
        $failureMessage = nl2br(POEM_LOGIN_FAILED);
      } elseif ( !$row['registration_confirmed']) {
        // user never confirmed registration
        $loginFailed = true;
        $failureMessage = nl2br(POEM_REGISTRATION_UNCONFIRMED);
      } elseif (password_verify($passPhrase, $row['pass_phrase'])) {
        // log user in and redirect to home page
        $_SESSION['user-id'] = $row['user_id'];

        if (!empty($_POST['remember-me'])) {
          // Set cookie for 30 days
          $interval = 30 * 24 * 60; // 30 days
          $token = generateToken();
          $qInsert = "INSERT INTO tokens
          (token, user_id, token_expires)
          VALUES ( '$token', ?, DATE_ADD(now(),
                    INTERVAL $interval MINUTE) )";

          try {
            $stmtInsert = $db->prepare($qInsert);

            if ($stmtInsert->execute( [$_SESSION['user-id']] )) {
              $expiration = time() + 60 * $interval;
              if (!setcookie('token', $token, $expiration)) {
                // Could not set cookie on browser. Fail silently.
                logError("Could not set cookie for $username.");
              }
            } else {
              // Likely SQL error. Log and redirect to error page.
              logError($stmtInsert->errorinfo()[2], true);
            }
          } catch (PDOException $e) {
            // Could not insert cookie token. Fail silently.
            logError($e->getMessage()); 
          }
        }
        header("Location: index.php");
      } else {
        // bad password
        $loginFailed = true;
        $failureMessage = nl2br(POEM_LOGIN_FAILED);
      }
    } catch (PDOException $e) {
      logError($e->getMessage());
      $loginFailed = true;
      $failureMessage = nl2br(POEM_GENERIC_ERROR);
    }
  }
?>
---- C O D E   O M I T T E D ----

Solution:

Authentication/Solutions/phppoetry.com/includes/header.php
<?php
  session_start();
  require_once 'config.php';
  require_once 'utilities.php';
  require_once 'constants.php';
  if (isDebugMode()) {
    ini_set('display_errors', '1');
  }

  // If $db isn't already set, set it.
  if (!isset($db)) {
    $db = dbConnect();
  }

  $currentUserId = $_SESSION['user-id'] ?? 0;
  if (!$currentUserId) {
    // Do we remember this user?
    if (isset($_COOKIE['token'])) {
      $qSelect = "SELECT user_id 
      FROM tokens 
      WHERE token = ? AND token_expires > now()";

      try {
        $stmt = $db->prepare($qSelect);
        $stmt->execute([$_COOKIE['token']]);
    
        if ($row = $stmt->fetch()) {
          // Found unexpired matching token
          $_SESSION['user-id'] = $row['user_id'];
          $currentUserId = $row['user_id'];
        }
      } catch (PDOException $e) {
        logError($e->getMessage());
      }
    }
  }
  
  $pageTitleTag = empty($pageTitle)
              ? 'The Poet Tree Club'
              : $pageTitle . ' | The Poet Tree Club';
?>
---- C O D E   O M I T T E D ----

<header>
  <nav id="main-nav">
    <!-- Bar icon for mobile menu -->
    <div id="mobile-menu-icon">
      <i class="fa fa-bars"></i>
    </div>
    <ul>
      <li><a href="index.php">Home</a></li>
      <li><a href="poems.php">Poems</a></li>
      <li><a href="poem-submit.php">Submit Poem</a></li>
      <?php if ($currentUserId) { ?>
        <li><a href="my-account.php">My Account</a></li>
      <?php } else { ?>
        <li><a href="login.php">Log in / Register</a></li>
      <?php } ?>
      <li><a href="contact.php">Contact us</a></li>
    </ul>
  </nav>
  <h1>
    <a href="index.php">The Poet Tree Club</a>
  </h1>
  <h2>Set your poems free...</h2>
</header>

Solution:

Authentication/Solutions/phppoetry.com/includes/footer.php
<footer>
  <span>Copyright &copy; 2019 The Poet Tree Club.</span>
  <nav>
    <?php
      if ($currentUserId) {
        echo '<a href="logout.php">Log out</a>';
      }
    ?>
    <a href="admin/index.php">Admin</a>
    <a href="about-us.php">About us</a>
  </nav>
</footer>
</body>
</html>

Logging Out

The logout page is very simple. It makes use of the following logout() function from the utilities.php file:

function logout() {
  unset($_SESSION['user-id']);
  unset($_COOKIE['token']); // unset on server
  setcookie('token', '', 0); // unset on client
}

Here is the code:

Code Sample:

Authentication/Exercises/phppoetry.com/logout.php
<?php
  require_once 'includes/utilities.php';
  session_start();
  logout();
  header("Location: index.php");
?>

Code Explanation

To test logging out:

  1. Log in at http://localhost:8888/Webucator/php/Authentication/Exercises/phppoetry.com/login.php using the account you created earlier.
  2. Click on the Logout link in the footer. You should be redirected to the home page and no longer be logged in.

$_REQUEST Variables

All variables in the $_GET, $_POST, and $_COOKIE superglobals are combined into the $_REQUEST superglobal array. The following example illustrates this:

Code Sample:

Authentication/Demos/request.php
---- C O D E   O M I T T E D ----
<main id="request-variables">
  <h4>REQUEST</h4>
  <?= $_REQUEST['greeting'] ?>
  <h4>GET</h4>
  <?= $_GET['greeting'] ?>
  <h4>POST</h4>
  <?= $_POST['greeting'] ?>
  <h3>Set Variables</h3>
  <div>
    <a href="request.php?greeting=Hello,+get!">Hello, get!</a>
  </div>
  <div>
    <form method="post" action="request.php">
      <button name="greeting" value="Hello, post!">
        Hello, post!
      </button>
    </form>
  </div>
</main>
</body>
</html>

Code Explanation

At the top of the page, we display $_REQUEST['greeting'], $_GETT['greeting'], and $_POST['greeting']. And at the bottom of the page, we provide a link and a form button for setting greeting via the query string and a form post, respectively. To see how it works:

  1. Open http://localhost:8888/Webucator/php/Authentication/Demos/request.php in your browser. The first time you visit, no variables exist in the $_GET or $_POST arrays, and so none exist in the $_REQUEST either:Request - first visit
  2. Now click the Hello, get! link. Notice that "Hello, get!" shows up in $_REQUEST as well as $_GET.Request - GET
  3. Now click the Hello, post! button. Notice that "Hello, post!" shows up in $_REQUEST as well as $_POST.Request - POST

Resetting the Pass Phrase

Duration: 30 to 45 minutes.

In this exercise, you will review and comment pages for resetting the pass phrase. The process is similar to a new registration:

  1. From the login page, the user clicks on the Forgot your pass phrase? link, taking them to pass-phrase-reset.php.
  2. On pass-phrase-reset.php, the user enters their email and submits the form.
  3. If the email is found, a token is generated and saved in the tokens table, and an email is sent to the user's email address with a link to pass-phrase-reset-confirm.php with the token on the query string.
  4. On pass-phrase-reset-confirm.php, the user will be able to reset their pass phrase, unless the token has timed out.

The pages are already complete and functioning. Your job is to review them and add detailed comments explaining what each piece of code does.

Code Sample:

Authentication/Exercises/phppoetry.com/pass-phrase-reset.php
<?php
  require 'mail-config.php';

  $pageTitle = 'Pass Phrase Reset';
  require 'includes/header.php';
  logout();

  $errors = [];

  $email = trim($_POST['email'] ?? '');
?>
<main id="pass-phrase-reset" class="narrow">
<?php
if (isset($_POST['submit'])) { 
  if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errors[] = 'You must enter a valid email.';
  } else {
    $selUser = "SELECT first_name, last_name, user_id 
                FROM users WHERE email = ?";

    try {
      $stmt = $db->prepare($selUser);
      $stmt->execute([$email]);
      $row = $stmt->fetch(); 
    } catch (PDOException $e) {
      logError($e->getMessage());
      $errors[] = nl2br(POEM_GENERIC_ERROR);
    }

    if (!empty($row)) {
      $userId = $row['user_id'];
      $firstName = $row['first_name'];
      $lastName = $row['last_name'];

      $token = generateToken(); 

      $qInsert = "INSERT INTO tokens
        (token, user_id, token_expires)
        VALUES ('$token', ?, DATE_ADD(now(), INTERVAL 1 HOUR))";

      try {
        $stmtInsert = $db->prepare($qInsert);

        if ($stmtInsert->execute( [$userId] )) {
          $qs = http_build_query(['token' => $token]);
          $confPath = getFullPath('pass-phrase-reset-confirm.php');
          $href = $confPath . '?' . $qs;

          $to = $email;
          $toName = $firstName . ' ' . $lastName;
          $subject = 'Pass phrase reset';

          $html = "<p>A reset-pass-phrase request has been made
          for your account for phppoetry.com. If you didn't make the
          request, you can ignore this email. If you did, reset your
          pass phrase by <a href='$href'>clicking here</a>.</p>";

          $text = "A reset-pass-phrase request has been made
          for your account for phppoetry.com. If you didn't make the
          request, you can ignore this email. If you did, visit $href
          to reset your pass phrase.";

          $mail = createMailer();
          $mail->addAddress($to, $toName);
          $mail->Subject = $subject;
          $mail->Body = $html;
          $mail->AltBody = $text;

          if (!$mail->send()) {
            echo '<p class="error">
              We could not send you a pass-phrase-reset email.</p>';
            echo "Mailer Error: " . $mail->ErrorInfo;
          } else {
            echo '<p class="success">We have sent you an email with
            instructions. Check your email.</p>';
          }
        } else {
          logError($stmtInsert->errorinfo()[2], true);
        }
      } catch (PDOException $e) {
        logError($e->getMessage());
        $errors[] = nl2br(POEM_GENERIC_ERROR);
      }
    } else {
      $errors[] = "Sorry. We don't recognize that email.";
    }
  }
}

if (!isset($_POST['submit']) || $errors) {

  if ($errors) {
    echo '<h3>Uh oh!</h3>';
    foreach ($errors as $error) {
      echo "<p class='error'>$error</p>";
    }
  }

  echo "<p>Use the form below to reset your pass phrase:</p>
  <form method='post' novalidate>
    <label for='email'>Email:</label>
    <input type='email' name='email' id='email'
      value='$email' required> 
    <button name='submit' value='1' class='wide'>
      Reset Pass Phrase</button>
  </form>";
}
?>
</main>
<?php
  require 'includes/footer.php';
?>

Code Explanation

  1. Open Authentication/Exercises/phppoetry.com/pass-phrase-reset.php in your editor.
  2. Review and add detailed comments to the page. The goal is to prove to yourself that you understand every line of code in the file by explaining what it does in a comment.
  3. Note that we check $_REQUEST for 'token'. We use $_REQUEST because 'token' will be in the $_GET array when the user visits from the link sent to their email and it will be in the $_POST array when the user submits the form to confirm their new pass phrase.
  4. If there is any line or block of code that you do not understand, add a comment trying to explain the point of confusion. Attempt to resolve it yourself through studying past demos and exercises and looking at the PHP documentation. If you still are not able to figure it out, review the solution.

Code Sample:

Authentication/Exercises/phppoetry.com/pass-phrase-reset-confirm.php
<?php
  if (!isset($_REQUEST['token'])) {
    header("Location: index.php");
  }

  $pageTitle = 'Reset Password Form';
  require 'includes/header.php';
  logout();

  $showForm = true;
?>
<main class="narrow">
<h1><?= $pageTitle ?></h1>

<?php
  if ($showForm && isset($_POST['token'])) {
    $token = $_POST['token']; 
    $passPhrase1 = $_POST['pass-phrase-1'];
    $passPhrase2 = $_POST['pass-phrase-2'];

    if (strlen($passPhrase1) < 20) {
      $error = 'Your pass phrase must be at least 20 characters.';
    } elseif ($passPhrase1 !== $passPhrase2) {
      $error = 'Your pass phrases don\'t match.';
    } else {
      $showForm = false;

      $hashedPhrase = password_hash($passPhrase1, PASSWORD_DEFAULT);

      $qUpdate = "UPDATE users
        SET pass_phrase = '$hashedPhrase',
          registration_confirmed = 1
        WHERE user_id = (SELECT user_id
                        FROM tokens
                        WHERE token = ?
                          AND token_expires > now() );";

      try {
        $stmtUpdate = $db->prepare($qUpdate);

        if ($stmtUpdate->execute( [$token] )) {
          echo "<span class='success'>Success. 
            <a href='login.php'>Login</a></span>";
        } else {
          logError($stmtUpdate->errorinfo()[2], true); 
        }
      } catch (PDOException $e) {
        logError($e->getMessage());
        $error = nl2br(POEM_GENERIC_ERROR) .
          '<p><a href="pass-phrase-reset.php">try again</a></p>';
      }
    }
  } elseif ($showForm) {
    $token = $_GET['token'];

    $qSelect = "SELECT * 
      FROM tokens 
      WHERE token = ? AND token_expires > now()";

    try {
      $stmt = $db->prepare($qSelect);
      $stmt->execute([$token]);
  
      if (!$stmt->fetch()) {
        $error = nl2br(POEM_INVALID_TOKEN_PASS_PHRASE_RESET);
        $showForm = false;
      }
    } catch (PDOException $e) {
      logError($e->getMessage());
      $error = nl2br(POEM_GENERIC_ERROR) .
        '<p><a href="pass-phrase-reset.php">try again</a></p>';
    }
  }
?>
<?php
  if (isset($error)) {
    echo "<article class='poem error'>$error</article>";
  }

  if ($showForm) {
?>
  <form method="post" action="pass-phrase-reset-confirm.php" novalidate>
    <input type="hidden" name="token" value="<?= $token ?>"> 
    <fieldset>
      <legend>Pass Phrase:</legend>
        <em>A hard-to-guess phrase at least 20 characters long.</em>
        <input type="password" placeholder="Pass Phrase"
          name="pass-phrase-1" id="pass-phrase-1"
          required minlength="20"> 
        <input type="password" placeholder="Repeat Pass Phrase"
          name="pass-phrase-2" id="pass-phrase-2"
          required minlength="20">
    </fieldset>
    <button>Change Pass Phrase</button>
  </form>
<?php
  }
  echo '</main>';
  require 'includes/footer.php';
?>

Code Explanation

To complete this page:

  1. Open Authentication/Exercises/phppoetry.com/pass-phrase-reset-confirm.php in your editor.
  2. Review and add detailed comments to the page.
  3. If there is any line or block of code that you do not understand, add a comment trying to explain the point of confusion. Attempt to resolve it yourself through studying past demos and exercises and looking at the PHP documentation. If you still are not able to figure it out, review the solution.

To see the commented solutions, open the following files in your editor:

  1. Authentication/Solutions/phppoetry.com/pass-phrase-reset.php
  2. Authentication/Solutions/phppoetry.com/pass-phrase-reset-confirm.php