~jan0sch/smederee
Showing details for patch da3551a633de7666cd3f74a1dca87c2873e6fa32.
diff -rN -u old-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala new-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala --- old-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala 2025-02-02 03:52:49.201589311 +0000 +++ new-smederee/modules/email/src/main/scala/de/smederee/email/SimpleJavaMailMiddleware.scala 2025-02-02 03:52:49.205589316 +0000 @@ -60,6 +60,7 @@ mailerBuilder } } yield builder.async().buildMailer() + override def send(email: EmailMessage): IO[Either[String, Unit]] = { val sending = for { mailer <- cachedMailer diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 2025-02-02 03:52:49.201589311 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala 2025-02-02 03:52:49.205589316 +0000 @@ -120,6 +120,8 @@ * An optional unlock token to be stored. * @param attempts * Optional number of failed login attempts. + * @param validationToken + * An optional validation token to be stored. * @return * The number of affected database rows. */ @@ -127,20 +129,29 @@ protected def createAccount( account: Account, hash: PasswordHash, - unlockToken: Option[UnlockToken], - attempts: Option[Int] + unlockToken: Option[UnlockToken] = None, + attempts: Option[Int] = None, + validationToken: Option[ValidationToken] = None ): IO[Int] = connectToDb(configuration).use { con => for { statement <- IO.delay { - unlockToken match { - case None => + (unlockToken, validationToken) match { + case (None, None) => con.prepareStatement( - """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, verified_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)""" + """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, created_at, updated_at, validated_email) VALUES(?, ?, ?, ?, ?, NOW(), NOW(), ?)""" ) - case Some(_) => + case (Some(_), None) => con.prepareStatement( - """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, verified_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" + """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" + ) + case (None, Some(_)) => + con.prepareStatement( + """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" + ) + case (Some(_), Some(_)) => + con.prepareStatement( + """INSERT INTO "accounts" (uid, name, email, password, failed_attempts, validated_email, locked_at, unlock_token, validation_token, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), NOW())""" ) } } @@ -149,7 +160,17 @@ _ <- IO.delay(statement.setString(3, account.email.toString)) _ <- IO.delay(statement.setString(4, hash.toString)) _ <- IO.delay(statement.setInt(5, attempts.getOrElse(1))) - _ <- IO.delay(statement.setBoolean(6, account.verifiedEmail)) + _ <- IO.delay(statement.setBoolean(6, account.validatedEmail)) + _ <- (unlockToken, validationToken) match { + case (None, None) => IO.unit + case (Some(ut), None) => IO.delay(statement.setString(7, ut.toString)) + case (None, Some(vt)) => IO.delay(statement.setString(7, vt.toString)) + case (Some(ut), Some(vt)) => + IO.delay { + statement.setString(7, ut.toString) + statement.setString(8, vt.toString) + } + } _ <- IO.delay { unlockToken.foreach { token => statement.setString(7, token.toString) @@ -202,7 +223,7 @@ for { statement <- IO.delay( con.prepareStatement( - """SELECT uid, name, email, password, created_at, updated_at, verified_email FROM "accounts" WHERE uid = ? LIMIT 1""" + """SELECT uid, name, email, password, created_at, updated_at, validated_email FROM "accounts" WHERE uid = ? LIMIT 1""" ) ) _ <- IO.delay(statement.setObject(1, uid)) @@ -214,7 +235,7 @@ uid = uid, name = Username(result.getString("name")), email = Email(result.getString("email")), - verifiedEmail = result.getBoolean("verified_email") + validatedEmail = result.getBoolean("validated_email") ) ) } else { @@ -225,6 +246,41 @@ } yield account } + /** Load the validation 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!") + protected def loadValidationColumns(uid: UserId): IO[Option[(Boolean, Option[ValidationToken])]] = + connectToDb(configuration).use { con => + for { + statement <- IO.delay( + con.prepareStatement( + """SELECT validated_email, validation_token FROM "accounts" WHERE uid = ? LIMIT 1""" + ) + ) + _ <- IO.delay(statement.setObject(1, uid)) + result <- IO.delay(statement.executeQuery) + columns <- IO.delay { + if (result.next()) { + Option( + ( + result.getBoolean("validated_email"), + ValidationToken + .from(result.getString("validation_token")) + ) + ) + } else { + None + } + } + _ <- IO.delay(statement.close()) + } yield columns + } + /** Delete the given directory recursively. * * @param path diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-02-02 03:52:49.201589311 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-02-02 03:52:49.205589316 +0000 @@ -64,6 +64,24 @@ } } + test("findByValidationToken must return the matching account") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(ac) => + val account = ac.copy(validatedEmail = true) + val token = ValidationToken.generate + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, validationToken = token.some) + o <- repo.findByValidationToken(token) + } yield o + test.map(result => assert(result === Some(account))) + } + } + test("findPasswordHash must return correct hash") { genValidAccount.sample match { case None => fail("Could not generate data samples!") @@ -80,4 +98,45 @@ test.map(result => assert(result.exists(_ === hash), "Unexpected result from database!")) } } + + test("markAsValidated must clear the validation token and set the validated column to true") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(ac) => + val account = ac.copy(validatedEmail = true) + val token = ValidationToken.generate + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, validationToken = token.some) + _ <- repo.markAsValidated(account.uid) + cols <- loadValidationColumns(account.uid) + } yield cols + test.map { result => + assert(result === Some((true, None)), "Unexpected result from database!") + } + } + } + + test("setValidationToken must set the validation token") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val token = ValidationToken.generate + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash) + _ <- repo.setValidationToken(account.uid, token) + cols <- loadValidationColumns(account.uid) + } yield cols + test.map { result => + assert(result === Some((account.validatedEmail, Some(token))), "Unexpected result from database!") + } + } + } } diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-02-02 03:52:49.201589311 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala 2025-02-02 03:52:49.205589316 +0000 @@ -212,7 +212,7 @@ repo.owner.uid, repo.owner.name, Email(s"${repo.owner.name}@example.com"), - verifiedEmail = true + validatedEmail = true ) ) val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) @@ -249,7 +249,7 @@ repo.owner.uid, repo.owner.name, Email(s"${repo.owner.name}@example.com"), - verifiedEmail = true + validatedEmail = true ) ) val expectedRepoList = vcsRepositories.filter(_.isPrivate === false) @@ -289,7 +289,7 @@ repo.owner.uid, repo.owner.name, Email(s"${repo.owner.name}@example.com"), - verifiedEmail = true + validatedEmail = true ) ) val expectedRepoList = vcsRepositories diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala --- old-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala 2025-02-02 03:52:49.201589311 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/Generators.scala 2025-02-02 03:52:49.205589316 +0000 @@ -96,11 +96,11 @@ } yield Username(prefix.toString + chars) val genValidAccount: Gen[Account] = for { - id <- genUserId - email <- genValidEmail - name <- genValidUsername - verifiedEmail <- Gen.oneOf(List(false, true)) - } yield Account(uid = id, name = name, email = email, verifiedEmail = verifiedEmail) + id <- genUserId + email <- genValidEmail + name <- genValidUsername + validatedEmail <- Gen.oneOf(List(false, true)) + } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail) given Arbitrary[Account] = Arbitrary(genValidAccount) diff -rN -u old-smederee/modules/hub/src/main/resources/assets/css/main.css new-smederee/modules/hub/src/main/resources/assets/css/main.css --- old-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-02-02 03:52:49.201589311 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-02-02 03:52:49.209589321 +0000 @@ -8,6 +8,11 @@ padding: 0px 10px; } +.validate-email-form { + border: 1px solid black; + padding: 0px 10px; +} + .pure-button { background-color: #1f8dd6; color: white; @@ -82,6 +87,10 @@ background: rgb(202, 60, 60); } +.alert-info { + background: #1f8dd6; +} + .alert-secondary { background: rgb(66, 184, 221); } diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql --- old-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql 2025-02-02 03:52:49.209589321 +0000 @@ -1,18 +1,18 @@ CREATE TABLE "accounts" ( - "uid" UUID NOT NULL, - "name" CHARACTER VARYING(32) NOT NULL, - "email" CHARACTER VARYING(128) NOT NULL, - "password" TEXT, - "failed_attempts" INTEGER DEFAULT 0, - "locked_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL, - "unlock_token" TEXT DEFAULT NULL, - "reset_expiry" TIMESTAMP WITH TIME ZONE DEFAULT NULL, - "reset_token" TEXT DEFAULT NULL, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "verified_email" BOOLEAN DEFAULT FALSE, - "verify_token" TEXT DEFAULT NULL, + "uid" UUID NOT NULL, + "name" CHARACTER VARYING(32) NOT NULL, + "email" CHARACTER VARYING(128) NOT NULL, + "password" TEXT, + "failed_attempts" INTEGER DEFAULT 0, + "locked_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL, + "unlock_token" TEXT DEFAULT NULL, + "reset_expiry" TIMESTAMP WITH TIME ZONE DEFAULT NULL, + "reset_token" TEXT DEFAULT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "validated_email" BOOLEAN DEFAULT FALSE, + "validation_token" TEXT DEFAULT NULL, CONSTRAINT "accounts_pk" PRIMARY KEY ("uid"), CONSTRAINT "accounts_unique_name" UNIQUE ("name"), CONSTRAINT "accounts_unique_email" UNIQUE ("email") @@ -33,8 +33,8 @@ COMMENT ON COLUMN "accounts"."reset_token" IS 'A token which can be used for a password reset.'; COMMENT ON COLUMN "accounts"."created_at" IS 'The timestamp of when the account was created.'; COMMENT ON COLUMN "accounts"."updated_at" IS 'A timestamp when the account was last changed.'; -COMMENT ON COLUMN "accounts"."verified_email" IS 'This flag indicates if the email address of the user has been verified via a verification email.'; -COMMENT ON COLUMN "accounts"."verify_token" IS 'A token used to verify the email address of the user.'; +COMMENT ON COLUMN "accounts"."validated_email" IS 'This flag indicates if the email address of the user has been validated via a validation email.'; +COMMENT ON COLUMN "accounts"."validation_token" IS 'A token used to validate the email address of the user.'; CREATE TABLE "sessions" ( diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties --- old-smederee/modules/hub/src/main/resources/messages_en.properties 2025-02-02 03:52:49.201589311 +0000 +++ new-smederee/modules/hub/src/main/resources/messages_en.properties 2025-02-02 03:52:49.205589316 +0000 @@ -10,7 +10,7 @@ # 2. Grouping continues downward if it makes sense, e.g. # error.forbidden.title, error.forbidden.message. # -errors.account.not-verified=Sorry, but your account has not been verified and is therefore not allowed to perform the desired action. Please verify your account. +errors.account.not-validated=Sorry, but your account has not been validated and is therefore not allowed to perform the desired action. Please validate your account. errors.forbidden.title=403 - Forbidden # Forms @@ -18,6 +18,8 @@ form.account.delete.i-am-sure=Yes, I am sure that I want to delete my account and all related data! form.account.delete.notice=If you delete your account then all related data will be permanently removed. This action CANNOT be undone! form.account.delete.password=Password +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.create-repo.button.submit=Create repository form.create-repo.name=Name form.create-repo.name.placeholder=Please enter a repository name. @@ -117,7 +119,7 @@ landingpage.terms-of-use.title=Terms of Use landingpage.welcome.image.alt=A neon sign saying: Do something great! -landingpage.welcome.ribbon.text=Welcome to the smederee! You can now use your credentials to login and start creating. Please note that some functionalities are locked until you have verified your email address. +landingpage.welcome.ribbon.text=Welcome to the smederee! You can now use your credentials to login and start creating. Please note that some functionalities are locked until you have validated your email address. landingpage.welcome.ribbon.title=Thank you and welcome! landingpage.welcome.title=Welcome to the Smederee! @@ -155,4 +157,5 @@ # User management / settings user.settings.account.delete.title=Delete your account +user.settings.account.validate-email.title=Validate your email address diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala 2025-02-02 03:52:49.209589321 +0000 @@ -36,6 +36,15 @@ */ def deleteAccount(uid: UserId): F[Int] + /** Find the account with the given validation token. + * + * @param token + * A validation token. + * @return + * An option to the account if it exists. + */ + def findByValidationToken(token: ValidationToken): F[Option[Account]] + /** Retrieve the password hash from the database. * * @param uid @@ -45,4 +54,30 @@ */ def findPasswordHash(uid: UserId): F[Option[PasswordHash]] + /** Mark the account with the given user id as validated. This includes the following operations in the + * database: + * + * {{{ + * 1. set the ´validated_email` column to `true` + * 2. clear the `validation_token` column + * }}} + * + * @param uid + * The unique id of the user account. + * @return + * The number of affected database rows. + */ + def markAsValidated(uid: UserId): F[Int] + + /** Set the validation token for the account with the given user id. + * + * @param uid + * The unique id of the user account. + * @param token + * The validation token to be stored in the database. + * @return + * The number of affected database rows. + */ + def setValidationToken(uid: UserId, token: ValidationToken): F[Int] + } diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-02-02 03:52:49.209589321 +0000 @@ -21,6 +21,7 @@ import cats.data._ import cats.effect._ import cats.syntax.all._ +import de.smederee.email._ import de.smederee.html.LinkTools._ import de.smederee.hub.RequestHelpers.instances.given import de.smederee.hub.config._ @@ -39,6 +40,8 @@ * The repository providing all needed database functionality for account management. * @param configuration * The hub service configuration. + * @param emailMiddleware + * Middleware layer needed to send emails. * @param signAndValidate * A class providing functions to handle session token signing and validation. * @tparam F @@ -47,6 +50,7 @@ final class AccountManagementRoutes[F[_]: Async]( accountManagementRepo: AccountManagementRepository[F], configuration: ServiceConfig, + emailMiddleware: EmailMiddleware[F], signAndValidate: SignAndValidate ) extends Http4sDsl[F] { private val log = LoggerFactory.getLogger(getClass) @@ -69,7 +73,10 @@ userIsSure <- Sync[F].delay(formData.get("i-am-sure").exists(_ === "yes")) rootUri <- Sync[F].delay(configuration.external.createFullUri(uri"/")) deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete")) - passwordHash <- accountManagementRepo.findPasswordHash(user.uid) + validateAction <- Sync[F].delay( + configuration.external.createFullUri(uri"user/settings/email/validate") + ) + passwordHash <- accountManagementRepo.findPasswordHash(user.uid) passwordCorrect <- Sync[F].delay( (passwordField, passwordHash) .mapN { case (enteredPassword, hashFromDatabase) => @@ -92,26 +99,67 @@ } else BadRequest( views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)( - deleteAction + deleteAction, + validateAction ) ) } yield response } } + private val sendValidationEmail: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / "user" / "settings" / "email" / "validate" as user => + for { + csrf <- Sync[F].delay(ar.req.getCsrfToken) + token <- Sync[F].delay(ValidationToken.generate) + _ <- accountManagementRepo.setValidationToken(user.uid, token) + from <- Sync[F].delay(FromAddress("noreply@smeder.ee")) + to <- Sync[F].delay(NonEmptyList.of(user.email.toToAddress)) + uri <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/email/validate")) + subject <- Sync[F].delay(SubjectLine("Smederee - Please validate your email address.")) + body <- Sync[F].delay( + TextBody(views.txt.emails.validate(user, token, uri).toString) + ) // TODO extension method? + message <- Sync[F].delay(EmailMessage(from, to, List.empty, List.empty, subject, body)) + result <- emailMiddleware.send(message) + response <- result match { + case Left(error) => InternalServerError(s"An error occured: $error") + case Right(_) => SeeOther(Location(configuration.external.createFullUri(uri"user/settings"))) + } + } yield response + } + private val showAccountSettings: AuthedRoutes[Account, F] = AuthedRoutes.of { case ar @ GET -> Root / "user" / "settings" as user => for { csrf <- Sync[F].delay(ar.req.getCsrfToken) deleteAction <- Sync[F].delay(configuration.external.createFullUri(uri"user/settings/delete")) + validateAction <- Sync[F].delay( + configuration.external.createFullUri(uri"user/settings/email/validate") + ) resp <- Ok( views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)( - deleteAction + deleteAction, + validateAction ) ) } yield resp } - val protectedRoutes = deleteAccount <+> showAccountSettings + private val validateEmailAddress: HttpRoutes[F] = HttpRoutes.of { + case req @ GET -> Root / "user" / "settings" / "email" / "validate" / ValidationTokenPathParameter( + token + ) => + for { + csrf <- Sync[F].delay(req.getCsrfToken) + account <- accountManagementRepo.findByValidationToken(token) + _ <- account.traverse(user => accountManagementRepo.markAsValidated(user.uid)) + resp <- SeeOther(Location(configuration.external.createFullUri(uri"/"))) + } yield resp + } + + val protectedRoutes = deleteAccount <+> sendValidationEmail <+> showAccountSettings + + val routes = validateEmailAddress } 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-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala 2025-02-02 03:52:49.209589321 +0000 @@ -209,7 +209,8 @@ opaque type ResetToken = String object ResetToken { - val Length: Int = 128 + val Format: Regex = "^[a-zA-z0-9]+".r + val Length: Int = 128 given Eq[ResetToken] = Eq.fromUniversalEquals @@ -230,10 +231,7 @@ * An option to the successfully converted ResetToken. */ def from(source: String): Option[ResetToken] = - Option(source).map(_.size === 128) match { - case Some(true) => Option(source) - case _ => None - } + Option(source).filter(s => s.length === Length && Format.matches(s)) /** Generate a new reset token. * @@ -246,7 +244,8 @@ opaque type UnlockToken = String object UnlockToken { - val Length: Int = 128 + val Format: Regex = "^[a-zA-z0-9]+".r + val Length: Int = 128 given Eq[UnlockToken] = Eq.fromUniversalEquals @@ -267,10 +266,7 @@ * An option to the successfully converted UnlockToken. */ def from(source: String): Option[UnlockToken] = - Option(source).map(_.size === 128) match { - case Some(true) => Option(source) - case _ => None - } + Option(source).filter(s => s.length === Length && Format.matches(s)) /** Generate a new unlock token. * @@ -350,6 +346,47 @@ } } +opaque type ValidationToken = String +object ValidationToken { + val Format: Regex = "^[a-zA-z0-9]+".r + val Length: Int = 64 + + given Eq[ValidationToken] = Eq.fromUniversalEquals + + /** Create an instance of ValidationToken from the given String type. + * + * @param source + * An instance of type String which will be returned as a ValidationToken. + * @return + * The appropriate instance of ValidationToken. + */ + def apply(source: String): ValidationToken = source + + /** Try to create an instance of ValidationToken from the given String. + * + * @param source + * A String that should fulfil the requirements to be converted into a ValidationToken. + * @return + * An option to the successfully converted ValidationToken. + */ + def from(source: String): Option[ValidationToken] = + Option(source).filter(s => s.length === Length && Format.matches(s)) + + /** Generate a new unlock token. + * + * @return + * A randomly generated unlock token. + */ + def generate: ValidationToken = scala.util.Random.alphanumeric.take(Length).mkString + +} + +/** Extractor to retrieve a validation token from a path parameter. + */ +object ValidationTokenPathParameter { + def unapply(str: String): Option[ValidationToken] = ValidationToken.from(str) +} + /** A user account. * * @param uid @@ -358,10 +395,10 @@ * A unique name which can be used for login and to identify the user. * @param email * The email address of the user. - * @param verifiedEmail - * This flag indicates if the email address of the user has been verified via a verification email. + * @param validatedEmail + * This flag indicates if the email address of the user has been validated via a validation email. */ -final case class Account(uid: UserId, name: Username, email: Email, verifiedEmail: Boolean) +final case class Account(uid: UserId, name: Username, email: Email, validatedEmail: Boolean) object Account { given Eq[Account] = diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala 2025-02-02 03:52:49.209589321 +0000 @@ -31,12 +31,30 @@ final class DoobieAccountManagementRepository[F[_]: Sync](tx: Transactor[F]) extends AccountManagementRepository[F] { - given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString) - given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) + given Meta[Email] = Meta[String].timap(Email.apply)(_.toString) + given Meta[PasswordHash] = Meta[String].timap(PasswordHash.apply)(_.toString) + given Meta[UserId] = Meta[UUID].timap(UserId.apply)(_.toUUID) + given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) + given Meta[ValidationToken] = Meta[String].timap(ValidationToken.apply)(_.toString) + + private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts"""" override def deleteAccount(uid: UserId): F[Int] = sql"""DELETE FROM "accounts" WHERE uid = $uid""".update.run.transact(tx) + override def findByValidationToken(token: ValidationToken): F[Option[Account]] = { + val query = selectAccountColumns ++ fr"""WHERE validation_token = $token""" ++ fr"""LIMIT 1""" + query.query[Account].option.transact(tx) + } + override def findPasswordHash(uid: UserId): F[Option[PasswordHash]] = sql"""SELECT password FROM "accounts" WHERE uid = $uid LIMIT 1""".query[PasswordHash].option.transact(tx) + + override def markAsValidated(uid: UserId): F[Int] = + sql"""UPDATE "accounts" SET validated_email = TRUE, validation_token = NULL WHERE uid = $uid""".update.run + .transact(tx) + + override def setValidationToken(uid: UserId, token: ValidationToken): F[Int] = + sql"""UPDATE "accounts" SET validation_token = $token WHERE uid = $uid""".update.run.transact(tx) + } 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-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAuthenticationRepository.scala 2025-02-02 03:52:49.209589321 +0000 @@ -36,7 +36,7 @@ private val lockedFilter = fr"""locked_at IS NOT NULL""" private val notLockedFilter = fr"""locked_at IS NULL""" - private val selectAccountColumns = fr"""SELECT uid, name, email, verified_email FROM "accounts"""" + private val selectAccountColumns = fr"""SELECT uid, name, email, validated_email FROM "accounts"""" override def createUserSession(session: Session): F[Int] = sql"""INSERT INTO "sessions" (id, uid, created_at, updated_at) VALUES (${session.id}, ${session.uid}, ${session.createdAt}, ${session.updatedAt})""".update.run diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieSignupRepository.scala 2025-02-02 03:52:49.209589321 +0000 @@ -32,7 +32,7 @@ given Meta[Username] = Meta[String].timap(Username.apply)(_.toString) override def createAccount(account: Account, hash: PasswordHash): F[Int] = - sql"""INSERT INTO "accounts" (uid, name, email, password, created_at, updated_at, verified_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.verifiedEmail})""".update.run + sql"""INSERT INTO "accounts" (uid, name, email, password, created_at, updated_at, validated_email) VALUES(${account.uid}, ${account.name}, ${account.email}, $hash, NOW(), NOW(), ${account.validatedEmail})""".update.run .transact(tx) override def findEmail(address: Email): F[Option[Email]] = 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-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-02-02 03:52:49.209589321 +0000 @@ -108,6 +108,7 @@ accountManagementRoutes = new AccountManagementRoutes[IO]( accountManagementRepo, configuration.service, + emailMiddleware, signAndValidate ) authenticationRoutes = new AuthenticationRoutes[IO]( @@ -130,11 +131,20 @@ vcsMetadataRepo ) protectedRoutesWithFallThrough = authenticationWithFallThrough( - authenticationRoutes.protectedRoutes <+> accountManagementRoutes.protectedRoutes <+> signUpRoutes.protectedRoutes <+> vcsRepoRoutes.protectedRoutes <+> landingPages.protectedRoutes + authenticationRoutes.protectedRoutes <+> + accountManagementRoutes.protectedRoutes <+> + signUpRoutes.protectedRoutes <+> + vcsRepoRoutes.protectedRoutes <+> + landingPages.protectedRoutes ) globalRoutes = Router( Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes, - "/" -> (protectedRoutesWithFallThrough <+> authenticationRoutes.routes <+> signUpRoutes.routes <+> vcsRepoRoutes.routes <+> landingPages.routes) + "/" -> (protectedRoutesWithFallThrough <+> + authenticationRoutes.routes <+> + accountManagementRoutes.routes <+> + signUpRoutes.routes <+> + vcsRepoRoutes.routes <+> + landingPages.routes) ).orNotFound // Create our ssh server fiber (or a dummy one if disabled). sshServerProvider = configuration.service.ssh.enabled match { diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRoutes.scala 2025-02-02 03:52:49.209589321 +0000 @@ -120,7 +120,7 @@ uid = uid, name = signupForm.name, email = signupForm.email, - verifiedEmail = false + validatedEmail = false ) ) hash <- Sync[F].delay(signupForm.password.encode) diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala 2025-02-02 03:52:49.209589321 +0000 @@ -702,9 +702,9 @@ ar.req.decodeStrict[F, UrlForm] { urlForm => for { csrf <- Sync[F].delay(ar.req.getCsrfToken) - _ <- Sync[F].raiseUnless(user.verifiedEmail)( + _ <- Sync[F].raiseUnless(user.validatedEmail)( new Error( - "An unverified account is not allowed to create a repository!" + "An unvalidated account is not allowed to create a repository!" ) // FIXME Proper error handling! ) formData <- Sync[F].delay { @@ -811,10 +811,10 @@ case ar @ GET -> Root / "repo" / "create" as user => for { csrf <- Sync[F].delay(ar.req.getCsrfToken) - resp <- user.verifiedEmail match { + resp <- user.validatedEmail match { case false => Forbidden( - views.html.errors.unverifiedAccount()(csrf, "Smederee - Account not verified!".some, user) + views.html.errors.unvalidatedAccount()(csrf, "Smederee - Account not validated!".some, user) ) case true => Ok( diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html 2025-02-02 03:52:49.209589321 +0000 @@ -1,8 +1,27 @@ -@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri) +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri, validateAction: Uri) @main(baseUri, lang)()(csrf, title, user.some) { @defining(lang.toLocale) { implicit locale => <div class="content"> <div class="pure-g"> + @if(user.validatedEmail) { + } else { + <div class="pure-u-1-1 pure-u-md-1-1"> + <div class="l-box"> + <div class="validate-email-form"> + <h4>@Messages("user.settings.account.validate-email.title")</h4> + <form action="@validateAction" class="pure-form" method="POST" accept-charset="UTF-8"> + <fieldset class="pure-group"> + <p class="alert alert-info"> + @Messages("form.account.validate-email.notice") + </p> + @csrfToken(csrf) + <button type="submit" class="button-success pure-button">@Messages("form.account.validate-email.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + } <div class="pure-u-1-1 pure-u-md-1-1"> <div class="l-box"> <div class="account-delete-form"> diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/emails/validate.scala.txt 2025-02-02 03:52:49.209589321 +0000 @@ -0,0 +1,18 @@ +@(user: Account, validationToken: ValidationToken, validationBaseUri: Uri) +Hello @{user.name}, + +you have registered an account at the Smederee (https://smeder.ee) and to +have fully operational access we have to validate your email address. + +Please click on the following link to finish the validation process: + +@validationBaseUri.addSegment(validationToken.toString) + +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/errors/unvalidatedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unvalidatedAccount.scala.html 2025-02-02 03:52:49.209589321 +0000 @@ -0,0 +1,18 @@ +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account) +@main(baseUri, lang)()(csrf, title, user.some) { +@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"> + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + <span class="sr-only">@Messages("global.error"):</span> + @Messages("errors.account.not-validated") + </p> + </div> + </div> + </div> + </div> +} +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html 2025-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/errors/unverifiedAccount.scala.html 1970-01-01 00:00:00.000000000 +0000 @@ -1,18 +0,0 @@ -@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account) -@main(baseUri, lang)()(csrf, title, user.some) { -@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"> - <p class="alert alert-error"> - <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> - <span class="sr-only">@Messages("global.error"):</span> - @Messages("errors.account.not-verified") - </p> - </div> - </div> - </div> - </div> -} -} 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-02-02 03:52:49.205589316 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/Generators.scala 2025-02-02 03:52:49.209589321 +0000 @@ -96,11 +96,11 @@ } yield Username(prefix.toString + chars) val genValidAccount: Gen[Account] = for { - id <- genUserId - email <- genValidEmail - name <- genValidUsername - verifiedEmail <- Gen.oneOf(List(false, true)) - } yield Account(uid = id, name = name, email = email, verifiedEmail = verifiedEmail) + id <- genUserId + email <- genValidEmail + name <- genValidUsername + validatedEmail <- Gen.oneOf(List(false, true)) + } yield Account(uid = id, name = name, email = email, validatedEmail = validatedEmail) given Arbitrary[Account] = Arbitrary(genValidAccount)