Implement password reset functionality
This commit is contained in:
parent
af79597572
commit
2c01776b02
|
@ -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
|
||||
```
|
|
@ -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": {
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue