Revamp UserManager structure

This commit is contained in:
Florine W. Dekker 2022-12-03 14:59:53 +01:00
parent 5898c95709
commit 1cd9dfc9d2
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
42 changed files with 1228 additions and 1711 deletions

View File

@ -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",

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "death-notifier",
"version": "0.16.0", "_comment_version": "Also update version in `composer.json`!",
"version": "0.16.1", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

@ -16,7 +16,7 @@ use com\fwdekker\deathnotifier\tracking\ListTrackingsAction;
use com\fwdekker\deathnotifier\tracking\RemoveTrackingAction;
use com\fwdekker\deathnotifier\tracking\TrackingManager;
use com\fwdekker\deathnotifier\tracking\UpdateTrackingsAction;
use com\fwdekker\deathnotifier\user\GetUserDataAction;
use com\fwdekker\deathnotifier\user\GetPublicUserDataAction;
use com\fwdekker\deathnotifier\user\LoginAction;
use com\fwdekker\deathnotifier\user\LogoutAction;
use com\fwdekker\deathnotifier\user\RegisterAction;
@ -24,8 +24,8 @@ use com\fwdekker\deathnotifier\user\ResendVerifyEmailAction;
use com\fwdekker\deathnotifier\user\ResetPasswordAction;
use com\fwdekker\deathnotifier\user\SendPasswordResetAction;
use com\fwdekker\deathnotifier\user\ToggleNotificationsAction;
use com\fwdekker\deathnotifier\user\UpdateEmailAction;
use com\fwdekker\deathnotifier\user\UpdatePasswordAction;
use com\fwdekker\deathnotifier\user\ChangeEmailAction;
use com\fwdekker\deathnotifier\user\ChangePasswordAction;
use com\fwdekker\deathnotifier\user\UserDeleteAction;
use com\fwdekker\deathnotifier\user\UserManager;
use com\fwdekker\deathnotifier\user\ValidatePasswordResetTokenAction;
@ -65,21 +65,21 @@ try {
// Dispatch request
$dispatcher = new ActionDispatcher();
// GET actions
$dispatcher->register_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));

View File

@ -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;

View File

@ -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.
*

View File

@ -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"]));

View File

@ -11,10 +11,6 @@ use Exception;
*/
class StartSessionAction extends Action
{
/**
* @var array<string, mixed> 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<string, mixed> $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;
}

View File

@ -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);
}
}

View File

@ -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.

View File

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

View File

@ -3,7 +3,6 @@
namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;

View File

@ -1,60 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* 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 an email to inform a user that a tracked article has been deleted.
*
* @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;
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* 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 an email to inform a user that a tracked article has been re-created.
*
* @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;
}
}

View File

@ -1,65 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* 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 an email to inform a user someone has died.
*
* @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;
}
}

View File

@ -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
{

View File

@ -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;

View File

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

View File

@ -0,0 +1,136 @@
<?php
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\IsEmailRule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use PDO;
/**
* Changes the user's email address.
*/
class ChangeEmailAction extends Action
{
/**
* @var PDO the connection to change the email address with
*/
private readonly PDO $conn;
/**
* @var UserManager the manager to change the email address with
*/
private readonly UserManager $user_manager;
/**
* @var MailManager the manager to send emails with
*/
private readonly MailManager $mail_manager;
/**
* Constructs a new `ChangeEmailAction`.
*
* @param PDO $conn the connection to change the email address with
* @param UserManager $user_manager the manager to change 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,
"update-email",
require_logged_in: true,
require_valid_csrf_token: true,
rule_lists: ["email" => [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;
}
}

View File

@ -0,0 +1,128 @@
<?php
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\IsSetRule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use PDO;
/**
* Changes the user's password.
*/
class ChangePasswordAction extends Action
{
/**
* @var PDO the connection to change the password with
*/
private readonly PDO $conn;
/**
* @var UserManager the manager to change the password with
*/
private readonly UserManager $user_manager;
/**
* @var MailManager the manager to send emails with
*/
private readonly MailManager $mail_manager;
/**
* Constructs a new `ChangePasswordAction`.
*
* @param PDO $conn the connection to change the password with
* @param UserManager $user_manager the manager to change 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,
"update-password",
require_logged_in: true,
require_valid_csrf_token: true,
rule_lists: [
"password_old" => [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;
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* An email informing a user that their email has been changed, and needs verification.
*/
class ChangedEmailEmail 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 an email informing a user that their email has been changed, and needs verification.
*
* @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;
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* An email informing a user that their password has been changed.
*/
class ChangedPasswordEmail extends Email
{
/**
* A string identifying the type of email.
*/
public const TYPE = "changed-password";
/**
* Constructs an email informing a user that their email has been changed, and needs verification.
*
* @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;
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
/**
* Returns the user's public data.
*/
class GetPublicUserDataAction extends Action
{
/**
* @var UserManager the manager to retrieve the data with
*/
private readonly UserManager $user_manager;
/**
* Constructs a new `GetPublicUserDataAction`.
*
* @param UserManager $user_manager the manager to retrieve the data with
*/
public function __construct(UserManager $user_manager)
{
parent::__construct(
ActionMethod::GET,
"get-user-data",
require_logged_in: true,
require_valid_csrf_token: true,
);
$this->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"],
];
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
class GetUserDataAction extends Action
{
private readonly UserManager $user_manager;
public function __construct(UserManager $user_manager)
{
parent::__construct(
ActionMethod::GET,
"get-user-data",
require_logged_in: true,
require_valid_csrf_token: true,
);
$this->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;
}
}

View File

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

View File

@ -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();

View File

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

View File

@ -1,63 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* 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 an email to be sent to a recently registered user, including instructions for email verification.
*
* @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;
}
}

View File

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

View File

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

View File

@ -1,61 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* An email to help a user reset their password.
*/
class ResetPasswordEmail 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 an email to help a user reset their password.
*
* @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;
}
}

View File

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

View File

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

View File

@ -1,38 +0,0 @@
<?php
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;
class UpdateEmailAction extends Action
{
private readonly UserManager $user_manager;
public function __construct(UserManager $user_manager)
{
parent::__construct(
ActionMethod::POST,
"update-email",
require_logged_in: true,
require_valid_csrf_token: true,
rule_lists: ["email" => [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;
}
}

View File

@ -1,44 +0,0 @@
<?php
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\HasLengthRule;
use com\fwdekker\deathnotifier\validator\IsSetRule;
class UpdatePasswordAction extends Action
{
private readonly UserManager $user_manager;
public function __construct(UserManager $user_manager)
{
parent::__construct(
ActionMethod::POST,
"update-password",
require_logged_in: true,
require_valid_csrf_token: true,
rule_lists: [
"password_old" => [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;
}
}

View File

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

View File

@ -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<string, mixed>|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<string, mixed>|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"];
}
}

View File

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

View File

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

View File

@ -1,59 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\mailer\Email;
/**
* An email to help a user verify their email address.
*/
class VerifyEmailEmail 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 an email to help a user verify their email address.
*
* @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;
}
}

View File

@ -1,668 +0,0 @@
<?php
namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\mailer\MailManager;
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\UserManager;
use com\fwdekker\deathnotifier\user\VerifyEmailEmail;
use PDO;
use PHPUnit\Framework\MockObject\MockObject;
/**
* Unit tests for `UserManager`.
*/
class UserManagerTest extends DatabaseTestCase
{
private UserManager $user_manager;
private MailManager&MockObject $mailer;
public function get_user_manager(): UserManager
{
return new UserManager($this->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);
}
}