~jan0sch/smederee
Showing details for patch 488a78c4566902c21b3367790b4eaae775ec5492.
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 12:50:55.968666591 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/Account.scala 2025-01-15 12:50:55.972666596 +0000 @@ -147,6 +147,12 @@ } +/** Extractor to retrieve an UnlockToken from a path parameter. + */ +object UnlockTokenPathParameter { + def unapply(str: String): Option[UnlockToken] = UnlockToken.from(str) +} + /** Extractor to retrieve an Username from a path parameter. */ object UsernamePathParameter { diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala 2025-01-15 12:50:55.968666591 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala 2025-01-15 12:50:55.972666596 +0000 @@ -37,6 +37,8 @@ import org.http4s.twirl.TwirlInstances._ import org.slf4j.LoggerFactory +import scala.concurrent.duration._ + /** Enumeration of possible kinds of authentication failures. */ enum AuthenticationFailure { @@ -112,27 +114,35 @@ val (hash, failedAttempts) = tuple (loginForm.password.matches(hash), failedAttempts <= authenticationConfig.lockAfter.toInt) } + source <- Sync[F].delay(request.from.map(_.toString).getOrElse("UNKNOWN_ADDRESS")) login <- check match { case None => // No account was found! - Sync[F].pure(Left(AuthenticationFailure.AccountNotFound)) + for { + delay <- Sync[F] + .delay(scala.util.Random.nextInt(2) + 1) // Prevent fast response to avoid account guessing. + _ <- Sync[F].sleep(FiniteDuration(delay, SECONDS)) + } yield Left(AuthenticationFailure.AccountNotFound) case Some((true, true)) => // The password is valid and the attempts are below or equal to the limit. Sync[F].delay(user.toRight(AuthenticationFailure.AccountNotFound)) case Some((false, true)) => // The password is wrong and the attempts are below or equal to the limit. - user.map(_.uid).traverse(repo.incrementFailedAttempts) *> Sync[F].pure( - Left(AuthenticationFailure.WrongPassword) - ) + for { + _ <- user.map(_.uid).traverse(repo.incrementFailedAttempts) + _ <- user.traverse(user => Sync[F].delay(log.warn(s"Login failure for ${user.name} from $source."))) + } yield Left(AuthenticationFailure.WrongPassword) case Some((_, false)) => // The login attempts are above the limit. for { _ <- user.map(_.uid).traverse(uid => repo.lockAccount(uid)(UnlockToken.generate.some)) - _ <- user - .map(_.uid) - .traverse(uid => - Sync[F].delay(log.info(s"Locking account $uid (too many authentication failures)!")) + _ <- user.traverse(user => + Sync[F].delay( + log.warn( + s"Locked account ${user.name} (too many authentication failures), latest request from $source!" + ) ) + ) } yield Left(AuthenticationFailure.AccountLocked) } response <- login match { @@ -193,7 +203,17 @@ SeeOther(Location(Uri(path = Uri.Path.Root))) // Redirect already logged in users. } - // private val unlockUser = ??? + private val unlockAccount: HttpRoutes[F] = HttpRoutes.of { + case request @ GET -> Root / "unlock" / UsernamePathParameter(name) / UnlockTokenPathParameter(token) => + for { + csrf <- Sync[F].delay(request.getCsrfToken) + user <- repo.findLockedAccount(name)(token.some) + _ <- user.map(_.uid).traverse(repo.unlockAccount) + _ <- user.map(_.uid).traverse(repo.resetFailedAttempts) + _ <- user.traverse(user => Sync[F].delay(log.info(s"Unlocked account for ${user.name}."))) + response <- SeeOther(Location(loginPath)) + } yield response + } val protectedRoutes = if (authenticationConfig.enabled) @@ -203,7 +223,7 @@ val routes = if (authenticationConfig.enabled) - showLoginForm <+> parseLoginForm + showLoginForm <+> parseLoginForm <+> unlockAccount else HttpRoutes.empty[F]