~jan0sch/smederee
Showing details for patch 9ebc1e945f81b8bf66d04108aba5bf7cd69e0bd4.
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] = {