Log in/out

Tags

Where are we?

The code to check permission, and change menus, relies on two functions:

  • isLoggedIn(): returns true if the user is logged in
  • hasRole($roleToCheck): returns true if the user has the role

With these two functions, we can make a simple user login system.

The two functions use data from the session to do their thing.

isLoggedIn()

isLoggedIn() relies on $_SESSION['logged_in'], a boolean:

  • /**
  •  * Is the current user logged in?
  •  * @return bool True if logged in, else false.
  •  */
  • function isLoggedIn() {
  •     $loggedIn = false;
  •     if (isset($_SESSION['logged_in'])) {
  •         if ($_SESSION['logged_in']) {
  •             $loggedIn = true;
  •         }
  •     }
  •     return $loggedIn;
  • }

The code uses the flag pattern.

Pattern

Flag

Use a variable as a flag. Set the flag if any of a number of things happens. After, check the flag to see if any of those things happened.

hasRole($roleToCheck)

hasRole($roleToCheck) relies on $_SESSION['user_roles'] to do its work.

  • /**
  •  * Does the current user have the given role?
  •  * @param string $roleName Role name, using consts above.
  •  * @return bool True if has role, else false.
  •  */
  • function hasRole(string $roleName) {
  •     if (!isLoggedIn()) {
  •         return false;
  •     }
  •     return in_array($roleName, $_SESSION['user_roles']);
  • }

Users table

We have a DB table with user names, password hashes, and roles.

Users table

What login should do

Here's the basic logic for login:

  • Show a login form.
  • When the user types in a username and password:
  •   Compare the username and password hash to the DB
  •   If they match:
  •     Put data into the session

Code on pages we want to protect will check the data in the session.

How to implement logout? The easiest way is to destroy the session.

The form

Here's what the form should look like:

Login form

It's a Bootstrap form, of course.

Earlier, you learned how to have form input and validation on the same page. Let's do that again.

Pattern

Same page validation

Put a form and its validation code on the same page.

The code

Here it is.

  1. <?php
  2. require_once 'library/useful-stuff.php';
  3. // Already logged in?
  4. if (isLoggedIn()) {
  5.     header('Location: index.php');
  6.     exit();
  7. }
  8. $errorMessage = '';
  9. // Load default values for widgets.
  10. $username = '';
  11. $password = '';
  12. // Is there post data?
  13. if ($_POST) {
  14.     // User filled in data and sent it to this page.
  15.     // Grab the data sent.
  16.     $username = getParamFromPost('username');
  17.     $password = getParamFromPost('password');
  18.     // Is it valid?
  19.     $success = logIn($username, $password);
  20.     if ($success) {
  21.         // Back to home page.
  22.         header('Location: index.php');
  23.         exit();
  24.     }
  25.     // Show an error message.
  26.     $errorMessage = 'Sorry, login failed.';
  27. }
  28. ?><!doctype html>
  29. <html lang="en">
  30.   ...
  31.   <h1>Login</h1>
  32.   <?php
  33.   // If there's an error message, show it.
  34.   if ($errorMessage != '') {
  35.       print "<div class='alert alert-danger' role='alert'>$errorMessage</div>";
  36.   }
  37.   ?>
  38.   <form method="post">
  39.       <div class="mb-3">
  40.           <label for="username" class="form-label">User name</label>
  41.           <input type="text" class="form-control" name="username"
  42.               value="<?= $username ?>"
  43.           >
  44.       </div>
  45.       <div class="mb-3">
  46.           <label for="password" class="form-label">Password</label>
  47.           <input type="password" class="form-control" name="password"
  48.                  value="<?= $password ?>"
  49.           >
  50.       </div>
  51.       <button type="submit" class="btn btn-primary">Login</button>
  52.   </form>
  53.   ...

Let's check it out. If the user is already logged in, then they don't need to log in again, so we can leave.

  1. if (isLoggedIn()) {
  2.     header('Location: index.php');
  3.     exit();
  4. }

Notice how easy to understand the code is. The function isLoggedIn() is true when... well, when the user is logged in. Set up and name your functions well, and your code is easy to understand, easy to debug, and easy to modify. That's three easys!

Init $errorMessage, and variables representing values for the widgets. Part of the same-page validation.

  • $errorMessage = '';
  • // Load default values for widgets.
  • $username = '';
  • $password = '';

