From 35db6e71fc7196af1757be17d2a3919246476683 Mon Sep 17 00:00:00 2001 From: Jonas Kohl Date: Thu, 19 Sep 2024 11:28:12 +0200 Subject: Add password reset --- src/application/messages/de.msg | 40 +++++++ src/application/mystic/forum/orm/User.php | 2 + src/application/views/form_login.php | 3 + src/application/views/form_new_password.php | 38 +++++++ src/application/views/form_password_reset.php | 41 +++++++ src/index.php | 152 +++++++++++++++++++++++++- 6 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/application/views/form_new_password.php create mode 100644 src/application/views/form_password_reset.php 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) { ?>
" method="post"> + +
" required autofocus> @@ -33,6 +35,7 @@ if (($_formError = RequestUtils::getAndClearFormError("login")) !== null) {
+
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 @@ + + +
+
+ $_formError]); +} +?> +" method="post"> + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
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 @@ + + +
+
+ $_formError]); +} +?> +
" method="post"> + +
+ + " required autofocus> +
+ +
+ +
+ +
+ '', + "/link" => '', + ]) ?> +
+
+
+
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; -- cgit v1.2.3