Improve password reset functionality

This commit is contained in:
Florine W. Dekker 2022-11-12 17:48:37 +01:00
parent 2c01776b02
commit d7c7bd7a78
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
7 changed files with 167 additions and 85 deletions

View File

@ -1,7 +1,7 @@
{
"name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.",
"version": "0.0.25", "_comment_version": "Also update version in `package.json`!",
"version": "0.0.26", "_comment_version": "Also update version in `package.json`!",
"type": "project",
"license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier",

View File

@ -1,6 +1,6 @@
{
"name": "death-notifier",
"version": "0.0.25", "_comment_version": "Also update version in `composer.json`!",
"version": "0.0.26", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

@ -104,10 +104,11 @@ if (isset($_POST["action"])) {
$response =
Validator::validate_inputs($_POST,
[
"token" => [new IsSetRule()],
"token" => [new EqualsRule($_SESSION["token"])],
"email" => [new IsSetRule(), new EmailRule()],
"verify_token" => [new IsSetRule()],
])
?? $user_manager->verify_email($_POST["email"], $_POST["token"]);
?? $user_manager->verify_email($_POST["email"], $_POST["verify_token"]);
break;
case "resend-verify-email":
$response =
@ -118,29 +119,29 @@ if (isset($_POST["action"])) {
case "send-password-reset":
$response =
Validator::validate_inputs($_POST,
[
"token" => [new EqualsRule($_SESSION["token"])],
"email" => [new IsSetRule(), new EmailRule()],
])
[
"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()],
])
[
"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"]
);
$_POST["email"],
$_POST["reset_token"],
$_POST["password"],
$_POST["password_confirm"]
);
break;
case "update-password":
$response =
@ -222,6 +223,15 @@ if (isset($_POST["action"])) {
$_SESSION["token"] = Util::generate_csrf_token($logger) ?? Util::http_exit(500);
}
break;
case "validate-password-reset-token":
$response = Validator::validate_inputs($_GET,
[
"token" => [new EqualsRule($_SESSION["token"])],
"reset_token" => [new IsSetRule()],
"email" => [new IsSetRule(), new EmailRule()]
])
?? $user_manager->validate_password_reset_token($_GET["email"], $_GET["reset_token"]);
break;
case "get-user-data":
$response =
Validator::validate_inputs($_SESSION, ["uuid" => [new IsSetRule()]])

View File

@ -117,8 +117,12 @@
<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="resetPasswordEmail">
Email
<input id="resetPasswordEmail" type="email" name="email" disabled />
<span class="validationInfo"></span>
</label>
<label for="resetPasswordPassword">
Password
<input id="resetPasswordPassword" type="password" name="password" />

View File

@ -49,15 +49,20 @@ const emptyFunction = () => {
* @param onSatisfied the callback to execute if the request returns successfully
* @param onUnsatisfied the callback to execute if the request returns unsuccessfully
* @param onError the callback to execute if there was an HTTP error
* @param onAlways the callback to execute regardless of output
*/
export function getApi(
params: Record<string, string>,
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
onError: (error: any) => void = emptyFunction,
onAlways: () => void = emptyFunction
): void {
interactWithApi("api.php?" + new URLSearchParams(params), undefined, form, onSatisfied, onUnsatisfied, onError);
interactWithApi(
"api.php?" + new URLSearchParams(params), undefined, form,
onSatisfied, onUnsatisfied, onError, onAlways
);
}
/**
@ -68,13 +73,15 @@ export function getApi(
* @param onSatisfied the callback to execute if the request returns successfully
* @param onUnsatisfied the callback to execute if the request returns unsuccessfully
* @param onError the callback to execute if there was an HTTP error
* @param onAlways the callback to execute regardless of output
*/
export function postApi(
params: object,
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
onError: (error: any) => void = emptyFunction,
onAlways: () => void = emptyFunction
): void {
interactWithApi("api.php",
{
@ -86,9 +93,7 @@ export function postApi(
body: JSON.stringify(params)
},
form,
onSatisfied,
onUnsatisfied,
onError
onSatisfied, onUnsatisfied, onError, onAlways
);
}
@ -101,6 +106,7 @@ export function postApi(
* @param onSatisfied the callback to execute if the request returns successfully
* @param onUnsatisfied the callback to execute if the request returns unsuccessfully
* @param onError the callback to execute if there was an HTTP error
* @param onAlways the callback to execute regardless of output
*/
function interactWithApi(
url: string,
@ -108,7 +114,8 @@ function interactWithApi(
form: HTMLFormElement,
onSatisfied: (response: ServerResponse) => void = emptyFunction,
onUnsatisfied: (response: ServerResponse) => void = emptyFunction,
onError: (error: any) => void = emptyFunction
onError: (error: any) => void = emptyFunction,
onAlways: () => void = emptyFunction
): void {
clearMessages(form);
const topErrorElement = $(".formValidationInfo", form) ?? sharedMessageElement;
@ -133,9 +140,11 @@ function interactWithApi(
} else {
onSatisfied(it);
}
onAlways();
})
.catch((error) => {
showError(topErrorElement, "Unexpected error. Please try again later.");
onError(error);
onAlways();
});
}

View File

@ -260,7 +260,6 @@ doAfterLoad(() => {
},
resetPasswordForm,
() => {
resetPasswordForm.reset();
redirectWithTimeout(
"./", 3, (secondsLeft) => {
showSuccess(
@ -276,12 +275,16 @@ doAfterLoad(() => {
$("#forgotPasswordGoTo").addEventListener("click", (event: MouseEvent) => {
event.preventDefault();
$("#sendPasswordResetEmail").value = $("#loginEmail").value;
$("#loginRow").classList.add("hidden");
$("#sendForgotPasswordRow").classList.remove("hidden");
});
$("#forgotPasswordGoBack").addEventListener("click", (event: MouseEvent) => {
event.preventDefault();
$("#loginEmail").value = $("#sendPasswordResetEmail").value;
$("#sendPasswordResetForm").reset();
$("#sendForgotPasswordRow").classList.add("hidden");
$("#loginRow").classList.remove("hidden");
@ -392,8 +395,6 @@ doAfterLoad(() => {
// Run initialization code
doAfterLoad(() => {
const get_params = new URLSearchParams(window.location.search);
// Start session
getApi(
{action: "start-session"},
sharedMessageElement,
@ -404,57 +405,76 @@ doAfterLoad(() => {
() => {
if (!get_params.has("action"))
logoutHandler.invokeListeners();
},
() => {
// Do nothing
},
() => {
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",
token: csrfToken,
email: get_params.get("email"),
verify_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")) {
getApi(
{
action: "validate-password-reset-token",
token: csrfToken ?? "",
email: get_params.get("email") ?? "",
reset_token: get_params.get("token") ?? "",
},
sharedMessageElement,
() => {
$("#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.`
);
}
);
}
}
);
// 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

@ -420,8 +420,19 @@ class UserManager
$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"];
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (sizeof($results) === 0) {
$this->conn->rollBack();
return new Response(
payload: [
"target" => null,
"message" => "No account with that email address has been registered."
],
satisfied: false
);
}
$token_timestamp = $results[0]["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();
@ -451,6 +462,34 @@ class UserManager
return new Response(payload: null, satisfied: true);
}
/**
* 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
{
$this->conn->beginTransaction();
$stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users
WHERE email=:email AND password_reset_token=:token);");
$stmt->bindValue(":email", $email);
$stmt->bindValue(":token", $token);
$stmt->execute();
$result = $stmt->fetch();
if ($result[0] !== 1) {
return new Response(
payload: ["target" => null, "message" => "This URL is invalid. Maybe you already reset your password?"],
satisfied: false
);
}
return new Response(payload: null, satisfied: true);
}
/**
* Verifies an attempt at resetting a password.
*
@ -478,7 +517,7 @@ class UserManager
if ($result[0] !== 1) {
$this->conn->rollBack();
return new Response(
payload: ["target" => null, "message" => "Failed to reset password. Maybe the link has expired?"],
payload: ["target" => null, "message" => "Failed to reset password. Maybe you already used this link?"],
satisfied: false
);
}