If there's POST data, put it into the variables we just made, and check it.

  1. if ($_POST) {
  2.     // User filled in data and sent it to this page.
  3.     // Grab the data sent.
  4.     $username = getParamFromPost('username');
  5.     $password = getParamFromPost('password');
  6.     // Is it valid?
  7.     $success = logIn($username, $password);
  8.     if ($success) {
  9.         // Back to home page.
  10.         header('Location: index.php');
  11.         exit();
  12.     }
  13.     // Show an error message.
  14.     $errorMessage = 'Sorry, login failed.';
  15. }

Line 19 is:

  • $success = logIn($username, $password);

logIn() is a new function, in the library. We send it the user name and password from the form. It returns either true, or false. It also stores the right data in the session.

If logIn() returns true, we jump to the home page. If not, the program continues, and shows the form again, but with an error message above it:

  • <h1>Login</h1>
  • <?php
  • // If there's an error message, show it.
  • if ($errorMessage != '') {
  •     print "<div class='alert alert-danger' role='alert'>$errorMessage</div>";
  • }
  • ?>
  • <form method="post">
  •   ...
  • </form>

logIn()

Here's some pseudocode for the function.

  • If the username or password are missing:
  •   Return false (login failed)
  • Hash the password
  • Check the DB for a user record with the username and hashed password
  • If it isn't there:
  •   Return false (login failed)
  • // If get to here, username and password are OK.
  • Put user data into the session
  • Return true (login OK)

Here's the code, from the library.

  1. /**
  2.  * @param string $userName Username.
  3.  * @param string $password Password.
  4.  * @return bool True if login succeeded, else false.
  5.  */
  6. function logIn($userName, $password) {
  7.     global $dbConnection;
  8.     $userName = strtolower(trim($userName));
  9.     $password = trim($password);
  10.     // Exit if either is MT or null.
  11.     if ($userName == '' || is_null($userName)
  12.         || $password == '' || is_null($password) ) {
  13.        return false;
  14.     }
  15.     $hashedPassword = md5($password);
  16.     $sql = 'select * from users where name=:username and password_hash=:pw_hash;';
  17.     /** @var PDO $dbConnection */
  18.     $stmnt = $dbConnection->prepare($sql);
  19.     $isQueryWorked = $stmnt->execute([
  20.         ':username' => $userName,
  21.         ':pw_hash' => $hashedPassword
  22.     ]);
  23.     if (! $isQueryWorked ) {
  24.         // Query did not work.
  25.         return false;
  26.     }
  27.     if ($stmnt->rowCount() != 1) {
  28.         // Row not found.
  29.         return false;
  30.     }
  31.     $userEntity = $stmnt->fetch();
  32.     // Store data in session.
  33.     $_SESSION['username'] = $userEntity['name'];
  34.     $_SESSION['user_roles'] = explode(',', $userEntity['roles']);
  35.     $_SESSION['logged_in'] = true;
  36.     return true;
  37. }

First, it makes sure both the username and password are given.

  1.     $userName = strtolower(trim($userName));
  2.     $password = trim($password);
  3.     // Exit if either is MT or null.
  4.     if ($userName == '' || is_null($userName)
  5.         || $password == '' || is_null($password) ) {
  6.        return false;
  7.     }
Adela
Adela

Oh, that's cool. The username and password are both trimmed, so if someone hits the spacebar after typing them in, it won't matter.

The username isn't case-sensitive, but the password is.

Right! Another reason there might be an extra space, is if the user copy-and-pasted, and didn't get the text selection quite right. It's easy to accidentally include a space.

The first validation (lines 11 to 14) is both fields are required. I could have made the test simpler, but I tested for both MT and null, just in case.

We're up to:

  • If the username or password are missing:
  •   Return false (login failed)
  • UP TO HERE
  • Hash the password
  • Check the DB for a user record with the username and hashed password
  • If it isn't there:
  •   Return false (login failed)
  • // If get to here, username and password are OK.
  • Put user data into the session
  • Return true (login OK)

Now, let's check to see if the username and password are in the DB. Actually, we know the password is not in the DB, because we don't store passwords. We store hashes, instead. So:

  1.     $hashedPassword = md5($password);
  2.     $sql = 'select * from users where name=:username and password_hash=:pw_hash;';
  3.     /** @var PDO $dbConnection */
  4.     $stmnt = $dbConnection->prepare($sql);
  5.     $isQueryWorked = $stmnt->execute([
  6.         ':username' => $userName,
  7.         ':pw_hash' => $hashedPassword
  8.     ]);
  9.     if (! $isQueryWorked ) {
  10.         // Query did not work.
  11.         return false;
  12.     }
  13.     if ($stmnt->rowCount() != 1) {
  14.         // Row not found.
  15.         return false;
  16.     }
  17.     $userEntity = $stmnt->fetch();

