505 lines
22 KiB
PHP
505 lines
22 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
|
|
{
|
|
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.
|
|
*
|
|
* @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(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"]];
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
// 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));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
$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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 === 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();
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* 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 ($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"]));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
$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));
|
|
});
|
|
}
|
|
}
|