~jan0sch/smederee

Showing details for patch 9ebc1e945f81b8bf66d04108aba5bf7cd69e0bd4.
2023-08-03 (Thu), 5:28 PM - Jens Grassel - 9ebc1e945f81b8bf66d04108aba5bf7cd69e0bd4

Hub: Add forgot password functionality.

- extend `AuthenticationRepository` with `deleteAllUserSessions` to be able to
  enforce a logout of all current sessions
- add routing, repository, e-mail and html templates for reset/forgot password
  functionality
- add tests and more test helper functions

A user can enter their email address and a reset token will be generated and
and email sent to the user. In case of a non existing email the delay for the
"sent page" is randomised to avoid an instant (too fast) feedback which might
be used to guess email addresses.

The link sent in the email can be used to open a form only once in which the
password can be reset. The correct username associated with the account / email
must be entered by the user. The processing logic checks also that the reset
token must match / exist for the user account.

When the password is changed all currently active user sessions are deleted.
Summary of changes
10 files added
  • modules/hub/src/main/scala/de/smederee/hub/ChangePasswordForm.scala
  • modules/hub/src/main/scala/de/smederee/hub/DoobieResetPasswordRepository.scala
  • modules/hub/src/main/scala/de/smederee/hub/ResetPasswordForm.scala
  • modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRepository.scala
  • modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala
  • modules/hub/src/main/twirl/de/smederee/hub/views/changePassword.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/emails/reset.scala.txt
  • modules/hub/src/main/twirl/de/smederee/hub/views/reset.scala.html
  • modules/hub/src/main/twirl/de/smederee/hub/views/resetSent.scala.html
  • modules/hub/src/test/scala/de/smederee/hub/DoobieResetPasswordRepositoryTest.scala
9 files modified with 142 lines added and 1 lines removed
  • modules/hub/src/main/resources/messages.properties with 13 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 6 added and 1 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala with 9 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala with 3 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/HubServer.scala with 10 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala with 69 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala with 28 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/Generators.scala with 2 added and 0 removed lines
  • modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala with 2 added and 0 removed lines
diff -rN -u old-smederee/modules/hub/src/main/resources/messages.properties new-smederee/modules/hub/src/main/resources/messages.properties
--- old-smederee/modules/hub/src/main/resources/messages.properties	2025-01-15 18:42:00.393190341 +0000
+++ new-smederee/modules/hub/src/main/resources/messages.properties	2025-01-15 18:42:00.397190349 +0000
@@ -25,6 +25,17 @@
 form.account.language.help=The language settings will affect the translation and display of date and numbers.
 form.account.validate-email.notice=You have not yet validated your email address, therefore some operations are not yet allowed. If you have not received a validation email from us, please use the button below to send the email.
 form.account.validate-email.button.submit=Send validation email
+form.change-password.button.submit=Change password
+form.change-password.help=Please note that all your currently active user sessions will be closed if you change your password.
+form.change-password.password-confirmation.help=Please confirm your password here to reduce possible errors due to typos.
+form.change-password.password-confirmation.placeholder=Repeat the password you entered above.
+form.change-password.password-confirmation=Confirm password
+form.change-password.password.help=Your password must be at least 12 characters long.
+form.change-password.password.placeholder=Please choose a secure password!
+form.change-password.password=New password
+form.change-password.username.help=Please enter the username for your account.
+form.change-password.username.placeholder=Please enter your username.
+form.change-password.username=Username
 form.create-repo.button.submit=Create repository
 form.create-repo.name=Name
 form.create-repo.name.placeholder=Please enter a repository name.
@@ -43,6 +54,8 @@
 form.repository.delete.i-am-sure=Yes, I am sure that I want to delete the repository {0}!
 form.repository.delete.notice=This action CANNOT be undone! Please be careful.
 form.repository.delete.title=Delete repository {0}
+form.reset-password.button.submit=Request password reset
+form.reset-password.help=You can use this form to request a reset link via email that will allow you to change your password.
 form.edit-repo.button.submit=Edit repository
 form.edit-repo.name=Name
 form.edit-repo.name.help=A repository name must start with a letter or number and must contain only alphanumeric ASCII characters as well as minus or underscore signs. It must be between 2 and 64 characters long.
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-15 18:42:00.393190341 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala	2025-01-15 18:42:00.397190349 +0000
@@ -105,7 +105,12 @@
     *   A randomly generated reset token.
     */
   def generate: ResetToken = scala.util.Random.alphanumeric.take(Length).mkString
+}
 
+/** Extractor to retrieve a ResetToken from a path parameter.
+  */
+object ResetTokenPathParameter {
+  def unapply(str: String): Option[ResetToken] = Option(str).flatMap(ResetToken.from)
 }
 
 opaque type UnlockToken = String
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala	2025-01-15 18:42:00.393190341 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala	2025-01-15 18:42:00.397190349 +0000
@@ -53,6 +53,15 @@
     */
   def createUserSession(session: Session): F[Int]
 
