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? +
diff --git a/src/views/NewPassword.php b/src/views/NewPassword.php new file mode 100644 index 0000000..50da315 --- /dev/null +++ b/src/views/NewPassword.php @@ -0,0 +1,35 @@ + + +
+

Enter your new password

+ +
+ + + + +
+
+ +> +
+

Password changed! 🎉

+

Your password has been successfully changed

+ +
+
\ No newline at end of file diff --git a/src/views/ResetPassword.php b/src/views/ResetPassword.php new file mode 100644 index 0000000..86d6c8e --- /dev/null +++ b/src/views/ResetPassword.php @@ -0,0 +1,36 @@ + + +
+

Reset Password

+

Just need to confirm your email to send you instructions to reset your password.

+
+ + +
+
+ +> +
+

Email submitted! 🎉

+

Thanks - if you have a Steamy Sips account, we've sent you an email.

+ +
+
\ No newline at end of file