Creating A Forgot Password System Part 1

Just providing some code here for now. Will do an updated series of articles that will explain everything later...

forgotpassword.php - controller

<?php
	
require_once "session.php";

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

require_once "forgotpasswordmodels.php";
use \ForgotPasswordModels\ForgotPasswordModels;

require_once "forgotpasswordviews.php";
use \ForgotPasswordViews\ForgotPasswordViews;

// 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($_GET["do"]) == true) {
	
	// we don't have a CSRF check for this particular $_GET.
	// there are reasons.  1. the user is clicking a link from an email.  a CSRF
	// might not be available.  2. a user might start on one device and finish on another.
	if ($_GET["do"] == "reset") {
		
		try {
			// check reset codes from user
			$resetCode = filter::hexCode($_GET["d"]);
			$emailHash = filter::hexCode($_GET["a"]);
			
			$obfuscatedId = ForgotPasswordModels::checkCodes($resetCode, $emailHash);
			
			if ($obfuscatedId != false) {
				// display password reset form
				ForgotPasswordViews::resetForm($obfuscatedId, $resetCode, $emailHash);
			}
			
		} catch (\Exception $e) {
			
			echo "

There was an error

"; syslog(LOG_INFO, "---Forgot Password Error---" ); syslog(LOG_INFO, '$_GET = ' . print_r($_GET, true) ); syslog(LOG_INFO, print_r($e->getMessage(), true) ); syslog(LOG_INFO, "------------------" ); } } 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"] == "step1") { try { $email = filter::email($_POST["email"]); $userId = ForgotPasswordModels::checkEmail($email); // if the email was not found, then we don't send an email $emailOut = ""; if ($userId != false) { // create password reset code $resetCode = bin2hex( random_bytes(16) ); // create hash of email $emailHash = md5($email); // store it in the database ForgotPasswordModels::storeCode($userId, $resetCode, $emailHash); // create the text user in the email // change the link to your actual URL (http://jaketest/...) $emailOut = " Here's your password reset code. This link will be available for 15 minutes. http://jaketest/forgotpassword.php?do=reset&d=" . $resetCode . '&a=' . $emailHash; // normally we would send an email here, but we want to display the email // in the browser for testing purposes // sendEmail($emailOut); } // normally, this would redirect to a page, so a user doesn't get resubmit // errors if they refresh the page or try to go back // header("Location: forgotpasswordsent.html"); // here, we want to display the email that we will send to the user, without // actually sending the email. for testing purposes, this will be much faster // to verify we are getting the correct output. with that in mind though, we // can't redirect the page... // we want to vague on whether or not an email was actually sent, because you could // potentially violate a user's privacy if you gave them a doesn't exist message // we always say "Your Request Has Been Sent" ForgotPasswordViews::sent($emailOut); } catch (\Exception $e) { ForgotPasswordViews::index(); // if someone didn't log in correctly, we'll log it to the syslog syslog(LOG_INFO, "---Forgot Password Error---" ); syslog(LOG_INFO, '$_POST = ' . print_r($_POST, true) ); syslog(LOG_INFO, print_r($e->getMessage(), true) ); syslog(LOG_INFO, "------------------" ); } } if ($_POST["do"] == "step3") { try { $obfuscatedId = $_POST["obfuscatedId"]; // validate the password - will throw an exception if not good $password = filter::password($_POST["password"]); $passwordAgain = filter::password($_POST["passwordAgain"]); // validate the codes $resetCode = filter::hexCode($_POST["d"]); $emailHash = filter::hexCode($_POST["a"]); if ($password == $passwordAgain) { $internalObfuscatedId = ForgotPasswordModels::checkCodes($resetCode, $emailHash); if ($internalObfuscatedId == $obfuscatedId) { ForgotPasswordModels::updatePassword($internalObfuscatedId, $resetCode, $emailHash, $password); header("Location: /login.php"); exit; } else { ForgotPasswordViews::resetForm($obfuscatedId, $resetCode, $emailHash, "Internal error"); } } else { ForgotPasswordViews::resetForm($obfuscatedId, $resetCode, $emailHash, "Passwords did not match"); } } catch (\Exception $e) { ForgotPasswordViews::resetForm($_POST["obfuscatedId"], $_POST["d"], $_POST["a"], $e->getMessage()); // if someone didn't log in correctly, we'll log it to the syslog syslog(LOG_INFO, "---Forgot Password Error---" ); syslog(LOG_INFO, '$_POST = ' . print_r($_POST, true) ); syslog(LOG_INFO, print_r($e->getMessage(), true) ); syslog(LOG_INFO, "------------------" ); } } } else { // just show forgot password page ForgotPasswordViews::index(); }

forgotpasswordmodels.php - models

<?php
	
namespace ForgotPasswordModels;

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

class ForgotPasswordModels {
	
	static public function checkEmail($email) {
		
		$db = new db;
		$results = $db->query(
			"select id from users where email = :email", 
			[ ":email"=>$email, ], $db, __FILE__, __LINE__, true
		)->fetchAll();
		
		if (count($results) == 1) {
			return $results[0]["id"];
		}
		
		return false;
	}
	
	
	static public function storeCode($userId, $resetCode, $emailHash) {
		$db = new db;
		$db->query(
			"insert into forgotpassword set created = now(), userId = :userId, code = :code, emailHash = :emailHash", 
			[ ":userId"=>$userId, ":code"=>$resetCode, ":emailHash"=>$emailHash, ], $db, __FILE__, __LINE__, true
		);
	}
	
	
	static public function checkCodes($resetCode, $emailHash) {
		
		$db = new db;
		$results = $db->query(
			"select u.obfuscatedId from users u join forgotpassword fp on u.id = fp.userId where fp.code = :code and fp.emailHash = :emailHash and now() < date_add(fp.created, interval 15 minute)", 
			[ ":code"=>$resetCode, ":emailHash"=>$emailHash, ], $db, __FILE__, __LINE__, true
		)->fetchAll();
		
		if (count($results) == 1) {
			return $results[0]["obfuscatedId"];
		}
		
		return false;
	}

	
	static public function updatePassword($obfuscatedId, $resetCode, $emailHash, $password) {
		
		// create hashed password
		$passwordHash = password_hash($password, PASSWORD_DEFAULT);
		
		$db = new db;
		
		// get userId
		$results = $db->query(
			"select u.id from users u join forgotpassword fp on u.id = fp.userId where fp.code = :code and fp.emailHash = :emailHash and now() < date_add(fp.created, interval 15 minute)", 
			[ ":code"=>$resetCode, ":emailHash"=>$emailHash, ], $db, __FILE__, __LINE__, true
		)->fetchAll();

		if (count($results) == 1) {
			// update password
			$db->query(
				"update users set passwordHash = :passwordHash where id = :id", 
				[ ":passwordHash"=>$passwordHash, ":id"=>$results[0]["id"], ], $db, __FILE__, __LINE__, true
			);
			
			// delete any forgotpassword records
			$db->query(
				"delete from forgotpassword where userId = :userId", 
				[ ":userId"=>$results[0]["id"], ], $db, __FILE__, __LINE__, true
			);
		}
		
	}
	
}

forgotpasswordviews.php - views

<?php
	
namespace ForgotPasswordViews;

class ForgotPasswordViews {
	static public function index() {
		ForgotPasswordViews::header();
		
		?> 
		<form action="forgotpassword.php" method="post">
		<input type="hidden" name="do" value="step1">
		<input type="hidden" name="csrf" value="<?= $_SESSION["csrf"] ?>">
		
		<label>Enter Your Email Address</label>
		<div><input type="text" name="email" value=""></div>
		
		<input type="submit" value="Send Change Email">
		
		</form>
		
		<?php
		
		ForgotPasswordViews::footer();
	}
	
	
	static public function sent($email) {

		ForgotPasswordViews::header();
		
		?>
		
		<h3>Your Request Has Been Sent</h3>
		
		<p>Remember to check your spam if you don't see the email.</p>
		
		<p><?= nl2br($email) ?></p>

		<?php
		
		ForgotPasswordViews::footer();
		
	}

	
	static public function resetForm($obfuscatedId, $resetCode, $emailHash, $error = "") {

		ForgotPasswordViews::header();

		?>
		
		<form action="forgotpassword.php" method="post">
		<input type="hidden" name="do" value="step3">
		<input type="hidden" name="obfuscatedId" value="<?= $obfuscatedId ?>">
		<input type="hidden" name="d" value="<?= $resetCode ?>">
		<input type="hidden" name="a" value="<?= $emailHash ?>">
		<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
		?>
		
		<h3>Enter Your New Password</h3>
		
		<label>Password</label>
		<div><input type="password" name="password" value=""></div>
		
		<label>Password Again</label>
		<div><input type="password" name="passwordAgain" value=""></div>
		
		<input type="submit" value="Reset Now">
		
		</form>
		
		<?php
		
		ForgotPasswordViews::footer();
		
	}

	
	static public function header() {
		
?>
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
	<meta charset="utf-8">
	<title>Employees - Forgot Password</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
	}
}