~jan0sch/smederee
Showing details for patch 7184525e4d0f70ff84ace7335642afc9eb4bf136.
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-02-01 06:43:19.599491011 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/AuthenticationRoutes.scala 2025-02-01 06:43:19.599491011 +0000 @@ -39,6 +39,23 @@ import org.slf4j.LoggerFactory import play.twirl.api._ +/** Enumeration of possible kinds of authentication failures. + */ +enum AuthenticationFailure { + + /** The account was not found in the database. + */ + case AccountNotFound extends AuthenticationFailure + + /** The account is locked. + */ + case AccountLocked extends AuthenticationFailure + + /** The provided password was wrong. + */ + case WrongPassword extends AuthenticationFailure +} + /** The routes for handling the authentication related tasks like login, logout, password reset, unlocking etc. * * @param clock @@ -98,13 +115,15 @@ login <- check match { case None => // No account was found! - Sync[F].pure(None) + Sync[F].pure(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) + 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(None) + user.map(_.uid).traverse(repo.incrementFailedAttempts) *> Sync[F].pure( + Left(AuthenticationFailure.WrongPassword) + ) case Some((_, false)) => // The login attempts are above the limit. for { @@ -114,17 +133,17 @@ .traverse(uid => Sync[F].delay(log.info(s"Locking account $uid (too many authentication failures)!")) ) - } yield None + } yield Left(AuthenticationFailure.AccountLocked) } response <- login match { - case None => + case Left(_) => BadRequest( views.html.login()(loginPath, csrf, title = "Smederee - Login to your account".some)( formData, Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!"))) ) ) - case Some(account) => + case Right(account) => for { session <- Sync[F].delay( Session.create( diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala --- old-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/AuthenticationRoutesTest.scala 2025-02-01 06:43:19.599491011 +0000 @@ -0,0 +1,298 @@ +/* + * 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 cats.effect._ +import cats.syntax.all._ +import com.typesafe.config.ConfigFactory +import de.smederee.hub.Generators._ +import de.smederee.hub.config._ +import de.smederee.hub.forms._ +import de.smederee.hub.forms.types._ +import de.smederee.security.{ SignAndValidate, SignedToken } +import fs2.Stream +import org.http4s._ +import org.http4s.headers._ +import org.http4s.implicits._ +import org.http4s.server._ +import org.http4s.twirl.TwirlInstances._ +import pureconfig.ConfigSource + +import munit._ +import org.scalacheck._ +import org.scalacheck.Prop._ + +class AuthenticationRoutesTest extends CatsEffectSuite { + val loginPath = uri"/login" + + protected final val configuration: SmedereeHubConfig = + ConfigSource.fromConfig(ConfigFactory.load(getClass.getClassLoader)).loadOrThrow[SmedereeHubConfig] + + test("GET /login must return the login form for guest users") { + val expectedHtml = views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)() + + val clock = java.time.Clock.systemUTC + val authenticationConfig = configuration.service.authentication + val repo = new TestAuthenticationRepository[IO](List.empty, List.empty) + val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + + def service: HttpRoutes[IO] = + Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes) + + def request = Request[IO](method = Method.GET, uri = loginPath) + + def response: IO[Response[IO]] = service.orNotFound.run(request) + + val test = for { + result <- response + body <- result.as[String] + } yield (result, body) + + test.map { output => + val (result, body) = output + assertEquals(result.status, Status.Ok) + assertEquals(body, expectedHtml.toString) + } + } + + test("POST /login must return 415 - Unsupported Media Type if the request is malformed") { + val clock = java.time.Clock.systemUTC + val authenticationConfig = configuration.service.authentication + val repo = new TestAuthenticationRepository[IO](List.empty, List.empty) + val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + + def service: HttpRoutes[IO] = + Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes) + + val payload = "This is not the request body you are looking for.".getBytes(StandardCharsets.UTF_8) + def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload) + + def response: IO[Response[IO]] = service.orNotFound.run(request) + + val test = for { + result <- response + body <- result.as[String] + } yield (result, body) + + test.map { output => + val (result, body) = output + assertEquals(result.status, Status.UnsupportedMediaType) + assertEquals( + body, + "Media type supplied in Content-Type header is not supported. Expected one of the following media ranges: application/*" + ) + } + } + + test("POST /login must return 400 - Bad Request if the form data is invalid") { + val expectedErrors = LoginForm.validate(Map.empty).leftMap(FormErrors.fromNec).swap.toOption.get + val expectedHtml = + views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)(formErrors = expectedErrors) + + val clock = java.time.Clock.systemUTC + val authenticationConfig = configuration.service.authentication + val repo = new TestAuthenticationRepository[IO](List.empty, List.empty) + val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + + def service: HttpRoutes[IO] = + Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes) + + val payload = UrlForm.empty + def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload) + + def response: IO[Response[IO]] = service.orNotFound.run(request) + + val test = for { + result <- response + body <- result.as[String] + } yield (result, body) + + test.map { output => + val (result, body) = output + assertEquals(result.status, Status.BadRequest) + assertEquals(body, expectedHtml.toString) + } + } + + test("POST /login must return 400 - Bad Request AND increase failed attempts if the credentials are invalid") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val clock = java.time.Clock.systemUTC + val authenticationConfig = configuration.service.authentication + val repo = new TestAuthenticationRepository[IO](List(account), List.empty) + val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + + def service: HttpRoutes[IO] = + Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes) + + val payload = UrlForm.empty + .updateFormField(LoginForm.fieldName.toString, account.name.toString) + .updateFormField(LoginForm.fieldPassword.toString, "doesn't matter") + def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload) + + val expectedHtml = + views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)( + formData = Map(LoginForm.fieldName.toString -> account.name.toString), + Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!"))) + ) + + def response: IO[Response[IO]] = service.orNotFound.run(request) + + val test = for { + result <- response + body <- result.as[String] + failed <- repo.getFailed + } yield (result, body, failed) + + test.map { output => + val (result, body, failed) = output + assertEquals(result.status, Status.BadRequest) + assertEquals(failed.find(_._1 === account.uid), Option((account.uid, 1))) + assertEquals(body, expectedHtml.toString) + } + } + } + + test( + "POST /login must return 400 - Bad Request AND lock the account if the credentials are invalid and failed attempts are too high" + ) { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val clock = java.time.Clock.systemUTC + val authenticationConfig = configuration.service.authentication + val repo = new TestAuthenticationRepository[IO](List(account), List.empty) + val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + + for (_ <- 0 to authenticationConfig.lockAfter.toInt) yield repo.incrementFailedAttempts(account.uid) + + def service: HttpRoutes[IO] = + Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes) + + val payload = UrlForm.empty + .updateFormField(LoginForm.fieldName.toString, account.name.toString) + .updateFormField(LoginForm.fieldPassword.toString, "doesn't matter") + def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload) + + val expectedHtml = + views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)( + formData = Map(LoginForm.fieldName.toString -> account.name.toString), + Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!"))) + ) + + def response: IO[Response[IO]] = service.orNotFound.run(request) + + val test = for { + result <- response + body <- result.as[String] + locked <- repo.getLocked + } yield (result, body, locked) + + test.map { output => + val (result, body, locked) = output + assertEquals(result.status, Status.BadRequest) + assert(locked.exists(_ === account.uid), s"Account was not locked! ($locked)") + assertEquals(body, expectedHtml.toString) + } + } + } + + test("POST /login must return 400 - Bad Request if the credentials are valid but the account is locked") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val clock = java.time.Clock.systemUTC + val authenticationConfig = configuration.service.authentication + // A locked account is not supposed to be returned by findAccount, so we leave the account list empty. + val repo = new TestAuthenticationRepository[IO](List.empty, List.empty) + val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + + def service: HttpRoutes[IO] = + Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes) + + val payload = UrlForm.empty + .updateFormField(LoginForm.fieldName.toString, account.name.toString) + .updateFormField(LoginForm.fieldPassword.toString, "doesn't matter") + def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload) + + val expectedHtml = + views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)( + formData = Map(LoginForm.fieldName.toString -> account.name.toString), + Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!"))) + ) + + def response: IO[Response[IO]] = service.orNotFound.run(request) + + val test = for { + result <- response + body <- result.as[String] + } yield (result, body) + + test.map { output => + val (result, body) = output + assertEquals(result.status, Status.BadRequest) + assertEquals(body, expectedHtml.toString) + } + } + } + + test("POST /login must return 303 - See Other AND set the authentication cookie if the credentials are valid") { + genValidAccount.sample match { + case None => fail("Could not generate data samples!") + case Some(account) => + val clock = java.time.Clock.systemUTC + val authenticationConfig = configuration.service.authentication + val repo = new TestAuthenticationRepository[IO](List(account), List.empty) + val signAndValidate = SignAndValidate(configuration.service.authentication.cookieSecret) + + def service: HttpRoutes[IO] = + Router("/" -> new AuthenticationRoutes[IO](clock, authenticationConfig, repo, signAndValidate).routes) + + val payload = UrlForm.empty + .updateFormField(LoginForm.fieldName.toString, account.name.toString) + .updateFormField(LoginForm.fieldPassword.toString, repo.DefaultPassword) + def request = Request[IO](method = Method.POST, uri = loginPath).withEntity(payload) + + val expectedHtml = + views.html.login()(loginPath, None, title = "Smederee - Login to your account".some)( + formData = Map(LoginForm.fieldName.toString -> account.name.toString), + Map(LoginForm.fieldGlobal -> List(FormFieldError("Invalid credentials!"))) + ) + + def response: IO[Response[IO]] = service.orNotFound.run(request) + + val test = for { + result <- response + body <- result.as[String] + } yield (result, body) + + test.map { output => + val (result, body) = output + assertEquals(result.status, Status.SeeOther) + assert(body.isEmpty, "Response body must be empty!") + result.cookies.find(_.name === Constants.authenticationCookieName.toString).map(_.content) match { + case None => fail("Authentication cookie not set!") + case Some(content) => assert(SignedToken.from(content).flatMap(signAndValidate.validate).nonEmpty) + } + } + } + } +} 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 1970-01-01 00:00:00.000000000 +0000 +++ new-smederee/modules/hub/src/test/scala/de/smederee/hub/TestAuthenticationRepository.scala 2025-02-01 06:43:19.599491011 +0000 @@ -0,0 +1,96 @@ +/* + * 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.util.UUID + +import cats.effect._ +import cats.syntax.all._ + +/** An implementation of a [[AuthenticationRepository]] for testing purposes. + * + * @param accounts + * A list of accounts from which data might be returned. + * @param sessions + * A list of sessions from which data might be returned. + * @tparam F + * A higher kinded type which wraps the actual return values. + */ +class TestAuthenticationRepository[F[_]: Sync](accounts: List[Account], sessions: List[Session]) + extends AuthenticationRepository[F] { + val DefaultPassword = "My voice is my passport. Verify me." + + private var failed = Map.empty[UserId, Int] + private var locked = List.empty[UserId] + + /** Return the map of user ids and related failed login attempts. + * + * @return + * A map of user ids pointing to an integer holding the number of failed login attempts. + */ + def getFailed: F[Map[UserId, Int]] = Sync[F].delay(failed) + + /** Return the list of locked user ids. + * + * @return + * A list with user ids that are locked. + */ + def getLocked: F[List[UserId]] = Sync[F].delay(locked) + + override def lockAccount(uid: UserId)(token: Option[UnlockToken]): F[Int] = { + locked = uid :: locked + Sync[F].pure(1) + } + + override def findAccountByName(name: Username): F[Option[Account]] = Sync[F].pure(accounts.headOption) + + override def resetFailedAttempts(uid: UserId): F[Int] = { + failed = failed.filterNot(_._1 === uid) + Sync[F].pure(1) + } + + override def createUserSession(session: Session): F[Int] = Sync[F].pure(1) + + override def incrementFailedAttempts(uid: UserId): F[Int] = { + failed = failed |+| Map(uid -> 1) + Sync[F].pure(1) + } + + override def findUserSession(id: SessionId): F[Option[Session]] = Sync[F].pure(sessions.headOption) + + override def findLockedAccount(name: Username)(token: UnlockToken): F[Option[Account]] = + Sync[F].pure(accounts.headOption) + + override def findPasswordHashAndAttempts(uid: UserId): F[Option[(PasswordHash, Int)]] = + Sync[F].delay( + Option((Password(DefaultPassword.getBytes(StandardCharsets.UTF_8)).encode, failed.withDefaultValue(0)(uid))) + ) + + override def findAccount(uid: UserId): F[Option[Account]] = Sync[F].pure(accounts.headOption) + + override def findAccountByEmail(email: Email): F[Option[Account]] = Sync[F].pure(accounts.headOption) + + override def unlockAccount(uid: UserId): F[Int] = { + locked = locked.filterNot(_ === uid) + Sync[F].pure(1) + } + + override def deleteUserSession(id: SessionId): F[Int] = Sync[F].pure(1) + +}