~jan0sch/smederee

Showing details for patch 488a78c4566902c21b3367790b4eaae775ec5492.
2023-08-09 (Wed), 8:06 AM - Jens Grassel - 488a78c4566902c21b3367790b4eaae775ec5492

Hub: Enable possible account unlocking.

- add log messages for authentication failures and locking to enable possible
  integration with log scrapers like fail2ban
- add route to unlock accounts using the username and the unlock token
Summary of changes
2 files modified with 36 lines added and 10 lines removed
  • modules/hub/src/main/scala/de/smederee/hub/Account.scala with 6 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala with 30 added and 10 removed lines
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]