diff options
author | Jonas Kohl | 2024-09-19 11:28:12 +0200 |
---|---|---|
committer | Jonas Kohl | 2024-09-19 11:28:12 +0200 |
commit | 35db6e71fc7196af1757be17d2a3919246476683 (patch) | |
tree | 07e7c6ddd3260cdbe7e662f434f1407a37c832d1 | |
parent | 01454544896827113e49db0d2848b5aab6ce14ae (diff) |
Add password reset
-rw-r--r-- | src/application/messages/de.msg | 40 | ||||
-rw-r--r-- | src/application/mystic/forum/orm/User.php | 2 | ||||
-rw-r--r-- | src/application/views/form_login.php | 3 | ||||
-rw-r--r-- | src/application/views/form_new_password.php | 38 | ||||
-rw-r--r-- | src/application/views/form_password_reset.php | 41 | ||||
-rw-r--r-- | src/index.php | 152 |
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; |