Skip to content

Commit

Permalink
Merge pull request #109 from Divyesh000/newtable
Browse files Browse the repository at this point in the history
Implement reset password functionality
  • Loading branch information
creme332 authored Apr 22, 2024
2 parents eea82cb + e5115e5 commit 3c22d32
Show file tree
Hide file tree
Showing 7 changed files with 401 additions and 4 deletions.
30 changes: 29 additions & 1 deletion resources/database/dump/cafe.sql
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,34 @@ DELIMITER ;
/*!50003 SET character_set_results = @saved_cs_results */ ;
/*!50003 SET collation_connection = @saved_col_connection */ ;

--
-- Table structure for table `password_change_request`
--

DROP TABLE IF EXISTS `password_change_request`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `password_change_request` (
`request_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) unsigned NOT NULL,
`token_hash` varchar(255) NOT NULL,
`expiry_date` datetime NOT NULL,
`used` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`request_id`),
KEY `request_fk` (`user_id`),
CONSTRAINT `request_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `password_change_request`
--

LOCK TABLES `password_change_request` WRITE;
/*!40000 ALTER TABLE `password_change_request` DISABLE KEYS */;
/*!40000 ALTER TABLE `password_change_request` ENABLE KEYS */;
UNLOCK TABLES;

--
-- Table structure for table `product`
--
Expand Down Expand Up @@ -330,4 +358,4 @@ UNLOCK TABLES;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2024-04-18 13:18:52
-- Dump completed on 2024-04-20 18:34:37
187 changes: 187 additions & 0 deletions src/controllers/Password.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

declare(strict_types=1);

namespace Steamy\Controller;

use Exception;
use Steamy\Core\Mailer;
use Steamy\Model\User;
use Steamy\Core\Controller;

/**
* Controller responsible for managing the entire password reset user flow. It is invoked
* for relative urls of the form /password. It
* displays a form asking for user email, handles email submission, sends email,
* handles submission for new password.
*/
class Password
{
use Controller;

private array $view_data = [];

public function __construct()
{
$this->view_data['email_submit_success'] = false;
$this->view_data['error'] = false;
$this->view_data['password_change_success'] = false;
}

/**
* Sends an email with a password reset link
* @throws Exception
*/
private function sendResetEmail(string $email, string $resetLink): void
{
//Implement logic to send reset email using Mailer class
$mailer = new Mailer();
$subject = "Reset Your Password | Steamy Sips";
$htmlMessage = "Click the link below to reset your password:<br><a href='$resetLink'>$resetLink</a>";
$plainMessage = "Click the link below to reset your password:\n$resetLink";
$mailer->sendMail($email, $subject, $htmlMessage, $plainMessage);
}

/**
* Invoked when user submits an email on form.
* @throws Exception Email could not be sent
*/
private function handleEmailSubmission(): void
{
$submitted_email = filter_var($_POST['email'] ?? "", FILTER_VALIDATE_EMAIL);

if (empty($submitted_email)) {
$this->view_data['error'] = 'Invalid email';
return;
}
// email is valid

// get user ID corresponding to user email
$userId = User::getUserIdByEmail($submitted_email);

// check if account is not present in database
if (empty($userId)) {
$this->view_data['error'] = 'Email does not exist';
return;
}

// Generate a token for a password change request
try {
$token_info = User::generatePasswordResetToken($userId);
} catch (Exception) {
$this->view_data['error'] = 'Mailing service is not operational. Try again later';
return;
}

// Send email to user with password reset link and user id
$passwordResetLink = ROOT . "/password/reset?token=" . $token_info['token'] .
"&id=" . $token_info['request_id'];
$this->sendResetEmail($submitted_email, $passwordResetLink);
}

/**
* Checks if password reset link contains the necessary token and id query parameters.
* @return bool True if valid
*/
private function validatePasswordResetLink(): bool
{
// check if query parameters are present
if (empty($_GET['token']) || empty($_GET['id'])) {
return false;
}

// validate request id data type
if (!filter_var($_GET['id'], FILTER_VALIDATE_INT)) {
return false;
}

return true;
}

/**
* This function is invoked when user opens password reset link from email
* and submits form.
* @return void
*/
private function handlePasswordSubmission(): void
{
if (!$this->validatePasswordResetLink()) {
$this->view_data['error'] = 'Invalid password reset link';
return;
}

if (!isset($_POST['pwd'], $_POST['pwd-repeat'])) {
$this->view_data['error'] = 'You must enter new password twice';
return;
}

$password = $_POST['pwd'];
$passwordRepeat = $_POST['pwd-repeat'];
$token = $_GET['token'];
$requestID = filter_var($_GET['id'], FILTER_VALIDATE_INT);

// Check if passwords match
if ($password !== $passwordRepeat) {
$this->view_data['error'] = 'Passwords do not match';
return;
}

// check if password valid
$password_errors = User::validatePlainPassword($password);
if (!empty($password_errors)) {
$this->view_data['error'] = $password_errors[0];
return;
}

$success = User::resetPassword($requestID, $token, $password);

if ($success) {
$this->view_data['password_change_success'] = true;
} else {
$this->view_data['error'] = 'Failed to change password. Try generating a new token.';
}
}

public function index(): void
{
// check if url is of form /password
if ($_GET['url'] === 'password') {
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// user has submitted his email
try {
$this->handleEmailSubmission();
} catch (Exception) {
$this->view_data['error'] = 'Mailing service is not operational. Please try again later.';
}
}
// display form asking for user email
// this form should be displayed before and after email submission
$this->view(
view_name: 'ResetPassword',
view_data: $this->view_data,
template_title: 'Reset Password'
);
return;
}

// check if url is of form /password/reset
if ($_GET['url'] === 'password/reset') {
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$this->handlePasswordSubmission();
}
// display form asking user for his new password
$this->view(
view_name: 'NewPassword',
view_data: $this->view_data,
template_title: 'New Password'
);
return;
}

// if url follows some other format display error page
$this->view(
view_name: '404'
);
}
}

