summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJonas Kohl <git@jonaskohl.de>2024-09-19 11:28:12 +0200
committerJonas Kohl <git@jonaskohl.de>2024-09-19 11:28:12 +0200
commit35db6e71fc7196af1757be17d2a3919246476683 (patch)
tree07e7c6ddd3260cdbe7e662f434f1407a37c832d1
parent01454544896827113e49db0d2848b5aab6ce14ae (diff)
Add password reset
-rw-r--r--src/application/messages/de.msg40
-rw-r--r--src/application/mystic/forum/orm/User.php2
-rw-r--r--src/application/views/form_login.php3
-rw-r--r--src/application/views/form_new_password.php38
-rw-r--r--src/application/views/form_password_reset.php41
-rw-r--r--src/index.php152
6 files changed, 275 insertions, 1 deletions
diff --git a/src/application/messages/de.msg b/src/application/messages/de.msg
index 3a334a9..cac30e7 100644
--- a/src/application/messages/de.msg
+++ b/src/application/messages/de.msg
@@ -353,3 +353,43 @@ metadata({
: "Retype password:"
= "Passwort wiederholen:"
+
+: "The password reset link is either invalid or it expired"
+= "Der Link zum Password Zurücksetzen ist entweder ungültig oder abgelaufen"
+
+: "Password reset successfully!"
+= "Passwort erfolgreich zurückgesetzt!"
+
+: "Forgot your password? No problem!"
+= "Passwort vergessen? Kein Problem!"
+
+: "If an account exists with the given email address, we will have sent a password reset link to that email address."
+= "Falls ein Nutzerkonto mit der angegebenen E-Mail-Adresse existiert haben wir dieser einen Link zum Password Zurücksetzen zugesandt."
+
+: "Hello, %user_display_name%!\n"
+ "\n"
+ "a password reset has been requested successfully! Please click the link below to set a new password:\n"
+ "%reset_link%\n"
+ "\n"
+ "If this wasn't you, you can safely ignore this email. The link will only be valid for one hour.\n"
+ "\n"
+ "Kind regards,\n"
+ "%forum_copyright%"
+= "Hallo, %user_display_name%!\n"
+ "\n"
+ "das Zurücksetzen Ihres Passwortes wurde erfolgreich angefragt. Bitte klicken Sie auf den untenstehenden Link, um Ihr Passwort zurückzusetzen:\n"
+ "%reset_link%\n"
+ "\n"
+ "Falls Sie dies nicht waren, können Sie diese E-Mail ignorieren. Der Link ist nur für eine Stunde gültig.\n"
+ "\n"
+ "Mit freundlichen Grüßen,\n"
+ "%forum_copyright%"
+
+: "Reset password"
+= "Passwort zurücksetzen"
+
+: "I forgot my password"
+= "Ich habe mein Passwort vergessen"
+
+: "I know my password and I want to %link%log in%/link%!"
+= "Ich kenne mein Passwort und möchte mich %link%anmelden%/link%!"
diff --git a/src/application/mystic/forum/orm/User.php b/src/application/mystic/forum/orm/User.php
index 97acbaf..1bf02f1 100644
--- a/src/application/mystic/forum/orm/User.php
+++ b/src/application/mystic/forum/orm/User.php
@@ -23,6 +23,8 @@ class User extends Entity {
public bool $passwordResetRequired;
public string $activationToken;
public bool $activated;
+ #[Unique] public ?string $passwordResetToken;
+ public ?\DateTimeImmutable $passwordResetTokenCreated;
#[Column(columnType: "bytea")] public ?string $profilePicture;
public ?\DateTimeImmutable $nameLastChanged;
diff --git a/src/application/views/form_login.php b/src/application/views/form_login.php
index 1c4a9ea..acef1ff 100644
--- a/src/application/views/form_login.php
+++ b/src/application/views/form_login.php
@@ -21,6 +21,8 @@ if (($_formError = RequestUtils::getAndClearFormError("login")) !== null) {
?>
<form action="<?= htmlentities($_SERVER["REQUEST_URI"]) ?>" method="post">
<input type="hidden" name="form_id" value="login">
+<input type="hidden" name="token" value="<?= htmlentities($token) ?>">
+<input type="hidden" name="sig" value="<?= htmlentities($signature) ?>">
<div class="form-group">
<label for="i_username"><?= __("Username:") ?></label>
<input class="form-control" type="text" id="i_username" name="username" value="<?= htmlentities($lastForm["username"] ?? "") ?>" required autofocus>
@@ -33,6 +35,7 @@ if (($_formError = RequestUtils::getAndClearFormError("login")) !== null) {
<div class="form-group">
<button class="btn btn-primary" type="submit"><?= __("Log in") ?></button>
+ <a href="?_action=pwreset"><?= __("I forgot my password") ?></a>
</div>
<div class="form-group">
diff --git a/src/application/views/form_new_password.php b/src/application/views/form_new_password.php
new file mode 100644
index 0000000..7431bd5
--- /dev/null
+++ b/src/application/views/form_new_password.php
@@ -0,0 +1,38 @@
+<?php
+
+use mystic\forum\utils\RequestUtils;
+
+$lastFormUri = "";
+$lastForm = RequestUtils::getLastForm($lastFormUri) ?? [];
+if ($lastFormUri !== $_SERVER["REQUEST_URI"]) $lastForm = [];
+RequestUtils::clearLastForm();
+
+?>
+<div class="page-header margin-top-0">
+ <h1><?= __("Reset password") ?></h1>
+</div>
+<div class="col-md-4"></div>
+<div class="well col-md-4">
+<?php
+if (($_formError = RequestUtils::getAndClearFormError("pwnew")) !== null) {
+ _view("alert_error", ["message" => $_formError]);
+}
+?>
+<form action="<?= htmlentities($_SERVER["REQUEST_URI"]) ?>" method="post">
+<input type="hidden" name="form_id" value="pwnew">
+<div class="form-group">
+ <label for="i_new_password"><?= __("New password:") ?></label>
+ <input class="form-control" type="password" id="i_new_password" name="new_password" required autofocus>
+</div>
+
+<div class="form-group">
+ <label for="i_retype_password"><?= __("Retype password:") ?></label>
+ <input class="form-control" type="password" id="i_retype_password" name="retype_password" required>
+</div>
+
+<div class="form-group">
+ <button class="btn btn-primary" type="submit"><?= __("Set new password") ?></button>
+</div>
+</form>
+</div>
+<div class="col-md-4"></div>
diff --git a/src/application/views/form_password_reset.php b/src/application/views/form_password_reset.php
new file mode 100644
index 0000000..57d8ed2
--- /dev/null
+++ b/src/application/views/form_password_reset.php
@@ -0,0 +1,41 @@
+<?php
+
+use mystic\forum\Messaging;
+use mystic\forum\utils\RequestUtils;
+
+$lastFormUri = "";
+$lastForm = RequestUtils::getLastForm($lastFormUri) ?? [];
+if ($lastFormUri !== $_SERVER["REQUEST_URI"]) $lastForm = [];
+RequestUtils::clearLastForm();
+
+?>
+<div class="page-header margin-top-0">
+ <h1><?= __("Reset password") ?></h1>
+</div>
+<div class="col-md-4"></div>
+<div class="well col-md-4">
+<?php
+if (($_formError = RequestUtils::getAndClearFormError("pwreset")) !== null) {
+ _view("alert_error", ["message" => $_formError]);
+}
+?>
+<form action="<?= htmlentities($_SERVER["REQUEST_URI"]) ?>" method="post">
+<input type="hidden" name="form_id" value="pwreset">
+<div class="form-group">
+ <label for="i_username"><?= __("Email address:") ?></label>
+ <input class="form-control" type="email" id="i_email" name="email" value="<?= htmlentities($lastForm["email"] ?? "") ?>" required autofocus>
+</div>
+
+<div class="form-group">
+ <button class="btn btn-primary" type="submit"><?= __("Reset password") ?></button>
+</div>
+
+<div class="form-group">
+ <?= __("I know my password and I want to %link%log in%/link%!", [
+ "link" => '<a href="?_action=auth">',
+ "/link" => '</a>',
+ ]) ?>
+</div>
+</form>
+</div>
+<div class="col-md-4"></div>
diff --git a/src/index.php b/src/index.php
index 801cc07..62874e6 100644
--- a/src/index.php
+++ b/src/index.php
@@ -15,6 +15,7 @@ use mystic\forum\orm\UserPermissions;
use mystic\forum\utils\FileUtils;
use mystic\forum\utils\RequestUtils;
use mystic\forum\utils\ValidationUtils;
+use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
@@ -70,6 +71,41 @@ function generateCaptchaText(): string {
return $phrase;
}
+function generatePasswordResetLink(Database &$db, User &$user): ?string {
+ $token = $db->generateId(20);
+
+ $user->passwordResetToken = $token;
+ $user->passwordResetTokenCreated = new \DateTimeImmutable();
+
+ if (!$db->update($user)) {
+ return null;
+ }
+
+ return env("PUBLIC_URL") . "?_action=pwreset&token=" . urlencode($user->passwordResetToken) . "&sig=" . urlencode(base64_encode(hash("sha256", env("SECRET") . $user->passwordResetToken . $user->id . $user->passwordHash, true)));
+}
+
+function decodePasswordResetLink(Database &$db, string $token, string $signature): ?User {
+ $user = new User();
+ $user->passwordResetToken = $token;
+
+ if (!$db->fetchWhere($user, "password_reset_token")) {
+ return null;
+ }
+
+ $then = $user->passwordResetTokenCreated;
+ $now = new \DateTimeImmutable();
+ if (($now->getTimestamp() - $then->getTimestamp()) >= 3600) {
+ return null;
+ }
+
+ $expectedSignature = base64_encode(hash("sha256", env("SECRET") . $user->passwordResetToken . $user->id . $user->passwordHash, true));
+ if ($expectedSignature !== $signature) {
+ return null;
+ }
+
+ return $user;
+}
+
function getThemeAndLangInfo(): array {
$availableThemes = [];
$currentTheme = $_GET["theme"] ?? $_COOKIE["theme"] ?? env("MYSTIC_FORUM_THEME") ?? "default";
@@ -385,7 +421,7 @@ if ($_action === "auth") {
"forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
]
))
- ->subject("Please activate your account")
+ ->subject(__("Please activate your account"))
);
$db->insert($user);
@@ -1311,6 +1347,120 @@ if ($_action === "auth") {
$next = $_POST["next"] ?? ".";
setcookie("lang", $lang, time()+60*60*24*30);
header("Location: $next");
+} elseif ($_action === "pwreset") {
+ if ($currentUser) {
+ header("Location: .");
+ exit;
+ }
+
+ if (RequestUtils::isRequestMethod("POST")) {
+ $token = $_GET["token"] ?? null;
+ $signature = $_GET["sig"] ?? null;
+
+ if ($token !== null && $signature !== null) {
+ RequestUtils::setFormErrorDestination("?_action=pwreset&token=" . urlencode($token) . "&sig=" . urlencode($signature));
+ $formId = "pwnew";
+ $newPassword = RequestUtils::getRequiredField("new_password", $formId);
+ $retypePassword = RequestUtils::getRequiredField("retype_password", $formId);
+ $resetUser = decodePasswordResetLink($db, $token, $signature);
+
+ if ($resetUser === null) {
+ http_response_code(400);
+ msg_error(__("The password reset link is either invalid or it expired"), true);
+ exit;
+ }
+
+ if ($newPassword !== $retypePassword) {
+ RequestUtils::triggerFormError(__("New passwords don't match"), $formId);
+ }
+
+ if (strlen($newPassword) < 8) {
+ RequestUtils::triggerFormError(__("Password too short! Your password must consist of 8 or more characters"), $formId);
+ }
+
+ $resetUser->passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);
+ $resetUser->passwordResetToken = null;
+ $resetUser->passwordResetTokenCreated = null;
+
+ if (!$db->update($resetUser)) {
+ RequestUtils::triggerFormError(__("Failed to update password"), $formId);
+ }
+
+ msg_info(__("Password reset successfully!"), true);
+ } else {
+ $formId = "pwreset";
+ $email = RequestUtils::getRequiredField("email", $formId);
+
+ $user = new User();
+ $user->email = $email;
+
+ if ($db->fetchWhere($user, "email")) {
+ try {
+ Transport::fromDsn(env("MAILER_DSN"))->send(
+ (new Email())
+ ->from(env("MAILER_FROM"))
+ ->to(new Address($user->email, $user->displayName))
+ ->text(__(
+ "Hello, %user_display_name%!\n" .
+ "\n" .
+ "a password reset has been requested successfully! Please click the link below to set a new password:\n" .
+ "%reset_link%\n" .
+ "\n" .
+ "If this wasn't you, you can safely ignore this email. The link will only be valid for one hour.\n" .
+ "\n" .
+ "Kind regards,\n" .
+ "%forum_copyright%",
+ params: [
+ "forum_title" => (env("MYSTIC_FORUM_TITLE") ?? "Forum"),
+ "user_display_name" => $user->displayName,
+ "reset_link" => generatePasswordResetLink($db, $user),
+ "forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
+ ]
+ ))
+ ->subject(__("Forgot your password? No problem!"))
+ );
+ } catch (TransportException $_) {
+ // fail silently
+ }
+ } else {
+ // don't make the delay difference too obvious
+ usleep(random_int(900, 4500) * 1000);
+
+ // ideally, at some point we would just want to queue up the email
+ // and send it asynchronously, but this'll have to do for now
+ }
+
+ msg_info(__("If an account exists with the given email address, we will have sent a password reset link to that email address."), true);
+ }
+ } else {
+ $token = $_GET["token"] ?? null;
+ $signature = $_GET["sig"] ?? null;
+
+ if ($token !== null && $signature !== null) {
+ $resetUser = decodePasswordResetLink($db, $token, $signature);
+ if ($resetUser === null) {
+ http_response_code(400);
+ msg_error(__("The password reset link is either invalid or it expired"), true);
+ exit;
+ }
+
+ _view("template_start", [ "_title" => __("Reset password") ]);
+ _view("template_navigation_start");
+ _view("template_navigation_end");
+ _view("form_new_password", [
+ "token" => $token,
+ "signature" => $signature,
+ ]);
+ _view("template_end", [...getThemeAndLangInfo()]);
+ } else {
+ _view("template_start", [ "_title" => __("Reset password") ]);
+ _view("template_navigation_start");
+ _view("template_navigation", ["user" => null]);
+ _view("template_navigation_end");
+ _view("form_password_reset");
+ _view("template_end", [...getThemeAndLangInfo()]);
+ }
+ }
} elseif ($_action === "ctheme") {
// options
$enableLogging = true;