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