death-notifier/src/main/php/com/fwdekker/deathnotifier/UserManager.php

499 lines
21 KiB
PHP

<?php
namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\mailer\ChangedEmailEmail;
use com\fwdekker\deathnotifier\mailer\ChangedPasswordEmail;
use com\fwdekker\deathnotifier\mailer\Mailer;
use com\fwdekker\deathnotifier\mailer\RegisterEmail;
use com\fwdekker\deathnotifier\mailer\ResetPasswordEmail;
use com\fwdekker\deathnotifier\mailer\VerifyEmailEmail;
use DateTime;
use Monolog\Logger;
use PDO;
/**
* Manages interaction with the database in the context of users.
*/
class UserManager
{
/**
* The minimum length of a password;
*/
public const MIN_PASSWORD_LENGTH = 8;
/**
* The maximum length of a password.
*/
public const MAX_PASSWORD_LENGTH = 64;
/**
* The minimum number of minutes between two verification emails.
*/
public const MINUTES_BETWEEN_VERIFICATION_EMAILS = 5;
/**
* The maximum number of minutes after which a validation email expires.
*/
public const MINUTES_VALID_VERIFICATION = 60;
/**
* The minimum number of minutes between two password reset emails.
*/
public const MINUTES_BETWEEN_PASSWORD_RESETS = 5;
/**
* The maximum number of minutes after which a password reset email expires.
*/
public const MINUTES_VALID_PASSWORD_RESET = 60;
/**
* @var Logger The logger to use for logging.
*/
private Logger $logger; // @phpstan-ignore-line Unused, but useful for debugging
/**
* @var PDO The database connection to interact with.
*/
private PDO $conn;
/**
* @var Mailer The mailer to send emails with.
*/
private Mailer $mailer;
/**
* Constructs a new user manager.
*
* @param Logger $logger the logger to use for logging
* @param PDO $conn the database connection to interact with
* @param Mailer $mailer the mailer to send emails with
*/
public function __construct(Logger $logger, PDO $conn, Mailer $mailer)
{
$this->logger = $logger;
$this->conn = $conn;
$this->mailer = $mailer;
}
/**
* Populates the database with the necessary structures for users.
*
* @return void
*/
public function install(): void
{
$this->conn->exec("CREATE TABLE users(uuid TEXT NOT NULL UNIQUE PRIMARY KEY DEFAULT(lower(hex(randomblob(16)))),
email TEXT NOT NULL UNIQUE,
email_verification_token TEXT DEFAULT(lower(hex(randomblob(16)))),
email_verification_token_timestamp INT NOT NULL DEFAULT(unixepoch()),
email_notifications_enabled INT NOT NULL DEFAULT(1),
password TEXT NOT NULL,
password_last_change INT NOT NULL DEFAULT(unixepoch()),
password_reset_token TEXT DEFAULT(null),
password_reset_token_timestamp INT NOT NULL DEFAULT(unixepoch()));");
}
/**
* 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
{
$minutes_since_timestamp = (new DateTime("@$timestamp"))->diff(new DateTime(), absolute: true)->i;
return $interval - $minutes_since_timestamp;
}
/**
* 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));
});
}
/**
* Deletes the user with the given UUID.
*
* @param string $uuid the UUID of the user to delete
* @return Response a satisfied `Response` with payload `null`
*/
public function delete_user(string $uuid): Response
{
$stmt = $this->conn->prepare("DELETE FROM users WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
return Response::satisfied();
}
/**
* Validates a login attempt with the given email address and password.
*
* @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
*/
public function check_login(string $email, string $password): array
{
$stmt = $this->conn->prepare("SELECT uuid, password FROM users WHERE email=:email;");
$stmt->bindValue(":email", $email);
$stmt->execute();
$user = $stmt->fetch();
return $user === false || !password_verify($password, $user["password"])
? [Response::unsatisfied("Incorrect combination of email and password.", "password"), null]
: [Response::satisfied(), $user["uuid"]];
}
/**
* Returns `true` if and only if a user with the given UUID exists.
*
* @param string $uuid the UUID of the user to check
* @return bool `true` if and only if a user with the given UUID exists
*/
public function user_exists(string $uuid): bool
{
$stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE uuid=:uuid);");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
return $stmt->fetch()[0] === 1;
}
/**
* Returns the user with the given UUID.
*
* @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
*/
public function get_user(string $uuid): Response
{
$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->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
return $user === false
? Response::unsatisfied("Something went wrong. Please try logging in again.")
: Response::satisfied($user);
}
/**
* Updates the indicated user's email address.
*
* @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
*/
public function set_email(string $uuid, string $email): Response
{
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));
});
}
/**
* Resends the email verification email.
*
* @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
*/
public function resend_verify_email(string $uuid): Response
{
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["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_timestamp=unixepoch()
WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
return $this->mailer->queue_email(new VerifyEmailEmail($user["email"], $user["email_verification_token"]));
});
}
/**
* Verifies an email address with a token.
*
* @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
*/
public function verify_email(string $email, string $token): Response
{
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();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (sizeof($results) === 0)
return Response::unsatisfied(
"Failed to verify email address. " .
"Maybe you already verified your email address?"
);
$minutes_remaining = $this->minutes_until_interval_elapsed(
$results[0]["email_verification_token_timestamp"],
self::MINUTES_VALID_VERIFICATION
);
if ($minutes_remaining < 0)
return Response::unsatisfied("This email verification link has expired.");
$stmt = $this->conn->prepare("UPDATE users SET email_verification_token=null WHERE email=:email;");
$stmt->bindValue(":email", $email);
$stmt->execute();
return Response::satisfied();
});
}
/**
* 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->fetchAll(PDO::FETCH_ASSOC)[0];
if ($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();
});
}
/**
* Updates 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
*/
public function set_password(string $uuid, string $password_old, string $password_new): Response
{
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 (!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"]));
});
}
/**
* Sends a password reset email to the given 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
*/
public function send_password_reset(string $email): Response
{
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();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (sizeof($results) === 0)
return Response::unsatisfied("No account with that email address has been registered.");
$minutes_left = $this->minutes_until_interval_elapsed(
$results[0]["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();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (sizeof($results) === 0)
return Response::unsatisfied(
"This password reset link is invalid. Maybe you already reset your password?"
);
$minutes_remaining = $this->minutes_until_interval_elapsed(
$results[0]["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));
});
}
}