Implement password reset functionality

This commit is contained in:
Florine W. Dekker 2022-11-12 14:18:00 +01:00
parent af79597572
commit 2c01776b02
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
14 changed files with 512 additions and 140 deletions

43
README.md Normal file
View File

@ -0,0 +1,43 @@
# Death Notifier
Get notified when a famous person dies.
Wikipedia's editors [are known](https://knowyourmeme.com/memes/wikipedia-editors-when-someone-dies) for updating pages
as soon as someone has passed away.
Why not turn that into a service?
This tool regularly checks if people are still alive according to Wikipedia, and emails you as soon as that changes.
## Development
### Requirements
* [composer](https://getcomposer.org/)
* [npm](https://www.npmjs.com/)
### Setting up
```shell script
# Install dependencies (only needed once)
$> composer.phar install
$> npm ci
```
### Building
```shell script
# Build the tool in `dist/` for development
$> npm run dev
# Same as above, but automatically rerun it whenever files are changed
$> npm run dev:server
# Build the tool in `dist/` for deployment
$> npm run deploy
```
### Pre-commit
```shell script
# Update lock files
$> composer.phar update
$> npm install
```
### Static analysis
```shell script
# PHP static analysis
$> npm run stan
```

View File

@ -1,7 +1,7 @@
{
"name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.",
"version": "0.0.24",
"version": "0.0.25", "_comment_version": "Also update version in `package.json`!",
"type": "project",
"license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier",
@ -22,7 +22,7 @@
"phpmailer/phpmailer": "^6.6"
},
"require-dev": {
"phpstan/phpstan": "^1.8"
"phpstan/phpstan": "^1.9"
},
"autoload": {
"psr-4": {

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"name": "death-notifier",
"version": "0.0.24",
"version": "0.0.25", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",
@ -26,9 +26,9 @@
"grunt-run": "^0.8.1",
"grunt-text-replace": "^0.4.0",
"grunt-webpack": "^5.0.0",
"ts-loader": "^9.3.1",
"typescript": "^4.7.4",
"webpack": "^5.74.0",
"ts-loader": "^9.4.1",
"typescript": "^4.8.4",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0"
}
}

View File

@ -38,7 +38,7 @@ if (Database::is_empty($conn)) {
}
session_start();
$_SESSION["token"] = $_SESSION["token"] ?? Util::generate_csrf_token($logger);
$_SESSION["token"] = $_SESSION["token"] ?? Util::generate_csrf_token($logger) ?? Util::http_exit(500);
// Process request
@ -102,12 +102,11 @@ if (isset($_POST["action"])) {
break;
case "verify-email":
$response =
Validator::validate_inputs($_SESSION, ["uuid" => [new IsSetRule()]])
?? Validator::validate_inputs($_POST,
[
"token" => [new IsSetRule()],
"email" => [new IsSetRule(), new EmailRule()],
])
Validator::validate_inputs($_POST,
[
"token" => [new IsSetRule()],
"email" => [new IsSetRule(), new EmailRule()],
])
?? $user_manager->verify_email($_POST["email"], $_POST["token"]);
break;
case "resend-verify-email":
@ -116,6 +115,33 @@ if (isset($_POST["action"])) {
?? Validator::validate_inputs($_POST, ["token" => [new EqualsRule($_SESSION["token"])]])
?? $user_manager->resend_verify_email($_SESSION["uuid"]);
break;
case "send-password-reset":
$response =
Validator::validate_inputs($_POST,
[
"token" => [new EqualsRule($_SESSION["token"])],
"email" => [new IsSetRule(), new EmailRule()],
])
?? $user_manager->send_password_reset($_POST["email"]);
break;
case "reset-password":
$response =
Validator::validate_inputs($_POST,
[
"token" => [new EqualsRule($_SESSION["token"])],
"password" => [
new IsSetRule(),
new LengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)
],
"password_confirm" => [new IsSetRule()],
])
?? $user_manager->reset_password(
$_POST["email"],
$_POST["reset_token"],
$_POST["password"],
$_POST["password_confirm"]
);
break;
case "update-password":
$response =
Validator::validate_inputs($_SESSION, ["uuid" => [new IsSetRule()]])
@ -236,6 +262,8 @@ if (isset($_POST["action"])) {
$response = new Response(payload: null, satisfied: true);
}
// Respond
header("Content-type:application/json;charset=utf-8");
exit(json_encode(array(
"payload" => $response->payload,

View File

@ -27,8 +27,6 @@ username = TODO
password = TODO
# Name to show to recipient
from_name = TODO
# Email address to send test emails to
to_address_test = TODO
[server]
# The path to the main page. Going to this path in your browser should show the contents of `index.html`

View File

@ -18,6 +18,12 @@ a.redLink {
}
/* Forgot password buttons */
#forgotPasswordGoTo, #forgotPasswordGoBack, #resetPasswordGoBack {
margin-left: 1em;
}
/* Trackings table */
#trackings form, #trackings button, #addTrackingPersonName {
margin-bottom: unset;

View File

@ -35,9 +35,7 @@
<section class="container">
<div class="row">
<div class="column">
<p id="sharedValidationInfo" class="formValidationInfo">
<span class="validationInfo"></span>
</p>
<p id="sharedValidationInfo" class="formValidationInfo"><span class="validationInfo"></span></p>
<p id="sharedHomeLink" class="hidden">
<a href="./">Click here to return to the main page</a>
</p>
@ -48,7 +46,6 @@
<div class="column">
<h2>Log in</h2>
<p>Already have an account? Welcome back!</p>
<!-- TODO: Forgot password option (with table for tokens?) -->
<form id="loginForm" novalidate>
<p class="formValidationInfo">
<!-- TODO: Make `formValidationInfo` elements closable with an X symbol -->
@ -65,6 +62,7 @@
<span class="validationInfo"></span>
</label>
<button id="loginButton">Log in</button>
<a id="forgotPasswordGoTo" href="#">Forgot password?</a>
</form>
</div>
<div class="column">
@ -76,9 +74,7 @@
Check the <a href="https://fwdekker.com/privacy/">privacy policy</a> for more information.
</p>
<form id="registerForm" novalidate>
<p class="formValidationInfo">
<span class="validationInfo"></span>
</p>
<p class="formValidationInfo"><span class="validationInfo"></span></p>
<label for="registerEmail">
Email
<input id="registerEmail" type="email" name="email" autocomplete="on" />
@ -99,6 +95,45 @@
</form>
</div>
</div>
<div class="row hidden" id="sendForgotPasswordRow">
<div class="column column-50">
<h2>Forgot password</h2>
<p>Send an email to help reset your password.</p>
<form id="sendPasswordResetForm" novalidate>
<p class="formValidationInfo"><span class="validationInfo"></span></p>
<label for="sendPasswordResetEmail">
Email
<input id="sendPasswordResetEmail" type="email" name="email" autocomplete="on" />
<span class="validationInfo"></span>
</label>
<button id="sendPasswordResetButton">Send email</button>
<a id="forgotPasswordGoBack" href="#">Return to log in form</a>
</form>
</div>
</div>
<div class="row hidden" id="resetPasswordRow">
<div class="column column-50">
<h2>Reset password</h2>
<p>Set a new password for your account.</p>
<form id="resetPasswordForm" novalidate>
<p class="formValidationInfo"><span class="validationInfo"></span></p>
<input id="resetPasswordEmail" type="hidden" name="email" />
<input id="resetPasswordToken" type="hidden" name="token" />
<label for="resetPasswordPassword">
Password
<input id="resetPasswordPassword" type="password" name="password" />
<span class="validationInfo"></span>
</label>
<label for="resetPasswordPasswordConfirm">
Confirm password
<input id="resetPasswordPasswordConfirm" type="password" name="password_confirm" />
<span class="validationInfo"></span>
</label>
<button id="resetPasswordButton">Set password</button>
<a id="resetPasswordGoBack" href="./">Return to log in form</a>
</form>
</div>
</div>
<div class="row hidden" id="trackingRow">
<div class="column">
@ -132,23 +167,20 @@
</div>
</div>
<!-- TODO: Hide personal data (and forms) by default -->
<div class="row hidden" id="accountRow">
<div class="column">
<h2>Manage account</h2>
<!-- TODO: Add way to delete account -->
<form id="logoutForm" novalidate>
<p class="formValidationInfo">
<span class="validationInfo"></span>
</p>
<p class="formValidationInfo"><span class="validationInfo"></span></p>
<button id="logoutButton">Log out</button>
</form>
</div>
<div class="column">
<h3>Change email</h3>
<form id="resendEmailVerificationForm" novalidate>
<p class="formValidationInfo">
<span class="validationInfo"></span>
</p>
<p class="formValidationInfo"><span class="validationInfo"></span></p>
<label>Current email:<span class="validationInfo"></span></label>
<span id="emailCurrent">ERROR</span>
<br />
@ -157,9 +189,7 @@
<button id="resendEmailVerificationButton" class="hidden">resend</button>
</form>
<form id="updateEmailForm" novalidate>
<p class="formValidationInfo">
<span class="validationInfo"></span>
</p>
<p class="formValidationInfo"><span class="validationInfo"></span></p>
<label for="updateEmailEmail">
Email
<input id="updateEmailEmail" type="email" name="email" autocomplete="on" />
@ -174,9 +204,7 @@
<b>Last changed:</b> <span id="passwordLastChanged">ERROR</span>
</form>
<form id="updatePasswordForm" novalidate>
<p class="formValidationInfo">
<span class="validationInfo"></span>
</p>
<p class="formValidationInfo"><span class="validationInfo"></span></p>
<label for="updatePasswordPasswordOld">
Old password
<input id="updatePasswordPasswordOld" type="password" name="password_old" />

View File

@ -3,7 +3,7 @@ const {$, $a, doAfterLoad, footer, header, nav} = window.fwdekker;
import {csrfToken, getApi, postApi, sharedMessageElement} from "./API";
import {CustomEventHandler} from "./CustomEventHandler";
import {clearMessage, clearMessages, showError, showSuccess, showWarning} from "./Message";
import {clearMessages, showError, showSuccess, showWarning} from "./Message";
/**
@ -84,8 +84,8 @@ function refreshUserData(): void {
// Email
$("#emailCurrent").innerText = userData.email;
$("#emailVerified").innerText = userData.email_is_verified ? "yes" : "no";
if (!userData.email_is_verified) {
$("#emailVerified").innerText = userData.email_verified ? "yes" : "no";
if (!userData.email_verified) {
showWarning(
sharedMessageElement,
"You will not receive any email notifications until you verify your email address. " +
@ -97,7 +97,7 @@ function refreshUserData(): void {
// Password update time
const today = new Date();
today.setHours(0, 0, 0, 0)
const updateTime = new Date(userData.password_update_time * 1000);
const updateTime = new Date(userData.password_last_change * 1000);
updateTime.setHours(0, 0, 0, 0);
const diff = (+today - +updateTime) / 86400000;
$("#passwordLastChanged").innerText = diff === 0 ? "today" : diff + " days ago";
@ -105,6 +105,25 @@ function refreshUserData(): void {
)
}
/**
* Redirects the user to `target` after `seconds` seconds, calling `doEachSecond` after every second.
*
* @param target the location to redirect the user to after the timeout
* @param seconds the number of seconds before redirecting the user
* @param doEachSecond the function to invoke each second; the only argument is the number of seconds left at that time
*/
function redirectWithTimeout(target: string, seconds: number, doEachSecond: (secondsLeft: number) => void): void {
let secondsLeft = seconds;
const update = () => {
doEachSecond(secondsLeft);
secondsLeft--;
setTimeout(update, 1000);
};
update();
setTimeout(() => window.location.href = target, seconds * 1000);
}
// Initialize template
doAfterLoad(() => {
@ -136,7 +155,8 @@ doAfterLoad(() => {
$("#accountRow").classList.add("hidden");
});
// Add event handlers
// Login
const loginForm = $("#loginForm");
loginForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
@ -202,6 +222,73 @@ doAfterLoad(() => {
);
});
// Forgot password
const sendPasswordResetForm = $("#sendPasswordResetForm");
sendPasswordResetForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "send-password-reset",
token: csrfToken,
email: $("#sendPasswordResetEmail").value,
},
sendPasswordResetForm,
() => {
sendPasswordResetForm.reset();
showSuccess(
$(".formValidationInfo", sendPasswordResetForm),
"Password reset email sent successfully!"
);
}
);
});
const resetPasswordForm = $("#resetPasswordForm");
resetPasswordForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
postApi(
{
action: "reset-password",
token: csrfToken,
email: $("#resetPasswordEmail").value,
reset_token: $("#resetPasswordToken").value,
password: $("#resetPasswordPassword").value,
password_confirm: $("#resetPasswordPasswordConfirm").value,
},
resetPasswordForm,
() => {
resetPasswordForm.reset();
redirectWithTimeout(
"./", 3, (secondsLeft) => {
showSuccess(
$(".formValidationInfo", resetPasswordForm),
`Your password has been updated. You will be redirected after ${secondsLeft} seconds.`
);
}
)
}
);
});
$("#forgotPasswordGoTo").addEventListener("click", (event: MouseEvent) => {
event.preventDefault();
$("#loginRow").classList.add("hidden");
$("#sendForgotPasswordRow").classList.remove("hidden");
});
$("#forgotPasswordGoBack").addEventListener("click", (event: MouseEvent) => {
event.preventDefault();
$("#sendPasswordResetForm").reset();
$("#sendForgotPasswordRow").classList.add("hidden");
$("#loginRow").classList.remove("hidden");
});
// Account management
const updateEmailForm = $("#updateEmailForm");
updateEmailForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
@ -277,6 +364,8 @@ doAfterLoad(() => {
clearMessages(updatePasswordForm);
});
// Tracking management
const addTrackingForm = $("#addTrackingForm");
addTrackingForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
@ -303,42 +392,69 @@ doAfterLoad(() => {
// Run initialization code
doAfterLoad(() => {
const get_params = new URLSearchParams(window.location.search);
if (get_params.get("action") === "verify-email" && get_params.has("email") && get_params.has("token")) {
postApi(
{action: "verify-email", email: get_params.get("email"), token: get_params.get("token")},
sharedMessageElement,
() => {
let secondsUntilRedirect = 3;
const updateMessage = () => {
showSuccess(
sharedMessageElement,
`Your email address has been verified. ` +
`You will be redirected after ${secondsUntilRedirect} seconds.`
);
secondsUntilRedirect -= 1;
setTimeout(updateMessage, 1000);
};
updateMessage();
setTimeout(() => window.location.href = "//" + location.host + location.pathname, 3000);
},
() => {
$("#sharedHomeLink").classList.remove("hidden");
showError(
sharedMessageElement,
"Failed to verify email address. Maybe you already verified your email address?"
);
}
);
return;
}
// Show content depending on whether user is logged in
// Start session
getApi(
{action: "start-session"},
sharedMessageElement,
() => loginHandler.invokeListeners(),
() => logoutHandler.invokeListeners()
() => {
if (!get_params.has("action"))
loginHandler.invokeListeners();
},
() => {
if (!get_params.has("action"))
logoutHandler.invokeListeners();
}
);
// Handle GET actions
let valid_action_params = true;
switch (get_params.get("action")) {
case "verify-email":
if (get_params.has("email") && get_params.has("token")) {
postApi(
{action: "verify-email", email: get_params.get("email"), token: get_params.get("token")},
sharedMessageElement,
() => {
redirectWithTimeout(
"./", 3, (secondsLeft) => {
showSuccess(
sharedMessageElement,
`Your email address has been verified. ` +
`You will be redirected after ${secondsLeft} seconds.`
);
});
},
() => $("#sharedHomeLink").classList.remove("hidden")
);
} else {
valid_action_params = false;
}
break;
case "reset-password":
if (get_params.has("email") && get_params.has("token")) {
$("#resetPasswordEmail").value = get_params.get("email");
$("#resetPasswordToken").value = get_params.get("token");
$("#resetPasswordRow").classList.remove("hidden");
} else {
valid_action_params = false;
}
break;
case null:
break;
default:
valid_action_params = false;
break;
}
if (!valid_action_params) {
redirectWithTimeout(
"./", 3, (secondsLeft) => {
showError(
sharedMessageElement,
`Invalid URL. You will be redirected after ${secondsLeft} seconds.`
);
}
);
}
});

View File

@ -63,7 +63,7 @@ class Mailer
* @param string $token the token the user can verify their email address with
* @return Response an empty satisfied response
*/
public function queue_registration(string $email, string $token): Response
public function queue_register_password(string $email, string $token): Response
{
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, arg1, arg2)
VALUES ('register', :email, :token);");
@ -107,7 +107,7 @@ class Mailer
* @param string $token the token the user can verify their email address with
* @return Response an empty satisfied response
*/
public function queue_verification(string $email, string $token): Response
public function queue_verify_email(string $email, string $token): Response
{
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, arg1, arg2)
VALUES ('verify', :email, :token);");
@ -142,6 +142,44 @@ class Mailer
];
}
/**
* Queues an email to be sent to a user to help reset a password.
*
* @param string $email the email address to send the password reset email to
* @param string $token the token to reset the password with
* @return void
*/
public function queue_password_reset(string $email, string $token): void
{
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, arg1, arg2)
VALUES ('reset-password', :email, :token);");
$stmt->bindValue(":email", $email);
$stmt->bindValue(":token", $token);
$stmt->execute();
}
/**
* Creates the email subject and body to be sent to a user who wants to reset their password.
*
* @param string $email the email address of the user
* @param string $token the password reset token of the user
* @return string[] the subject and body of the email
*/
private function create_reset_password(string $email, string $token): array
{
$base_path = $this->config["server"]["base_path"];
$verify_path = "$base_path?action=reset-password&email=" . rawurlencode($email) . "&token=$token";
// TODO: What if user did not ask for password to be reset?
return [
"Reset your password",
"A password reset request has been sent for your account at Death Notifier. " .
"You can choose a new password by clicking the link below." .
"\n\n" .
"Reset password: $verify_path"
];
}
/**
* Queues an email to be sent to a user to notify them of a tracked person's death.
*
@ -183,6 +221,7 @@ class Mailer
*/
public function process_queue(): void
{
// TODO: Progress indicator
// Open mailer
$mailer = new PHPMailer();
$mailer->IsSMTP();
@ -216,6 +255,7 @@ class Mailer
foreach ($email_tasks as ["type" => $type, "arg1" => $arg1, "arg2" => $arg2]) {
// TODO: Reduce duplication between branches
switch ($type) {
// TODO: Use ENUM for type
case "register":
[$mailer->Subject, $mailer->Body] = $this->create_register_email($arg1, $arg2);
@ -247,6 +287,21 @@ class Mailer
$mailer->getSMTPInstance()->reset();
}
break;
case "reset-password":
[$mailer->Subject, $mailer->Body] = $this->create_reset_password($arg1, $arg2);
try {
$mailer->addAddress($arg1);
$mailer->send();
$stmt->execute();
} catch (Exception $exception) {
$this->logger->error(
"Failed to send password reset mail.",
["cause" => $exception, "recipient" => $arg1]
);
$mailer->getSMTPInstance()->reset();
}
break;
case "notify-death":
// TODO: Set name somehow
[$mailer->Subject, $mailer->Body] = $this->create_death_notification_email($arg2);
@ -262,6 +317,7 @@ class Mailer
);
$mailer->getSMTPInstance()->reset();
}
break;
}
$mailer->clearAddresses();
}

