Improve password reset functionality
This commit is contained in:
parent
2c01776b02
commit
d7c7bd7a78
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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()]])
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue