death-notifier/src/main/php/UserManager.php

410 lines
16 KiB
PHP

<?php
namespace php;
use DateTime;
use PDO;
/**
* Manages interaction with the database in the context of users.
*/
// TODO: Remove duplication in this class
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.
*/
private const MINUTES_BETWEEN_VERIFICATION_EMAILS = 5;
/**
* @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 PDO $conn the database connection to interact with
* @param Mailer $mailer the mailer to send emails with
*/
public function __construct(PDO $conn, Mailer $mailer)
{
$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,
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);
}
}