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, email_verification_token_timestamp INT NOT NULL, password TEXT NOT NULL, password_update_time INT NOT NULL);"); } /** * Registers a new user. * * @param string $email the user-submitted email address * @param string $password the user-submitted password * @param string $password_confirm the user-submitted password confirmation * @return Response a satisfied `Response` with payload `null` if the registration was successful, or an unsatisfied * `Response` otherwise */ public function register(string $email, string $password, string $password_confirm): Response { if ($password !== $password_confirm) return new Response( payload: ["target" => "password_confirm", "message" => "Passwords do not match."], satisfied: false ); // Begin transaction $this->conn->beginTransaction(); // Check if email address is already in use $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE email=:email);"); $stmt->bindValue(":email", $email); $stmt->execute(); $result = $stmt->fetch(); if ($result[0] === 1) { $this->conn->rollBack(); return new Response( payload: ["target" => "email", "message" => "Email address already in use."], satisfied: false ); } // Register user $stmt = $this->conn->prepare("INSERT INTO users (email, email_verification_token, email_verification_token_timestamp, password, password_update_time) VALUES (:email, lower(hex(randomblob(16))), unixepoch(), :password, unixepoch()) RETURNING email_verification_token;"); $stmt->bindValue(":email", $email); // TODO: Specify password hash function, for forwards compatibility $stmt->bindValue(":password", password_hash($password, PASSWORD_DEFAULT)); $stmt->execute(); $email_verification_token = $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["email_verification_token"]; $this->mailer->queue_registration($email, $email_verification_token); // Respond $this->conn->commit(); return new Response(payload: null, satisfied: true); } /** * 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(string $uuid): Response { $stmt = $this->conn->prepare("DELETE FROM users WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->execute(); return new Response(payload: null, satisfied: true); } /** * 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(); $results = $stmt->fetchAll(PDO::FETCH_ASSOC); if (sizeof($results) === 0 || !password_verify($password, $results[0]["password"])) { return [ new Response( payload: ["target" => "password", "message" => "Incorrect combination of email and password."], satisfied: false ), null ]; } return [new Response(payload: null, satisfied: true), $results[0]["uuid"]]; } /** * Returns a satisfied response if a user with the given UUID exists, or an unsatisfied response otherwise. * * @param string $uuid the UUID of the user to check * @return Response a satisfied `Response` with payload `null` if a user with the given UUID exists, or an * unsatisfied `Response` otherwise */ public function user_exists(string $uuid): Response { $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE uuid=:uuid);"); $stmt->bindValue(":uuid", $uuid); $stmt->execute(); $result = $stmt->fetch(); return $result[0] === 1 ? new Response(payload: null, satisfied: true) : new Response(payload: ["target" => null, "message" => null], satisfied: false); } /** * 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_data(string $uuid): Response { $stmt = $this->conn->prepare("SELECT uuid, email, email_verification_token IS NULL AS email_is_verified, password_update_time FROM users WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->execute(); $user = $stmt->fetch(PDO::FETCH_ASSOC); if ($user === false) return new Response( payload: ["target" => "uuid", "message" => "Something went wrong. Please try logging in again."], satisfied: false ); return new Response(payload: $user, satisfied: true); } /** * 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 { // Begin transaction $this->conn->beginTransaction(); // Check if email address is different $stmt = $this->conn->prepare("SELECT email FROM users WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->execute(); $email_old = $stmt->fetch(PDO::FETCH_ASSOC)["email"]; if (hash_equals($email_old, $email)) { $this->conn->rollBack(); return new Response( payload: ["target" => "email", "message" => "That is already your email address."], satisfied: false ); } // TODO: Reject update if email address was changed too recently (within, say, 5 minutes) // Check if email address is already in use $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE email=:email);"); $stmt->bindValue(":email", $email); $stmt->execute(); $result = $stmt->fetch(); if ($result[0] === 1) { $this->conn->rollBack(); return new Response( payload: ["target" => "email", "message" => "Email address already in use."], satisfied: false ); } // Update email address $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"]; // Respond $this->conn->commit(); $this->mailer->queue_verification($email, $email_verification_token); return new Response(payload: null, satisfied: true); } /** * Verifies an email address with a token. * * @param string $email the email address to verify * @param string $email_verification_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 $email_verification_token): Response { $this->conn->beginTransaction(); $stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE email=:email AND email_verification_token=:email_verification_token);"); $stmt->bindValue(":email", $email); $stmt->bindValue(":email_verification_token", $email_verification_token); $stmt->execute(); $result = $stmt->fetch(); if ($result[0] !== 1) { $this->conn->rollBack(); return new Response( payload: [ "target" => "email", "message" => "Failed to verify email address. " . "Perhaps this email address has already been verified." ], satisfied: false ); } $stmt = $this->conn->prepare("UPDATE users SET email_verification_token=null WHERE email=:email;"); $stmt->bindValue(":email", $email); $stmt->execute(); $this->conn->commit(); return new Response(payload: null, satisfied: true); } /** * 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 { $this->conn->beginTransaction(); $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 (!isset($user["email_verification_token"])) { $this->conn->rollBack(); return new Response( payload: ["target" => null, "message" => "Your email address is already verified"], satisfied: false ); } $verify_email_time = new DateTime("@{$user["email_verification_token_timestamp"]}"); $minutes_since_last_verify_email = $verify_email_time->diff(new DateTime(), absolute: true)->i; if ($minutes_since_last_verify_email < self::MINUTES_BETWEEN_VERIFICATION_EMAILS) { $this->conn->rollBack(); $minutes_left = self::MINUTES_BETWEEN_VERIFICATION_EMAILS - $minutes_since_last_verify_email; return new Response( payload: [ "target" => null, "message" => "A verification email was sent recently. " . "Please wait $minutes_left more minute(s) before requesting a new email." ], satisfied: false ); } $stmt = $this->conn->prepare("UPDATE users SET email_verification_token_timestamp=unixepoch() WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->execute(); $this->mailer->queue_verification($user["email"], $user["email_verification_token"]); $this->conn->commit(); return new Response(payload: null, satisfied: true); } /** * 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 * @param string $password_confirm the confirmation of 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, string $password_confirm): Response { if ($password_new !== $password_confirm) return new Response( payload: ["target" => "passwordConfirm", "message" => "New passwords do not match."], satisfied: false ); // Begin transaction $this->conn->beginTransaction(); // Validate old password $stmt = $this->conn->prepare("SELECT 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"])) { $this->conn->rollBack(); return new Response( payload: ["target" => "passwordOld", "message" => "Incorrect old password."], satisfied: false ); } // Update password $stmt = $this->conn->prepare("UPDATE users SET password=:password, password_update_time=unixepoch() WHERE uuid=:uuid;"); $stmt->bindValue(":uuid", $uuid); $stmt->bindValue(":password", password_hash($password_new, PASSWORD_DEFAULT)); $stmt->execute(); // Respond $this->conn->commit(); return new Response(payload: null, satisfied: true); } }