Creating A Log In System - Part 2

Let's add a log in page!

We'll create three new files. login.php, loginmodels.php, and loginviews.php.

Our login system will be mostly similar to any other MVC files, but will have a couple of slightly new things we'll do with it. For example, if someone is already logged in, they will be redirected to our index.php controller automatically. We'll also show errors to the user via PHP exceptions.

This code should be put into loginviews.php in your webroot directory.

<?php
	
namespace LoginViews;

class LoginViews {
	static public function index($error = "") {
		LoginViews::header();
		
		?> 
		<form action="login.php" method="post">
		<input type="hidden" name="do" value="login">
		<input type="hidden" name="csrf" value="<?= $_SESSION["csrf"] ?>">
		
		<?php
		
		// if there is an error, echo it
		if ( $error != "" ) {
			?>
			<div class="error">
				<?= $error ?>
			</div>
			<?php
		}
		
		// show the log in form
		?>
		
		<label>Username</label>
		<div><input type="text" name="username" value=""></div>
		<label>Password</label>
		<div><input type="password" name="password" value=""></div>
		
		<input type="submit" value="Log In">
		
		</form> 
		<?php
		
		LoginViews::footer();
	}
	
	static public function header() {
		
?>
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
	<meta charset="utf-8">
	<title>Employees - Login</title>
	<style>
		body{ background-color: #333; color: #ddd; }
		a{ color: #ddd; }
		.error{color: red;}
	</style>
</head>
<body>
<?php
	}
	
	static public function footer() {
?>
</body>
</html>
<?php
	}
}

Coming from our previous lessons, the above should look mostly familiar. The log in form is just two text inputs. We've also included a do action and a CSRF token.

If there is an error in our $error variable, then we'll display it in red above the form.

Let's move on to our login.php controller.

This code should be put into login.php in your webroot directory.

<?php
	
require_once "session.php";

require_once "filter.php";
use \filter\filter;

require_once "loginmodels.php";
use \LoginModels\LoginModels;

require_once "loginviews.php";
use \LoginViews\LoginViews;

// if we're already logged in, then just send the user to index.php
if ( isset($_SESSION["loggedIn"]) == true && $_SESSION["loggedIn"] == true ) {
	header("Location: index.php");
	exit;
}

if (isset($_POST["do"]) == true) {
	
	// check csrf token to make sure it matches
	// exit if any of these conditions are not met
	if (isset($_POST["csrf"]) == false || $_POST["csrf"] != $_SESSION["csrf"]) {
		exit;
	}
	
	// is this a log in attempt?
	if ($_POST["do"] == "login") {
		
		// we'll use a try catch block here so that our filters can throw an error
		// if something is wrong.  we'll use the message to show an error to the user
		try {
			
			// validate the username - will throw an exception if not good
			$username = filter::username($_POST["username"]);
			
			// validate the password - will throw an exception if not good
			$password = filter::password($_POST["password"]);
			
			// check the username and password against the database - will throw an exception if not good
			$loggedIn = LoginModels::checkLogin($username, $password);
			
			// if we've gotten the all clear aka loggedIn = true, 
			// then set our $_SESSION and redirect to index.php
			if ($loggedIn == true) {
				$_SESSION["loggedIn"] = true;
				header("Location: index.php");
				exit;
			} else {
				LoginViews::index("There was an error logging in.");
			}
			
		} catch (\Exception $e) {

			// if we get here something was not right.  pass the error message into the
			// log in view and allow the user to try again
			LoginViews::index($e->getMessage());
			
			// if someone didn't log in correctly, we'll log it to the syslog
			syslog(LOG_INFO, "---Log in error---" );
			syslog(LOG_INFO, '$_POST = ' . print_r($_POST, true) );
			syslog(LOG_INFO, print_r($e->getMessage(), true) );
			syslog(LOG_INFO, "------------------" );
			
		}
		
	}
	
} else {
	// show the default screen with no error
	LoginViews::index();
}

In our controller, we're doing things a little differently. We're using exceptions to handle errors. This is a modern and simple way to do error handling. In our validation code, if something doesn't validate properly, it throws an error. If an error is thrown, we catch the error and display it to the user.

This manner of handling errors should look familiar to you. We are handling errors the same way in our db.php class in the section entitled, Handling Errors. We're using try...catch blocks to try code and catch errors. If something doesn't validate properly, it will throw an error and be caught in the catch (\Exception $e) block of code.

try {
	// try to run some code
} catch (\Exception $e) {
	// if code above throws an error, this block of code is run
}

Once all the main validation is done, we check to make sure our database check passes back loggedIn == true via our LoginModels::checkLogin() method. If it does, then we set the $_SESSION["loggedIn"] = true and we forward the user to our index.php page to start their work.

The next step is to look at two new filter functions, for our log in system.

This code should be put into filters.php in your webroot directory. The code should be inserted into the class.

// validate username
static public function username($username) {
	
	// throw an error if empty
	if ($username == "") {
		throw new \Exception("All fields are required");
	}
	
	// we only want letters, numbers, dots, and dashes via a "regular expression".
	// if anything else is passed, throw an error
	if (preg_match("/[^A-Za-z0-9\.\-]/", $username) == true) {
		throw new \Exception("Usernames can only contain letters, numbers, dots, and dashes");
	}
	
	// the username must be four or more chars long, otherwise throw an error
	if (strlen($username) < 4) {
		throw new \Exception("Username is not long enough");
	}
	
	// the username must be four or more chars long, otherwise throw an error
	if (strlen($username) > 128) {
		throw new \Exception("Username is too long");
	}
	
	// lowercase the username
	$username = strtolower($username);
	
	// return the filtered username back to the controller
	return $username;
}

// validate password
static public function password($password) {

	// throw an error if empty
	if ($password == "") {
		throw new \Exception("All fields are required");
	}
	
	// the password must be ten or more chars long, otherwise throw an error
	if (strlen($password) < 10) {
		throw new \Exception("Password is not long enough");
	}
	
	// the password must not be than 72 chars, otherwise throw an error
	if (strlen($password) > 72) {
		throw new \Exception("Password is too long");
	}
	
	// we can also add other requirements such as forcing special characters and numbers...
	
	// return the filtered password back to the controller
	return $password;
}

We have strict requirements that our usernames only contain specific characters. If we detect any unwanted characters, we throw an error, which the controller intercepts and runs the code in our catch() block. We're returning a text error message that then gets passed to the user.

Our passwords are basically the same, except we want the user to use any character. We do not want to limit the length or limit the characters in a password, because that can weaken the password. In fact, if we do anything, we want to detect if there are missing characters to enforce a more complex password. In our example, we're not detecting any characters for enforcement, but it would be pretty easy to add that functionality if need be. You would basically add a preg_match to the filter method and error out if it didn't detect the required characters. Some applications don't need strict password requirements, although these days, it's recommended that you force at least 10 characters.

Let's take a look at our LoginModels class.

This code should be put into loginmodels.php in your webroot directory.

<?php

namespace LoginModels;

require_once "db.php";
use \database\db;

class LoginModels {
	
	// check the username and password against the database
	static public function checkLogin($username, $password) {
		
		// retrieve the record for the username, if it exists
		$db = new db;
		$results = $db->query(
			"select id, passwordHash from users where username = :username and isEnabled = true and isDeleted = false",
			[ ":username"=>$username, ]
		)->fetchAll();
		
		// if the username exists, test the password
		if (count($results) == 1) {
			
			// verify the password against the passwordHash
			if ( password_verify($password, $results[0]["passwordHash"]) == true ) {
				
				// update the lastLogin field for this user id
				$db->query(
					"update users set lastLogin=now() where id = :id",
					[ ":id"=>$results[0]["id"], ]
				);
				
				// pass true back to the controller to indicate loggedIn == true
				return true;
				
			}
			
		}	
		
		// if we get here, then nothing went right, so we'll throw an error
		throw new \Exception("That username and password are incorrect");
	}
}

In this code, we search the database for a record that matches the username. If you have a lot of users, this would be a great field to index to make this search much faster. Here's what you would use to do that:

mysql> alter table users add index username (username);

In this search, we're returning the user id and passwordHash. We need the passwordHash to pass into the PHP password_verify() function to verify that the passwords match. Behind the scenes, PHP is verifying in a cryptographically secure manner that the password matches what was created for that user.

If the password matches the passwordHash, we update the user lastLogin field to show when they last logged in, and then we return true to denote that there was a successful log in.

If none of the conditions are met correctly, we then throw a new Exception that the user supplied credentials were incorrect.