View File

@ -65,8 +65,8 @@ class TrackingManager
ON DELETE CASCADE
ON UPDATE CASCADE);");
$this->conn->exec("CREATE TABLE people(name TEXT NOT NULL UNIQUE PRIMARY KEY,
status TEXT NOT NULL DEFAULT '',
is_deleted INT NOT NULL DEFAULT 0);");
status TEXT NOT NULL DEFAULT(''),
is_deleted INT NOT NULL DEFAULT(0));");
$this->conn->exec("CREATE TRIGGER people_cull_orphans
AFTER DELETE ON trackings
FOR EACH ROW

View File

@ -24,6 +24,10 @@ class UserManager
* The minimum number of minutes between two verification emails.
*/
private const MINUTES_BETWEEN_VERIFICATION_EMAILS = 5;
/**
* The minimum number of minutes between two password reset emails.
*/
private const MINUTES_BETWEEN_PASSWORD_RESETS = 5;
/**
* @var PDO The database connection to interact with.
@ -57,10 +61,12 @@ class UserManager
{
$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,
email_verification_token TEXT DEFAULT(lower(hex(randomblob(16)))),
email_verification_token_timestamp INT NOT NULL DEFAULT(unixepoch()),
password TEXT NOT NULL,
password_update_time INT 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()));");
}
@ -98,23 +104,15 @@ class UserManager
}
// 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())
$stmt = $this->conn->prepare("INSERT INTO users (email, password)
VALUES (:email, :password)
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);
$this->mailer->queue_register_password($email, $email_verification_token);
// Respond
$this->conn->commit();
@ -191,8 +189,8 @@ class UserManager
*/
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
$stmt = $this->conn->prepare("SELECT uuid, email, email_verification_token IS NULL AS email_verified,
password_last_change
FROM users
WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid);
@ -262,48 +260,7 @@ class UserManager
// 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();
$this->mailer->queue_verify_email($email, $email_verification_token);
return new Response(payload: null, satisfied: true);
}
@ -324,16 +281,16 @@ class UserManager
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!isset($user["email_verification_token"])) {
if ($user["email_verification_token"] === null) {
$this->conn->rollBack();
return new Response(
payload: ["target" => null, "message" => "Your email address is already verified"],
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;
$minutes_since_last_verify_email =
(new DateTime("@{$user["email_verification_token_timestamp"]}"))->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;
@ -353,7 +310,48 @@ class UserManager
WHERE uuid=:uuid;");
$stmt->bindValue(":uuid", $uuid);
$stmt->execute();
$this->mailer->queue_verification($user["email"], $user["email_verification_token"]);
$this->mailer->queue_verify_email($user["email"], $user["email_verification_token"]);
$this->conn->commit();
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" => null,
"message" =>
"Failed to verify email address. " .
"Maybe you already verified your email address?"
],
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);
@ -396,7 +394,8 @@ class UserManager
// Update password
$stmt = $this->conn->prepare("UPDATE users
SET password=:password, password_update_time=unixepoch()
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_DEFAULT));
@ -406,4 +405,101 @@ class UserManager
$this->conn->commit();
return new Response(payload: null, satisfied: true);
}
/**
* 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
{
$this->conn->beginTransaction();
$stmt = $this->conn->prepare("SELECT password_reset_token_timestamp FROM users WHERE email=:email;");
$stmt->bindValue(":email", $email);
$stmt->execute();
$token_timestamp = $stmt->fetch(PDO::FETCH_ASSOC)["password_reset_token_timestamp"];
$minutes_since_last_reset_email = (new DateTime("@$token_timestamp"))->diff(new DateTime(), absolute: true)->i;
if ($minutes_since_last_reset_email < self::MINUTES_BETWEEN_PASSWORD_RESETS) {
$this->conn->rollBack();
$minutes_left = self::MINUTES_BETWEEN_PASSWORD_RESETS - $minutes_since_last_reset_email;
return new Response(
payload: [
"target" => null,
"message" =>
"A password reset 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 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"];
$this->mailer->queue_password_reset($email, $reset_token);
$this->conn->commit();
return new Response(payload: null, satisfied: true);
}
/**
* Verifies an attempt at resetting a password.
*
* @param string $email the email to reset the password of
* @param string $password_reset_token the token to reset the password with
* @param string $password_new the new password
* @param string $password_confirm confirmation of 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 $password_reset_token, string $password_new,
string $password_confirm): Response
{
$this->conn->beginTransaction();
// TODO: Make password reset tokens expire
$stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1
FROM users
WHERE email=:email
AND password_reset_token=:password_reset_token);");
$stmt->bindValue(":email", $email);
$stmt->bindValue(":password_reset_token", $password_reset_token);
$stmt->execute();
$result = $stmt->fetch();
if ($result[0] !== 1) {
$this->conn->rollBack();
return new Response(
payload: ["target" => null, "message" => "Failed to reset password. Maybe the link has expired?"],
satisfied: false
);
}
if ($password_new !== $password_confirm) {
$this->conn->rollBack();
return new Response(
payload: ["target" => "password_confirm", "message" => "Passwords do not match."],
satisfied: false
);
}
$stmt = $this->conn->prepare("UPDATE users
SET password=:password,
password_reset_token=null
WHERE email=:email;");
$stmt->bindValue(":password", password_hash($password_new, PASSWORD_DEFAULT));
$stmt->bindValue(":email", $email);
$stmt->execute();
$this->conn->commit();
return new Response(payload: null, satisfied: true);
}
}

View File

@ -261,6 +261,7 @@ class LengthRule extends Rule
return new Response(
payload: [
"target" => $key,
// TODO: More natural message by capitalising `$key` and removing quotes
"message" => $this->override_message ?? "'$key' should be at least $this->min_length characters."
],
satisfied: false