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; |