~jan0sch/smederee
Showing details for patch 19a937351174b10e6d59e5bc573a9d8dc8d84611.
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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieAccountManagementRepositoryTest.scala 2025-02-02 03:57:42.669960040 +0000 @@ -0,0 +1,83 @@ +/* + * 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.util.UUID + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.hub.Generators._ +import de.smederee.hub.config.SmedereeHubConfig +import doobie._ +import org.flywaydb.core.Flyway + +import munit._ + +final class DoobieAccountManagementRepositoryTest extends BaseSpec { + override def beforeEach(context: BeforeEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load() + val _ = flyway.migrate() + val _ = flyway.clean() + val _ = flyway.migrate() + } + + override def afterEach(context: AfterEach): Unit = { + val dbConfig = configuration.database + val flyway: Flyway = + Flyway.configure().cleanDisabled(false).dataSource(dbConfig.url, dbConfig.user, dbConfig.pass).load() + val _ = flyway.migrate() + val _ = flyway.clean() + } + + test("deleteAccount must remove the account from the database") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + _ <- repo.deleteAccount(account.uid) + o <- loadAccount(account.uid) + } yield o + test.map(result => assert(result === None, "Account not deleted from database!")) + } + } + + test("findPasswordHash must return correct hash") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val dbConfig = configuration.database + val tx = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass) + val repo = new DoobieAccountManagementRepository[IO](tx) + val attempts = scala.util.Random.nextInt(128) + val hash = PasswordHash("Yet another weak password!") + val test = for { + _ <- createAccount(account, hash, None, Option(attempts)) + o <- repo.findPasswordHash(account.uid) + } yield o + test.map(result => assert(result.exists(_ === hash), "Unexpected result from database!")) + } + } +} 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:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/resources/assets/css/main.css 2025-02-02 03:57:42.669960040 +0000 @@ -3,6 +3,11 @@ /*margin-right: 1em;*/ } +.account-delete-form { + border: 1px solid black; + padding: 0px 10px; +} + .pure-button { background-color: #1f8dd6; color: white; 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:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/resources/db/migration/V1__base_tables.sql 2025-02-02 03:57:42.673960045 +0000 @@ -12,6 +12,7 @@ "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, CONSTRAINT "accounts_pk" PRIMARY KEY ("uid"), CONSTRAINT "accounts_unique_name" UNIQUE ("name"), CONSTRAINT "accounts_unique_email" UNIQUE ("email") @@ -33,6 +34,7 @@ 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.'; 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:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/resources/messages_en.properties 2025-02-02 03:57:42.669960040 +0000 @@ -14,6 +14,10 @@ errors.forbidden.title=403 - Forbidden # Forms +form.account.delete.button.submit=Delete my account! +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.create-repo.button.submit=Create repository form.create-repo.name=Name form.create-repo.name.placeholder=Please enter a repository name. @@ -54,6 +58,7 @@ global.navbar.top.repositories.all=All repositories global.navbar.top.repositories.yours=Your repositories global.navbar.top.repository.new=New repository +global.navbar.top.settings=Settings global.privacy=Privacy Policy global.signup=Sign Up global.terms.of.use=Terms of Use @@ -148,3 +153,6 @@ repository.overview.latest-changes=Latest changes repository.overview.latest-changes.timestamp={0,date,yyyy-MM-dd (E)}, {0,time,short} +# User management / settings +user.settings.account.delete.title=Delete your account + 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRepository.scala 2025-02-02 03:57:42.673960045 +0000 @@ -0,0 +1,48 @@ +/* + * 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 + +/** The base class for database operations related to account management for users. + * + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +abstract class AccountManagementRepository[F[_]] { + + /** Delete the account with the given user id from the database. + * + * The internal database logic (foreign keys, cascading) SHALL ensure that everything related to the user + * is deleted from the database. + * + * @param uid + * The unique id of the user account. + * @return + * The number of affected database rows. + */ + def deleteAccount(uid: UserId): F[Int] + + /** Retrieve the password hash from the database. + * + * @param uid + * The unique id of the user account. + * @return + * An option to the password hash. + */ + def findPasswordHash(uid: UserId): F[Option[PasswordHash]] + +} 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AccountManagementRoutes.scala 2025-02-02 03:57:42.673960045 +0000 @@ -0,0 +1,117 @@ +/* + * 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._ +import cats.data._ +import cats.effect._ +import cats.syntax.all._ +import de.smederee.html.LinkTools._ +import de.smederee.hub.RequestHelpers.instances.given +import de.smederee.hub.config._ +import de.smederee.security.SignAndValidate +import org.http4s._ +import org.http4s.dsl._ +import org.http4s.headers.Location +import org.http4s.implicits._ +import org.http4s.twirl.TwirlInstances._ +import org.slf4j.LoggerFactory + +/** Routes for user account self management which should provide every functionality needed by users to manage + * their account. + * + * @param accountManagementRepo + * The repository providing all needed database functionality for account management. + * @param configuration + * The hub service configuration. + * @param signAndValidate + * A class providing functions to handle session token signing and validation. + * @tparam F + * A higher kinded type providing needed functionality, which is usually an IO monad like Async or Sync. + */ +final class AccountManagementRoutes[F[_]: Async]( + accountManagementRepo: AccountManagementRepository[F], + configuration: ServiceConfig, + signAndValidate: SignAndValidate +) extends Http4sDsl[F] { + private val log = LoggerFactory.getLogger(getClass) + + private val deleteAccount: AuthedRoutes[Account, F] = AuthedRoutes.of { + case ar @ POST -> Root / "user" / "settings" / "delete" as user => + ar.req.decodeStrict[F, UrlForm] { urlForm => + for { + csrf <- Sync[F].delay(ar.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)! + } + } + passwordField <- Sync[F].delay(formData.get("password").flatMap(Password.from)) + 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) + passwordCorrect <- Sync[F].delay( + (passwordField, passwordHash) + .mapN { case (enteredPassword, hashFromDatabase) => + enteredPassword.matches(hashFromDatabase) + } + .getOrElse(false) + ) + response <- + if (passwordCorrect && userIsSure) { + // Delete the user account and redirect the user ot the start page removing the authentication cookie in the process. + // FIXME Also delete the repositories on disk! + for { + _ <- Sync[F].delay( + log.info(s"Going to delete account ${user.name} as requested by the user.") + ) + response <- accountManagementRepo.deleteAccount(user.uid) *> SeeOther(Location(rootUri)).map( + _.removeCookie(Constants.authenticationCookieName.toString) + ) + } yield response + } else + BadRequest( + views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)( + deleteAction + ) + ) + } 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")) + resp <- Ok( + views.html.account.settings()(csrf, Option(s"Smederee/~${user.name} - Settings"), user)( + deleteAction + ) + ) + } yield resp + } + + val protectedRoutes = deleteAccount <+> showAccountSettings + +} 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-02-02 03:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRepository.scala 2025-02-02 03:57:42.673960045 +0000 @@ -28,8 +28,8 @@ * 3. Also the function `findPasswordHashAndAttempts` must only consider *unlocked* accounts! * }}} * - * @tparm - * F A higher kinded type which wraps the actual return values. + * @tparam F + * A higher kinded type which wraps the actual return values. */ abstract class AuthenticationRepository[F[_]] { 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieAccountManagementRepository.scala 2025-02-02 03:57:42.673960045 +0000 @@ -0,0 +1,42 @@ +/* + * 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.util.UUID + +import cats.effect._ +import cats.syntax.all._ +import de.smederee.hub.VcsMetadataRepositoriesOrdering._ +import doobie._ +import doobie.Fragments._ +import doobie.implicits._ +import doobie.postgres.implicits._ +import fs2.Stream +import org.http4s.Uri + +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) + + override def deleteAccount(uid: UserId): F[Int] = + sql"""DELETE FROM "accounts" WHERE uid = $uid""".update.run.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) +} 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:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-02-02 03:57:42.673960045 +0000 @@ -102,8 +102,14 @@ configuration.service.authentication.timeouts ) ) - darcsWrapper = new DarcsCommands[IO](configuration.service.darcs.executable) - emailMiddleware = new SimpleJavaMailMiddleware(configuration.service.email) + darcsWrapper = new DarcsCommands[IO](configuration.service.darcs.executable) + emailMiddleware = new SimpleJavaMailMiddleware(configuration.service.email) + accountManagementRepo = new DoobieAccountManagementRepository[IO](transactor) + accountManagementRoutes = new AccountManagementRoutes[IO]( + accountManagementRepo, + configuration.service, + signAndValidate + ) authenticationRoutes = new AuthenticationRoutes[IO]( cryptoClock, configuration.service.authentication, @@ -124,7 +130,7 @@ vcsMetadataRepo ) protectedRoutesWithFallThrough = authenticationWithFallThrough( - authenticationRoutes.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, diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala 2025-02-02 03:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/SignupRepository.scala 2025-02-02 03:57:42.673960045 +0000 @@ -19,8 +19,8 @@ /** A base class for our signup repository which provides the needed database functions. * - * @tparm - * F A higher kinded type which wraps the actual return values. + * @tparam F + * A higher kinded type which wraps the actual return values. */ abstract class SignupRepository[F[_]] { diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-02-02 03:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala 2025-02-02 03:57:42.673960045 +0000 @@ -22,8 +22,8 @@ /** A base class for a database repository that should handle all functionality regarding vcs repositories and * their metadata in the database. * - * @tparm - * F A higher kinded type which wraps the actual return values. + * @tparam F + * A higher kinded type which wraps the actual return values. */ abstract class VcsMetadataRepository[F[_]] { 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/account/settings.scala.html 2025-02-02 03:57:42.673960045 +0000 @@ -0,0 +1,31 @@ +@(baseUri: Uri = Uri(path = Uri.Path.Root), lang: LanguageCode = LanguageCode("en"))(csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Account)(deleteAction: Uri) +@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"> + <div class="account-delete-form"> + <h4>@Messages("user.settings.account.delete.title")</h4> + <form action="@deleteAction" class="pure-form" method="POST" accept-charset="UTF-8"> + <fieldset> + <p class="alert alert-error"> + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> + @Messages("form.account.delete.notice") + </p> + <div class="pure-control-group"> + <label for="password">@Messages("form.account.delete.password")</label> + <input class="pure-input-1" id="password" name="password" maxlength="128" required="" type="password"> + </div> + <label for="i-am-sure" class="pure-checkbox"><input id="i-am-sure" name="i-am-sure" required="" type="checkbox" value="yes"/> @Messages("form.account.delete.i-am-sure")</label> + @csrfToken(csrf) + <button type="submit" class="button-warning pure-button">@Messages("form.account.delete.button.submit")</button> + </fieldset> + </form> + </div> + </div> + </div> + </div> +</div> +} +} diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html --- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html 2025-02-02 03:57:42.669960040 +0000 +++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/navbar.scala.html 2025-02-02 03:57:42.673960045 +0000 @@ -10,6 +10,7 @@ <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath(s"~${account.name}")}">@Messages("global.navbar.top.repositories.yours")</a></li> } <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("repo/create")}">+ @Messages("global.navbar.top.repository.new")</a></li> + <li class="pure-menu-item"><a class="pure-menu-link" href="@{baseUri.addPath("user/settings")}">@Messages("global.navbar.top.settings")</a></li> <li class="pure-menu-item"> <form action="@{baseUri.addPath("logout")}" method="POST" accept-charset="UTF-8" class="pure-form"> @csrfToken(csrf)