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
}
}