4 changes: 2 additions & 2 deletions src/core/Mailer.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class Mailer
public function __construct()
{
//Create a new PHPMailer instance
$this->mail = new PHPMailer();
$this->mail = new PHPMailer(true); // class will throw exceptions on errors, which we need to catch

//Tell PHPMailer to use SMTP
$this->mail->isSMTP();
Expand All @@ -33,7 +33,7 @@ public function __construct()
//SMTP::DEBUG_OFF = off (for production use)
//SMTP::DEBUG_CLIENT = client messages
//SMTP::DEBUG_SERVER = client and server messages
$this->mail->SMTPDebug = SMTP::DEBUG_SERVER;
$this->mail->SMTPDebug = SMTP::DEBUG_OFF;

//Set the hostname of the mail server
$this->mail->Host = 'smtp.gmail.com';
Expand Down
108 changes: 108 additions & 0 deletions src/models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Steamy\Model;

use Exception;
use Steamy\Core\Model;

abstract class User
Expand Down Expand Up @@ -202,4 +203,111 @@ public function setUserID(int $new_id): void
{
$this->user_id = $new_id;
}

public static function getUserIdByEmail(string $email): ?int
{
//Implement logic to fetch user ID by email from the database
$query = "SELECT user_id FROM user WHERE email = :email";
$result = self::query($query, ['email' => $email]);

if (!$result || count($result) == 0) {
return null;
}
return $result[0]->user_id;
}

/**
* Generates a password reset token for a user. A hash of the token
* is stored in the database.
*
* @param int $userId ID of user to which password reset token belongs
* @return array|null An associative array with attributes `request_id` and `token`
* @throws Exception
*/
public static function generatePasswordResetToken(int $userId): ?array
{
if ($userId < 0) {
return null;
}

$info = []; // array to store token and request ID

// Generate random token and its associated information
$info['token'] = "";
try {
$info['token'] = bin2hex(random_bytes(16)); // length 32 bytes (hexadecimal format)
} catch (Exception) {
throw new Exception('Token cannot be generated');
}

$expiryDate = date('Y-m-d H:i:s', strtotime('+1 day')); // Expiry date set to 1 day from now
$tokenHash = password_hash($info['token'], PASSWORD_BCRYPT); // Hashing the token for security

// Save token info to database
$query = "INSERT INTO password_change_request (user_id, token_hash, expiry_date)
VALUES (:userId, :tokenHash, :expiryDate)";
self::query($query, ['userId' => $userId, 'tokenHash' => $tokenHash, 'expiryDate' => $expiryDate]);


// get ID of last inserted record
// Ref: https://stackoverflow.com/a/39141771/17627866
$query = <<< EOL
SELECT LAST_INSERT_ID(request_id) as request_id
FROM password_change_request
ORDER BY LAST_INSERT_ID(request_id)
DESC LIMIT 1;
EOL;
$info['request_id'] = self::query($query)[0]->request_id;

return $info;
}


/**
* Uses a password reset token to change the password of a user.
* @param int $requestID request ID corresponding to password reset token
* @param string $token Password reset token
* @param string $new_password New password of user in plain text (not hashed)
* @return bool
*/
public static function resetPassword(int $requestID, string $token, string $new_password): bool
{
// fetch matching request from database if valid (not expired and unused)
$query = <<< EOL
SELECT * FROM password_change_request
WHERE expiry_date > NOW() AND used = 0 AND request_id = :requestID
EOL;

$result = self::query($query, ['requestID' => $requestID]);

if (empty($result)) {
return false;
}

$request = $result[0];

// verify token
if (!password_verify($token, $request->token_hash)) {
return false;
}

// validate password
if (!empty(self::validatePlainPassword($new_password))) {
return false;
}

$hashedPassword = password_hash($new_password, PASSWORD_BCRYPT);

// Update user's password in the database
$query = "UPDATE user SET password = :password WHERE user_id = :userId";
self::query($query, ['password' => $hashedPassword, 'userId' => $request->user_id]);


// Invalidate password request token so that token cannot be used again
$query = "UPDATE password_change_request SET used = 1 WHERE request_id = :requestID";
self::query($query, ['requestID' => $request->request_id]);

return true;
}

}
5 changes: 4 additions & 1 deletion src/views/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
required/>

<button name="login_submit" type="submit" class="contrast">Login</button>
<small>Don't have an account yet? <a href="<?= ROOT ?>/register">Register</a></small>
<small class="grid">
<a href="<?= ROOT ?>/register">Register</a>
<a style="display: flex; justify-content: flex-end" href="<?= ROOT ?>/password">Forgot Password?</a>
</small>
</form>
</div>
<div></div>
Expand Down
Loading

0 comments on commit 3c22d32

Please sign in to comment.