From 1cd9dfc9d29caf529eb7c73fbef8a46cb746541d Mon Sep 17 00:00:00 2001 From: "Florine W. Dekker" Date: Sat, 3 Dec 2022 14:59:53 +0100 Subject: [PATCH] Revamp UserManager structure --- composer.json | 2 +- composer.lock | Bin 77196 -> 77196 bytes package-lock.json | Bin 226174 -> 226174 bytes package.json | 2 +- src/main/api.php | 26 +- .../com/fwdekker/deathnotifier/Database.php | 1 - .../deathnotifier/EmulateCronAction.php | 2 +- .../com/fwdekker/deathnotifier/LoggerUtil.php | 2 +- .../deathnotifier/StartSessionAction.php | 15 +- .../php/com/fwdekker/deathnotifier/Util.php | 32 +- .../fwdekker/deathnotifier/mailer/Email.php | 11 - .../mailer/ProcessEmailQueueAction.php | 2 +- .../tracking/ListTrackingsAction.php | 1 - .../tracking/NotifyArticleDeletedEmail.php | 60 -- .../tracking/NotifyArticleUndeletedEmail.php | 58 -- .../tracking/NotifyStatusChangedEmail.php | 65 -- .../tracking/RemoveTrackingAction.php | 3 +- .../tracking/TrackingManager.php | 2 - .../tracking/UpdateTrackingsAction.php | 166 ++++- .../deathnotifier/user/ChangeEmailAction.php | 136 ++++ .../user/ChangePasswordAction.php | 128 ++++ .../deathnotifier/user/ChangedEmailEmail.php | 59 -- .../user/ChangedPasswordEmail.php | 48 -- .../user/GetPublicUserDataAction.php | 59 ++ .../deathnotifier/user/GetUserDataAction.php | 36 - .../deathnotifier/user/LoginAction.php | 28 +- .../deathnotifier/user/LogoutAction.php | 14 +- .../deathnotifier/user/RegisterAction.php | 112 ++- .../deathnotifier/user/RegisterEmail.php | 63 -- .../user/ResendVerifyEmailAction.php | 117 ++- .../user/ResetPasswordAction.php | 96 ++- .../deathnotifier/user/ResetPasswordEmail.php | 61 -- .../user/SendPasswordResetAction.php | 116 ++- .../user/ToggleNotificationsAction.php | 42 +- .../deathnotifier/user/UpdateEmailAction.php | 38 - .../user/UpdatePasswordAction.php | 44 -- .../deathnotifier/user/UserDeleteAction.php | 23 +- .../deathnotifier/user/UserManager.php | 451 +++--------- .../user/ValidatePasswordResetTokenAction.php | 29 +- .../deathnotifier/user/VerifyEmailAction.php | 62 +- .../deathnotifier/user/VerifyEmailEmail.php | 59 -- .../deathnotifier/UserManagerTest.php | 668 ------------------ 42 files changed, 1228 insertions(+), 1711 deletions(-) delete mode 100644 src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php create mode 100644 src/main/php/com/fwdekker/deathnotifier/user/ChangeEmailAction.php create mode 100644 src/main/php/com/fwdekker/deathnotifier/user/ChangePasswordAction.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/ChangedPasswordEmail.php create mode 100644 src/main/php/com/fwdekker/deathnotifier/user/GetPublicUserDataAction.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/GetUserDataAction.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/UpdateEmailAction.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/UpdatePasswordAction.php delete mode 100644 src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php delete mode 100644 src/test/php/com/fwdekker/deathnotifier/UserManagerTest.php diff --git a/composer.json b/composer.json index 5cbbaf5..be7c3d8 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "fwdekker/death-notifier", "description": "Get notified when a famous person dies.", - "version": "0.16.0", "_comment_version": "Also update version in `package.json`!", + "version": "0.16.1", "_comment_version": "Also update version in `package.json`!", "type": "project", "license": "MIT", "homepage": "https://git.fwdekker.com/tools/death-notifier", diff --git a/composer.lock b/composer.lock index d75487d92d66698e3bd4cfefe4abf1eed1e995c7..c2e5dd67cdb082f01f5f9b5c4bf085960bf5bc90 100644 GIT binary patch delta 51 zcmeCV&C+w5WrHxIf=NoUMWRt^vSDIsN}7eSd5TerWvaQUrLnPLvVoz2VY4ygc4J1y GNF4yJwhqkz delta 51 zcmeCV&C+w5WrHxIfnPs$p89S+gregister_action(new StartSessionAction($config, $user_manager)); - $dispatcher->register_action(new GetUserDataAction($user_manager)); + $dispatcher->register_action(new StartSessionAction($user_manager)); + $dispatcher->register_action(new GetPublicUserDataAction($user_manager)); $dispatcher->register_action(new ListTrackingsAction($tracking_manager)); $dispatcher->register_action(new ValidatePasswordResetTokenAction($user_manager)); // POST actions - $dispatcher->register_action(new RegisterAction($user_manager)); + $dispatcher->register_action(new RegisterAction($db->conn, $user_manager, $mail_manager)); $dispatcher->register_action(new LoginAction($user_manager)); $dispatcher->register_action(new LogoutAction()); - $dispatcher->register_action(new ResendVerifyEmailAction($user_manager)); - $dispatcher->register_action(new VerifyEmailAction($user_manager)); - $dispatcher->register_action(new UpdateEmailAction($user_manager)); - $dispatcher->register_action(new ToggleNotificationsAction($user_manager)); - $dispatcher->register_action(new UpdatePasswordAction($user_manager)); - $dispatcher->register_action(new SendPasswordResetAction($user_manager)); - $dispatcher->register_action(new ResetPasswordAction($user_manager)); + $dispatcher->register_action(new ResendVerifyEmailAction($db->conn, $user_manager, $mail_manager)); + $dispatcher->register_action(new VerifyEmailAction($db->conn, $user_manager, $mail_manager)); + $dispatcher->register_action(new ChangeEmailAction($db->conn, $user_manager, $mail_manager)); + $dispatcher->register_action(new ToggleNotificationsAction($db->conn, $user_manager)); + $dispatcher->register_action(new ChangePasswordAction($db->conn, $user_manager, $mail_manager)); + $dispatcher->register_action(new SendPasswordResetAction($db->conn, $user_manager, $mail_manager)); + $dispatcher->register_action(new ResetPasswordAction($db->conn, $user_manager, $mail_manager)); $dispatcher->register_action(new UserDeleteAction($user_manager)); $dispatcher->register_action(new AddTrackingAction($tracking_manager, $mediawiki)); $dispatcher->register_action(new RemoveTrackingAction($tracking_manager)); diff --git a/src/main/php/com/fwdekker/deathnotifier/Database.php b/src/main/php/com/fwdekker/deathnotifier/Database.php index afa8b90..5f3029a 100644 --- a/src/main/php/com/fwdekker/deathnotifier/Database.php +++ b/src/main/php/com/fwdekker/deathnotifier/Database.php @@ -7,7 +7,6 @@ use com\fwdekker\deathnotifier\tracking\TrackingManager; use com\fwdekker\deathnotifier\user\UserManager; use Composer\Semver\Comparator; use Error; -use Exception; use Monolog\Logger; use PDO; diff --git a/src/main/php/com/fwdekker/deathnotifier/EmulateCronAction.php b/src/main/php/com/fwdekker/deathnotifier/EmulateCronAction.php index 0f29175..9467026 100644 --- a/src/main/php/com/fwdekker/deathnotifier/EmulateCronAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/EmulateCronAction.php @@ -2,9 +2,9 @@ namespace com\fwdekker\deathnotifier; - use com\fwdekker\deathnotifier\validator\ValidationException; + /** * Periodically executes several other actions, as if cron jobs have been set up to do so. * diff --git a/src/main/php/com/fwdekker/deathnotifier/LoggerUtil.php b/src/main/php/com/fwdekker/deathnotifier/LoggerUtil.php index 0e60080..397cc75 100644 --- a/src/main/php/com/fwdekker/deathnotifier/LoggerUtil.php +++ b/src/main/php/com/fwdekker/deathnotifier/LoggerUtil.php @@ -31,7 +31,7 @@ class LoggerUtil public static function with_name(string $name = "main"): Logger { if (self::$main_logger === null) { - $config = Config::get(); + $config = Config::get()["logger"]; self::$main_logger = new Logger("main"); self::$main_logger->pushHandler(new StreamHandler($config["file"], $config["level"])); diff --git a/src/main/php/com/fwdekker/deathnotifier/StartSessionAction.php b/src/main/php/com/fwdekker/deathnotifier/StartSessionAction.php index 97c6d86..9c94fac 100644 --- a/src/main/php/com/fwdekker/deathnotifier/StartSessionAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/StartSessionAction.php @@ -11,10 +11,6 @@ use Exception; */ class StartSessionAction extends Action { - /** - * @var array the application's configuration - */ - private readonly array $config; /** * @var UserManager the manager to validate the session through */ @@ -24,14 +20,12 @@ class StartSessionAction extends Action /** * Constructs a new `StartSessionAction`. * - * @param array $config the application's configuration * @param UserManager $user_manager the manager to validate the session through */ - public function __construct(array $config, UserManager $user_manager) + public function __construct(UserManager $user_manager) { parent::__construct(ActionMethod::GET, "start-session"); - $this->config = $config; $this->user_manager = $user_manager; } @@ -45,12 +39,13 @@ class StartSessionAction extends Action */ function handle(): array { + $config = Config::get(); $payload = []; // Check if user is logged in if (!isset($_SESSION["uuid"])) { $payload["logged_in"] = false; - } else if ($this->user_manager->user_exists($_SESSION["uuid"])) { + } else if ($this->user_manager->has_user_with_uuid($_SESSION["uuid"])) { $payload["logged_in"] = true; } else { // User account was deleted @@ -66,8 +61,8 @@ class StartSessionAction extends Action } // Read global message - if (isset($this->config["server"]["global_message"]) && trim($this->config["server"]["global_message"]) !== "") - $payload["global_message"] = trim($this->config["server"]["global_message"]); + if (isset($config["server"]["global_message"]) && trim($config["server"]["global_message"]) !== "") + $payload["global_message"] = trim($config["server"]["global_message"]); return $payload; } diff --git a/src/main/php/com/fwdekker/deathnotifier/Util.php b/src/main/php/com/fwdekker/deathnotifier/Util.php index d6a9773..5cdaa1b 100644 --- a/src/main/php/com/fwdekker/deathnotifier/Util.php +++ b/src/main/php/com/fwdekker/deathnotifier/Util.php @@ -3,10 +3,6 @@ namespace com\fwdekker\deathnotifier; use Exception; -use InvalidArgumentException; -use Monolog\ErrorHandler; -use Monolog\Handler\StreamHandler; -use Monolog\Logger; /** @@ -14,19 +10,6 @@ use Monolog\Logger; */ class Util { - /** - * Sets HTTP status code and exits the script. - * - * @param int $status the HTTP status code to set - * @return never - */ - static function http_exit(int $status): never - { - http_response_code($status); - exit(1); - } - - /** * Parses POST values from JSON-based inputs. * @@ -68,4 +51,19 @@ class Util { return bin2hex(random_bytes(32)); } + + + /** + * Returns the number of minutes until `timestamp` was `interval` minutes ago. + * + * For example, if `timestamp` was 5 minutes ago, and `interval` is 7, then this function returns 2. + * + * @param string $timestamp the timestamp at which some event occurred + * @param int $interval the number of minutes to measure against + * @return int the number of minutes until `timestamp` was `interval` minutes ago + */ + static function minutes_until_interval_elapsed(string $timestamp, int $interval): int + { + return $interval - ((time() - intval($timestamp)) / 60); + } } diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php b/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php index 34db774..a769797 100644 --- a/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php @@ -2,17 +2,6 @@ namespace com\fwdekker\deathnotifier\mailer; -use com\fwdekker\deathnotifier\IllegalStateException; -use com\fwdekker\deathnotifier\tracking\NotifyArticleDeletedEmail; -use com\fwdekker\deathnotifier\tracking\NotifyArticleUndeletedEmail; -use com\fwdekker\deathnotifier\tracking\NotifyStatusChangedEmail; -use com\fwdekker\deathnotifier\user\ChangedEmailEmail; -use com\fwdekker\deathnotifier\user\ChangedPasswordEmail; -use com\fwdekker\deathnotifier\user\RegisterEmail; -use com\fwdekker\deathnotifier\user\ResetPasswordEmail; -use com\fwdekker\deathnotifier\user\VerifyEmailEmail; -use JsonSerializable; - /** * An email that can be queued in a database and then sent. diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/ProcessEmailQueueAction.php b/src/main/php/com/fwdekker/deathnotifier/mailer/ProcessEmailQueueAction.php index d065909..d749f7d 100644 --- a/src/main/php/com/fwdekker/deathnotifier/mailer/ProcessEmailQueueAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/ProcessEmailQueueAction.php @@ -52,7 +52,7 @@ class ProcessEmailQueueAction extends Action /** * Processes the queue. * - * @return mixed `null` + * @return null * @throws ActionException if the mailer could not be created or if an email could not be sent */ public function handle(): mixed diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php b/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php index b1fb957..8ee6c7c 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/ListTrackingsAction.php @@ -3,7 +3,6 @@ namespace com\fwdekker\deathnotifier\tracking; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php deleted file mode 100644 index 35775c3..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleDeletedEmail.php +++ /dev/null @@ -1,60 +0,0 @@ -name = $name; - } - - - public function get_subject(): string - { - return "$this->name article has been deleted"; - } - - public function get_body(): string - { - $base_path = Config::get()["server"]["base_path"]; - - return - "The Wikipedia article about $this->name has been deleted. " . - "Death Notifier is now unable to send you a notification if $this->name dies. " . - "If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " . - "article, and you will receive another notification." . - "\n\n" . - "You are receiving this message because of the preferences in your Death Notifier account. " . - "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . - "preferences." . - "\n\n" . - $base_path; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php deleted file mode 100644 index ebe045d..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyArticleUndeletedEmail.php +++ /dev/null @@ -1,58 +0,0 @@ -name = $name; - } - - - public function get_subject(): string - { - return "$this->name article has been re-created"; - } - - public function get_body(): string - { - $base_path = Config::get()["server"]["base_path"]; - - return - "The Wikipedia article about $this->name has been re-created. " . - "Death Notifier will once again track the article and notify you if $this->name dies." . - "\n\n" . - "You are receiving this message because of the preferences in your Death Notifier account. " . - "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . - "preferences." . - "\n\n" . - $base_path; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php b/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php deleted file mode 100644 index 5701834..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/NotifyStatusChangedEmail.php +++ /dev/null @@ -1,65 +0,0 @@ -name = $name; - $this->new_status = $new_status; - } - - - public function get_subject(): string - { - return "$this->name may be $this->new_status"; - } - - public function get_body(): string - { - $base_path = Config::get()["server"]["base_path"]; - - return - "Someone has edited Wikipedia to state that $this->name is $this->new_status. " . - "For more information, read their Wikipedia article at " . - "https://en.wikipedia.org/wiki/" . rawurlencode($this->name) . - "\n\n" . - "You are receiving this message because of the preferences in your Death Notifier account. " . - "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . - "preferences." . - "\n\n" . - $base_path; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php b/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php index 0062d13..cc94b77 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/RemoveTrackingAction.php @@ -3,7 +3,6 @@ namespace com\fwdekker\deathnotifier\tracking; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; use com\fwdekker\deathnotifier\validator\IsNotBlankRule; @@ -41,7 +40,7 @@ class RemoveTrackingAction extends Action /** * Removes the current user's tracking of `$_POST["person_name"]`. * - * @return mixed `null` + * @return null */ function handle(): mixed { diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php b/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php index 0f6e3ca..35cad4f 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/TrackingManager.php @@ -3,10 +3,8 @@ namespace com\fwdekker\deathnotifier\tracking; use com\fwdekker\deathnotifier\Database; -use com\fwdekker\deathnotifier\LoggerUtil; use com\fwdekker\deathnotifier\mediawiki\ArticleType; use com\fwdekker\deathnotifier\mediawiki\PersonStatus; -use Monolog\Logger; use PDO; diff --git a/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsAction.php b/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsAction.php index a34ce78..b370c3b 100644 --- a/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/tracking/UpdateTrackingsAction.php @@ -8,6 +8,7 @@ use com\fwdekker\deathnotifier\ActionMethod; use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\LoggerUtil; +use com\fwdekker\deathnotifier\mailer\Email; use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mediawiki\MediaWiki; use com\fwdekker\deathnotifier\mediawiki\MediaWikiException; @@ -72,7 +73,7 @@ class UpdateTrackingsAction extends Action /** * Updates all trackings that users have added. * - * @return mixed `null` + * @return null * @throws ActionException if the Wikipedia API could not be reached */ public function handle(): mixed @@ -124,3 +125,166 @@ class UpdateTrackingsAction extends Action return null; } } + + +/** + * An email to inform a user that a tracked article has been deleted. + */ +class NotifyArticleDeletedEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "notify-article-deleted"; + + /** + * @var string the name of the article that was deleted + */ + public string $name; + + + /** + * Constructs a new `NotifyArticleDeletedEmail`. + * + * @param string $recipient the intended recipient of the email + * @param string $name the name of the article that was deleted + */ + public function __construct(string $recipient, string $name) + { + parent::__construct($this::TYPE . "/" . $name, $recipient); + + $this->name = $name; + } + + + public function get_subject(): string + { + return "$this->name article has been deleted"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + + return + "The Wikipedia article about $this->name has been deleted. " . + "Death Notifier is now unable to send you a notification if $this->name dies. " . + "If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " . + "article, and you will receive another notification." . + "\n\n" . + "You are receiving this message because of the preferences in your Death Notifier account. " . + "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . + "preferences." . + "\n\n" . + $base_path; + } +} + +/** + * An email to inform a user that a tracked article has been re-created. + */ +class NotifyArticleUndeletedEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "notify-article-undeleted"; + + /** + * @var string the name of the article that was re-created + */ + public string $name; + + + /** + * Constructs a new `NotifyArticleUndeletedEmail`. + * + * @param string $recipient the intended recipient of the email + * @param string $name the name of the article that was re-created + */ + public function __construct(string $recipient, string $name) + { + parent::__construct($this::TYPE . "/" . $name, $recipient); + + $this->name = $name; + } + + + public function get_subject(): string + { + return "$this->name article has been re-created"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + + return + "The Wikipedia article about $this->name has been re-created. " . + "Death Notifier will once again track the article and notify you if $this->name dies." . + "\n\n" . + "You are receiving this message because of the preferences in your Death Notifier account. " . + "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . + "preferences." . + "\n\n" . + $base_path; + } +} + +/** + * An email to inform a user a tracker person's status has changed. + */ +class NotifyStatusChangedEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "notify-status-changed"; + + /** + * @var string the name of the person whose status has changed + */ + public string $name; + /** + * @var string the new status of the person + */ + public string $new_status; + + + /** + * Constructs a new `NotifyStatusChangedEmail`. + * + * @param string $recipient the intended recipient of the email + * @param string $name the name of the person who died + * @param string $new_status the new status of the person + */ + public function __construct(string $recipient, string $name, string $new_status) + { + parent::__construct($this::TYPE . "/" . $name, $recipient); + + $this->name = $name; + $this->new_status = $new_status; + } + + + public function get_subject(): string + { + return "$this->name may be $this->new_status"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + + return + "Someone has edited Wikipedia to state that $this->name is $this->new_status. " . + "For more information, read their Wikipedia article at " . + "https://en.wikipedia.org/wiki/" . rawurlencode($this->name) . + "\n\n" . + "You are receiving this message because of the preferences in your Death Notifier account. " . + "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . + "preferences." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ChangeEmailAction.php b/src/main/php/com/fwdekker/deathnotifier/user/ChangeEmailAction.php new file mode 100644 index 0000000..b3c1f22 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/user/ChangeEmailAction.php @@ -0,0 +1,136 @@ + [new IsEmailRule()]], + ); + + $this->conn = $conn; + $this->user_manager = $user_manager; + $this->mail_manager = $mail_manager; + } + + + /** + * Changes the user's email address. + * + * @return null + * @throws ValidationException if the email address is not different or the email address is already used + */ + function handle(): mixed + { + Database::transaction($this->conn, function () { + $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); + if ($user_data === null) + throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); + if ($_POST["email"] === $user_data["email"]) + throw new ValidationException("That is already your email address.", "email"); + if ($this->user_manager->has_user_with_email($_POST["email"])) + throw new ValidationException("That email address is already in use by someone else.", "email"); + + $token = $this->user_manager->set_email($_SESSION["uuid"], $_POST["email"]); + // TODO: Also send email to old email address + $this->mail_manager->queue_email(new ChangeEmailEmail($_POST["email"], $token)); + }); + + return null; + } +} + + +/** + * An email informing a user that their email has been changed, and needs verification. + */ +class ChangeEmailEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "changed-email"; + + /** + * @var string the token to verify the email address with + */ + public string $token; + + + /** + * Constructs a new `ChangeEmailEmail`. + * + * @param string $recipient the intended recipient of the email + * @param string $token the token to verify the email address with + */ + public function __construct(string $recipient, string $token) + { + parent::__construct($this::TYPE, $recipient); + + $this->token = $token; + } + + + public function get_subject(): string + { + return "Verify your new email address"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You changed the email address of your Death Notifier account. " . + "Until you verify your email address, you will not receive any notifications. " . + "You can verify your new email address by clicking the link below. " . + "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . + "\n" . + "Verify: $verify_path" . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ChangePasswordAction.php b/src/main/php/com/fwdekker/deathnotifier/user/ChangePasswordAction.php new file mode 100644 index 0000000..2c6a9e2 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/user/ChangePasswordAction.php @@ -0,0 +1,128 @@ + [new IsSetRule()], + "password_new" => [ + new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH) + ], + ], + ); + + $this->conn = $conn; + $this->user_manager = $user_manager; + $this->mail_manager = $mail_manager; + } + + + /** + * Changes the user's password. + * + * @return null + * @throws ValidationException if the old password is incorrect + */ + function handle(): mixed + { + Database::transaction($this->conn, function () { + $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); + if ($user_data === null) + throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); + if (!password_verify($user_data["password"], $_POST["password_old"])) + throw new ValidationException("Incorrect old password.", "password_old"); + + $this->user_manager->set_password($_SESSION["uuid"], $_POST["password_new"]); + $this->mail_manager->queue_email(new ChangePasswordEmail($user_data["email"])); + }); + + return null; + } +} + + +/** + * An email informing a user that their password has been changed. + */ +class ChangePasswordEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "changed-password"; + + + /** + * Constructs a new `ChangedPasswordEmail`. + * + * @param string $recipient the intended recipient of the email + */ + public function __construct(string $recipient) + { + parent::__construct($this::TYPE, $recipient); + } + + + public function get_subject(): string + { + return "Your password has been changed"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + + return + "You changed the password of your Death Notifier account." . + "\n\n" . + "If you did not change the password of your account, go to the Death Notifier website and use the forgot " . + "password option to change your password back." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php deleted file mode 100644 index 7ca2cb1..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/ChangedEmailEmail.php +++ /dev/null @@ -1,59 +0,0 @@ -token = $token; - } - - - public function get_subject(): string - { - return "Verify your new email address"; - } - - public function get_body(): string - { - $base_path = Config::get()["server"]["base_path"]; - $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You changed the email address of your Death Notifier account. " . - "Until you verify your email address, you will not receive any notifications. " . - "You can verify your new email address by clicking the link below. " . - "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . - "\n" . - "Verify: $verify_path" . - "\n\n" . - $base_path; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ChangedPasswordEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/ChangedPasswordEmail.php deleted file mode 100644 index f59226a..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/ChangedPasswordEmail.php +++ /dev/null @@ -1,48 +0,0 @@ -user_manager = $user_manager; + } + + + /** + * Returns the user's public data. + * + * @return array{"email": string, "email_verified": bool, "email_notifications_enabled": bool, + * "password_last_change": int} the user's public data + * @throws ActionException if the user's data could not be retrieved + */ + function handle(): array + { + $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); + if ($user_data === null) + throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); + + return [ + "email" => $user_data["email"], + "email_verified" => $user_data["email_verification_token"] === null, + "email_notifications_enabled" => $user_data["email_notifications_enabled"] === 1, + "password_last_change" => $user_data["password_last_change"], + ]; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/GetUserDataAction.php b/src/main/php/com/fwdekker/deathnotifier/user/GetUserDataAction.php deleted file mode 100644 index 0cd0252..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/GetUserDataAction.php +++ /dev/null @@ -1,36 +0,0 @@ -user_manager = $user_manager; - } - - - function handle(): mixed - { - $response = $this->user_manager->get_user($_SESSION["uuid"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); - - return $response->payload; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/LoginAction.php b/src/main/php/com/fwdekker/deathnotifier/user/LoginAction.php index c8e2093..64f801f 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/LoginAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/LoginAction.php @@ -3,15 +3,27 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; +use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; use com\fwdekker\deathnotifier\validator\IsEmailRule; +/** + * Logs in the user. + */ class LoginAction extends Action { + /** + * @var UserManager the manager to log in with + */ private readonly UserManager $user_manager; + /** + * Constructs a new `LoginAction`. + * + * @param UserManager $user_manager the manager to log in with + */ public function __construct(UserManager $user_manager) { parent::__construct( @@ -26,11 +38,21 @@ class LoginAction extends Action } + /** + * Logs in the user. + * + * @return null + * @throws ActionException if the user's data could not be retrieved or if the given credentials are incorrect + */ function handle(): mixed { - [$response, $uuid] = $this->user_manager->check_login($_POST["email"], $_POST["password"]); - if ($response->satisfied) $_SESSION["uuid"] = $uuid; + $user_data = $this->user_manager->get_user_by_email($_POST["email"]); + if ($user_data === null) + throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); + if (!password_verify($_POST["password"], $user_data["password"])) + throw new ActionException("Incorrect combination of email and password.", "password"); - return $response->payload; + $_SESSION["uuid"] = $user_data["uuid"]; + return null; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/LogoutAction.php b/src/main/php/com/fwdekker/deathnotifier/user/LogoutAction.php index 180fe80..194b986 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/LogoutAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/LogoutAction.php @@ -6,13 +6,17 @@ use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; use com\fwdekker\deathnotifier\Util; -use com\fwdekker\deathnotifier\validator\IsEmailRule; use Exception; -use Monolog\Logger; +/** + * Terminates the current user session. + */ class LogoutAction extends Action { + /** + * Constructs a new `LogoutAction`. + */ public function __construct() { parent::__construct( @@ -24,6 +28,12 @@ class LogoutAction extends Action } + /** + * Terminates the current user session. + * + * @return null + * @throws ActionException if no CSRF token could be generated + */ function handle(): mixed { session_destroy(); diff --git a/src/main/php/com/fwdekker/deathnotifier/user/RegisterAction.php b/src/main/php/com/fwdekker/deathnotifier/user/RegisterAction.php index 7c4ebe4..db0db25 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/RegisterAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/RegisterAction.php @@ -5,16 +5,43 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; +use com\fwdekker\deathnotifier\Config; +use com\fwdekker\deathnotifier\Database; +use com\fwdekker\deathnotifier\mailer\Email; +use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\validator\HasLengthRule; use com\fwdekker\deathnotifier\validator\IsEmailRule; +use com\fwdekker\deathnotifier\validator\ValidationException; +use PDO; +/** + * Registers a new user. + */ class RegisterAction extends Action { + /** + * @var PDO the connection to register the user with + */ + private readonly PDO $conn; + /** + * @var UserManager the manager to register the user with + */ private readonly UserManager $user_manager; + /** + * @var MailManager the manager to send emails with + */ + private readonly MailManager $mail_manager; - public function __construct(UserManager $user_manager) + /** + * Constructs a new `RegisterAction`. + * + * @param PDO $conn the connection to register the user with + * @param UserManager $user_manager the manager to register the user with + * @param MailManager $mail_manager the manager to send emails with + */ + public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) { parent::__construct( ActionMethod::POST, @@ -27,15 +54,90 @@ class RegisterAction extends Action ], ); + $this->conn = $conn; $this->user_manager = $user_manager; + $this->mail_manager = $mail_manager; } + + /** + * Registers a new user. + * + * @return null + * @throws ValidationException if the email address is already used + */ function handle(): mixed { - $response = $this->user_manager->register_user($_POST["email"], $_POST["password"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + Database::transaction($this->conn, function () { + if ($this->user_manager->has_user_with_email($_POST["email"])) + throw new ValidationException("That email address already in use by someone else.", "email"); - return $response->payload; + $uuid = $this->user_manager->add_user($_POST["email"], $_POST["password"]); + $user_data = $this->user_manager->get_user_by_uuid($uuid); + if ($user_data === null) + throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); + $token = $user_data["email_verification_token"]; + + $this->mail_manager->queue_email(new RegisterEmail($_POST["email"], $token)); + }); + + return null; + } +} + + +/** + * An email to be sent to a recently registered user, including instructions for email verification. + */ +class RegisterEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "register"; + + /** + * @var string the token to verify the email address with + */ + public string $token; + + + /** + * Constructs a new `RegisterEmail`. + * + * @param string $recipient the intended recipient of the email + * @param string $token the token to verify the email address with + */ + public function __construct(string $recipient, string $token) + { + parent::__construct($this::TYPE, $recipient); + + $this->token = $token; + } + + public function get_subject(): string + { + return "Welcome to Death Notifier"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You have successfully created an account with Death Notifier. " . + "Welcome!" . + "\n\n" . + "Until you verify your email address, you will not receive any notifications. " . + "You can verify your email address by clicking the link below. " . + "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . + "\n" . + "Verify: $verify_path" . + "\n\n" . + "If you did not create this account, you can delete the account. " . + "Go to the Death Notifier website, reset your password, log in, and delete the account." . + "\n\n" . + $base_path; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php deleted file mode 100644 index ca2b6e1..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/RegisterEmail.php +++ /dev/null @@ -1,63 +0,0 @@ -token = $token; - } - - public function get_subject(): string - { - return "Welcome to Death Notifier"; - } - - public function get_body(): string - { - $base_path = Config::get()["server"]["base_path"]; - $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You have successfully created an account with Death Notifier. " . - "Welcome!" . - "\n\n" . - "Until you verify your email address, you will not receive any notifications. " . - "You can verify your email address by clicking the link below. " . - "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . - "\n" . - "Verify: $verify_path" . - "\n\n" . - "If you did not create this account, you can delete the account. " . - "Go to the Death Notifier website, reset your password, log in, and delete the account." . - "\n\n" . - $base_path; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ResendVerifyEmailAction.php b/src/main/php/com/fwdekker/deathnotifier/user/ResendVerifyEmailAction.php index 200c286..e3a627f 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/ResendVerifyEmailAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/ResendVerifyEmailAction.php @@ -3,16 +3,43 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; +use com\fwdekker\deathnotifier\Config; +use com\fwdekker\deathnotifier\Database; +use com\fwdekker\deathnotifier\mailer\Email; +use com\fwdekker\deathnotifier\mailer\MailManager; +use com\fwdekker\deathnotifier\Util; +use com\fwdekker\deathnotifier\validator\ValidationException; +use PDO; +/** + * Resets the email verification process and sends a new verification email. + */ class ResendVerifyEmailAction extends Action { + /** + * @var PDO the connection to recreate and resend the verification email with + */ + private readonly PDO $conn; + /** + * @var UserManager the manager to recreate and resend the verification email with + */ private readonly UserManager $user_manager; + /** + * @var MailManager the manager to send emails with + */ + private readonly MailManager $mail_manager; - public function __construct(UserManager $user_manager) + /** + * Constructs a new `ResendVerifyEmailAction`. + * + * @param PDO $conn the connection to recreate and resend the verification email with + * @param UserManager $user_manager the manager to recreate and resend the verification email with + * @param MailManager $mail_manager the manager to send emails with + */ + public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) { parent::__construct( ActionMethod::POST, @@ -21,16 +48,94 @@ class ResendVerifyEmailAction extends Action require_valid_csrf_token: true, ); + $this->conn = $conn; $this->user_manager = $user_manager; + $this->mail_manager = $mail_manager; } + /** + * Resets the email verification process and sends a new verification email. + * + * @return null + * @throws ValidationException if the email address is already verified or if a verification email was sent too + * recently + */ function handle(): mixed { - $response = $this->user_manager->resend_verify_email($_SESSION["uuid"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + Database::transaction($this->conn, function () { + $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); + if ($user_data["email_verification_token"] === null) + throw new ValidationException("Your email address is already verified."); - return $response->payload; + $minutes_left = Util::minutes_until_interval_elapsed( + $user_data["email_verification_token_timestamp"], + UserManager::MINUTES_BETWEEN_VERIFICATION_EMAILS + ); + if ($minutes_left > 0) { + throw new ValidationException( + "A verification email was sent recently. " . + "Please wait $minutes_left more minute(s) before requesting a new email." + ); + } + + $token = $this->user_manager->set_email($_SESSION["uuid"], $user_data["email"]); + $this->mail_manager->queue_email(new ResendVerifyEmailEmail($user_data["email"], $token)); + }); + + return null; + } +} + + +/** + * An email to help a user verify their email address in case they cannot use the original email. + */ +class ResendVerifyEmailEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "verify-email"; + + /** + * @var string the token to verify the email address with + */ + public string $token; + + + /** + * Constructs a new `ResendVerifyEmailEmail`. + * + * @param string $recipient the intended recipient of the email + * @param string $token the token to verify the email address with + */ + public function __construct(string $recipient, string $token) + { + parent::__construct($this::TYPE, $recipient); + + $this->token = $token; + } + + + public function get_subject(): string + { + return "Verify your email address"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You requested a new verification link for your Death Notifier account. " . + "You can verify your email address by clicking the link below. " . + "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes. " . + "Until you verify your email address, you will not receive any notifications." . + "\n" . + "Verify: $verify_path" . + "\n\n" . + $base_path; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordAction.php b/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordAction.php index 6a499a6..b31db24 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordAction.php @@ -3,37 +3,121 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; +use com\fwdekker\deathnotifier\Config; +use com\fwdekker\deathnotifier\Database; +use com\fwdekker\deathnotifier\mailer\Email; +use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\validator\HasLengthRule; +use com\fwdekker\deathnotifier\validator\IsEmailRule; +use com\fwdekker\deathnotifier\validator\ValidationException; +use PDO; +/** + * Resets the user's password after they forgot it. + */ class ResetPasswordAction extends Action { + /** + * @var PDO the connection to reset the password with + */ + private readonly PDO $conn; + /** + * @var UserManager the manager to reset the password with + */ private readonly UserManager $user_manager; + /** + * @var MailManager the manager to send emails with + */ + private readonly MailManager $mail_manager; - public function __construct(UserManager $user_manager) + /** + * Constructs a new `ResetPasswordAction`. + * + * @param PDO $conn the connection to reset the password with + * @param UserManager $user_manager the manager to reset the password with + * @param MailManager $mail_manager the manager to send emails with + */ + public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) { parent::__construct( ActionMethod::POST, "reset-password", require_valid_csrf_token: true, rule_lists: [ + "email" => [new IsEmailRule()], "password" => [new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)], ], ); + $this->conn = $conn; $this->user_manager = $user_manager; + $this->mail_manager = $mail_manager; } + /** + * Resets the user's password after they forgot it. + * + * @return null + */ function handle(): mixed { - $response = $this->user_manager->reset_password($_POST["email"], $_POST["reset_token"], $_POST["password"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + Database::transaction($this->conn, function () { + $user_data = $this->user_manager->get_user_by_email($_POST["email"]); + if ($_GET["reset_token"] !== $user_data["password_reset_token"]) + throw new ValidationException( + "This password reset link is invalid. Maybe you already reset your password?" + ); - return $response->payload; + $this->user_manager->set_password($user_data["uuid"], $_POST["password"]); + $this->mail_manager->queue_email(new ResetPasswordEmail($_POST["email"])); + }); + + return null; + } +} + + +/** + * An email informing a user that their password has been reset. + */ +class ResetPasswordEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "reset-password"; + + + /** + * Constructs a new `ChangedPasswordEmail`. + * + * @param string $recipient the intended recipient of the email + */ + public function __construct(string $recipient) + { + parent::__construct($this::TYPE, $recipient); + } + + + public function get_subject(): string + { + return "Your password has been reset"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + + return + "You requested a password reset and changed the password of your Death Notifier account." . + "\n\n" . + "If you did not change the password of your account, go to the Death Notifier website and use the forgot " . + "password option to change your password back." . + "\n\n" . + $base_path; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php deleted file mode 100644 index ebc57e7..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/ResetPasswordEmail.php +++ /dev/null @@ -1,61 +0,0 @@ -token = $token; - } - - - public function get_subject(): string - { - return "Reset your password"; - } - - public function get_body(): string - { - $base_path = Config::get()["server"]["base_path"]; - $verify_path = - "$base_path?action=reset-password&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You requested a password reset link for your Death Notifier account. " . - "You can choose a new password by clicking the link below. " . - "This link expires after " . UserManager::MINUTES_VALID_PASSWORD_RESET . " minutes." . - "\n" . - "Reset password: $verify_path" . - "\n\n" . - "If you did not request a new password, you can safely ignore this message." . - "\n\n" . - $base_path; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/SendPasswordResetAction.php b/src/main/php/com/fwdekker/deathnotifier/user/SendPasswordResetAction.php index 32c233c..9f9fac5 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/SendPasswordResetAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/SendPasswordResetAction.php @@ -3,17 +3,44 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; +use com\fwdekker\deathnotifier\Config; +use com\fwdekker\deathnotifier\Database; +use com\fwdekker\deathnotifier\mailer\Email; +use com\fwdekker\deathnotifier\mailer\MailManager; +use com\fwdekker\deathnotifier\Util; use com\fwdekker\deathnotifier\validator\IsEmailRule; +use com\fwdekker\deathnotifier\validator\ValidationException; +use PDO; +/** + * Sends a password reset email. + */ class SendPasswordResetAction extends Action { + /** + * @var PDO the connection to send the password reset with + */ + private readonly PDO $conn; + /** + * @var UserManager the manager to send the password reset with + */ private readonly UserManager $user_manager; + /** + * @var MailManager the manager to send emails with + */ + private readonly MailManager $mail_manager; - public function __construct(UserManager $user_manager) + /** + * Constructs a new `SendPasswordResetAction`. + * + * @param PDO $conn the connection to send the password reset with + * @param UserManager $user_manager the manager to send the password reset with + * @param MailManager $mail_manager the manager to send emails with + */ + public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) { parent::__construct( ActionMethod::POST, @@ -22,16 +49,93 @@ class SendPasswordResetAction extends Action rule_lists: ["email" => [new IsEmailRule()]], ); + $this->conn = $conn; $this->user_manager = $user_manager; + $this->mail_manager = $mail_manager; } + /** + * Sends a password reset email. + * + * @return null + * @throws ValidationException if another password reset email was sent too recently + */ function handle(): mixed { - $response = $this->user_manager->send_password_reset($_POST["email"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + Database::transaction($this->conn, function () { + $user_data = $this->user_manager->get_user_by_email($_POST["email"]); - return $response->payload; + $minutes_left = Util::minutes_until_interval_elapsed( + $user_data["password_reset_token_timestamp"], + UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + ); + if ($minutes_left > 0) { + throw new ValidationException( + "A password reset email was sent recently. " . + "Please wait $minutes_left more minute(s) before requesting a new email." + ); + } + + $token = $this->user_manager->register_password_reset($_POST["email"]); + $this->mail_manager->queue_email(new SendPasswordResetEmail($_POST["email"], $token)); + }); + + return null; + } +} + + +/** + * An email to help a user reset their password. + */ +class SendPasswordResetEmail extends Email +{ + /** + * A string identifying the type of email. + */ + public const TYPE = "reset-password"; + + /** + * @var string the token to reset the password with + */ + public string $token; + + + /** + * Constructs a new `ResetPasswordEmail`. + * + * @param string $recipient the intended recipient of the email + * @param string $token the token to reset the password with + */ + public function __construct(string $recipient, string $token) + { + parent::__construct($this::TYPE, $recipient); + + $this->token = $token; + } + + + public function get_subject(): string + { + return "Reset your password"; + } + + public function get_body(): string + { + $base_path = Config::get()["server"]["base_path"]; + $verify_path = + "$base_path?action=reset-password&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You requested a password reset link for your Death Notifier account. " . + "You can choose a new password by clicking the link below. " . + "This link expires after " . UserManager::MINUTES_VALID_PASSWORD_RESET . " minutes." . + "\n" . + "Reset password: $verify_path" . + "\n\n" . + "If you did not request a new password, you can safely ignore this message." . + "\n\n" . + $base_path; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ToggleNotificationsAction.php b/src/main/php/com/fwdekker/deathnotifier/user/ToggleNotificationsAction.php index b91e463..eebce61 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/ToggleNotificationsAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/ToggleNotificationsAction.php @@ -3,34 +3,64 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; +use com\fwdekker\deathnotifier\Database; +use com\fwdekker\deathnotifier\validator\IsSetRule; +use com\fwdekker\deathnotifier\validator\ValidationException; +use PDO; +/** + * Sets whether email notifications are sent. + */ class ToggleNotificationsAction extends Action { + /** + * @var PDO the connection to toggle notifications with + */ + private readonly PDO $conn; + /** + * @var UserManager the manager to toggle notifications with + */ private readonly UserManager $user_manager; - public function __construct(UserManager $user_manager) + /** + * Constructs a new `ToggleNotificationsAction`. + * + * @param PDO $conn the connection to toggle notifications with + * @param UserManager $user_manager the manager to toggle notifications with + */ + public function __construct(PDO $conn, UserManager $user_manager) { parent::__construct( ActionMethod::POST, "toggle-notifications", require_logged_in: true, require_valid_csrf_token: true, + rule_lists: ["enable_notifications" => [new IsSetRule()]] ); $this->user_manager = $user_manager; } + /** + * Sets whether email notifications are sent. + * + * @return null + * @throws ValidationException if the user's email address has not been verified + */ function handle(): mixed { - $response = $this->user_manager->toggle_notifications($_SESSION["uuid"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + Database::transaction($this->conn, function() { + $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); + if ($user_data["email_verification_token"] === null) + throw new ValidationException("Please verify your email address before enabling notifications."); - return $response->payload; + $this->user_manager->set_notifications_enabled($_SESSION["uuid"], $_POST["enable_notifications"] == true); + }); + + return null; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/UpdateEmailAction.php b/src/main/php/com/fwdekker/deathnotifier/user/UpdateEmailAction.php deleted file mode 100644 index 2f13269..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/UpdateEmailAction.php +++ /dev/null @@ -1,38 +0,0 @@ - [new IsEmailRule()]], - ); - - $this->user_manager = $user_manager; - } - - - function handle(): mixed - { - $response = $this->user_manager->set_email($_SESSION["uuid"], $_POST["email"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); - - return $response->payload; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/UpdatePasswordAction.php b/src/main/php/com/fwdekker/deathnotifier/user/UpdatePasswordAction.php deleted file mode 100644 index a79e1ba..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/UpdatePasswordAction.php +++ /dev/null @@ -1,44 +0,0 @@ - [new IsSetRule()], - "password_new" => [ - new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH) - ], - ], - ); - - $this->user_manager = $user_manager; - } - - - function handle(): mixed - { - $response = $this->user_manager->set_password($_SESSION["uuid"], $_POST["password_old"], $_POST["password_new"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); - - return $response->payload; - } -} diff --git a/src/main/php/com/fwdekker/deathnotifier/user/UserDeleteAction.php b/src/main/php/com/fwdekker/deathnotifier/user/UserDeleteAction.php index c11bbd4..21aeb2c 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/UserDeleteAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/UserDeleteAction.php @@ -11,11 +11,22 @@ use com\fwdekker\deathnotifier\validator\IsSetRule; use Exception; +/** + * Deletes the currently logged-in user, and terminates the current user session. + */ class UserDeleteAction extends Action { + /** + * @var UserManager the manager to delete the user with + */ private readonly UserManager $user_manager; + /** + * Constructs a new `UserDeleteAction`. + * + * @param UserManager $user_manager the manager to delete the user with + */ public function __construct(UserManager $user_manager) { parent::__construct( @@ -35,11 +46,15 @@ class UserDeleteAction extends Action } + /** + * Deletes the currently logged-in user, and terminates the current user session. + * + * @return null + * @throws ActionException if no CSRF token could be generated + */ function handle(): mixed { - $response = $this->user_manager->delete_user($_SESSION["uuid"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + $this->user_manager->remove_user_by_uuid($_SESSION["uuid"]); session_destroy(); session_start(); @@ -49,6 +64,6 @@ class UserDeleteAction extends Action throw new ActionException("Failed to generate new CSRF token. Please try again later.", null); } - return $response->payload; + return null; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php b/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php index 5e036db..4f49e24 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/UserManager.php @@ -88,89 +88,33 @@ class UserManager } - /** - * Returns `true` if there is a user with the given email address. - * - * @param string $email the email address to check - * @return bool `true` if there is a user with the given email address - */ - private function query_email_used(string $email): bool - { - $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE email=:email);"); - $stmt->bindValue(":email", $email); - $stmt->execute(); - return $stmt->fetch()[0] === 1; - } - - /** - * Returns the number of minutes until `timestamp` was `interval` minutes ago. - * - * For example, if `timestamp` was 5 minutes ago, and `interval` is 7, then this function returns 2. - * - * @param string $timestamp the timestamp at which some event occurred - * @param int $interval the number of minutes to measure against - * @return int the number of minutes until `timestamp` was `interval` minutes ago - */ - private function minutes_until_interval_elapsed(string $timestamp, int $interval): int - { - return $interval - ((time() - intval($timestamp)) / 60); - } - - /** * Registers a new user. * - * @param string $email the user-submitted email address - * @param string $password the user-submitted password - * @return Response a satisfied `Response` with payload `null` if the registration was successful, or an unsatisfied - * `Response` otherwise - */ - public function register_user(string $email, string $password): Response - { - return Database::transaction($this->conn, function () use ($email, $password) { - if ($this->query_email_used($email)) - return Response::unsatisfied("Email address already in use.", "email"); - - $stmt = $this->conn->prepare("INSERT INTO users (email, password) - VALUES (:email, :password) - RETURNING email_verification_token;"); - $stmt->bindValue(":email", $email); - $stmt->bindValue(":password", password_hash($password, PASSWORD_BCRYPT)); - $stmt->execute(); - $email_verification_token = $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["email_verification_token"]; - - return $this->mailer->queue_email(new RegisterEmail($email, $email_verification_token)); - }); - } - - /** - * Validates a login attempt with the given email address and password. + * Throws an exception if a user with the given email address is already registered. * - * @param string $email the email address of the user whose password should be checked - * @param string $password the password to check against the specified user - * @return array{Response, string|null} a satisfied `Response` with payload `null` and the user's UUID if the - * password is correct, or an unsatisfied `Response` and `null` otherwise + * @param string $email the user's email address + * @param string $password the user's password + * @return string the user's UUID */ - public function check_login(string $email, string $password): array + public function add_user(string $email, string $password): string { - $stmt = $this->conn->prepare("SELECT uuid, password FROM users WHERE email=:email;"); + $stmt = $this->conn->prepare("INSERT INTO users (email, password) + VALUES (:email, :password) + RETURNING uuid;"); $stmt->bindValue(":email", $email); + $stmt->bindValue(":password", password_hash($password, PASSWORD_BCRYPT)); $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - - // TODO: Expose whether account exists, it's exposed in forgot password anyway - return $user === false || !password_verify($password, $user["password"]) - ? [Response::unsatisfied("Incorrect combination of email and password.", "password"), null] - : [Response::satisfied(), $user["uuid"]]; + return $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["uuid"]; } /** - * Returns `true` if and only if a user with the given UUID exists. + * Returns `true` if and only if a user exists with the given UUID. * - * @param string $uuid the UUID of the user to check - * @return bool `true` if and only if a user with the given UUID exists + * @param string $uuid the UUID to check existence of + * @return bool `true` if and only if a user exists with the given UUID */ - public function user_exists(string $uuid): bool + public function has_user_with_uuid(string $uuid): bool { $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE uuid=:uuid);"); $stmt->bindValue(":uuid", $uuid); @@ -179,322 +123,149 @@ class UserManager } /** - * Returns the user with the given UUID. + * Returns `true` if and only if a user exists with the given email address. * - * @param string $uuid the UUID of the user to return - * @return Response a satisfied `Response` with the user's data if the user exists, or an unsatisfied `Response` - * otherwise + * @param string $email the email address to check existence of + * @return bool `true` if and only if a user exists with the given email address */ - public function get_user(string $uuid): Response + public function has_user_with_email(string $email): bool { - $stmt = $this->conn->prepare("SELECT email, email_verification_token IS NULL AS email_verified, - email_notifications_enabled, password_last_change - FROM users - WHERE uuid=:uuid;"); - $stmt->bindValue(":uuid", $uuid); + $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE email=:email);"); + $stmt->bindValue(":email", $email); $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - - return $user === false - ? Response::unsatisfied("Something went wrong. Please try logging in again.") - : Response::satisfied($user); + return $stmt->fetch()[0] === 1; } /** - * Deletes the user with the given UUID. + * Removes the user with the given UUID. * - * @param string $uuid the UUID of the user to delete - * @return Response a satisfied `Response` with payload `null` + * @param string $uuid the UUID of the user to remove + * @return void */ - public function delete_user(string $uuid): Response + public function remove_user_by_uuid(string $uuid): void { $stmt = $this->conn->prepare("DELETE FROM users WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->execute(); - return Response::satisfied(); + } + + /** + * Returns all data of the user with the given UUID. + * + * @param string $uuid the UUID of the user to return the data of + * @return array|null all data of the user with the given UUID, or `null` if the user could not be + * found + */ + public function get_user_by_uuid(string $uuid): ?array + { + $stmt = $this->conn->prepare("SELECT * FROM users WHERE uuid=:uuid;"); + $stmt->bindValue(":uuid", $uuid); + $stmt->execute(); + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + return empty($results) ? null : $results[0]; + } + + /** + * Returns all data of the user with the given email address. + * + * @param string $email the email address of the user to return the data of + * @return array|null all data of the user with the given email address + */ + public function get_user_by_email(string $email): ?array + { + $stmt = $this->conn->prepare("SELECT * FROM users WHERE email=:email;"); + $stmt->bindValue(":email", $email); + $stmt->execute(); + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + return empty($results) ? null : $results[0]; } /** - * Updates the indicated user's email address. + * Updates the email address of the user with the given UUID, and resets the email verification token. + * + * Settings the email address to the current value resets the email verification token. * * @param string $uuid the UUID of the user whose email address should be updated * @param string $email the new email address - * @return Response a satisfied `Response` with payload `null` if the email address was updated, or an unsatisfied - * `Response` otherwise + * @return string the verification token for the updated email address */ - public function set_email(string $uuid, string $email): Response + public function set_email(string $uuid, string $email): string { - // TODO: Also send message to old email address - return Database::transaction($this->conn, function () use ($uuid, $email) { - $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE uuid=:uuid AND email=:email);"); - $stmt->bindValue(":uuid", $uuid); - $stmt->bindValue(":email", $email); - $stmt->execute(); - if ($stmt->fetch()[0] === 1) - return Response::unsatisfied("That is already your email address.", "email"); - - if ($this->query_email_used($email)) - return Response::unsatisfied("Email address already in use.", "email"); - - $stmt = $this->conn->prepare("UPDATE users - SET email=:email, - email_verification_token=lower(hex(randomblob(16))), - email_verification_token_timestamp=unixepoch() - WHERE uuid=:uuid - RETURNING email_verification_token;"); - $stmt->bindValue(":uuid", $uuid); - $stmt->bindValue(":email", $email); - $stmt->execute(); - $email_verification_token = $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["email_verification_token"]; - - return $this->mailer->queue_email(new ChangedEmailEmail($email, $email_verification_token)); - }); + $stmt = $this->conn->prepare("UPDATE users + SET email=:email, + email_verification_token=lower(hex(randomblob(16))), + email_verification_token_timestamp=unixepoch() + WHERE uuid=:uuid + RETURNING email_verification_token;"); + $stmt->bindValue(":uuid", $uuid); + $stmt->bindValue(":email", $email); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["email_verification_token"]; } /** - * Verifies an email address with a token. + * Sets the user's email address as verified. * - * @param string $email the email address to verify - * @param string $token the token to verify the email address with - * @return Response a satisfied `Response` if the email address was newly verified, or an unsatisfied response if - * the email address is unknown, already verified, or the token is incorrect + * @param string $uuid the UUID of the user whose email address has been verified + * @return void */ - public function verify_email(string $email, string $token): Response + public function set_email_verified(string $uuid): void { - return Database::transaction($this->conn, function () use ($email, $token) { - $stmt = $this->conn->prepare("SELECT email_verification_token_timestamp - FROM users - WHERE email=:email AND email_verification_token=:token;"); - $stmt->bindValue(":email", $email); - $stmt->bindValue(":token", $token); - $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - if ($user === false) - return Response::unsatisfied( - "Failed to verify email address. " . - "Maybe you already verified your email address?" - ); - - $minutes_remaining = $this->minutes_until_interval_elapsed( - $user["email_verification_token_timestamp"], - self::MINUTES_VALID_VERIFICATION - ); - if ($minutes_remaining < 0) - return Response::unsatisfied( - "This email verification link has expired. Log in and request a new verification email." - ); - - $stmt = $this->conn->prepare("UPDATE users SET email_verification_token=null WHERE email=:email;"); - $stmt->bindValue(":email", $email); - $stmt->execute(); - - return Response::satisfied(); - }); + $stmt = $this->conn->prepare("UPDATE users SET email_verification_token=null WHERE uuid=:uuid;"); + $stmt->bindValue(":uuid", $uuid); + $stmt->execute(); } /** - * Resends the email verification email. + * Sets whether email notifications are enabled for the given user. * - * @param string $uuid the UUID of the user to resend the email for - * @return Response a satisfied `Response` with payload `null` if the email was sent, or an unsatisfied `Response` - * otherwise + * @param string $uuid the UUID of the user whose notifications should be enabled or disabled + * @param bool $enabled `true` if notifications should be enabled, `false` if notifications should be disabled + * @return void */ - public function resend_verify_email(string $uuid): Response + public function set_notifications_enabled(string $uuid, bool $enabled): void { - return Database::transaction($this->conn, function () use ($uuid) { - $stmt = $this->conn->prepare("SELECT email, email_verification_token, email_verification_token_timestamp - FROM users - WHERE uuid=:uuid;"); - $stmt->bindValue(":uuid", $uuid); - $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - if ($user === false || $user["email_verification_token"] === null) - return Response::unsatisfied("Your email address is already verified."); - - $minutes_left = $this->minutes_until_interval_elapsed( - $user["email_verification_token_timestamp"], - self::MINUTES_BETWEEN_VERIFICATION_EMAILS - ); - if ($minutes_left > 0) { - return Response::unsatisfied( - "A verification email was sent recently. " . - "Please wait $minutes_left more minute(s) before requesting a new email." - ); - } - - $stmt = $this->conn->prepare("UPDATE users - SET email_verification_token=lower(hex(randomblob(16))), - email_verification_token_timestamp=unixepoch() - WHERE uuid=:uuid - RETURNING email_verification_token;"); - $stmt->bindValue(":uuid", $uuid); - $stmt->execute(); - $verification_token = $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["email_verification_token"]; - - return $this->mailer->queue_email(new VerifyEmailEmail($user["email"], $verification_token)); - }); - } - - /** - * Toggles whether the user receives death notifications. - * - * @param string $uuid the UUID of the user whose notifications to toggle - * @return Response a satisfied `Response` if notifications were toggle, or an unsatisfied response if the user's - * email is not verified - */ - public function toggle_notifications(string $uuid): Response - { - return Database::transaction($this->conn, function () use ($uuid) { - $stmt = $this->conn->prepare("SELECT email_verification_token, email_notifications_enabled - FROM users - WHERE uuid=:uuid;"); - $stmt->bindValue(":uuid", $uuid); - $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - if ($user === false || $user["email_verification_token"] !== null) - return Response::unsatisfied("Please verify your email address before enabling notifications."); - - $stmt = $this->conn->prepare("UPDATE users SET email_notifications_enabled=:enabled WHERE uuid=:uuid;"); - $stmt->bindValue(":enabled", !$user["email_notifications_enabled"]); - $stmt->bindValue(":uuid", $uuid); - $stmt->execute(); - - return Response::satisfied(); - }); + $stmt = $this->conn->prepare("UPDATE users SET email_notifications_enabled=:enabled WHERE uuid=:uuid;"); + $stmt->bindValue(":enabled", $enabled); + $stmt->bindValue(":uuid", $uuid); + $stmt->execute(); } /** - * Updates the indicated user's password. + * Sets the indicated user's password. * - * @param string $uuid the UUID of the user whose password should be updated - * @param string $password_old the old password - * @param string $password_new the new password - * @return Response a satisfied `Response` with payload `null` if the password was updated, or an unsatisfied - * `Response` otherwise + * @param string $uuid the UUID of the user whose password should be changed + * @param string $password the new password + * @return void */ - public function set_password(string $uuid, string $password_old, string $password_new): Response + public function set_password(string $uuid, string $password): void { - return Database::transaction($this->conn, function () use ($uuid, $password_old, $password_new) { - $stmt = $this->conn->prepare("SELECT email, password FROM users WHERE uuid=:uuid;"); - $stmt->bindValue(":uuid", $uuid); - $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - if ($user === false || !password_verify($password_old, $user["password"])) - return Response::unsatisfied("Incorrect old password.", "password_old"); - - $stmt = $this->conn->prepare("UPDATE users - SET password=:password, password_last_change=unixepoch(), - password_reset_token=null - WHERE uuid=:uuid;"); - $stmt->bindValue(":uuid", $uuid); - $stmt->bindValue(":password", password_hash($password_new, PASSWORD_BCRYPT)); - $stmt->execute(); - - return $this->mailer->queue_email(new ChangedPasswordEmail($user["email"])); - }); + $stmt = $this->conn->prepare("UPDATE users + SET password=:password, password_last_change=unixepoch(), + password_reset_token=null + WHERE uuid=:uuid;"); + $stmt->bindValue(":uuid", $uuid); + $stmt->bindValue(":password", password_hash($password, PASSWORD_BCRYPT)); + $stmt->execute(); } /** - * Sends a password reset email to the given address. + * Generates and registers a password reset token for the user with the given email address. * - * @param string $email the address to send the password reset email to - * @return Response a satisfied `Response` with payload `null` if the password reset email was updated, or an - * unsatisfied `Response` otherwise + * @param string $email the address for which a password reset token should be generated + * @return string the generate password reset token */ - public function send_password_reset(string $email): Response + public function register_password_reset(string $email): string { - return Database::transaction($this->conn, function () use ($email) { - $stmt = $this->conn->prepare("SELECT password_reset_token_timestamp FROM users WHERE email=:email;"); - $stmt->bindValue(":email", $email); - $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - if ($user === false) - return Response::unsatisfied("No account with that email address has been registered."); - - $minutes_left = $this->minutes_until_interval_elapsed( - $user["password_reset_token_timestamp"], - self::MINUTES_BETWEEN_PASSWORD_RESETS - ); - if ($minutes_left > 0) { - return Response::unsatisfied( - "A password reset email was sent recently. " . - "Please wait $minutes_left more minute(s) before requesting a new email." - ); - } - - $stmt = $this->conn->prepare("UPDATE users - SET password_reset_token=lower(hex(randomblob(16))), - password_reset_token_timestamp=unixepoch() - WHERE email=:email - RETURNING password_reset_token;"); - $stmt->bindValue(":email", $email); - $stmt->execute(); - $reset_token = $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["password_reset_token"]; - - return $this->mailer->queue_email(new ResetPasswordEmail($email, $reset_token)); - }); - } - - /** - * Validates a password reset token for the given email address. - * - * @param string $email the address to check the password reset token of - * @param string $token the token to check - * @return Response a satisfied `Response` with payload `null` if the password reset token is currently valid, or an - * unsatisfied `Response` otherwise - */ - public function validate_password_reset_token(string $email, string $token): Response - { - return Database::transaction($this->conn, function () use ($email, $token) { - $stmt = $this->conn->prepare("SELECT password_reset_token_timestamp - FROM users - WHERE email=:email AND password_reset_token=:token;"); - $stmt->bindValue(":email", $email); - $stmt->bindValue(":token", $token); - $stmt->execute(); - $user = $stmt->fetch(PDO::FETCH_ASSOC); - if ($user === false) - return Response::unsatisfied( - "This password reset link is invalid. Maybe you already reset your password?" - ); - - $minutes_remaining = $this->minutes_until_interval_elapsed( - $user["password_reset_token_timestamp"], - self::MINUTES_VALID_PASSWORD_RESET - ); - if ($minutes_remaining < 0) - return Response::unsatisfied("This password reset link has expired."); - - return Response::satisfied(); - }); - } - - /** - * Verifies an attempt at resetting a password. - * - * @param string $email the email to reset the password of - * @param string $token the token to reset the password with - * @param string $password_new the new password - * @return Response a satisfied `Response` with payload `null` if the password was reset, or an unsatisfied - * `Response` otherwise - */ - public function reset_password(string $email, string $token, string $password_new): Response - { - return Database::transaction($this->conn, function () use ($email, $token, $password_new) { - $token_is_valid = $this->validate_password_reset_token($email, $token); - if (!$token_is_valid->satisfied) - return $token_is_valid; - - $stmt = $this->conn->prepare("UPDATE users - SET password=:password, password_reset_token=null - WHERE email=:email;"); - $stmt->bindValue(":password", password_hash($password_new, PASSWORD_BCRYPT)); - $stmt->bindValue(":email", $email); - $stmt->execute(); - - return $this->mailer->queue_email(new ChangedPasswordEmail($email)); - }); + $stmt = $this->conn->prepare("UPDATE users + SET password_reset_token=lower(hex(randomblob(16))), + password_reset_token_timestamp=unixepoch() + WHERE email=:email + RETURNING password_reset_token;"); + $stmt->bindValue(":email", $email); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["password_reset_token"]; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/ValidatePasswordResetTokenAction.php b/src/main/php/com/fwdekker/deathnotifier/user/ValidatePasswordResetTokenAction.php index 306647b..929d99d 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/ValidatePasswordResetTokenAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/ValidatePasswordResetTokenAction.php @@ -3,17 +3,28 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsSetRule; +use com\fwdekker\deathnotifier\validator\ValidationException; +/** + * Checks whether the given password reset token is valid. + */ class ValidatePasswordResetTokenAction extends Action { + /** + * @var UserManager the manager to check the password reset token validity with + */ private readonly UserManager $user_manager; + /** + * Constructs a new `ValidatePasswordResetTokenAction`. + * + * @param UserManager $user_manager the manager to check the password reset token validity with + */ public function __construct(UserManager $user_manager) { parent::__construct( @@ -30,12 +41,20 @@ class ValidatePasswordResetTokenAction extends Action } + /** + * Checks whether the given password reset token is valid. + * + * @return null + * @throws ValidationException if the password reset link is valid + */ function handle(): mixed { - $response = $this->user_manager->validate_password_reset_token($_GET["email"], $_GET["reset_token"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + $user_data = $this->user_manager->get_user_by_email($_GET["email"]); + if ($_GET["reset_token"] !== $user_data["password_reset_token"]) + throw new ValidationException( + "This password reset link is invalid. Maybe you already reset your password?" + ); - return $response->payload; + return null; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailAction.php b/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailAction.php index 5a54eda..80691e5 100644 --- a/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailAction.php +++ b/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailAction.php @@ -3,18 +3,43 @@ namespace com\fwdekker\deathnotifier\user; use com\fwdekker\deathnotifier\Action; -use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionMethod; +use com\fwdekker\deathnotifier\Database; +use com\fwdekker\deathnotifier\mailer\MailManager; +use com\fwdekker\deathnotifier\Util; use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsSetRule; +use com\fwdekker\deathnotifier\validator\ValidationException; +use PDO; +/** + * Verifies the user's email address. + */ class VerifyEmailAction extends Action { + /** + * @var PDO the connection to verify the email address with + */ + private readonly PDO $conn; + /** + * @var UserManager the manager to verify the email address with + */ private readonly UserManager $user_manager; + /** + * @var MailManager the manager to send emails with + */ + private readonly MailManager $mail_manager; - public function __construct(UserManager $user_manager) + /** + * Constructs a new `VerifyEmailAction`. + * + * @param PDO $conn the connection to verify the email address with + * @param UserManager $user_manager the manager to verify the email address with + * @param MailManager $mail_manager the manager to send emails with + */ + public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) { parent::__construct( ActionMethod::POST, @@ -26,16 +51,41 @@ class VerifyEmailAction extends Action ], ); + $this->conn = $conn; $this->user_manager = $user_manager; + $this->mail_manager = $mail_manager; } + /** + * Verifies the user's email address. + * + * @return null + * @throws ValidationException if the email address does not exist, if the email is already verified, or if the + * token expired + */ function handle(): mixed { - $response = $this->user_manager->verify_email($_POST["email"], $_POST["verify_token"]); - if (!$response->satisfied) - throw new ActionException($response->payload["message"], $response->payload["target"]); + Database::transaction($this->conn, function () { + // TODO: Validate that the email even exists + $user_data = $this->user_manager->get_user_by_email($_POST["email"]); + if ($user_data["email_verification_token"] !== $_POST["verify_token"]) + throw new ValidationException( + "Failed to verify email address. Maybe you already verified your email address?" + ); - return $response->payload; + $minutes_remaining = Util::minutes_until_interval_elapsed( + $user_data["email_verification_token_timestamp"], + UserManager::MINUTES_VALID_VERIFICATION + ); + if ($minutes_remaining < 0) + throw new ValidationException( + "This email verification link has expired. Log in and request a new verification email." + ); + + $this->user_manager->set_email_verified($_SESSION["uuid"]); + }); + + return null; } } diff --git a/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php b/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php deleted file mode 100644 index cf8776a..0000000 --- a/src/main/php/com/fwdekker/deathnotifier/user/VerifyEmailEmail.php +++ /dev/null @@ -1,59 +0,0 @@ -token = $token; - } - - - public function get_subject(): string - { - return "Verify your email address"; - } - - public function get_body(): string - { - $base_path = Config::get()["server"]["base_path"]; - $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You requested a new verification link for your Death Notifier account. " . - "You can verify your email address by clicking the link below. " . - "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes. " . - "Until you verify your email address, you will not receive any notifications." . - "\n" . - "Verify: $verify_path" . - "\n\n" . - $base_path; - } -} diff --git a/src/test/php/com/fwdekker/deathnotifier/UserManagerTest.php b/src/test/php/com/fwdekker/deathnotifier/UserManagerTest.php deleted file mode 100644 index 45c1e89..0000000 --- a/src/test/php/com/fwdekker/deathnotifier/UserManagerTest.php +++ /dev/null @@ -1,668 +0,0 @@ -logger, $this->database->conn, $this->mailer); - } - - public function setUp(): void - { - $this->mailer = $this->createMock(MailManager::class); - $this->mailer->method("queue_email")->willReturn(Response::satisfied()); - - parent::setUp(); - - $this->user_manager = $this->get_user_manager(); - } - - - /** - * Returns the UUID of the given email address. - * - * @param string $email the email address to return the email verification token of - * @return string|null the email verification token of the given email address - */ - private function get_uuid(string $email): ?string - { - $stmt = $this->database->conn->prepare("SELECT uuid FROM users WHERE email=:email;"); - $stmt->bindValue(":email", $email); - $stmt->execute(); - $results = $stmt->fetchAll(PDO::FETCH_ASSOC); - return empty($results) ? null : $results[0]["uuid"]; - } - - /** - * Returns the email verification token of the given email address. - * - * @param string $email the email address to return the email verification token of - * @return string|null the email verification token of the given email address, or `null` if there is no user with - * the given email address - */ - private function get_email_token(string $email): ?string - { - $stmt = $this->database->conn->prepare("SELECT email_verification_token FROM users WHERE email=:email;"); - $stmt->bindValue(":email", $email); - $stmt->execute(); - $results = $stmt->fetchAll(PDO::FETCH_ASSOC); - return empty($results) ? null : $results[0]["email_verification_token"]; - } - - /** - * Changes the timestamp of the email verification token of the given email address, if a user with that email - * address exists. - * - * @param string $email the email address to set the timestamp of - * @param int $timestamp the timestamp to set - */ - private function set_email_token_timestamp(string $email, int $timestamp): void - { - $stmt = $this->database->conn->prepare("UPDATE users - SET email_verification_token_timestamp=:timestamp - WHERE email=:email;"); - $stmt->bindValue(":email", $email); - $stmt->bindValue(":timestamp", $timestamp); - $stmt->execute(); - } - - /** - * Returns the password verification token of the given email address. - * - * @param string $email the email address to return the password verification token of - * @return string|null the password verification token of the given email address, or `null` if there is no user - * with the given email address - */ - private function get_password_token(string $email): ?string - { - $stmt = $this->database->conn->prepare("SELECT password_reset_token FROM users WHERE email=:email;"); - $stmt->bindValue(":email", $email); - $stmt->execute(); - $results = $stmt->fetchAll(PDO::FETCH_ASSOC); - return empty($results) ? null : $results[0]["password_reset_token"]; - } - - /** - * Changes the timestamp of the password verification token of the given email address, if a user with that email - * address exists. - * - * @param string $email the email address to set the timestamp of - * @param int $timestamp the timestamp to set - */ - private function set_password_token_timestamp(string $email, int $timestamp): void - { - $stmt = $this->database->conn->prepare("UPDATE users - SET password_reset_token_timestamp=:timestamp - WHERE email=:email;"); - $stmt->bindValue(":email", $email); - $stmt->bindValue(":timestamp", $timestamp); - $stmt->execute(); - } - - - public function test_register_user_returns_an_unsatisfied_response_if_the_email_is_used(): void - { - $this->user_manager->register_user("john@example.com", "password"); - - $response = $this->user_manager->register_user("john@example.com", "password"); - - self::assertFalse($response->satisfied); - } - - public function test_register_user_registers_the_user_and_returns_a_satisfied_response_if_the_email_is_unused(): void - { - $response = $this->user_manager->register_user("john@example.com", "password"); - - self::assertTrue($response->satisfied); - self::assertNotNull($this->get_uuid("john@example.com")); - } - - public function test_register_user_sends_an_email_with_the_verification_token(): void - { - $this->mailer - ->expects($this->once()) - ->method("queue_email") - ->with($this->callback( - fn($email) => $email instanceof RegisterEmail && $email->recipient === "john@example.com") - ); - - $response = $this->user_manager->register_user("john@example.com", "password"); - - self::assertTrue($response->satisfied); - } - - - public function test_check_login_returns_an_unsatisfied_response_if_the_email_does_not_exist(): void - { - $this->user_manager->register_user("john@example.com", "password"); - - $response = $this->user_manager->check_login("lydia@other.example.com", "password"); - - self::assertFalse($response[0]->satisfied); - self::assertNull($response[1]); - } - - public function test_check_login_returns_an_unsatisfied_response_if_the_email_is_incorrect(): void - { - $this->user_manager->register_user("john@example.com", "password"); - - $response = $this->user_manager->check_login("lydia@other.example.com", "password"); - - self::assertFalse($response[0]->satisfied); - self::assertNull($response[1]); - } - - public function test_check_login_returns_a_satisfied_response_if_the_email_and_password_are_correct(): void - { - $this->user_manager->register_user("john@example.com", "password"); - - $response = $this->user_manager->check_login("john@example.com", "password"); - - self::assertTrue($response[0]->satisfied); - self::assertNotNull($response[1]); - } - - - public function test_user_exists_returns_false_if_the_user_does_not_exist(): void - { - self::assertFalse($this->user_manager->user_exists("invalid-uuid")); - } - - public function test_user_exists_returns_true_if_the_user_exists(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - self::assertTrue($this->user_manager->user_exists($uuid)); - } - - - public function test_get_user_returns_a_satisfied_response_with_the_user_data_if_the_uuid_exists(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $response = $this->user_manager->get_user($uuid); - - self::assertTrue($response->satisfied); - self::assertArrayHasKey("email", $response->payload); - } - - public function test_get_user_returns_an_unsatisfied_response_if_the_uuid_does_not_exist(): void - { - $response = $this->user_manager->get_user("invalid-uuid"); - - self::assertFalse($response->satisfied); - } - - - public function test_delete_user_deletes_the_user_and_returns_a_satisfied_response_if_the_uuid_is_used(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $response = $this->user_manager->delete_user($uuid); - - self::assertTrue($response->satisfied); - self::assertFalse($this->user_manager->user_exists($uuid)); - } - - public function test_delete_user_returns_a_satisfied_response_even_if_the_user_was_already_deleted(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - $this->user_manager->delete_user($uuid); - - $response = $this->user_manager->delete_user($uuid); - - self::assertTrue($response->satisfied); - } - - public function test_delete_user_returns_a_satisfied_response_even_if_the_uuid_does_not_exist(): void - { - $response = $this->user_manager->delete_user("invalid-uuid"); - - self::assertTrue($response->satisfied); - } - - - public function test_set_email_returns_an_unsatisfied_response_if_the_email_is_not_different(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $response = $this->user_manager->set_email($uuid, "john@example.com"); - - self::assertFalse($response->satisfied); - } - - public function test_set_email_returns_an_unsatisfied_response_if_the_email_is_already_used(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->user_manager->register_user("lydia@other.example.com", "another-password"); - $uuid = $this->get_uuid("john@example.com"); - - $response = $this->user_manager->set_email($uuid, "lydia@other.example.com"); - - self::assertFalse($response->satisfied); - } - - public function test_set_email_returns_a_satisfied_response_and_sends_an_email_if_the_email_is_unused(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $this->mailer - ->expects($this->once()) - ->method("queue_email") - ->with($this->callback( - fn($email) => $email instanceof ChangedEmailEmail && $email->recipient === "lydia@other.example.com") - ); - $response = $this->user_manager->set_email($uuid, "lydia@other.example.com"); - - self::assertTrue($response->satisfied); - self::assertNull($this->get_email_token("john@example.com")); - self::assertNotNull($this->get_email_token("lydia@other.example.com")); - } - - public function test_set_email_retains_the_uuid_of_the_user(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $old_uuid = $this->get_uuid("john@example.com"); - - $this->user_manager->set_email($old_uuid, "lydia@other.example.com"); - $new_uuid = $this->get_uuid("lydia@other.example.com"); - - self::assertEquals($old_uuid, $new_uuid); - } - - public function test_set_email_disallows_logging_in_with_the_old_email_address(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $this->user_manager->set_email($uuid, "lydia@other.example.com"); - - self::assertFalse($this->user_manager->check_login("john@example.com", "password")[0]->satisfied); - } - - - public function test_verify_email_returns_an_unsatisfied_response_if_the_email_does_not_exist(): void - { - $response = $this->user_manager->verify_email("does-not-exist@example.com", "token"); - - self::assertFalse($response->satisfied); - } - - public function test_verify_email_returns_an_unsatisfied_response_if_the_wrong_token_is_given(): void - { - $this->user_manager->register_user("john@example.com", "password"); - - $response = $this->user_manager->verify_email("john@example.com", "incorrect-token"); - - self::assertFalse($response->satisfied); - } - - public function test_verify_email_returns_an_unsatisfied_response_if_an_email_is_already_verified(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $token = $this->get_email_token("john@example.com"); - - $this->user_manager->verify_email("john@example.com", $token); - $response = $this->user_manager->verify_email("john@example.com", $token); - - self::assertFalse($response->satisfied); - } - - public function test_verify_email_returns_an_unsatisfied_response_if_the_token_is_expired(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_email_token_timestamp("john@example.com", "1640995200"); - $token = $this->get_email_token("john@example.com"); - - $response = $this->user_manager->verify_email("john@example.com", $token); - - self::assertFalse($response->satisfied); - } - - public function test_verify_email_verifies_the_email_and_returns_a_satisfied_response_if_the_verification_token_is_valid(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - $token = $this->get_email_token("john@example.com"); - - $response = $this->user_manager->verify_email("john@example.com", $token); - - self::assertTrue($response->satisfied); - self::assertNull($this->get_email_token("john@example.com")); - self::assertTrue($this->user_manager->get_user($uuid)->payload["email_verified"] === 1); - } - - - public function test_resend_verify_email_returns_an_unsatisfied_response_if_the_uuid_does_not_exist(): void - { - $response = $this->user_manager->resend_verify_email("invalid-uuid"); - - self::assertFalse($response->satisfied); - } - - public function test_resend_verify_email_does_not_send_an_email_and_returns_an_unsatisfied_response_if_the_email_is_already_verified(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - $token = $this->get_email_token("john@example.com"); - $this->user_manager->verify_email("john@example.com", $token); - - $this->mailer->expects($this->never())->method("queue_email"); - $response = $this->user_manager->resend_verify_email($uuid); - - self::assertFalse($response->satisfied); - } - - public function test_resend_verify_email_does_not_send_an_email_and_returns_an_unsatisfied_response_if_the_user_recently_registered(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $this->mailer->expects($this->never())->method("queue_email"); - $response = $this->user_manager->resend_verify_email($uuid); - - self::assertFalse($response->satisfied); - } - - public function test_resend_verify_email_does_not_send_an_email_and_returns_an_unsatisfied_response_if_an_email_was_recently_sent(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_email_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_VERIFICATION_EMAILS + 1) - ); - $uuid = $this->get_uuid("john@example.com"); - $this->user_manager->resend_verify_email($uuid); - - $this->mailer->expects($this->never())->method("queue_email"); - $response = $this->user_manager->resend_verify_email($uuid); - - self::assertFalse($response->satisfied); - } - - public function test_resend_verify_email_sends_an_email_and_returns_a_satisfied_response_if_no_email_was_recently_sent(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_email_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_VERIFICATION_EMAILS + 1) - ); - $uuid = $this->get_uuid("john@example.com"); - - $this->mailer - ->expects($this->once()) - ->method("queue_email") - ->with($this->callback( - fn($email) => $email instanceof VerifyEmailEmail && $email->recipient === "john@example.com") - ); - $response = $this->user_manager->resend_verify_email($uuid); - - self::assertTrue($response->satisfied); - } - - public function test_resend_verify_email_disallows_verifying_the_email_with_the_old_token(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $old_token = $this->get_email_token("john@example.com"); - $this->set_email_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_VERIFICATION_EMAILS + 1) - ); - $uuid = $this->get_uuid("john@example.com"); - $this->user_manager->resend_verify_email($uuid); - - $response = $this->user_manager->verify_email("john@example.com", $old_token); - - self::assertFalse($response->satisfied); - } - - - public function test_toggle_notifications_returns_an_unsatisfied_response_if_the_uuid_does_not_exist(): void - { - $response = $this->user_manager->toggle_notifications("invalid-uuid"); - - self::assertFalse($response->satisfied); - } - - public function test_toggle_notifications_does_not_toggle_notifications_and_returns_an_unsatisfied_response_if_the_email_is_not_verified(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $response = $this->user_manager->toggle_notifications($uuid); - - self::assertFalse($response->satisfied); - self::assertTrue($this->user_manager->get_user($uuid)->payload["email_notifications_enabled"] === 1); - } - - public function test_toggle_notifications_enables_notifications_and_returns_a_satisfied_response_if_they_are_disabled(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - $token = $this->get_email_token("john@example.com"); - $this->user_manager->verify_email("john@example.com", $token); - - $this->user_manager->toggle_notifications($uuid); - $response = $this->user_manager->toggle_notifications($uuid); - - self::assertTrue($response->satisfied); - self::assertTrue($this->user_manager->get_user($uuid)->payload["email_notifications_enabled"] === 1); - } - - public function test_toggle_notifications_disables_notifications_and_returns_a_satisfied_response_if_they_are_enabled(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - $token = $this->get_email_token("john@example.com"); - $this->user_manager->verify_email("john@example.com", $token); - - $response = $this->user_manager->toggle_notifications($uuid); - - self::assertTrue($response->satisfied); - self::assertFalse($this->user_manager->get_user($uuid)->payload["email_notifications_enabled"] === 1); - } - - - public function test_set_password_returns_an_unsatisfied_response_if_the_uuid_does_not_exist(): void - { - $response = $this->user_manager->set_password("invalid_uuid", "old-password", "new-password"); - - self::assertFalse($response->satisfied); - } - - public function test_set_password_returns_an_unsatisfied_response_if_the_old_password_is_incorrect(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $response = $this->user_manager->set_password($uuid, "wrong-password", "new-password"); - - self::assertFalse($response->satisfied); - self::assertFalse($this->user_manager->check_login("john@example.com", "new-password")[0]->satisfied); - } - - public function test_set_password_sends_an_email_and_returns_a_satisfied_response_if_the_old_password_is_correct(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $uuid = $this->get_uuid("john@example.com"); - - $this->mailer - ->expects($this->once()) - ->method("queue_email") - ->with($this->callback( - fn($email) => $email instanceof ChangedPasswordEmail && $email->recipient === "john@example.com") - ); - $response = $this->user_manager->set_password($uuid, "password", "new-password"); - - self::assertTrue($response->satisfied); - self::assertTrue($this->user_manager->check_login("john@example.com", "new-password")[0]->satisfied); - } - - public function test_set_password_disallows_resetting_the_password_with_an_old_token(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_password_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + 1) - ); - $this->user_manager->send_password_reset("john@example.com"); - $uuid = $this->get_uuid("john@example.com"); - $token = $this->get_password_token("john@example.com"); - - $this->user_manager->set_password($uuid, "password", "new-password"); - $response = $this->user_manager->reset_password("john@example.com", $token, "newer-password"); - - self::assertFalse($response->satisfied); - self::assertFalse($this->user_manager->check_login("john@example.com", "newer-password")[0]->satisfied); - } - - - public function test_send_password_reset_returns_an_unsatisfied_response_if_the_email_does_not_exist(): void - { - $response = $this->user_manager->send_password_reset("invalid@example.com"); - - self::assertFalse($response->satisfied); - } - - public function test_send_password_reset_does_not_send_an_email_and_returns_an_unsatisfied_response_if_the_user_recently_registered(): void - { - $this->user_manager->register_user("john@example.com", "password"); - - $this->mailer->expects($this->never())->method("queue_email"); - $response = $this->user_manager->send_password_reset("john@example.com"); - - self::assertFalse($response->satisfied); - } - - public function test_send_password_reset_does_not_send_an_email_and_returns_an_unsatisfied_response_if_an_email_was_recently_sent(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_password_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + 1) - ); - $this->user_manager->send_password_reset("john@example.com"); - - $this->mailer->expects($this->never())->method("queue_email"); - $response = $this->user_manager->send_password_reset("john@example.com"); - - self::assertFalse($response->satisfied); - } - - public function test_send_password_reset_sends_an_email_and_returns_a_satisfied_response_if_no_email_was_recently_sent(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_password_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + 1) - ); - - $this->mailer - ->expects($this->once()) - ->method("queue_email") - ->with($this->callback( - fn($email) => $email instanceof ResetPasswordEmail && $email->recipient === "john@example.com") - ); - $response = $this->user_manager->send_password_reset("john@example.com"); - - self::assertTrue($response->satisfied); - } - - public function test_send_password_reset_disallows_resetting_the_password_with_the_old_token(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_password_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + 1) - ); - $this->user_manager->send_password_reset("john@example.com"); - $old_token = $this->get_password_token("john@example.com"); - $this->set_password_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + 1) - ); - $this->user_manager->send_password_reset("john@example.com"); - - $response = $this->user_manager->reset_password("john@example.com", $old_token, "password-new"); - - self::assertFalse($response->satisfied); - } - - - public function test_reset_password_returns_an_unsatisfied_response_if_the_email_does_not_exist(): void - { - $response = $this->user_manager->reset_password("does-not-exist@example.com", "token", "new-password"); - - self::assertFalse($response->satisfied); - } - - public function test_reset_password_does_not_change_the_password_and_returns_an_unsatisfied_response_if_the_wrong_token_is_given(): void - { - $this->user_manager->register_user("john@example.com", "password"); - - $response = $this->user_manager->reset_password("john@example.com", "incorrect-token", "new-password"); - - self::assertFalse($response->satisfied); - self::assertFalse($this->user_manager->check_login("john@example.com", "new-password")[0]->satisfied); - } - - public function test_reset_password_does_not_change_the_password_and_returns_an_unsatisfied_response_if_the_token_is_expired(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_password_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + 1) - ); - $this->user_manager->send_password_reset("john@example.com"); - $this->set_password_token_timestamp("john@example.com", "1640995200"); - $token = $this->get_password_token("john@example.com"); - - $response = $this->user_manager->reset_password("john@example.com", $token, "new-password"); - - self::assertFalse($response->satisfied); - self::assertFalse($this->user_manager->check_login("john@example.com", "new-password")[0]->satisfied); - } - - public function test_reset_password_changes_the_password_if_the_verification_token_is_valid(): void - { - $this->user_manager->register_user("john@example.com", "password"); - $this->set_password_token_timestamp( - "john@example.com", - time() - 60 * (UserManager::MINUTES_BETWEEN_PASSWORD_RESETS + 1) - ); - $this->user_manager->send_password_reset("john@example.com"); - $token = $this->get_password_token("john@example.com"); - - $response = $this->user_manager->reset_password("john@example.com", $token, "new-password"); - - self::assertTrue($response->satisfied); - self::assertNull($this->get_password_token("john@example.com")); - self::assertTrue($this->user_manager->check_login("john@example.com", "new-password")[0]->satisfied); - } -}