If there isn't a row with the right username and password, login fails.

Ethan
Ethan

So the code doesn't know whether it was the password that was wrong, or the username. Or even both.

Right! It doesn't need to know, since it wouldn't tell the user what was bad anyway. The whole login works, or the whole login doesn't.

OK, we're up to:

  • If the username or password are missing:
  •   Return false (login failed)
  • Hash the password
  • Check the DB for a user record with the username and hashed password
  • If it isn't there:
  •   Return false (login failed)
  • UP TO HERE
  • // If get to here, username and password are OK.
  • Put user data into the session
  • Return true (login OK)

Last bit:

  1. $userEntity = $stmnt->fetch();
  2. // Store data in session.
  3. $_SESSION['username'] = $userEntity['name'];
  4. $_SESSION['user_roles'] = explode(',', $userEntity['roles']);
  5. $_SESSION['logged_in'] = true;
  6. return true;

The user's data is in $userEntity, the record fetched from the database.

Line 35 sets $_SESSION['logged_in']. That's what isLoggedIn() checks:

  • /**
  •  * Is the current user logged in?
  •  * @return bool True if logged in, else false.
  •  */
  • function isLoggedIn() {
  •     $loggedIn = false;
  •     if (isset($_SESSION['logged_in'])) {
  •         if ($_SESSION['logged_in']) {
  •             $loggedIn = true;
  •         }
  •     }
  •     return $loggedIn;
  • }

$_SESSION['user_roles'] is used in hasRole($roleToCheck):

  • /**
  •  * Does the current user have the given role?
  •  * @param string $roleName Role name, using consts above.
  •  * @return bool True if has role, else false.
  •  */
  • function hasRole(string $roleName) {
  •     if (!isLoggedIn()) {
  •         return false;
  •     }
  •     return in_array($roleName, $_SESSION['user_roles']);
  • }

Remember, we want $_SESSION['user_roles'] to be an array, with an entry for each role the user has. That's an easy way to let users have more than one role. Then, we can ask whether the role we want to check ($roleName) is in the array:

  • return in_array($roleName, $_SESSION['user_roles']);

Here's what the debugger shows when checking for a role:

Debugger

You can see that $_SESSION['user_roles'] is an array, with an element for each role the user has. There's just one element in the array here, but there could be more than one.

When we were making the users table, we decided to put the roles in a text field. If someone has more than one role, we'd put something like this in the roles field: admin,manager. That is, we'd separate the role names with commas.

We want to make admin,manager into the array, with the first item as admin, and the second as manager. How to do that? like this:

  • $_SESSION['user_roles'] = explode(',', $userEntity['roles']);

explode(separator,string) chops up a string, using the separator, and puts the pieces in array elements. For example,...

explode(' ', 'Doggos are great!')

... would return an array with three elements...

[0] => 'Doggos', [1] => 'are', [2] => 'great!'

If $userEntity['roles'] is 'admin,manager', then...

explode(',', $userEntity['roles'])

... would return an array with two elements...

[0] => 'admin', [1] => 'manager'

Logout

The functions isLoggedIn() and hasRole($roleToCheck) rely on session variables. For example:

  • /**
  •  * Is the current user logged in?
  •  * @return bool True if logged in, else false.
  •  */
  • function isLoggedIn() {
  •     $loggedIn = false;
  •     if (isset($_SESSION['logged_in'])) {
  •         if ($_SESSION['logged_in']) {
  •             $loggedIn = true;
  •         }
  •     }
  •     return $loggedIn;
  • }

If $_SESSION['logged_in'] isn't there, isLoggedIn() returns false. So does hasRole($roleToCheck).

So, an easy way to log someone out is to destroy the session. This is logout.php, the whole thing:

  • <?php
  • require_once 'library/useful-stuff.php';
  • logOut();

Here's logOut(), from the library:

  • /**
  •  * Logout.
  •  */
  • function logOut() {
  •     session_destroy();
  •     header('Location: index.php');
  •     exit();
  • }

session_destroy() wipes out the session variables, all of them. That's all you need to do!

Exercise

Exercise

Wombat user accounts

Earlier, you wrote an app to "list wombats:/exercise/wombat-list. Add user accounts to it, like the product app.

  • Redirect non-logged in users to a login page.
  • Add a form for username and password.
  • Add isLoggedIn() and hasRole() functions in your library.
  • A menu with a Logout link.
  • A users table in your database.
  • Apply hashing to the password.

Submit the URL to your app, and a zip file of your code. Include a valid username and password for testing. The usual standards apply.