+  /** Delete all user sessions of the given user from the database.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @return
+    *   The number of affected database rows.
+    */
+  def deleteAllUserSessions(uid: UserId): F[Int]
+
   /** Delete the user session with the given ID from the database.
     *
     * @param id
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/ChangePasswordForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/ChangePasswordForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/ChangePasswordForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/ChangePasswordForm.scala	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.data._
+import cats.syntax.all._
+import de.smederee.hub.forms._
+import de.smederee.hub.forms.types._
+import de.smederee.security._
+
+/** Data container for changine an account password in the reset password process.
+  *
+  * @param name
+  *   The username which must match the name of the account with the appropriate reset token.
+  * @param password
+  *   The password that shall be set.
+  * @param passwordConfirmation
+  *   A password confirmation field for safety reasons to reduce errors due to typos.
+  * @param token
+  *   The reset password token for the current request.
+  */
+final case class ChangePasswordForm(
+    name: Username,
+    password: Password,
+    passwordConfirmation: Password,
+    token: ResetToken
+)
+
+object ChangePasswordForm extends FormValidator[ChangePasswordForm] {
+  val fieldName: FormField                 = FormField("name")
+  val fieldPassword: FormField             = FormField("password")
+  val fieldPasswordConfirmation: FormField = FormField("password_confirmation")
+  val fieldResetToken: FormField           = FormField("token")
+
+  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, ChangePasswordForm] = {
+    val name: ValidatedNec[FormErrors, Username] = data
+      .get(fieldName)
+      .fold(FormFieldError("No username given!").invalidNec)(s =>
+        Username.from(s).fold(FormFieldError("Invalid username!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldName -> es.toList)))
+    val password: ValidatedNec[FormErrors, Password] = data
+      .get(fieldPassword)
+      .fold("No password given!".invalidNec)(Password.validate)
+      .leftMap(es => NonEmptyChain.of(Map(fieldPassword -> es.toList.map(FormFieldError.apply))))
+    val passwordConfirmation: ValidatedNec[FormErrors, Password] = data
+      .get(fieldPasswordConfirmation)
+      .fold("No password given!".invalidNec)(Password.validate)
+      .leftMap(es => NonEmptyChain.of(Map(fieldPasswordConfirmation -> es.toList.map(FormFieldError.apply))))
+    val passwordsMatching: ValidatedNec[FormErrors, Password] = (password.toOption, passwordConfirmation.toOption)
+      .mapN { case (pw, pwc) =>
+        (pw, pwc)
+      }
+      .filter(tuple => tuple._1.matches(tuple._2.encode)) match {
+      case None          => Map(fieldPasswordConfirmation -> List(FormFieldError("Passwords do not match!"))).invalidNec
+      case Some((pw, _)) => pw.validNec
+    }
+    val token: ValidatedNec[FormErrors, ResetToken] = data
+      .get(fieldResetToken)
+      .fold(FormFieldError("No reset token!").invalidNec)(s =>
+        ResetToken.from(s).fold(FormFieldError("Invalid reset token!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldResetToken -> es.toList)))
+    (name, password, passwordConfirmation, passwordsMatching, token).mapN {
+      case (validName, validPassword, validPasswordConfirmation, _, validResetToken) =>
+        ChangePasswordForm(validName, validPassword, validPasswordConfirmation, validResetToken)
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-01-15 18:42:00.393190341 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala	2025-01-15 18:42:00.397190349 +0000
@@ -52,6 +52,9 @@
     sql"""INSERT INTO "hub"."sessions" (id, uid, created_at, updated_at) VALUES (${session.id}, ${session.uid}, ${session.createdAt}, ${session.updatedAt})""".update.run
       .transact(tx)
 
+  override def deleteAllUserSessions(uid: UserId): F[Int] =
+    sql"""DELETE FROM "hub"."sessions" WHERE uid = $uid""".update.run.transact(tx)
+
   override def deleteUserSession(id: SessionId): F[Int] =
     sql"""DELETE FROM "hub"."sessions" WHERE id = $id""".update.run.transact(tx)
 
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieResetPasswordRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieResetPasswordRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieResetPasswordRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieResetPasswordRepository.scala	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.time.OffsetDateTime
+import java.util.UUID
+
+import cats.effect._
+import de.smederee.email.EmailAddress
+import de.smederee.i18n.LanguageCode
+import de.smederee.security._
+import doobie.Fragments._
+import doobie._
+import doobie.implicits._
+import doobie.postgres.implicits._
+
+final class DoobieResetPasswordRepository[F[_]: Sync](tx: Transactor[F]) extends ResetPasswordRepository[F] {
+  given Meta[EmailAddress] = Meta[String].timap(EmailAddress.apply)(_.toString)
+  given Meta[LanguageCode] = Meta[String].timap(LanguageCode.apply)(_.toString)
+  given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString)
+  given Meta[ResetToken]   = Meta[String].timap(ResetToken.apply)(_.toString)
+  given Meta[SessionId]    = Meta[String].timap(SessionId.apply)(_.toString)
+  given Meta[UnlockToken]  = Meta[String].timap(UnlockToken.apply)(_.toString)
+  given Meta[UserId]       = Meta[UUID].timap(UserId.apply)(_.toUUID)
+  given Meta[Username]     = Meta[String].timap(Username.apply)(_.toString)
+
+  private val notLockedFilter              = fr"""locked_at IS NULL"""
+  private val resetTokenExpiryNotSetFilter = fr"""reset_expiry IS NULL"""
+  private val resetTokenExpirySetFilter    = fr"""reset_expiry IS NOT NULL"""
+  private val resetTokenNotExpiredFilter   = fr"""reset_expiry > NOW()"""
+  private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email, language FROM "hub"."accounts""""
+
+  override def findByNameAndResetPasswordToken(name: Username, token: ResetToken): F[Option[Account]] = {
+    val nameFilter       = fr"""name = $name"""
+    val resetTokenFilter = fr"""reset_token = $token"""
+    val query = selectAccountColumns ++ whereAnd(
+      notLockedFilter,
+      nameFilter,
+      resetTokenExpiryNotSetFilter,
+      resetTokenFilter
+    ) ++ fr"""LIMIT 1"""
+    query.query[Account].option.transact(tx)
+  }
+
+  override def findByResetPasswordToken(token: ResetToken): F[Option[Account]] = {
+    val resetTokenFilter = fr"""reset_token = $token"""
+    val query = selectAccountColumns ++ whereAnd(
+      notLockedFilter,
+      resetTokenExpirySetFilter,
+      resetTokenNotExpiredFilter,
+      resetTokenFilter
+    ) ++ fr"""LIMIT 1"""
+    query.query[Account].option.transact(tx)
+  }
+
+  override def removeResetPasswordExpirationDate(uid: UserId): F[Int] =
+    sql"""UPDATE "hub"."accounts" SET reset_expiry = NULL WHERE uid = $uid""".update.run.transact(tx)
+
+  override def removeResetPasswordToken(uid: UserId): F[Int] =
+    sql"""UPDATE "hub"."accounts" SET reset_expiry = NULL, reset_token = NULL WHERE uid = $uid""".update.run
+      .transact(tx)
+
+  override def setPassword(uid: UserId)(hash: PasswordHash): F[Int] =
+    sql"""UPDATE "hub"."accounts" SET password = $hash WHERE uid = $uid""".update.run.transact(tx)
+
+  override def setResetPasswordToken(uid: UserId)(token: ResetToken, tokenExpiration: OffsetDateTime): F[Int] =
+    sql"""UPDATE "hub"."accounts" SET  reset_expiry = $tokenExpiration, reset_token = $token WHERE uid = $uid""".update.run
+      .transact(tx)
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-15 18:42:00.393190341 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala	2025-01-15 18:42:00.397190349 +0000
@@ -431,6 +431,14 @@
             authenticationRepo,
             signAndValidate
           )
+          resetPasswordRepo = new DoobieResetPasswordRepository[IO](hubTransactor)
+          resetPasswordRoutes = new ResetPasswordRoutes[IO](
+            hubConfiguration.service.authentication,
+            authenticationRepo,
+            emailMiddleware,
+            hubConfiguration.service.external,
+            resetPasswordRepo
+          )
           signUpRepo      = new DoobieSignupRepository[IO](hubTransactor)
           signUpRoutes    = new SignupRoutes[IO](hubConfiguration.service, signUpRepo)
           landingPages    = new LandingPageRoutes[IO](hubConfiguration.service)
@@ -444,6 +452,7 @@
           protectedRoutesWithFallThrough = authenticationWithFallThrough(
             authenticationRoutes.protectedRoutes <+>
               accountManagementRoutes.protectedRoutes <+>
+              resetPasswordRoutes.protectedRoutes <+>
               signUpRoutes.protectedRoutes <+>
               ticketLabelRoutes.protectedRoutes <+>
               ticketMilestoneRoutes.protectedRoutes <+>
@@ -455,6 +464,7 @@
             Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes,
             "/" -> (protectedRoutesWithFallThrough <+>
               authenticationRoutes.routes <+>
+              resetPasswordRoutes.routes <+>
               accountManagementRoutes.routes <+>
               signUpRoutes.routes <+>
               ticketLabelRoutes.routes <+>
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordForm.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordForm.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordForm.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordForm.scala	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import cats.data._
+import cats.syntax.all._
+import de.smederee.email.EmailAddress
+import de.smederee.hub.forms._
+import de.smederee.hub.forms.types._
+import de.smederee.security._
+
+/** Data container for the form used to reset a password.
+  *
+  * @param email
+  *   The email address to which the reset link shall be sent that must exist and belong to an account.
+  */
+final case class ResetPasswordForm(email: EmailAddress)
+
+object ResetPasswordForm extends FormValidator[ResetPasswordForm] {
+  val fieldEmail: FormField = FormField("email")
+
+  override def validate(data: Map[String, String]): ValidatedNec[FormErrors, ResetPasswordForm] = {
+    val email: ValidatedNec[FormErrors, EmailAddress] = data
+      .get(fieldEmail)
+      .fold(FormFieldError("No email address given!").invalidNec)(s =>
+        EmailAddress.from(s).fold(FormFieldError("Invalid email address!").invalidNec)(_.validNec)
+      )
+      .leftMap(es => NonEmptyChain.of(Map(fieldEmail -> es.toList)))
+    email.map(ResetPasswordForm.apply)
+  }
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRepository.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRepository.scala	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.time.OffsetDateTime
+
+import de.smederee.security._
+
+/** A base class for database functionality related to resetting a user password.
+  *
+  * ### General notes ###
+  *
+  * {{{
+  * 1. An account is considered *locked* **NOT** by the presence of an unlock token **BUT** by the presence
+  *    of the `locked_at` date!
+  * 2. All functions that are used to find accounts by email, id or name must return only *unlocked* accounts!
+  * 3. Also the function `findPasswordHashAndAttempts` must only consider *unlocked* accounts!
+  * }}}
+  *
+  * @tparam F
+  *   A higher kinded type which wraps the actual return values.
+  */
+abstract class ResetPasswordRepository[F[_]] {
+
+  /** Find a user account using the given name and reset token. This function shall not check for the expiration date
+    * but for the expiration date being NULL and for the given combination of username and reset token.
+    *
+    * The nulled out expiration date is considered proof that the link with the reset url has been called.
+    *
+    * @param name
+    *   The username which must be unique according to our requirements.
+    * @param token
+    *   A token that must be present in the reset token column.
+    * @return
+    *   An option to the found user account.
+    */
+  def findByNameAndResetPasswordToken(name: Username, token: ResetToken): F[Option[Account]]
+
+  /** Find a user account via the given password reset token which must not be expired.
+    *
+    * @param token
+    *   A token that must be present in the reset token column.
+    * @return
+    *   An option to the found user account.
+    */
+  def findByResetPasswordToken(token: ResetToken): F[Option[Account]]
+
+  /** Remove just the expiration date for the reset password token from the database. This is used to prevent opening
+    * the reset password link a second time while still being able to perform a password change.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @return
+    *   The number of affected database rows.
+    */
+  def removeResetPasswordExpirationDate(uid: UserId): F[Int]
+
+  /** Remove the reset password token and the expiration date from the database.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @return
+    *   The number of affected database rows.
+    */
+  def removeResetPasswordToken(uid: UserId): F[Int]
+
+  /** Set the password hash for the account with the given user id.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @param hash
+    *   The password hash for the account.
+    * @return
+    *   The number of affected database rows.
+    */
+  def setPassword(uid: UserId)(hash: PasswordHash): F[Int]
+
+  /** Set the password reset token for the given user and the related expiration time.
+    *
+    * @param uid
+    *   The unique id of the user account.
+    * @param token
+    *   A token that is written into the reset token column and must be present in the reset request uri.
+    * @param tokenExpiration
+    *   The timestamp when the token shall expire.
+    * @return
+    *   The number of affected database rows.
+    */
+  def setResetPasswordToken(uid: UserId)(token: ResetToken, tokenExpiration: OffsetDateTime): F[Int]
+
+}
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/ResetPasswordRoutes.scala	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.time.{ OffsetDateTime, ZoneOffset }
+
+import cats.data._
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.email._
+import de.smederee.html._
+import de.smederee.html.LinkTools._
+import de.smederee.hub.RequestHelpers.instances.given_RequestHelpers_Request
+import de.smederee.hub._
+import de.smederee.hub.config._
+import de.smederee.hub.forms.types.{ FormErrors, FormFieldError }
+import de.smederee.i18n.LanguageCode
+import org.http4s._
+import org.http4s.dsl.Http4sDsl
+import org.http4s.headers.Location
+import org.http4s.implicits._
+import org.http4s.twirl.TwirlInstances._
+import org.slf4j.LoggerFactory
+
+import scala.concurrent.duration._
+
+final class ResetPasswordRoutes[F[_]: Async](
+    authenticationConfig: AuthenticationConfiguration,
+    authenticationRepo: AuthenticationRepository[F],
+    emailMiddleware: EmailMiddleware[F],
+    external: ExternalUrlConfiguration,
+    resetPasswordRepo: ResetPasswordRepository[F]
+) extends Http4sDsl[F] {
+  private val log = LoggerFactory.getLogger(getClass)
+
+  private val loginPath               = uri"/login"
+  private val resetPath               = uri"/forgot-password"
+  private val resetChangePasswordPath = uri"/forgot-password/change-password"
+  private val resetRequestPath        = uri"/forgot-password/request-email"
+  private val resetSentPath           = uri"/forgot-password/email-sent"
+
+  private val passwordResetRequestForLoggedInUsers: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case _ @POST -> Root / "forgot-password" / "request-email" as _ =>
+      SeeOther(Location(Uri(path = Uri.Path.Root))) // Redirect already logged in users.
+  }
+
+  private val passwordResetRequest: HttpRoutes[F] = HttpRoutes.of {
+    case req @ POST -> Root / "forgot-password" / "request-email" =>
+      req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf <- Sync[F].delay(req.getCsrfToken)
+          formData <- Sync[F].delay {
+            urlForm.values.map { t =>
+              val (key, values) = t
+              (
+                key,
+                values.headOption.getOrElse("")
+              ) // Pick the first value (a field might get submitted multiple times)!
+            }
+          }
+          form <- Sync[F].delay(ResetPasswordForm.validate(formData))
+          response <- form match {
+            case Validated.Invalid(es) =>
+              BadRequest(
+                views.html.reset()(resetPath, csrf, title = "Smederee - Reset your account password".some)(
+                  formData,
+                  FormErrors.fromNec(es)
+                )
+              )
+            case Validated.Valid(resetForm) =>
+              authenticationRepo.findAccountByEmail(resetForm.email).flatMap {
+                case None =>
+                  for {
+                    delay <- Sync[F]
+                      .delay(scala.util.Random.nextInt(3)) // Prevent fast response to avoid email guessing.
+                    _        <- Sync[F].sleep(FiniteDuration(delay, SECONDS))
+                    response <- SeeOther(Location(resetSentPath))
+                  } yield response
+                case Some(user) =>
+                  for {
+                    language <- Sync[F].delay(user.language.getOrElse(LanguageCode("en")))
+                    token    <- Sync[F].delay(ResetToken.generate)
+                    _ <- resetPasswordRepo
+                      .setResetPasswordToken(user.uid)(token, OffsetDateTime.now(ZoneOffset.UTC).plusDays(3L))
+                    from <- Sync[F].delay(FromAddress("noreply@smeder.ee"))
+                    to   <- Sync[F].delay(NonEmptyList.of(user.email.toToAddress))
+                    uri  <- Sync[F].delay(external.createFullUri(uri"reset").addPath(token.toString))
+                    subject <- Sync[F]
+                      .delay(SubjectLine("Smederee - Someone has requested a password reset for your email address."))
+                    body <- Sync[F].delay(
+                      TextBody(views.txt.emails.reset(user, uri).toString)
+                    ) // TODO: extension method?
+                    message  <- Sync[F].delay(EmailMessage(from, to, List.empty, List.empty, subject, body))
+                    result   <- emailMiddleware.send(message)
+                    _        <- Sync[F].delay(result.leftMap(error => log.error(error)))
+                    response <- SeeOther(Location(resetSentPath))
+                  } yield response
+              }
+          }
+        } yield response
+      }
+  }
+
+  private val requestPasswordReset: HttpRoutes[F] = HttpRoutes.of { case req @ GET -> Root / "forgot-password" =>
+    for {
+      csrf <- Sync[F].delay(req.getCsrfToken)
+      response <- Ok(
+        views.html.reset()(resetRequestPath, csrf, title = "Smederee - Reset your account password".some)()
+      )
+    } yield response
+  }
+
+  private val requestPasswordResetForLoggedInUsers: AuthedRoutes[Account, F] = AuthedRoutes.of {
+    case _ @GET -> Root / "forgot-password" as _ =>
+      SeeOther(Location(Uri(path = Uri.Path.Root))) // Redirect already logged in users.
+  }
+
+  private val passwordResetEmailSent: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / "forgot-password" / "email-sent" =>
+      for {
+        csrf     <- Sync[F].delay(req.getCsrfToken)
+        response <- Ok(views.html.resetSent()(csrf, title = "Reset password email sent.".some))
+      } yield response
+  }
+
+  private val changePassword: HttpRoutes[F] = HttpRoutes.of {
+    case req @ POST -> Root / "forgot-password" / "change-password" / ResetTokenPathParameter(token) =>
+      req.decodeStrict[F, UrlForm] { urlForm =>
+        for {
+          csrf <- Sync[F].delay(req.getCsrfToken)
+          formData <- Sync[F].delay {
+            urlForm.values.map { t =>
+              val (key, values) = t
+              (
+                key,
+                values.headOption.getOrElse("")
+              ) // Pick the first value (a field might get submitted multiple times)!
+            }
+          }
+          form <- Sync[F].delay(
+            ChangePasswordForm
+              .validate(formData)
+              .andThen(form =>
+                if (form.token === token) form.validNec
+                else
+                  Map(
+                    ChangePasswordForm.fieldGlobal -> List(FormFieldError("Invalid password reset token!"))
+                  ).invalidNec
+              )
+          )
+          changePasswordUri = resetChangePasswordPath.addSegment(token.toString)
+          response <- form match {
+            case Validated.Invalid(es) =>
+              BadRequest(
+                views.html
+                  .changePassword()(changePasswordUri, csrf, title = "Smederee - Change your password.".some, token)(
+                    formData,
+                    FormErrors.fromNec(es)
+                  )
+              )
+            case Validated.Valid(changePasswordForm) =>
+              for {
+                user <- resetPasswordRepo.findByNameAndResetPasswordToken(changePasswordForm.name, token)
+                _ <- user match {
+                  case None =>
+                    Sync[F].delay(log.info(s"Password reset form: No user named ${changePasswordForm.name} found!"))
+                  case Some(user) =>
+                    Sync[F].delay(log.info(s"Password reset form: Changing password for ${user.name}."))
+                }
+                _ <- user.traverse(user => resetPasswordRepo.setPassword(user.uid)(changePasswordForm.password.encode))
+                _ <- user.traverse(user => resetPasswordRepo.removeResetPasswordToken(user.uid))
+                _ <- user.traverse(user => authenticationRepo.deleteAllUserSessions(user.uid)) // Close all sessions.
+                response <- SeeOther(Location(loginPath))
+              } yield response
+          }
+        } yield response
+      }
+  }
+
+  private val changePasswordForm: HttpRoutes[F] = HttpRoutes.of {
+    case req @ GET -> Root / "forgot-password" / "change-password" / ResetTokenPathParameter(token) =>
+      for {
+        csrf <- Sync[F].delay(req.getCsrfToken)
+        user <- resetPasswordRepo.findByResetPasswordToken(token)
+        response <- user match {
+          case None => Sync[F].delay(log.debug(s"Requested password reset token $token was not found!")) *> NotFound()
+          case Some(user) =>
+            for {
+              _ <- resetPasswordRepo.removeResetPasswordExpirationDate(user.uid) // The URL shall only work once!
+              _ <- Sync[F].delay(log.debug(s"Password reset uri called for ${user.email}."))
+              changePasswordUri = resetChangePasswordPath.addSegment(token.toString)
+              response <- Ok(
+                views.html.changePassword()(
+                  changePasswordUri,
+                  csrf,
+                  title = "Smederee - Change your password.".some,
+                  token
+                )()
+              )
+            } yield response
+        }
+      } yield response
+  }
+
+  val protectedRoutes =
+    if (authenticationConfig.enabled)
+      requestPasswordResetForLoggedInUsers <+> passwordResetRequestForLoggedInUsers
+    else
+      AuthedRoutes.empty[Account, F]
+
+  val routes =
+    if (authenticationConfig.enabled)
+      changePassword <+> changePasswordForm <+> requestPasswordReset <+> passwordResetRequest <+> passwordResetEmailSent
+    else
+      HttpRoutes.empty[F]
+
+}
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/changePassword.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/changePassword.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/changePassword.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/changePassword.scala.html	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,60 @@
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
+@import de.smederee.hub.views.html.forms.renderFormErrors
+@import ChangePasswordForm._
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"), tags: MetaTags = MetaTags.default)(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, token: ResetToken)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
+@main(baseUri, lang, tags)()(csrf, title, user = None) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+            <p class="alert alert-error">
+              <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+              <span class="sr-only">Fehler:</span>
+              @error
+            </p>
+            }
+          }
+        </div>
+        <div class="change-password-form">
+          <form action="@action" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned" autocomplete="on">
+            <fieldset id="change-password-data">
+              <div class="pure-control-group">
+                <label for="@{fieldName}">@Messages("form.change-password.username")</label>
+                <input class="pure-input-1-2" id="@{fieldName}" name="@{fieldName}" placeholder="@Messages("form.change-password.username.placeholder")" maxlength="31" required="" type="text" value="@{formData.get(fieldName)}" autocomplete="username" autofocus>
+                <small class="pure-form-message" id="@{fieldName}-help">@Messages("form.change-password.username.help")</small>
+                @renderFormErrors(fieldName, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldPassword}">@Messages("form.change-password.password")</label>
+                <input class="pure-input-1-2" id="@{fieldPassword}" name="@{fieldPassword}" placeholder="@Messages("form.change-password.password.placeholder")" maxlength="128" required="" type="password" value="" autocomplete="password">
+                <small class="pure-form-message" id="@{fieldPassword}-help">@Messages("form.change-password.password.help")</small>
+                @renderFormErrors(fieldPassword, formErrors)
+              </div>
+              <div class="pure-control-group">
+                <label for="@{fieldPasswordConfirmation}">@Messages("form.change-password.password-confirmation")</label>
+                <input class="pure-input-1-2" id="@{fieldPasswordConfirmation}" name="@{fieldPasswordConfirmation}" placeholder="@Messages("form.change-password.password-confirmation.placeholder")" maxlength="128" required="" type="password" value="" autocomplete="password">
+                <small class="pure-form-message" id="@{fieldPasswordConfirmation}-help">@Messages("form.change-password.password-confirmation.help")</small>
+                @renderFormErrors(fieldPasswordConfirmation, formErrors)
+              </div>
+              <input type="hidden" name="@fieldResetToken" value="@token">
+              @csrfToken(csrf)
+              <div class="pure-controls">
+                <button type="submit" class="pure-button">@Messages("form.change-password.button.submit")</button>
+                <small class="pure-form-message">@Messages("form.change-password.help")</small>
+              </div>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/reset.scala.txt new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/reset.scala.txt
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/reset.scala.txt	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/reset.scala.txt	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,26 @@
+@import de.smederee.hub._
+@(user: Account, resetUri: Uri)
+Hello,
+
+someone has requested a password reset for the email address that is
+registered with your account at the Smederee (https://smeder.ee).
+
+To complete the request and change your password please click on the
+following link:
+
+@resetUri
+
+Please be aware that this link only works exactly once.
+
+If you have not issued a password reset then simply ignore this email.
+The link will only work for a limited amount of time.
+
+With kind regards,
+
+the crew of the Smederee.
+
+-- 
+Smederee - Craft great software!
+
+https://smeder.ee
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/reset.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/reset.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/reset.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/reset.scala.html	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,46 @@
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
+@import de.smederee.hub.views.html.forms.renderFormErrors
+@import ResetPasswordForm._
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(action: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None)(formData: Map[String, String] = Map.empty, formErrors: FormErrors = FormErrors.empty)
+@main(baseUri, lang)()(csrf, title, user = None) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <div class="form-errors">
+          @formErrors.get(fieldGlobal).map { es =>
+            @for(error <- es) {
+            <p class="alert alert-error">
+              <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+              <span class="sr-only">Fehler:</span>
+              @error
+            </p>
+            }
+          }
+        </div>
+        <div class="reset-password-form">
+          <form action="@action" method="POST" accept-charset="UTF-8" class="pure-form pure-form-aligned" autocomplete="on">
+            <fieldset id="reset-password-data">
+              <div class="pure-control-group">
+                <label for="@{fieldEmail}">Email address</label>
+                <input class="pure-input-1-2" id="@{fieldEmail}" name="@{fieldEmail}" placeholder="some@@somewhere.org" maxlength="128" required="" type="email" value="@{formData.get(fieldEmail)}" autocomplete="email">
+                <small class="pure-form-message" id="reset-password-help">@Messages("form.reset-password.help")</small>
+                @renderFormErrors(fieldEmail, formErrors)
+              </div>
+              @csrfToken(csrf)
+              <div class="pure-controls">
+                <button type="submit" class="pure-button">@Messages("form.reset-password.button.submit")</button>
+              </div>
+            </fieldset>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/resetSent.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/resetSent.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/resetSent.scala.html	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/resetSent.scala.html	2025-01-15 18:42:00.397190349 +0000
@@ -0,0 +1,21 @@
+@import de.smederee.hub._
+@import de.smederee.hub.forms.types._
+@import de.smederee.hub.views.html.forms.renderFormErrors
+@import ResetPasswordForm._
+
+@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None)
+@main(baseUri, lang)()(csrf, title, user = None) {
+@defining(lang.toLocale) { implicit locale =>
+<div class="content">
+  <div class="pure-g">
+    <div class="pure-u-1-1 pure-u-md-1-1">
+      <div class="l-box">
+        <h1>Password reset email sent.</h1>
+        <p>An email with instructions how to reset your password has been sent.</p>
+      </div>
+    </div>
+  </div>
+</div>
+}
+}
+
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala	2025-01-15 18:42:00.397190349 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/BaseSpec.scala	2025-01-15 18:42:00.397190349 +0000
@@ -21,6 +21,7 @@
 import java.net.ServerSocket
 import java.nio.file._
 import java.nio.file.attribute.BasicFileAttributes
+import java.time.OffsetDateTime
 
 import cats.effect._
 import cats.syntax.all._
@@ -279,6 +280,74 @@
       } yield account
     }
 
+  /** Load the password hash for the account with the given unique user id.
+    *
+    * @param uid
+    *   The unique identifier for the account.
+    * @return
+    *   An option to the password hash if the user account exists.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  @SuppressWarnings(Array("DisableSyntax.null"))
+  protected def loadPasswordHash(uid: UserId): IO[Option[PasswordHash]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement("""SELECT password FROM "hub"."accounts" WHERE uid = ? LIMIT 1""")
+        )
+        _      <- IO.delay(statement.setObject(1, uid))
+        result <- IO.delay(statement.executeQuery)
+        password <- IO.delay {
+          if (result.next()) {
+            Option(result.getString("password")).flatMap(PasswordHash.from)
+          } else {
+            None
+          }
+        }
+        _ <- IO.delay(statement.close())
+      } yield password
+    }
+
+  /** Load the password reset related columns for the account with the given unique user id.
+    *
+    * @param uid
+    *   The unique identifier for the account.
+    * @return
+    *   An option of the columns if it exists.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  @SuppressWarnings(Array("DisableSyntax.null"))
+  protected def loadResetColumns(uid: UserId): IO[Option[(Option[OffsetDateTime], Option[ResetToken])]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement(
+            """SELECT reset_expiry, reset_token FROM "hub"."accounts" WHERE uid = ? LIMIT 1"""
+          )
+        )
+        _      <- IO.delay(statement.setObject(1, uid))
+        result <- IO.delay(statement.executeQuery)
+        columns <- IO.delay {
+          if (result.next()) {
+            val expiry =
+              if (result.getString("reset_expiry") =!= null)
+                Option(result.getObject("reset_expiry", classOf[OffsetDateTime]))
+              else
+                None
+            val token =
+              if (result.getString("reset_token") =!= null)
+                ResetToken.from(result.getString("reset_token"))
+              else
+                None
+            Option(expiry, token)
+          } else {
+            None
+          }
+        }
+        _ <- IO.delay(statement.close())
+      } yield columns
+    }
+
   /** Load the validation related columns for the account with the given unique user id.
     *
     * @param uid
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala	2025-01-15 18:42:00.397190349 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieAuthenticationRepositoryTest.scala	2025-01-15 18:42:00.397190349 +0000
@@ -118,6 +118,34 @@
     }
   }
 
+  test("deleteAllUserSessions must delete all sessions of the user".tag(NeedsDatabase)) {
+    (genValidSessions.sample, genValidAccount.sample) match {
+      case (Some(generatedSessions), Some(account)) =>
+        val sessions = generatedSessions.map(_.copy(uid = account.uid))
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieAuthenticationRepository[IO](tx)
+        val test = for {
+          _             <- createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          _             <- sessions.traverse(createUserSession)
+          deleted       <- repo.deleteAllUserSessions(account.uid)
+          foundSessions <- sessions.traverse(s => repo.findUserSession(s.id))
+        } yield (deleted, foundSessions)
+        test.map { result =>
+          val (deleted, foundSessions) = result
+          assertEquals(deleted, sessions.size, "Number of deleted sessions differs from number of sessions!")
+          assert(foundSessions.flatten.isEmpty, "Not all sessions were deleted!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
   test("deleteUserSession must delete the session".tag(NeedsDatabase)) {
     (genValidSession.sample, genValidAccount.sample) match {
       case (Some(s), Some(account)) =>
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieResetPasswordRepositoryTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieResetPasswordRepositoryTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieResetPasswordRepositoryTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/DoobieResetPasswordRepositoryTest.scala	2025-01-15 18:42:00.401190356 +0000
@@ -0,0 +1,290 @@
+/*
+ * Copyright (C) 2022  Contributors as noted in the AUTHORS.md file
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.smederee.hub
+
+import java.nio.charset.StandardCharsets
+import java.time.{ OffsetDateTime, ZoneOffset }
+
+import cats.effect._
+import cats.syntax.all._
+import de.smederee.TestTags._
+import de.smederee.hub.Generators._
+import de.smederee.security._
+import doobie._
+import org.flywaydb.core.Flyway
+
+final class DoobieResetPasswordRepositoryTest extends BaseSpec {
+  override def beforeEach(context: BeforeEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+    val _ = flyway.migrate()
+  }
+
+  override def afterEach(context: AfterEach): Unit = {
+    val dbConfig = configuration.database
+    val flyway: Flyway =
+      DatabaseMigrator.configureFlyway(dbConfig.url, dbConfig.user, dbConfig.pass).cleanDisabled(false).load()
+    val _ = flyway.migrate()
+    val _ = flyway.clean()
+  }
+
+  test("findByNameAndResetPasswordToken must find a matching account".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).plusDays(3L)
+        val expected        = user.copy(language = None)
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          _      <- repo.removeResetPasswordExpirationDate(user.uid)
+          result <- repo.findByNameAndResetPasswordToken(user.name, token)
+        } yield result
+        test.map { result =>
+          assertEquals(result, Option(expected))
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findByNameAndResetPasswordToken must not respect tokens with expiration date".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).plusDays(3L)
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          result <- repo.findByNameAndResetPasswordToken(user.name, token)
+        } yield result
+        test.map { result =>
+          assertEquals(result, None)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findByResetPasswordToken must find a matching account".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).plusDays(3L)
+        val expected        = user.copy(language = None)
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          result <- repo.findByResetPasswordToken(token)
+        } yield result
+        test.map { result =>
+          assertEquals(result, Option(expected))
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findByResetPasswordToken must not return accounts with expired tokens".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).minusDays(3L)
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          result <- repo.findByResetPasswordToken(token)
+        } yield result
+        test.map { result =>
+          assertEquals(result, None)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("findByResetPasswordToken must not return accounts without expiration date".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).minusDays(3L)
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          _      <- repo.removeResetPasswordExpirationDate(user.uid)
+          result <- repo.findByResetPasswordToken(token)
+        } yield result
+        test.map { result =>
+          assertEquals(result, None)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("removeResetPasswordExpirationDate must remove the expiration date".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).plusDays(3L).withNano(0) // Nanos are troublesome!
+        val expected        = ((None, token.some)).some
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          _      <- repo.removeResetPasswordExpirationDate(user.uid)
+          result <- loadResetColumns(user.uid)
+        } yield result
+        test.map { result =>
+          assertEquals(result, expected)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("removeResetPasswordToken must remove the token and the expiration date".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).plusDays(3L).withNano(0) // Nanos are troublesome!
+        val expected        = ((None, None)).some
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          _      <- repo.removeResetPasswordToken(user.uid)
+          result <- loadResetColumns(user.uid)
+        } yield result
+        test.map { result =>
+          assertEquals(result, expected)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("setPassword must set the password correctly".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val expected = Password("This is not the password you're looking for!".getBytes(StandardCharsets.UTF_8)).encode
+        val dbConfig = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setPassword(user.uid)(expected)
+          result <- loadPasswordHash(user.uid)
+        } yield result
+        test.map { result =>
+          assertEquals(result, expected.some)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
+  test("setResetPasswordToken must set token and expiry date correctly".tag(NeedsDatabase)) {
+    genValidAccount.sample match {
+      case Some(user) =>
+        val token           = ResetToken.generate
+        val tokenExpiration = OffsetDateTime.now(ZoneOffset.UTC).plusDays(3L).withNano(0) // Nanos are troublesome!
+        val expected        = ((tokenExpiration.some, token.some)).some
+        val dbConfig        = configuration.database
+        val tx = Transactor.fromDriverManager[IO](
+          driver = dbConfig.driver,
+          url = dbConfig.url,
+          user = dbConfig.user,
+          password = dbConfig.pass,
+          logHandler = None
+        )
+        val repo = new DoobieResetPasswordRepository[IO](tx)
+        val test = for {
+          _      <- createAccount(user, PasswordHash("I am not a password hash!"), None, None)
+          _      <- repo.setResetPasswordToken(user.uid)(token, tokenExpiration)
+          result <- loadResetColumns(user.uid)
+        } yield result
+        test.map { result =>
+          assertEquals(result, expected)
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-15 18:42:00.397190349 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala	2025-01-15 18:42:00.401190356 +0000
@@ -138,6 +138,8 @@
 
   given Arbitrary[Session] = Arbitrary(genValidSession)
 
+  val genValidSessions: Gen[List[Session]] = Gen.nonEmptyListOf(genValidSession)
+
   val genValidVcsRepositoryName: Gen[VcsRepositoryName] = Gen
     .nonEmptyListOf(
       Gen.oneOf(
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala	2025-01-15 18:42:00.397190349 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala	2025-01-15 18:42:00.401190356 +0000
@@ -71,6 +71,8 @@
     Sync[F].pure(1)
   }
 
+  override def deleteAllUserSessions(uid: UserId): F[Int] = Sync[F].pure(1)
+
   override def createUserSession(session: Session): F[Int] = Sync[F].pure(1)
 
   override def incrementFailedAttempts(uid: UserId): F[Int] = {