diff --git a/resources/database/dump/cafe.sql b/resources/database/dump/cafe.sql
index a4e1ae7..1373809 100644
--- a/resources/database/dump/cafe.sql
+++ b/resources/database/dump/cafe.sql
@@ -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`
--
@@ -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
diff --git a/src/controllers/Password.php b/src/controllers/Password.php
new file mode 100644
index 0000000..a9bd9de
--- /dev/null
+++ b/src/controllers/Password.php
@@ -0,0 +1,187 @@
+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:
$resetLink";
+ $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'
+ );
+ }
+}
+
diff --git a/src/core/Mailer.php b/src/core/Mailer.php
index 9349f9d..3eedebb 100644
--- a/src/core/Mailer.php
+++ b/src/core/Mailer.php
@@ -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();
@@ -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';
diff --git a/src/models/User.php b/src/models/User.php
index 0179b67..50a5e49 100644
--- a/src/models/User.php
+++ b/src/models/User.php
@@ -4,6 +4,7 @@
namespace Steamy\Model;
+use Exception;
use Steamy\Core\Model;
abstract class User
@@ -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;
+ }
+
}
diff --git a/src/views/Login.php b/src/views/Login.php
index 3ebd235..4a83ee4 100644
--- a/src/views/Login.php
+++ b/src/views/Login.php
@@ -22,7 +22,10 @@
required/>
- Don't have an account yet? Register
+
+ Register
+ Forgot Password?
+
Just need to confirm your email to send you instructions to reset your password.
+ +