diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23590beef..ccb6e7ac3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,6 +44,13 @@ jobs: run: "sbt coverage test" env: GECKO_DRIVER: /usr/bin/geckodriver + FILES_OVH_S3_ACCESS_KEY: unused + FILES_OVH_S3_SECRET_KEY: unused + FILES_OVH_S3_ENDPOINT: https://unused.example.com + FILES_OVH_S3_REGION: unused + FILES_OVH_S3_BUCKET: unused + FILES_CURRENT_ENCRYPTION_KEY_ID: unused-key + FILES_ENCRYPTION_KEYS: "unused-key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" - name: Generate coverage report run: "sbt coverageReport" diff --git a/app/actions/LoginAction.scala b/app/actions/LoginAction.scala index 1dd3a1a40..6627dcbdc 100644 --- a/app/actions/LoginAction.scala +++ b/app/actions/LoginAction.scala @@ -1,22 +1,19 @@ package actions +import cats.data.EitherT +import cats.effect.IO import cats.syntax.all._ import constants.Constants import controllers.routes import helper.ScalatagsHelpers.writeableOf_Modifier import helper.UUIDHelper +import java.time.Instant import java.util.UUID import javax.inject.{Inject, Singleton} -import models.{Area, Authorization, EventType, LoginToken, User, UserSession} -import models.EventType.{ - AuthByKey, - AuthWithDifferentIp, - ExpiredToken, - LoginByKey, - ToCGURedirected, - TryLoginByKey -} -import play.api.{Configuration, Logger} +import models.{Area, Authorization, Error, EventType, LoginToken, User, UserSession} +import models.EventType.{AuthWithDifferentIp, ExpiredToken, ToCGURedirected, TryLoginByKey} +import modules.AppConfig +import play.api.Logger import play.api.mvc._ import play.api.mvc.Results.{InternalServerError, TemporaryRedirect} import scala.concurrent.{ExecutionContext, Future} @@ -39,13 +36,20 @@ object LoginAction { Authorization.readUserRights(user) ) + def signupSessionKeys: List[String] = + List( + Keys.Session.signupId, + Keys.Session.signupLoginExpiresAt, + Keys.Session.signupAgentConnectSubject + ) + } //TODO : this class is complicated. Maybe we can split the logic. @Singleton class LoginAction @Inject() ( - configuration: Configuration, + config: AppConfig, dependencies: ServicesDependencies, eventService: EventService, parser: BodyParsers.Default, @@ -54,7 +58,7 @@ class LoginAction @Inject() ( userService: UserService, )(implicit ec: ExecutionContext) extends BaseLoginAction( - configuration, + config, dependencies, eventService, ec, @@ -66,7 +70,7 @@ class LoginAction @Inject() ( def withPublicPage(publicPage: Result): BaseLoginAction = new BaseLoginAction( - configuration, + config, dependencies, eventService, ec, @@ -80,7 +84,7 @@ class LoginAction @Inject() ( } class BaseLoginAction( - configuration: Configuration, + config: AppConfig, dependencies: ServicesDependencies, eventService: EventService, implicit val executionContext: ExecutionContext, @@ -96,14 +100,6 @@ class BaseLoginAction( private val log = Logger(classOf[LoginAction]) - private lazy val areasWithLoginByKey = configuration.underlying - .getString("app.areasWithLoginByKey") - .split(",") - .flatMap(UUIDHelper.fromString) - - private lazy val tokenExpirationInMinutes = - configuration.underlying.getInt("app.tokenExpirationInMinutes") - private def queryToString(qs: Map[String, Seq[String]]) = { val queryString = qs.map { case (key, value) => key + "=" + value.sorted.mkString("|,|") }.mkString("&") @@ -125,6 +121,9 @@ class BaseLoginAction( .get(Keys.Session.userId) .flatMap(UUIDHelper.fromString) + val userByKey: Option[User] = + request.getQueryString(Keys.QueryParam.key).flatMap(userService.byKey) + (userBySession, userByKey, tokenOpt, signupOpt) match { // Note: this case is deliberately put here for failing fast, if the token is invalid, // we don't want to continue doing sensitive operations @@ -140,34 +139,7 @@ class BaseLoginAction( // Next `GET url` will go to the case (Some(userId), None, _, _) Future(Left(TemporaryRedirect(Call(request.method, url).url))) case (_, Some(user), None, None) => - LoginAction.readUserRights(user).map { userRights => - val area = user.areas.headOption - .flatMap(Area.fromId) - .getOrElse(Area.all.head) - implicit val requestWithUserData = - new RequestWithUserData(user, userRights, none, request) - if (areasWithLoginByKey.contains(area.id) && !user.admin) { - // areasWithLoginByKey is an insecure setting for demo usage - eventService.log( - LoginByKey, - "Connexion par clé réussie (seulement pour la demo / " + - "CE LOG NE DOIT PAS APPARAITRE EN PROD !!! Si c'est le cas, " + - "il faut vider la variable d'environnement correspondant à areasWithLoginByKey)" - ) - Left( - TemporaryRedirect(Call(request.method, url).url) - .withSession( - request.session - Keys.Session.userId + (Keys.Session.userId -> user.id.toString) - ) - ) - } else { - eventService.log(TryLoginByKey, "Clé dans l'url, redirige vers la page de connexion") - Left( - TemporaryRedirect(routes.LoginController.login.url) - .flashing("email" -> user.email, "path" -> path) - ) - } - } + tryInsecureAuthByKey(user, url, path).map(_.asLeft) case (Some(userId), None, None, None) => val sessionId = request.session.get(Keys.Session.sessionId) userService @@ -206,13 +178,21 @@ class BaseLoginAction( ) } } else { - manageUserLogged(user, userSession) + // The None case is legacy to avoid disconnecting everybody + val sessionIsValid = userSession.map(_.isValid(Instant.now())).getOrElse(true) + if (sessionIsValid) { + manageUserLogged(user, userSession) + } else { + Future.successful( + userNotLogged("Votre session a expiré. Veuillez vous reconnecter.") + ) + } } } ) ) case (_, _, _, Some(signupId)) => - // the exchange between signupId and userId is logged by EventType.SignupFormSuccessful + // Note: the exchange between signupId and userId is logged by EventType.SignupFormSuccessful manageSignup(signupId) case _ => if (routes.HomeController.index.url.contains(path)) { @@ -231,6 +211,66 @@ class BaseLoginAction( } } + private def tryInsecureAuthByKey[A]( + user: User, + redirectUrl: String, + redirectPath: String + )(implicit request: Request[A]): Future[Result] = + LoginAction.readUserRights(user).flatMap { userRights => + val area = user.areas.headOption + .flatMap(Area.fromId) + .getOrElse(Area.all.head) + if (config.insecureAreasWithLoginByKey.contains(area.id) && !user.admin) { + // areasWithLoginByKey is an insecure setting for demo usage + val loginExpiresAt = + Instant.now().plusSeconds(config.magicLinkSessionDurationInSeconds) + ( + for { + userSession <- userService + .createNewUserSession( + user.id, + UserSession.LoginType.InsecureDemoKey, + loginExpiresAt, + request.remoteAddress, + ) + _ <- EitherT + .right[Error](IO.blocking(userService.recordLogin(user.id))) + _ <- EitherT.right[Error]( + IO.blocking( + eventService.log( + EventType.LoginByKey, + "Connexion par clé réussie (seulement pour la demo / " + + "CE LOG NE DOIT PAS APPARAITRE EN PROD !!! Si c'est le cas, " + + "il faut vider la variable d'environnement correspondant à areasWithLoginByKey)" + )( + new RequestWithUserData(user, userRights, userSession.some, request) + ) + ) + ) + } yield TemporaryRedirect(Call(request.method, redirectUrl).url) + .removingFromSession(LoginAction.signupSessionKeys: _*) + .addingToSession( + Keys.Session.userId -> user.id.toString, + Keys.Session.sessionId -> userSession.id, + ) + ).valueOrF(error => + IO.blocking( + eventService.logError(error)( + new RequestWithUserData(user, userRights, none, request) + ) + ).as(InternalServerError(views.errors.public500(None))) + ).unsafeToFuture() + } else { + eventService.log(TryLoginByKey, "Clé dans l'url, redirige vers la page de connexion")( + new RequestWithUserData(user, userRights, none, request) + ) + Future.successful( + TemporaryRedirect(routes.LoginController.login.url) + .flashing("email" -> user.email, "path" -> redirectPath) + ) + } + } + private def tryAuthByToken[A]( rawToken: String )(implicit request: Request[A]): Future[Either[Result, RequestWithUserData[A]]] = { @@ -342,35 +382,63 @@ class BaseLoginAction( ) Future(userNotLogged("Une erreur s'est produite, votre utilisateur n'existe plus")) case Some(user) => - LoginAction.readUserRights(user).map { userRights => - // hack: we need RequestWithUserData to call the logger - implicit val requestWithUserData = - new RequestWithUserData(user, userRights, none, request) - + LoginAction.readUserRights(user).flatMap { userRights => if (token.ipAddress =!= request.remoteAddress) { eventService.log( AuthWithDifferentIp, s"Utilisateur $userId à une adresse ip différente pour l'essai de connexion" - ) + )(new RequestWithUserData(user, userRights, none, request)) } + if (token.isActive) { - userService.recordLogin(user.id) val url = request.path + queryToString( request.queryString - Keys.QueryParam.key - Keys.QueryParam.token ) - eventService.log(AuthByKey, s"Identification par token") - Left( - TemporaryRedirect(Call(request.method, url).url) - .withSession( - request.session - Keys.Session.userId - Keys.Session.signupId + - (Keys.Session.userId -> user.id.toString) + val loginExpiresAt = Instant.now().plusSeconds(config.magicLinkSessionDurationInSeconds) + ( + for { + userSession <- userService + .createNewUserSession( + user.id, + UserSession.LoginType.MagicLink, + loginExpiresAt, + request.remoteAddress, + ) + _ <- EitherT + .right[Error](IO.blocking(userService.recordLogin(user.id))) + _ <- EitherT.right[Error]( + IO.blocking( + eventService.log( + EventType.AuthByKey, + s"Identification par token (expiration : $loginExpiresAt)" + )( + new RequestWithUserData(user, userRights, userSession.some, request) + ) + ) ) - ) + } yield TemporaryRedirect(Call(request.method, url).url) + .removingFromSession(LoginAction.signupSessionKeys: _*) + .addingToSession( + Keys.Session.userId -> user.id.toString, + Keys.Session.sessionId -> userSession.id, + ) + ).valueOrF(error => + IO.blocking( + eventService.logError(error)( + new RequestWithUserData(user, userRights, none, request) + ) + ).as(InternalServerError(views.errors.public500(None))) + ).unsafeToFuture() + .map(_.asLeft) } else { - eventService.log(ExpiredToken, s"Token expiré pour $userId") - redirectToHomeWithEmailSendbackButton( - user.email, - s"Votre lien de connexion a expiré, il est valable $tokenExpirationInMinutes minutes à réception." + eventService.log(ExpiredToken, s"Token expiré pour $userId")( + new RequestWithUserData(user, userRights, none, request) + ) + Future( + redirectToHomeWithEmailSendbackButton( + user.email, + s"Votre lien de connexion a expiré, il est valable ${config.tokenExpirationInMinutes} minutes à réception." + ) ) } } @@ -403,14 +471,17 @@ class BaseLoginAction( val url = request.path + queryToString( request.queryString - Keys.QueryParam.key - Keys.QueryParam.token ) + val loginExpiresAt = + Instant.now().plusSeconds(config.magicLinkSessionDurationInSeconds) eventService.logSystem( EventType.AuthBySignupToken, - s"Identification par token avec la préinscription ${signupRequest.id}" + s"Identification par token avec la préinscription ${signupRequest.id} (expiration : $loginExpiresAt)" ) Left( TemporaryRedirect(Call(request.method, url).url) - .withSession( - request.session - Keys.Session.signupId + (Keys.Session.signupId -> signupRequest.id.toString) + .addingToSession( + Keys.Session.signupId -> signupRequest.id.toString, + Keys.Session.signupLoginExpiresAt -> loginExpiresAt.getEpochSecond.toString, ) ) } else { @@ -420,7 +491,7 @@ class BaseLoginAction( ) redirectToHomeWithEmailSendbackButton( signupRequest.email, - s"Votre lien de connexion a expiré, il est valable $tokenExpirationInMinutes minutes à réception." + s"Votre lien de connexion a expiré, il est valable ${config.tokenExpirationInMinutes} minutes à réception." ) } } @@ -430,7 +501,9 @@ class BaseLoginAction( private def userNotLogged[A](message: String)(implicit request: Request[A]) = Left( TemporaryRedirect(routes.LoginController.login.url) - .withSession(request.session - Keys.Session.userId - Keys.Session.signupId) + .removingFromSession( + (Keys.Session.userId :: Keys.Session.sessionId :: LoginAction.signupSessionKeys): _* + ) .flashing("error" -> message) ) @@ -439,14 +512,18 @@ class BaseLoginAction( ) = Left( TemporaryRedirect(routes.HomeController.index.url) - .withSession(request.session - Keys.Session.userId - Keys.Session.signupId) + .removingFromSession( + (Keys.Session.userId :: Keys.Session.sessionId :: LoginAction.signupSessionKeys): _* + ) .flashing("email" -> email, "error" -> message) ) private def userNotLoggedOnLoginPage[A](implicit request: Request[A]) = Left( TemporaryRedirect(routes.HomeController.index.url) - .withSession(request.session - Keys.Session.userId) + .removingFromSession( + (Keys.Session.userId :: Keys.Session.sessionId :: LoginAction.signupSessionKeys): _* + ) ) // Note: Instead of a blank page with a message, sending back to the home page @@ -458,7 +535,4 @@ class BaseLoginAction( "nous vous invitons à réessayer plus tard." ) - private def userByKey[A](implicit request: Request[A]): Option[User] = - request.getQueryString(Keys.QueryParam.key).flatMap(userService.byKey) - } diff --git a/app/controllers/ApplicationController.scala b/app/controllers/ApplicationController.scala index 82f55ec2f..721dd120e 100644 --- a/app/controllers/ApplicationController.scala +++ b/app/controllers/ApplicationController.scala @@ -2,6 +2,7 @@ package controllers import actions.{BaseLoginAction, LoginAction, RequestWithUserData} import cats.data.EitherT +import cats.effect.IO import cats.syntax.all._ import constants.Constants import helper.{Time, UUIDHelper} @@ -20,6 +21,7 @@ import models.{ Application, Area, Authorization, + Error, EventType, FileMetadata, Mandat, @@ -28,7 +30,6 @@ import models.{ UserGroup } import models.Answer.AnswerType -import models.EventType._ import models.forms.{ AnswerFormData, ApplicationFormData, @@ -62,6 +63,7 @@ import services.{ MandatService, NotificationService, OrganisationService, + ServicesDependencies, UserGroupService, UserService } @@ -75,6 +77,7 @@ case class ApplicationController @Inject() ( applicationService: ApplicationService, businessDaysService: BusinessDaysService, config: AppConfig, + dependencies: ServicesDependencies, eventService: EventService, fileService: FileService, loginAction: LoginAction, @@ -91,6 +94,8 @@ case class ApplicationController @Inject() ( with Operators.ApplicationOperators with Operators.UserOperators { + import dependencies.ioRuntime + private val success = "success" private def filterVisibleGroups(areaId: UUID, user: User, rights: Authorization.UserRights)( @@ -173,7 +178,10 @@ case class ApplicationController @Inject() ( def create: Action[AnyContent] = loginAction.async { implicit request => - eventService.log(ApplicationFormShowed, "Visualise le formulaire de création de demande") + eventService.log( + EventType.ApplicationFormShowed, + "Visualise le formulaire de création de demande" + ) currentArea.flatMap(currentArea => userGroupService .byIdsFuture(request.currentUser.groupIds) @@ -208,7 +216,7 @@ case class ApplicationController @Inject() ( } private def handlingFiles(applicationId: UUID, answerId: Option[UUID])( - onError: models.Error => Future[Result] + onError: Error => Future[Result] )( onSuccess: List[FileMetadata] => Future[Result] )(implicit request: RequestWithUserData[AnyContent]): Future[Result] = { @@ -236,7 +244,7 @@ case class ApplicationController @Inject() ( // Note that the 2 futures insert and select file_metadata in a very racy way // but we don't care about the actual status here, only filenames uniqueNewFiles = newFiles.filter(file => pendingFiles.forall(_.id =!= file.id)) - result <- EitherT(onSuccess(pendingFiles ::: uniqueNewFiles).map(_.asRight[models.Error])) + result <- EitherT(onSuccess(pendingFiles ::: uniqueNewFiles).map(_.asRight[Error])) } yield result).value.flatMap(_.fold(onError, Future.successful)) } @@ -285,7 +293,7 @@ case class ApplicationController @Inject() ( fetchGroupsWithInstructors(currentArea.id, request.currentUser, request.rights) .map { case (groupsOfAreaWithInstructor, instructorsOfGroups, coworkers) => eventService.log( - ApplicationCreationInvalid, + EventType.ApplicationCreationInvalid, s"L'utilisateur essaie de créer une demande invalide ${formErrorsLog(formWithErrors)}" ) BadRequest( @@ -364,13 +372,13 @@ case class ApplicationController @Inject() ( if (applicationService.createApplication(application)) { notificationsService.newApplication(application) eventService.log( - ApplicationCreated, + EventType.ApplicationCreated, s"La demande ${application.id} a été créée", applicationId = application.id.some ) application.invitedUsers.foreach { case (userId, _) => eventService.log( - ApplicationCreated, + EventType.ApplicationCreated, s"Envoi de la nouvelle demande ${application.id} à l'utilisateur $userId", applicationId = application.id.some, involvesUser = userId.some @@ -385,7 +393,7 @@ case class ApplicationController @Inject() ( .onComplete { case Failure(error) => eventService.log( - ApplicationLinkedToMandatError, + EventType.ApplicationLinkedToMandatError, s"Erreur pour faire le lien entre le mandat $mandatId et la demande $applicationId", applicationId = application.id.some, underlyingException = error.some @@ -394,7 +402,7 @@ case class ApplicationController @Inject() ( eventService.logError(error, applicationId = application.id.some) case Success(Right(_)) => eventService.log( - ApplicationLinkedToMandat, + EventType.ApplicationLinkedToMandat, s"La demande ${application.id} a été liée au mandat $mandatId", applicationId = application.id.some ) @@ -410,7 +418,7 @@ case class ApplicationController @Inject() ( .flashing(success -> "Votre demande a bien été envoyée") } else { eventService.log( - ApplicationCreationError, + EventType.ApplicationCreationError, s"La demande ${application.id} n'a pas pu être créée", applicationId = application.id.some ) @@ -474,7 +482,7 @@ case class ApplicationController @Inject() ( ) { () => val (areaOpt, numOfMonthsDisplayed) = extractApplicationsAdminQuery eventService.log( - AllApplicationsShowed, + EventType.AllApplicationsShowed, s"Accède à la page des métadonnées des demandes [$areaOpt ; $numOfMonthsDisplayed]" ) Future( @@ -501,7 +509,7 @@ case class ApplicationController @Inject() ( numOfMonthsDisplayed ).map { applications => eventService.log( - AllApplicationsShowed, + EventType.AllApplicationsShowed, "Accède à la liste des metadata des demandes " + s"[territoire ${areaOpt.map(_.name).getOrElse("tous")} ; " + s"taille : ${applications.size}]" @@ -842,7 +850,7 @@ case class ApplicationController @Inject() ( controllers.routes.ApplicationController.myApplications.url ) { infos => eventService.log( - MyApplicationsShowed, + EventType.MyApplicationsShowed, s"Visualise la liste des demandes : ${infos.countsLog}" ) } @@ -971,7 +979,7 @@ case class ApplicationController @Inject() ( charts .map { html => eventService.log( - StatsShowed, + EventType.StatsShowed, "Visualise les stats [Territoires '" + areaIds.mkString(",") + "' ; Organismes '" + queryOrganisationIds.mkString(",") + "' ; Groupes '" + queryGroupIds.mkString(",") + @@ -1000,7 +1008,7 @@ case class ApplicationController @Inject() ( ) { infos => eventService .log( - AllAsShowed, + EventType.AllAsShowed, s"Visualise la vue de l'utilisateur $userId : ${infos.countsLog}", involvesUser = Some(otherUser.id) ) @@ -1080,7 +1088,7 @@ case class ApplicationController @Inject() ( val date = Time.formatPatternFr(currentDate, "YYY-MM-dd-HH'h'mm") val csvContent = applicationsToCSV(exportedApplications) - eventService.log(MyCSVShowed, s"Visualise le CSV de mes demandes") + eventService.log(EventType.MyCSVShowed, s"Visualise le CSV de mes demandes") Ok(csvContent) .withHeaders( CONTENT_DISPOSITION -> s"""attachment; filename="aplus-demandes-$date.csv"""", @@ -1224,7 +1232,7 @@ case class ApplicationController @Inject() ( openedTab = request.flash.get("opened-tab").getOrElse("answer"), ) { html => eventService.log( - ApplicationShowed, + EventType.ApplicationShowed, s"Demande $id consultée", applicationId = application.id.some ) @@ -1235,158 +1243,161 @@ case class ApplicationController @Inject() ( def file(fileId: UUID): Action[AnyContent] = loginAction.async { implicit request => - fileService - .fileMetadata(fileId) - .flatMap( - _.fold( - error => { - eventService.logError(error) - Future.successful( - InternalServerError( - "Une erreur est survenue pour trouver le fichier. " + - "Cette erreur est probablement temporaire." - ) - ) - }, - metadataOpt => { - metadataOpt match { - case None => - eventService.log(FileNotFound, s"Le fichier $fileId n'existe pas") - Future.successful(NotFound("Nous n'avons pas trouvé ce fichier")) - case Some((path, metadata)) => - val applicationId = metadata.attached match { - case FileMetadata.Attached.Application(id) => id - case FileMetadata.Attached.Answer(applicationId, _) => applicationId - } - withApplication(applicationId) { (application: Application) => - val isAuthorized = - Authorization - .fileCanBeShown(config.filesExpirationInDays)( - metadata.attached, - application - )(request.rights) - if (isAuthorized) { - metadata.status match { - case FileMetadata.Status.Scanning => + EitherT(fileService.fileMetadata(fileId)) + .flatMap(metadataOpt => + EitherT.right[Error]( + metadataOpt match { + case None => + IO.blocking( + eventService.log(EventType.FileNotFound, s"Le fichier $fileId n'existe pas") + ).as(NotFound("Nous n'avons pas trouvé ce fichier")) + case Some((path, metadata)) => + val applicationId = metadata.attached match { + case FileMetadata.Attached.Application(id) => id + case FileMetadata.Attached.Answer(applicationId, _) => applicationId + } + withApplicationIO(applicationId) { (application: Application) => + val isAuthorized = + Authorization + .fileCanBeShown(config.filesExpirationInDays)( + metadata.attached, + application + )(request.rights) + if (isAuthorized) { + metadata.status match { + case FileMetadata.Status.Scanning => + IO.blocking( eventService.log( - FileNotFound, + EventType.FileNotFound, s"Le fichier ${metadata.id} du document ${metadata.attached} est en cours de scan", applicationId = applicationId.some ) - Future.successful( - NotFound( - "Le fichier est en cours de scan par un antivirus. Il devrait être disponible d'ici peu." - ) + ).as( + NotFound( + "Le fichier est en cours de scan par un antivirus. Il devrait être disponible d'ici peu." ) - case FileMetadata.Status.Quarantined => + ) + case FileMetadata.Status.Quarantined => + IO.blocking( eventService.log( EventType.FileQuarantined, s"Le fichier ${metadata.id} du document ${metadata.attached} est en quarantaine", applicationId = applicationId.some ) - Future.successful( - NotFound( - "L'antivirus a mis en quarantaine le fichier. Si vous avez envoyé ce fichier, il est conseillé de vérifier votre ordinateur avec un antivirus. Si vous pensez qu'il s'agit d'un faux positif, nous vous invitons à changer le format, puis envoyer à nouveau sous un nouveau format." - ) + ).as( + NotFound( + "L'antivirus a mis en quarantaine le fichier. Si vous avez envoyé ce fichier, il est conseillé de vérifier votre ordinateur avec un antivirus. Si vous pensez qu'il s'agit d'un faux positif, nous vous invitons à changer le format, puis envoyer à nouveau sous un nouveau format." ) - case FileMetadata.Status.Available => + ) + case FileMetadata.Status.Available => + IO.blocking( eventService.log( - FileOpened, + EventType.FileOpened, s"Le fichier ${metadata.id} du document ${metadata.attached} a été ouvert", applicationId = applicationId.some ) + ) >> sendFile(path, metadata) - case FileMetadata.Status.Expired => + case FileMetadata.Status.Expired => + IO.blocking( eventService.log( EventType.FileNotFound, s"Le fichier ${metadata.id} du document ${metadata.attached} est expiré", applicationId = applicationId.some ) - Future.successful(NotFound("Ce fichier à expiré.")) - case FileMetadata.Status.Error => + ).as(NotFound("Ce fichier à expiré.")) + case FileMetadata.Status.Error => + IO.blocking( eventService.log( EventType.FileNotFound, s"Le fichier ${metadata.id} du document ${metadata.attached} a une erreur", applicationId = applicationId.some ) - Future.successful( - NotFound( - "Une erreur est survenue lors de l'enregistrement du fichier. Celui-ci n'est pas disponible." - ) + ).as( + NotFound( + "Une erreur est survenue lors de l'enregistrement du fichier. Celui-ci n'est pas disponible." ) - } - } else { + ) + } + } else { + IO.blocking( eventService.log( - FileUnauthorized, + EventType.FileUnauthorized, s"L'accès aux fichiers sur la demande $applicationId n'est pas autorisé (fichier $fileId)", applicationId = application.id.some ) - Future.successful( - Unauthorized( - s"Vous n'avez pas les droits suffisants pour voir les fichiers sur cette demande. Vous pouvez contacter l'équipe A+ : ${Constants.supportEmail}" - ) + ).as( + Unauthorized( + s"Vous n'avez pas les droits suffisants pour voir les fichiers sur cette demande. Vous pouvez contacter l'équipe A+ : ${Constants.supportEmail}" ) + ) - } } - } + } } ) ) - + .valueOrF(error => + IO.blocking(eventService.logError(error)) + .as( + InternalServerError( + "Une erreur est survenue pour trouver le fichier. " + + "Cette erreur est probablement temporaire." + ) + ) + ) + .unsafeToFuture() } private def sendFile(localPath: Path, metadata: FileMetadata)(implicit - request: actions.RequestWithUserData[_] - ): Future[Result] = - if (Files.exists(localPath)) { - Future( - Ok.sendPath( - localPath, - // Will set "Content-Disposition: attachment" - // This avoids potential security issues if a malicious HTML page is uploaded - `inline` = false, - fileName = (_: Path) => Some(metadata.filename) - ).withHeaders(CACHE_CONTROL -> "no-store") + request: RequestWithUserData[_] + ): IO[Result] = { + val fileResult = (fileExists: Boolean) => + IO( + if (fileExists) + fileService + .fileStream(metadata) + .map(contentSource => + Ok.streamed( + content = contentSource, + contentLength = Some(metadata.filesize.toLong), + // Will set "Content-Disposition: attachment" + // This avoids potential security issues if a malicious HTML page is uploaded + `inline` = false, + fileName = Some(metadata.filename) + ).withHeaders(CACHE_CONTROL -> "no-store") + ) + else if (Files.exists(localPath)) + // TODO: this branch is legacy and should be removed + Ok.sendPath( + localPath, + `inline` = false, + fileName = (_: Path) => Some(metadata.filename) + ).withHeaders(CACHE_CONTROL -> "no-store") + .asRight + else + NotFound("Nous n'avons pas trouvé ce fichier").asRight ) - } else { - config.filesSecondInstanceHost match { - case None => - eventService.log( - FileNotFound, - s"Le fichier n'existe pas sur le serveur" - ) - Future(NotFound("Nous n'avons pas trouvé ce fichier")) - case Some(domain) => - val cookies = request.headers.getAll(COOKIE) - val url = domain + routes.ApplicationController.file(metadata.id).url - ws.url(url) - .addHttpHeaders(cookies.map(cookie => (COOKIE, cookie)): _*) - .get() - .map { response => - if (response.status / 100 === 2) { - val body = response.bodyAsSource - val contentLength: Option[Long] = - response.header(CONTENT_LENGTH).flatMap(raw => Try(raw.toLong).toOption) - // Note: `streamed` should set `Content-Disposition` - // https://github.com/playframework/playframework/blob/2.8.x/core/play/src/main/scala/play/api/mvc/Results.scala#L523 - Ok.streamed( - content = body, - contentLength = contentLength, - `inline` = false, - fileName = Some(metadata.filename) - ).withHeaders(CACHE_CONTROL -> "no-store") - } else { - eventService.log( - FileNotFound, - s"La requête vers le serveur distant a échoué (status ${response.status})", - s"Url '$url'".some - ) - NotFound("Nous n'avons pas trouvé ce fichier") - } - } - } - } + + ( + for { + fileExists <- EitherT(fileService.fileExistsOnS3(metadata.id)) + result <- EitherT(fileResult(fileExists)) + } yield result + ).value.flatMap( + _.fold( + error => + IO.blocking(eventService.logError(error)) + .as( + InternalServerError( + "Une erreur est survenue pour trouver le fichier. " + + "Cette erreur est probablement temporaire." + ) + ), + IO.pure, + ) + ) + } private def buildAnswerMessage(message: String, signature: Option[String]) = signature.map(s => message + "\n\n" + s).getOrElse(message) @@ -1503,7 +1514,7 @@ case class ApplicationController @Inject() ( if (answerAdded === 1) { eventService.log( - AnswerCreated, + EventType.AnswerCreated, s"La réponse ${answer.id} a été créée sur la demande $applicationId", applicationId = application.id.some ) @@ -1545,7 +1556,11 @@ case class ApplicationController @Inject() ( s"Erreur dans le formulaire d’invitation (${formWithErrors.errors.map(_.format).mkString(", ")})." val error = s"Erreur dans le formulaire d’invitation (${formErrorsLog(formWithErrors)})" - eventService.log(InviteFormValidationError, error, applicationId = application.id.some) + eventService.log( + EventType.InviteFormValidationError, + error, + applicationId = application.id.some + ) Future( Redirect( routes.ApplicationController.show(applicationId).withFragment("answer-error") @@ -1558,7 +1573,7 @@ case class ApplicationController @Inject() ( val error = s"Erreur dans le formulaire d’invitation (une personne ou un organisme doit être sélectionné)" eventService.log( - InviteFormValidationError, + EventType.InviteFormValidationError, error, applicationId = application.id.some ) @@ -1616,13 +1631,13 @@ case class ApplicationController @Inject() ( if (applicationService.addAnswer(applicationId, answer) === 1) { notificationsService.newAnswer(application, answer) eventService.log( - AgentsAdded, + EventType.AgentsAdded, s"L'ajout d'utilisateur (réponse ${answer.id}) a été créé sur la demande $applicationId", applicationId = application.id.some ) answer.invitedUsers.foreach { case (userId, _) => eventService.log( - AgentsAdded, + EventType.AgentsAdded, s"Utilisateur $userId invité sur la demande $applicationId (réponse ${answer.id})", applicationId = application.id.some, involvesUser = userId.some @@ -1632,7 +1647,7 @@ case class ApplicationController @Inject() ( .flashing(success -> "Les utilisateurs ont été invités sur la demande") } else { eventService.log( - AgentsNotAdded, + EventType.AgentsNotAdded, s"L'ajout d'utilisateur ${answer.id} n'a pas été créé sur la demande $applicationId : problème BDD", applicationId = application.id.some ) @@ -1677,13 +1692,13 @@ case class ApplicationController @Inject() ( if (applicationService.addAnswer(applicationId, answer, expertInvited = true) === 1) { notificationsService.newAnswer(application, answer) eventService.log( - AddExpertCreated, + EventType.AddExpertCreated, s"La réponse ${answer.id} a été créée sur la demande $applicationId", applicationId = application.id.some ) answer.invitedUsers.foreach { case (userId, _) => eventService.log( - AddExpertCreated, + EventType.AddExpertCreated, s"Expert $userId invité sur la demande $applicationId (réponse ${answer.id})", applicationId = application.id.some, involvesUser = userId.some @@ -1693,7 +1708,7 @@ case class ApplicationController @Inject() ( .flashing(success -> "Un expert a été invité sur la demande") } else { eventService.log( - AddExpertNotCreated, + EventType.AddExpertNotCreated, s"L'invitation d'experts ${answer.id} n'a pas été créée sur la demande $applicationId : problème BDD", applicationId = application.id.some ) @@ -1702,7 +1717,7 @@ case class ApplicationController @Inject() ( } } else { eventService.log( - AddExpertUnauthorized, + EventType.AddExpertUnauthorized, s"L'invitation d'experts pour la demande $applicationId n'est pas autorisée", applicationId = application.id.some ) @@ -1726,17 +1741,20 @@ case class ApplicationController @Inject() ( .filter(identity) .map { _ => val message = "La demande a bien été réouverte" - eventService.log(ReopenCompleted, message, applicationId = application.id.some) + eventService + .log(EventType.ReopenCompleted, message, applicationId = application.id.some) Redirect(routes.ApplicationController.myApplications).flashing(success -> message) } .recover { _ => val message = "La demande n'a pas pu être réouverte" - eventService.log(ReopenError, message, applicationId = application.id.some) + eventService + .log(EventType.ReopenError, message, applicationId = application.id.some) InternalServerError(message) } case false => val message = s"Non autorisé à réouvrir la demande $applicationId" - eventService.log(ReopenUnauthorized, message, applicationId = application.id.some) + eventService + .log(EventType.ReopenUnauthorized, message, applicationId = application.id.some) Future.successful(Unauthorized(message)) } } @@ -1750,7 +1768,7 @@ case class ApplicationController @Inject() ( formWithErrors => { eventService .log( - TerminateIncompleted, + EventType.TerminateIncompleted, s"La demande de clôture pour $applicationId est incomplète", applicationId = application.id.some ) @@ -1773,7 +1791,7 @@ case class ApplicationController @Inject() ( ) { eventService .log( - TerminateCompleted, + EventType.TerminateCompleted, s"La demande $applicationId est archivée", applicationId = application.id.some ) @@ -1786,7 +1804,7 @@ case class ApplicationController @Inject() ( ) } else { eventService.log( - TerminateError, + EventType.TerminateError, s"La demande $applicationId n'a pas pu être archivée en BDD", applicationId = application.id.some ) @@ -1798,7 +1816,7 @@ case class ApplicationController @Inject() ( } } else { eventService.log( - TerminateUnauthorized, + EventType.TerminateUnauthorized, s"L'utilisateur n'a pas le droit de clôturer la demande $applicationId", applicationId = application.id.some ) diff --git a/app/controllers/LoginController.scala b/app/controllers/LoginController.scala index 0d8036e95..3d1112af7 100644 --- a/app/controllers/LoginController.scala +++ b/app/controllers/LoginController.scala @@ -9,13 +9,23 @@ import helper.ScalatagsHelpers.writeableOf_Modifier import helper.Time import java.time.Instant import javax.inject.{Inject, Singleton} -import models.{AgentConnectClaims, Authorization, Error, EventType, LoginToken, SignupRequest, User} +import models.{ + AgentConnectClaims, + Authorization, + Error, + EventType, + LoginToken, + SignupRequest, + User, + UserSession +} import models.EventType.{GenerateToken, UnknownEmail} import modules.AppConfig import org.webjars.play.WebJarsUtil import play.api.mvc.{Action, AnyContent, InjectedController, Request, Result} import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters._ +import scala.util.Try import serializers.Keys import services.{ AgentConnectService, @@ -98,6 +108,7 @@ class LoginController @Inject() ( val loginToken = LoginToken .forUserId(user.id, config.tokenExpirationInMinutes, request.remoteAddress) + // userSession = none since there are no session around val requestWithUserData = new RequestWithUserData(user, userRights, none, request) loginHappyPath(loginToken, user.email, requestWithUserData.some) @@ -328,9 +339,23 @@ class LoginController @Inject() ( ) ) } else { + val expiresAtIO = IO.realTimeInstant.flatMap(now => + request.session + .get(Keys.Session.signupLoginExpiresAt) + .flatMap(epoch => Try(Instant.ofEpochSecond(epoch.toLong)).toOption) match { + case None => AgentConnectService.calculateExpiresAt(now) + case Some(expiresAt) => IO.pure(expiresAt) + } + ) for { + expiresAt <- EitherT.right[Error](expiresAtIO) _ <- EitherT.right[Error](IO.blocking(userService.recordLogin(user.id))) - session <- userService.createNewUserSessionFromAgentConnect(user.id, none) + session <- userService.createNewUserSession( + user.id, + UserSession.LoginType.AgentConnect, + expiresAt, + request.remoteAddress + ) _ <- EitherT.right[Error] { val requestWithUserData = new RequestWithUserData(user, userRights, session.some, request) @@ -402,8 +427,24 @@ class LoginController @Inject() ( } def disconnect: Action[AnyContent] = - Action { - Redirect(routes.LoginController.login).withNewSession + Action.async { implicit request => + def result = Redirect(routes.LoginController.login).withNewSession + request.session.get(Keys.Session.sessionId) match { + case None => Future.successful(result) + case Some(sessionId) => + userService + .revokeUserSession(sessionId) + .flatMap( + _.fold( + e => + IO.blocking(eventService.logErrorNoUser(e)) + .as(InternalServerError(views.errors.public500(None))), + _ => IO.pure(result) + ) + ) + .unsafeToFuture() + } + } private val agentConnectErrorTitleFlashKey = "agentConnectErrorTitle" diff --git a/app/controllers/Operators.scala b/app/controllers/Operators.scala index a19aaffee..9c9bc6ae7 100644 --- a/app/controllers/Operators.scala +++ b/app/controllers/Operators.scala @@ -1,6 +1,7 @@ package controllers import actions.RequestWithUserData +import cats.effect.IO import cats.syntax.all._ import constants.Constants import helper.BooleanHelper.not @@ -146,32 +147,42 @@ object Operators { def applicationService: ApplicationService def eventService: EventService + private def applicationErrorResult(applicationId: UUID, error: Error): Result = + error match { + case _: Error.EntityNotFound | _: Error.RequirementFailed => + NotFound("Nous n'avons pas trouvé cette demande") + case _: Error.Authorization | _: Error.Authentication => + Unauthorized( + s"Vous n'avez pas les droits suffisants pour voir cette demande. " + + s"Vous pouvez contacter l'équipe A+ : ${Constants.supportEmail}" + ) + case _: Error.Database | _: Error.SqlException | _: Error.UnexpectedServerResponse | + _: Error.Timeout | _: Error.MiscException => + InternalServerError( + s"Une erreur s'est produite sur le serveur. " + + "Celle-ci semble être temporaire. Nous vous invitons à réessayer plus tard. " + + s"Si cette erreur persiste, " + + s"vous pouvez contacter l'équipe A+ : ${Constants.supportEmail}" + ) + } + private def manageApplicationError(applicationId: UUID, error: Error)(implicit request: RequestWithUserData[_], ec: ExecutionContext ): Future[Result] = { - val result = - error match { - case _: Error.EntityNotFound | _: Error.RequirementFailed => - NotFound("Nous n'avons pas trouvé cette demande") - case _: Error.Authorization | _: Error.Authentication => - Unauthorized( - s"Vous n'avez pas les droits suffisants pour voir cette demande. " + - s"Vous pouvez contacter l'équipe A+ : ${Constants.supportEmail}" - ) - case _: Error.Database | _: Error.SqlException | _: Error.UnexpectedServerResponse | - _: Error.Timeout | _: Error.MiscException => - InternalServerError( - s"Une erreur s'est produite sur le serveur. " + - "Celle-ci semble être temporaire. Nous vous invitons à réessayer plus tard. " + - s"Si cette erreur persiste, " + - s"vous pouvez contacter l'équipe A+ : ${Constants.supportEmail}" - ) - } + val result = applicationErrorResult(applicationId, error) eventService.logError(error) Future(result) } + private def manageApplicationErrorIO(applicationId: UUID, error: Error)(implicit + request: RequestWithUserData[_] + ): IO[Result] = { + val result = applicationErrorResult(applicationId, error) + IO.blocking(eventService.logError(error)) + .as(result) + } + def withApplication( applicationId: UUID )( @@ -190,6 +201,24 @@ object Operators { ) ) + def withApplicationIO( + applicationId: UUID + )( + payload: Application => IO[Result] + )(implicit request: RequestWithUserData[_]): IO[Result] = + applicationService + .byIdIO( + applicationId, + userId = request.currentUser.id, + rights = request.rights + ) + .flatMap( + _.fold( + error => manageApplicationErrorIO(applicationId, error), + (application: Application) => payload(application) + ) + ) + } } diff --git a/app/controllers/SignupController.scala b/app/controllers/SignupController.scala index d3e8d8eaa..bf5a7e297 100644 --- a/app/controllers/SignupController.scala +++ b/app/controllers/SignupController.scala @@ -18,7 +18,7 @@ import modules.AppConfig import org.webjars.play.WebJarsUtil import play.api.data.Form import play.api.i18n.I18nSupport -import play.api.mvc.{Action, AnyContent, InjectedController, Request, Result, Session} +import play.api.mvc.{Action, AnyContent, InjectedController, Request, Result} import scala.concurrent.{ExecutionContext, Future} import scala.util.Try import serializers.Keys @@ -50,7 +50,7 @@ case class SignupController @Inject() ( import dependencies.ioRuntime def signupForm: Action[AnyContent] = - withSignupInSession { implicit request => signupRequest => + withSignupInSession { implicit request => (signupRequest, _) => eventService.logSystem( EventType.SignupFormShowed, s"Visualisation de la page d'inscription ${signupRequest.id}" @@ -61,7 +61,7 @@ case class SignupController @Inject() ( } def createSignup: Action[AnyContent] = - withSignupInSession { implicit request => signupRequest => + withSignupInSession { implicit request => (signupRequest, loginExpiresAt) => SignupFormData.form .bindFromRequest() .fold( @@ -133,46 +133,18 @@ case class SignupController @Inject() ( }, _ => LoginAction.readUserRights(user).flatMap { userRights => - val agentConnectSubject = - request.session.get(Keys.Session.signupAgentConnectSubject) - - val sessions: EitherT[IO, Error, (Session, Option[UserSession])] = - agentConnectSubject match { - case None => - EitherT.pure( - ( - request.session - Keys.Session.signupId + - (Keys.Session.userId -> user.id.toString), - none - ) - ) - case Some(subject) => - val loginExpiresAt = request.session - .get(Keys.Session.signupLoginExpiresAt) - .flatMap(epoch => Try(Instant.ofEpochSecond(epoch.toLong)).toOption) - for { - _ <- EitherT( - userService.linkUserToAgentConnectClaims(user.id, subject) - ) - session <- userService - .createNewUserSessionFromAgentConnect(user.id, loginExpiresAt) - } yield ( - ( - request.session - - Keys.Session.signupId - - Keys.Session.signupAgentConnectSubject - - Keys.Session.signupLoginExpiresAt + - (Keys.Session.userId -> user.id.toString) + - (Keys.Session.sessionId -> session.id), - session.some - ) - ) - } - ( for { - (playSession, userSession) <- sessions - _ <- EitherT.right[Error](IO.blocking(userService.recordLogin(user.id))) + loginType <- maybeLinkUserToAgentConnectClaims(user.id, request) + userSession <- userService + .createNewUserSession( + user.id, + loginType, + loginExpiresAt, + request.remoteAddress, + ) + _ <- EitherT + .right[Error](IO.blocking(userService.recordLogin(user.id))) _ <- EitherT.right[Error]( IO.blocking( eventService.log( @@ -181,11 +153,17 @@ case class SignupController @Inject() ( s"(créateur de la préinscription : ${signupRequest.invitingUserId})", s"Utilisateur ${user.toLogString}".some, involvesUser = signupRequest.invitingUserId.some - )(new RequestWithUserData(user, userRights, userSession, request)) + )( + new RequestWithUserData(user, userRights, userSession.some, request) + ) ) ) } yield Redirect(routes.HomeController.welcome) - .withSession(playSession) + .removingFromSession(LoginAction.signupSessionKeys: _*) + .addingToSession( + Keys.Session.userId -> user.id.toString, + Keys.Session.sessionId -> userSession.id, + ) .flashing( "success" -> "Votre compte est maintenant créé. Merci d’utiliser Administration+." ) @@ -346,65 +324,115 @@ case class SignupController @Inject() ( ) ) + private def maybeLinkUserToAgentConnectClaims( + userId: UUID, + request: Request[_] + ): EitherT[IO, Error, UserSession.LoginType] = + request.session.get(Keys.Session.signupAgentConnectSubject) match { + case None => EitherT.rightT[IO, Error](UserSession.LoginType.MagicLink) + case Some(subject) => + EitherT( + userService.linkUserToAgentConnectClaims(userId, subject) + ).map(_ => UserSession.LoginType.AgentConnect) + } + /** Note: parameter is curried to easily mark `Request` as implicit. */ private def withSignupInSession( - action: Request[_] => SignupRequest => Future[Result] + action: Request[_] => (SignupRequest, Instant) => Future[Result] ): Action[AnyContent] = Action.async { implicit request => val signupOpt = request.session.get(Keys.Session.signupId).flatMap(UUIDHelper.fromString) + + val loginExpiresAtOpt = request.session + .get(Keys.Session.signupLoginExpiresAt) + .flatMap(epoch => Try(Instant.ofEpochSecond(epoch.toLong)).toOption) + signupOpt match { case None => val message = "Merci de vous connecter pour accéder à cette page." Future.successful( Redirect(routes.LoginController.login) .flashing("error" -> message) - .withSession(request.session - Keys.Session.signupId) + .removingFromSession(LoginAction.signupSessionKeys: _*) ) case Some(signupId) => - signupService - .byId(signupId) - .flatMap( - _.fold( - e => { - eventService.logErrorNoUser(e) - Future.successful( - Redirect(routes.LoginController.login) - .flashing("error" -> Constants.error500FlashMessage) - .withSession(request.session - Keys.Session.signupId) - ) - }, - { - case None => - eventService.logSystem( - EventType.MissingSignup, - s"Tentative d'inscription avec l'id $signupId en session, mais n'existant pas en BDD" - ) - val message = "Une erreur interne est survenue. " + - "Si celle-ci persiste, vous pouvez contacter le support Administration+." - Future.successful( - Redirect(routes.LoginController.login) - .flashing("error" -> message) - .withSession(request.session - Keys.Session.signupId) - ) - case Some(signupRequest) => - userService.byEmailFuture(signupRequest.email).flatMap { - case None => action(request)(signupRequest) - case Some(existingUser) => - // The user exists already, we exchange its signup session by a user session - // (this case happen if the signup session has not been purged after user creation) - IO.blocking(userService.recordLogin(existingUser.id)) - .as( - Redirect(routes.HomeController.welcome) - .withSession( - request.session - Keys.Session.userId - Keys.Session.signupId + - (Keys.Session.userId -> existingUser.id.toString) + // TODO: we allow loginExpiresAtOpt === None as legacy, it should be removed + val loginExpiresAt = loginExpiresAtOpt.getOrElse( + Instant.now().plusSeconds(config.magicLinkSessionDurationInSeconds) + ) + val isExpired = Instant.now().isAfter(loginExpiresAt) + if (isExpired) { + eventService.logSystem( + EventType.SignupLoginExpired, + s"Session de la préinscription $signupId expirée (expiration : $loginExpiresAt)" + ) + val message = "Votre session a expiré. Veuillez vous reconnecter. " + Future.successful( + Redirect(routes.LoginController.login) + .flashing("error" -> message) + .removingFromSession(LoginAction.signupSessionKeys: _*) + ) + } else { + signupService + .byId(signupId) + .flatMap( + _.fold( + e => { + eventService.logErrorNoUser(e) + // TODO (accessibility): we want the logged in error page here + Future.successful(InternalServerError(views.errors.public500(None))) + }, + { + case None => + eventService.logSystem( + EventType.MissingSignup, + s"Tentative d'inscription avec l'id $signupId en session, mais n'existant pas en BDD" + ) + val message = "Une erreur interne est survenue. " + + "Si celle-ci persiste, vous pouvez contacter le support Administration+." + Future.successful( + Redirect(routes.LoginController.login) + .flashing("error" -> message) + .removingFromSession(LoginAction.signupSessionKeys: _*) + ) + case Some(signupRequest) => + userService.byEmailFuture(signupRequest.email).flatMap { + case None => action(request)(signupRequest, loginExpiresAt) + case Some(existingUser) => + // The user exists already, we exchange its signup session by a user session + // (this case happen if the signup session has not been purged after user creation) + ( + for { + loginType <- maybeLinkUserToAgentConnectClaims( + existingUser.id, + request + ) + userSession <- userService + .createNewUserSession( + existingUser.id, + loginType, + loginExpiresAt, + request.remoteAddress, + ) + _ <- EitherT + .right[Error](IO.blocking(userService.recordLogin(existingUser.id))) + } yield Redirect(routes.HomeController.welcome) + .removingFromSession(LoginAction.signupSessionKeys: _*) + .addingToSession( + Keys.Session.userId -> existingUser.id.toString, + Keys.Session.sessionId -> userSession.id, ) ) - .unsafeToFuture() - } - } + .valueOrF(error => + IO.blocking(eventService.logErrorNoUser(error)) + .as(InternalServerError(views.errors.public500(None))) + ) + .unsafeToFuture() + } + } + ) ) - ) + } } } diff --git a/app/helper/Crypto.scala b/app/helper/Crypto.scala new file mode 100644 index 000000000..284e7cfc7 --- /dev/null +++ b/app/helper/Crypto.scala @@ -0,0 +1,184 @@ +package helper + +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.util.{Arrays, Base64} +import javax.crypto.{Cipher, KeyGenerator, SecretKey} +import javax.crypto.spec.{IvParameterSpec, SecretKeySpec} +import scala.util.Try + +/** This singleton regroups high level functions using AEAD with ChaCha20-Poly1305 + * + * Libsodium gives a summary of limitations of AEAD schemes: + * https://doc.libsodium.org/secret-key_cryptography/aead + * + * Java code for ChaCha20-Poly1305 can be found here: https://openjdk.java.net/jeps/329 + * + * Rekeying: + * - NIST recommandations: + * - SP 800-57 Part 1 Rev. 5 + * - Recommendation for Key Management: Part 1 – General + * https://csrc.nist.gov/publications/detail/sp/800-57-part-1/rev-5/final 5.6.4 + * - Transitioning to New Algorithms and Key Sizes in Systems If the protected data is + * retained, it should be re-protected using an approved algorithm and key size that will + * protect the information for the remainder of its security life. + */ +object Crypto { + + // Key generator for use with the ChaCha20 and ChaCha20-Poly1305 algorithms. + // https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html + val KEYGENERATOR_ALGORITHM = "ChaCha20" + val KEY_SIZE_BYTES = 32 + val KEY_SIZE_BITS = 256 + val AE_ALGORITHM = "ChaCha20-Poly1305" + val NONCE_SIZE_BYTES = 12 // 96 bits + val TAG_SIZE_BYTES = 16 // the authentication tag is 128 bits + + // Basic Base64 Alphabet (contains '+' and '/') + // https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Base64.html + val base64Decoder = Base64.getDecoder() + val base64Encoder = Base64.getEncoder() + + case class Key(key: SecretKey) + + def generateRandomKey(): Key = { + val generator = KeyGenerator.getInstance(KEYGENERATOR_ALGORITHM) + val random = SecureRandom.getInstanceStrong() + generator.init(KEY_SIZE_BITS, random) + val key = generator.generateKey() + Key(key) + } + + def decodeKeyBase64(base64Key: String): Key = { + val bytes = base64Decoder.decode(base64Key) + val key = new SecretKeySpec(bytes, KEYGENERATOR_ALGORITHM) + Key(key) + } + + def encodeKeyBase64(key: Key): String = + base64Encoder.encodeToString(key.key.getEncoded) + + private def encryptCipher(aad: String, key: Key): (Cipher, Array[Byte]) = { + val cipher = Cipher.getInstance(AE_ALGORITHM) + val nonce = { + val nonce = Array.ofDim[Byte](NONCE_SIZE_BYTES) + new SecureRandom().nextBytes(nonce) + nonce + } + cipher.init(Cipher.ENCRYPT_MODE, key.key, new IvParameterSpec(nonce)) + cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8)) + (cipher, nonce) + } + + /** Returns (nonce || ciphertext) */ + def encryptBytes(plaintext: Array[Byte], aad: String, key: Key): Try[Array[Byte]] = Try { + val (cipher, nonce) = encryptCipher(aad, key) + + // Always plaintext.length + 16 bytes of ChaCha20-Poly1305 authentication tag + val outputSize = cipher.getOutputSize(plaintext.length) + + // We allocate only one Array for the ciphertext + val ciphertextWithNonce = Arrays.copyOf(nonce, NONCE_SIZE_BYTES + outputSize) + + // Fills the result Array after the nonce + cipher.doFinal(plaintext, 0, plaintext.length, ciphertextWithNonce, NONCE_SIZE_BYTES) + + ciphertextWithNonce + } + + /** Returns base64(nonce || ciphertext) */ + def encryptField(plaintext: String, aad: String, key: Key): Try[String] = + encryptBytes(plaintext.getBytes(StandardCharsets.UTF_8), aad, key) + .map(base64Encoder.encodeToString) + + private def decryptCipher(nonce: Array[Byte], aad: String, key: Key): Cipher = { + val cipher = Cipher.getInstance(AE_ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, key.key, new IvParameterSpec(nonce)) + cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8)) + cipher + } + + def decryptBytes(ciphertextWithNonce: Array[Byte], aad: String, key: Key): Try[Array[Byte]] = + Try { + val nonce = Arrays.copyOf(ciphertextWithNonce, NONCE_SIZE_BYTES) + val cipher = decryptCipher(nonce, aad, key) + val plaintext = { + val inputOffset = NONCE_SIZE_BYTES + val inputLen = ciphertextWithNonce.length - NONCE_SIZE_BYTES + // We avoid allocating a new Array + cipher.doFinal(ciphertextWithNonce, inputOffset, inputLen) + } + plaintext + } + + def decryptField(ciphertextBase64: String, aad: String, key: Key): Try[String] = + Try(base64Decoder.decode(ciphertextBase64)) + .flatMap(ciphertextWithNonce => decryptBytes(ciphertextWithNonce, aad, key)) + .flatMap(plaintext => Try(new String(plaintext, StandardCharsets.UTF_8))) + + /** ******************** // Stream Utilities // ********************* + * + * This implementation buffers the whole file before encrypting it. This is done to reduce + * complexity as encrypting by chunks in a robust way is difficult. (See references below) + * + * Implementation note: Encryption/Decryption is CPU blocking, we use IO instead of IO.blocking + * in order to stay on the compute pool. + * + * References: + * - An implementation is done in + * https://github.com/jwojnowski/fs2-aes/blob/main/src/main/scala/me/wojnowski/fs2/aes/Aes.scala + * however we do not use it as it is vulnerable to chunks reordering. Since we do not allow + * uploads of more than 10Mb, we avoid adding the complexity of a secure chunking strategy. + * - The java class used to encrypt files + * https://docs.oracle.com/javase/8/docs/api/javax/crypto/CipherInputStream.html is noted as + * potentially not suitable for use with decryption in an authenticated mode of operation. + * - libsodium documentation on ChaCha20-Poly1305: + * https://libsodium.gitbook.io/doc/secret-key_cryptography/aead/chacha20-poly1305 + * - Exchange on the difficulty in implementing encryption by chunks: + * https://crypto.stackexchange.com/questions/106983/encrypting-arbitrary-large-files-in-aead-chunks-how-to-protect-against-chunk-r + * - The fs2 streaming implementation of hashing: + * https://github.com/typelevel/fs2/blob/24a3e72b9dabd5076c8696586df7bc727da4c9be/core/jvm/src/main/scala/fs2/hash.scala#L54 + */ + object stream { + import cats.effect.IO + import fs2.{Chunk, Pipe, Stream} + + /** The implementation tries to reduce copies to a minimum. */ + def encrypt(aad: String, key: Key): Pipe[IO, Byte, Byte] = + in => + Stream.eval(IO(Crypto.encryptCipher(aad, key))).flatMap { case (cipher, nonce) => + Stream.emits(nonce) ++ + in.chunks.noneTerminate + .evalMapAccumulate(cipher) { (cipher, chunkOpt) => + chunkOpt match { + case None => + val f = cipher.doFinal() + IO((cipher, Some(Chunk.array(f)))) + case Some(chunk) => + val next = + Option(cipher.update(chunk.toArray)) + .filter(_.nonEmpty) + .map(a => Chunk.array(a)) + IO((cipher, next)) + } + } + .evalMapFilter { case (_, c) => IO.pure(c) } + .unchunks + } + + def decrypt(aad: String, key: Key): Pipe[IO, Byte, Byte] = + // `.chunkAll` is efficient and does not copy chunks + _.chunkAll + .evalMap { chunk => + IO { + val (nonce, ciphertext) = chunk.splitAt(NONCE_SIZE_BYTES) + val cipher = decryptCipher(nonce.toArray, aad, key) + val plaintext = cipher.doFinal(ciphertext.toArray) + Chunk.array(plaintext) + } + } + .unchunks + + } + +} diff --git a/app/helper/Time.scala b/app/helper/Time.scala index 09b6c1b0e..8879dfc4d 100644 --- a/app/helper/Time.scala +++ b/app/helper/Time.scala @@ -4,6 +4,7 @@ import cats.Order import java.time.{Instant, LocalDate, ZoneId, ZonedDateTime} import java.time.format.DateTimeFormatter import java.util.Locale +import scala.concurrent.duration.FiniteDuration object Time { @@ -42,6 +43,15 @@ object Time { val dateWithHourFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/YYYY H'h'", Locale.FRANCE) + def readableDuration(duration: FiniteDuration): String = { + val millis = duration.toMillis + val hours = millis / 3600000 + val minutes = (millis % 3600000) / 60000 + val seconds = (millis % 60000) / 1000 + val remainingMillis = millis % 1000 + f"$hours%02dh:$minutes%02dm:$seconds%02ds.$remainingMillis%03d" + } + implicit final val zonedDateTimeInstance: Order[ZonedDateTime] = new Order[ZonedDateTime] { override def compare(x: ZonedDateTime, y: ZonedDateTime): Int = x.compareTo(y) diff --git a/app/models/EventType.scala b/app/models/EventType.scala index 9afa1117f..47120f4c8 100644 --- a/app/models/EventType.scala +++ b/app/models/EventType.scala @@ -188,6 +188,7 @@ object EventType { object InvalidToken extends Warn object TokenError extends Error object MissingSignup extends Warn + object SignupLoginExpired extends Info object AuthByKey extends Info // Incorrectly named (this is an auth by token) object AuthWithDifferentIp extends Warn object LoginByKey extends Info @@ -204,6 +205,7 @@ object EventType { object FileAvailable extends Info object FileQuarantined extends Warn object FileScanError extends Error + object FileError extends Error object FilesDeletion extends Info object FileDeletionError extends Error diff --git a/app/models/FileMetadata.scala b/app/models/FileMetadata.scala index 462661314..259dbfd0f 100644 --- a/app/models/FileMetadata.scala +++ b/app/models/FileMetadata.scala @@ -12,6 +12,7 @@ case class FileMetadata( filesize: Int, status: FileMetadata.Status, attached: FileMetadata.Attached, + encryptionKeyId: Option[String], ) object FileMetadata { diff --git a/app/models/UserSession.scala b/app/models/UserSession.scala index a54c0517f..5ae87e4ac 100644 --- a/app/models/UserSession.scala +++ b/app/models/UserSession.scala @@ -9,6 +9,8 @@ object UserSession { object LoginType { case object AgentConnect extends LoginType + case object InsecureDemoKey extends LoginType + case object MagicLink extends LoginType } } @@ -17,8 +19,14 @@ case class UserSession( id: String, userId: UUID, creationDate: Instant, + creationIpAddress: String, lastActivity: Instant, loginType: UserSession.LoginType, expiresAt: Instant, - isRevoked: Option[Boolean], -) + revokedAt: Option[Instant], +) { + + def isValid(now: Instant): Boolean = + now.isBefore(expiresAt) && revokedAt.map(revokedAt => now.isBefore(revokedAt)).getOrElse(true) + +} diff --git a/app/models/dataModels.scala b/app/models/dataModels.scala index d0f030582..2c4779177 100644 --- a/app/models/dataModels.scala +++ b/app/models/dataModels.scala @@ -139,7 +139,8 @@ object dataModels { filesize: Int, status: String, applicationId: Option[UUID], - answerId: Option[UUID] + answerId: Option[UUID], + encryptionKeyId: Option[String], ) { import FileMetadata._ @@ -165,7 +166,8 @@ object dataModels { filename = filename, filesize = filesize, status = status, - attached = attached + attached = attached, + encryptionKeyId = encryptionKeyId, ) } } @@ -196,7 +198,8 @@ object dataModels { filesize = metadata.filesize, status = statusFromFileMetadata(metadata.status), applicationId = applicationId, - answerId = answerId + answerId = answerId, + encryptionKeyId = metadata.encryptionKeyId, ) } diff --git a/app/modules/AppConfig.scala b/app/modules/AppConfig.scala index 84aa6de7b..5f8aea112 100644 --- a/app/modules/AppConfig.scala +++ b/app/modules/AppConfig.scala @@ -1,7 +1,7 @@ package modules import com.typesafe.config.{Config, ConfigFactory} -import helper.UUIDHelper +import helper.{Crypto, UUIDHelper} import java.nio.file.{Files, Path, Paths} import java.util.UUID import javax.inject.{Inject, Singleton} @@ -18,6 +18,9 @@ class AppConfig @Inject() (configuration: Configuration) { val tokenExpirationInMinutes: Int = configuration.get[Int]("app.tokenExpirationInMinutes") + val magicLinkSessionDurationInSeconds: Long = + configuration.get[Long]("app.magicLinkSessionDurationInSeconds") + val featureMandatSms: Boolean = configuration.get[Boolean]("app.features.smsMandat") val useLiveSmsApi: Boolean = configuration @@ -40,18 +43,46 @@ class AppConfig @Inject() (configuration: Configuration) { dir } - // This is a feature that is temporary and should be activated - // for short period of time during migrations for smooth handling of files. - // Just remove the env variable FILES_SECOND_INSTANCE_HOST to deactivate. - val filesSecondInstanceHost: Option[String] = - configuration.getOptional[String]("app.filesSecondInstanceHost") - val filesExpirationInDays: Int = configuration.get[Int]("app.filesExpirationInDays") + val filesOvhS3AccessKey: String = configuration.get[String]("app.filesOvhS3AccessKey") + val filesOvhS3SecretKey: String = configuration.get[String]("app.filesOvhS3SecretKey") + val filesOvhS3Endpoint: String = configuration.get[String]("app.filesOvhS3Endpoint") + val filesOvhS3Region: String = configuration.get[String]("app.filesOvhS3Region") + val filesOvhS3Bucket: String = configuration.get[String]("app.filesOvhS3Bucket") + + val filesCurrentEncryptionKeyId: String = + configuration.get[String]("app.filesCurrentEncryptionKeyId") + + /** Each key has an id, the id of the key used to encrypt a file is stored in the database as file + * metadata. + */ + val filesEncryptionKeys: Map[String, Crypto.Key] = + configuration + .get[String]("app.filesEncryptionKeys") + .split(",") + .map(_.split(":") match { + case Array(id, key) => (id, Crypto.decodeKeyBase64(key)) + case _ => + throw new Exception( + "Invalid `app.filesEncryptionKeys` format, should be of the form 'keyid1:,keyid2:'" + ) + }) + .toMap + + val filesCurrentEncryptionKey: Crypto.Key = + filesEncryptionKeys.get(filesCurrentEncryptionKeyId) match { + case None => + throw new Exception( + s"Cannot find current file encryption key '$filesCurrentEncryptionKeyId'" + ) + case Some(key) => key + } + val topHeaderWarningMessage: Option[String] = configuration.getOptional[String]("app.topHeaderWarningMessage") - val areasWithLoginByKey: List[UUID] = configuration + val insecureAreasWithLoginByKey: List[UUID] = configuration .get[String]("app.areasWithLoginByKey") .split(",") .flatMap(UUIDHelper.fromString) diff --git a/app/services/ApplicationService.scala b/app/services/ApplicationService.scala index c629427cd..911582ae6 100644 --- a/app/services/ApplicationService.scala +++ b/app/services/ApplicationService.scala @@ -2,6 +2,7 @@ package services import anorm._ import aplus.macros.Macros +import cats.effect.IO import cats.syntax.all._ import helper.StringHelper.StringListOps import java.sql.Connection @@ -102,35 +103,39 @@ class ApplicationService @Inject() ( row.map(_.toApplication(answersRows)) } - def byId(id: UUID, userId: UUID, rights: UserRights): Future[Either[Error, Application]] = - Future { - db.withTransaction { implicit connection => - val result = byId(id) match { - case Some(application) => - val newSeen = SeenByUser.now(userId) - val seenByUsers = newSeen :: application.seenByUsers.filter(_.userId =!= userId) - setSeenByUsers(id, seenByUsers) - case None => empty[Application] - } - result match { - case None => - val message = s"Tentative d'accès à une application inexistante: $id" - Error.EntityNotFound(EventType.ApplicationNotFound, message, none).asLeft[Application] - case Some(application) => - if (Authorization.canSeeApplication(application)(rights)) { - if (Authorization.canSeePrivateDataOfApplication(application)(rights)) - application.asRight[Error] - else application.anonymousApplication.asRight[Error] - } else { - val message = s"Tentative d'accès à une application non autorisé: $id" - Error - .Authorization(EventType.ApplicationUnauthorized, message, none) - .asLeft[Application] - } - } + private def byIdBlocking(id: UUID, userId: UUID, rights: UserRights): Either[Error, Application] = + db.withTransaction { implicit connection => + val result = byId(id) match { + case Some(application) => + val newSeen = SeenByUser.now(userId) + val seenByUsers = newSeen :: application.seenByUsers.filter(_.userId =!= userId) + setSeenByUsers(id, seenByUsers) + case None => empty[Application] + } + result match { + case None => + val message = s"Tentative d'accès à une application inexistante: $id" + Error.EntityNotFound(EventType.ApplicationNotFound, message, none).asLeft[Application] + case Some(application) => + if (Authorization.canSeeApplication(application)(rights)) { + if (Authorization.canSeePrivateDataOfApplication(application)(rights)) + application.asRight[Error] + else application.anonymousApplication.asRight[Error] + } else { + val message = s"Tentative d'accès à une application non autorisé: $id" + Error + .Authorization(EventType.ApplicationUnauthorized, message, none) + .asLeft[Application] + } } } + def byId(id: UUID, userId: UUID, rights: UserRights): Future[Either[Error, Application]] = + Future(byIdBlocking(id, userId, rights)) + + def byIdIO(id: UUID, userId: UUID, rights: UserRights): IO[Either[Error, Application]] = + IO.blocking(byIdBlocking(id, userId, rights)) + def openAndOlderThan(numberOfDays: Int): List[Application] = db.withConnection { implicit connection => val rows = SQL(s""" diff --git a/app/services/FileService.scala b/app/services/FileService.scala index c37e351d2..efab292c1 100644 --- a/app/services/FileService.scala +++ b/app/services/FileService.scala @@ -3,126 +3,262 @@ package services import anorm._ import aplus.macros.Macros import cats.data.EitherT +import cats.effect.IO import cats.syntax.all._ +import eu.timepit.refined.types.string.NonEmptyString +import fs2.Stream +import fs2.aws.s3.S3 +import fs2.aws.s3.models.Models.{BucketName, FileKey} +import fs2.io.file.{Files => FsFiles, Path => FsPath} +import helper.Crypto import helper.StringHelper.normalizeNFKC -import java.nio.file.{Files, Path, Paths} +import io.laserdisc.pure.s3.tagless.{Interpreter => S3Interpreter} +import java.net.URI +import java.nio.file.{Files, Path => NioPath, Paths} import java.time.{Instant, ZonedDateTime} +import java.time.temporal.ChronoUnit.DAYS import java.util.UUID import javax.inject.{Inject, Singleton} import models.{Error, EventType, FileMetadata, User} import models.dataModels.FileMetadataRow import modules.AppConfig -import org.apache.pekko.Done -import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.stream.scaladsl._ +import org.apache.pekko.stream.scaladsl.Source +import org.reactivestreams.FlowAdapters import play.api.db.Database -import play.api.libs.concurrent.{ActorSystemProvider, MaterializerProvider} +import play.api.inject.ApplicationLifecycle import play.api.mvc.Request import scala.concurrent.{ExecutionContext, Future} import scala.util.Try +import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3AsyncClient +import software.amazon.awssdk.services.s3.model.{HeadObjectRequest, NoSuchKeyException} @Singleton class FileService @Inject() ( config: AppConfig, db: Database, + dependencies: ServicesDependencies, eventService: EventService, - materializer: MaterializerProvider, + lifecycle: ApplicationLifecycle, notificationsService: NotificationService, - system: ActorSystemProvider )(implicit ec: ExecutionContext) { - implicit val actorSystem: ActorSystem = system.get + + import dependencies.ioRuntime + + private val credentials: AwsBasicCredentials = AwsBasicCredentials.create( + config.filesOvhS3AccessKey, + config.filesOvhS3SecretKey + ) + + private def fileAAD(fileId: UUID): String = s"File_$fileId" + + private val ovhS3Client = S3AsyncClient + .builder() + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .endpointOverride(URI.create(config.filesOvhS3Endpoint)) + .region(Region.of(config.filesOvhS3Region)) + .build() + + lifecycle.addStopHook { () => + Future(ovhS3Client.close()) + } + + private val bucket = BucketName(NonEmptyString.unsafeFrom(config.filesOvhS3Bucket)) + private def s3fileName(fileId: UUID) = FileKey(NonEmptyString.unsafeFrom(s"$fileId")) + + def ovhS3 = S3.create(S3Interpreter[IO].create(ovhS3Client)) // Play is supposed to give us temporary files here def saveFiles( - pathsWithFilenames: List[(Path, String)], + pathsWithFilenames: List[(NioPath, String)], document: FileMetadata.Attached, uploader: User )(implicit request: Request[_] ): Future[Either[Error, List[FileMetadata]]] = { - val result: EitherT[Future, Error, List[(Path, FileMetadata)]] = pathsWithFilenames.traverse { - case (path, filename) => + val result: EitherT[Future, Error, List[(NioPath, FileMetadata)]] = + pathsWithFilenames.traverse { case (path, filename) => val metadata = FileMetadata( id = UUID.randomUUID(), uploadDate = Instant.now(), filename = normalizeNFKC(filename), - filesize = path.toFile.length().toInt, + // Note that Play does it that way: https://github.com/playframework/playframework/blob/fbe1c146e17ad3a0dc58d65ffd30c6640602f33d/core/play/src/main/scala/play/api/mvc/Results.scala#L651 + filesize = Files.size(path).toInt, status = FileMetadata.Status.Scanning, attached = document, + encryptionKeyId = config.filesCurrentEncryptionKeyId.some, ) EitherT(insertMetadata(metadata)).map(_ => (path, metadata)) - } + } // Scan in background, only on success, and sequentially result.value.foreach { case Right(metadataList) => - scanFilesBackground(metadataList, uploader) + handleUploadedFilesAndDeleteFromFs( + metadataList.map { case (path, metadata) => (FsPath.fromNioPath(path), metadata) }, + uploader + ).unsafeRunAndForget() case _ => } result.map(_.map { case (_, metadata) => metadata }).value } - def fileMetadata(fileId: UUID): Future[Either[Error, Option[(Path, FileMetadata)]]] = + def fileMetadata(fileId: UUID): IO[Either[Error, Option[(NioPath, FileMetadata)]]] = byId(fileId).map( _.map(_.map(metadata => (Paths.get(s"${config.filesPath}/$fileId"), metadata))) ) - private def scanFilesBackground(metadataList: List[(Path, FileMetadata)], uploader: User)(implicit + /** This is the "official" way to check https://stackoverflow.com/a/56038360 + * + * The AWS library throws software.amazon.awssdk.services.s3.model.NoSuchKeyException when the + * file does not exist. + * + * See also https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html + */ + def fileExistsOnS3(fileId: UUID): IO[Either[Error, Boolean]] = + S3Interpreter[IO] + .create(ovhS3Client) + .headObject( + HeadObjectRequest + .builder() + .bucket(bucket.value.value) + .key(s3fileName(fileId).value.value) + .build() + ) + .map(_ => true) + .recover { case _: NoSuchKeyException => false } + .attempt + .map( + _.left.map(e => + Error.MiscException( + EventType.FileError, + s"Impossible de vérifier si le fichier $fileId existe", + e, + none + ) + ) + ) + + /** Array[Byte] is used here in order to have the least amount of copy. Play uses akka ByteString + * which is a wrapper around Array[Byte]. We cannot do a 0-copy implementation due to fs2-io not + * allowing mutable access. This def is written in a way that it does only 1 copy of the data. + * + * `.unsafeToPublisher()` is used instead of `.toPublisherResource` because Play cannot handle + * Resource and using it would have the publisher closed before play begins streaming. + * + * See also `.toPublisher` in + * https://www.javadoc.io/doc/co.fs2/fs2-docs_2.13/3.10.2/fs2/interop/flow/index.html + * + * S3 wrapper + * https://github.com/laserdisc-io/fs2-aws/blob/main/pure-aws/pure-s3-tagless/src/main/scala/io/laserdisc/pure/s3/tagless/Interpreter.scala + * S3 implementation + * https://github.com/laserdisc-io/fs2-aws/blob/main/fs2-aws-s3/src/main/scala/fs2/aws/s3/S3.scala + */ + def fileStream(file: FileMetadata): Either[Error, Source[Array[Byte], _]] = + file.encryptionKeyId + .flatMap(config.filesEncryptionKeys.get) + .toRight( + Error.EntityNotFound( + EventType.FileMetadataError, + s"Clé de chiffrement non trouvée pour le fichier ${file.id}, " + + s"la clé ${file.encryptionKeyId} n'est pas dans l'environnement", + none + ) + ) + .map(decryptionKey => + Source + .fromPublisher( + FlowAdapters.toPublisher( + ovhS3 + .readFile(bucket, s3fileName(file.id)) + .through(Crypto.stream.decrypt(fileAAD(file.id), decryptionKey)) + .chunks + .map(_.toArray) // Copies each chunk into an Array + .unsafeToPublisher() + ) + ) + ) + + private def uploadFile(path: FsPath, metadata: FileMetadata, uploader: User)(implicit request: Request[_] - ): Future[Done] = - // sequential => parallelism = 1 - Source - .fromIterator(() => metadataList.iterator) - .mapAsync(1) { case (path, metadata) => - val scanResult: Future[Either[Error, Unit]] = { - // TODO: rewrite this with cats-effect - // if (config.clamAvIsEnabled) { - // } else { + ): Stream[IO, Unit] = + FsFiles[IO] + .readAll(path) + .through(Crypto.stream.encrypt(fileAAD(metadata.id), config.filesCurrentEncryptionKey)) + .through(ovhS3.uploadFile(bucket, s3fileName(metadata.id))) + .evalMap(etag => + IO.blocking { eventService.logSystem( EventType.FileAvailable, - s"Le fichier ${metadata.id} est disponible. ClamAV est désactivé. " + - "Aucun scan n'a été effectué" + s"Upload du fichier ${metadata.id} terminé avec etag $etag" ) - val fileDestination = Paths.get(s"${config.filesPath}/${metadata.id}") - Files.copy(path, fileDestination) - Files.deleteIfExists(path) - updateStatus(metadata.id, FileMetadata.Status.Available) } + ) + .evalMap(_ => updateStatus(metadata.id, FileMetadata.Status.Available)) + // Any error in updateStatus, we try to put the status to error and log everything we can + .evalMap( + _.fold( + error => + IO.blocking(eventService.logErrorNoUser(error)) >> + updateStatus(metadata.id, FileMetadata.Status.Error).flatMap( + _.fold(e => IO.blocking(eventService.logErrorNoUser(e)), _ => IO.pure(())) + ), + _ => IO.pure(()) + ) + ) + // Log residual errors and if there are any, try to set file status to Error + .attempt + .evalMap( + _.fold( + error => + IO.blocking( + eventService.logSystem( + EventType.FileScanError, + s"Erreur lors de l'upload ou la recherche de virus dans le fichier ${metadata.id}", + underlyingException = Some(error) + ) + ) >> + updateStatus(metadata.id, FileMetadata.Status.Error).flatMap( + _.fold(e => IO.blocking(eventService.logErrorNoUser(e)), Function.const(IO.unit)) + ) >> + IO.blocking( + notificationsService + .fileUploadStatus(metadata.attached, FileMetadata.Status.Error, uploader) + ), + _ => IO.pure(()) + ) + ) - scanResult - .map { - case Right(_) => () - case Left(error) => - eventService.logErrorNoUser(error) - Files.deleteIfExists(path) - val status = FileMetadata.Status.Error - updateStatus(metadata.id, status) - .foreach(_.left.foreach(e => eventService.logErrorNoUser(e))) - } - .recover { case error => + /** Will also delete the files */ + private def handleUploadedFilesAndDeleteFromFs( + metadataList: List[(FsPath, FileMetadata)], + uploader: User + )(implicit + request: Request[_] + ): IO[Unit] = + Stream + .bracket(IO.pure(metadataList)) { metadataList => + // Whatever happens, we want the files to be deleted from the filesystem + metadataList.traverse { case (path, _) => FsFiles[IO].deleteIfExists(path) }.void + } + .flatMap(metadataList => Stream.emits(metadataList)) + .flatMap { case (path, metadata) => uploadFile(path, metadata, uploader) } + .handleErrorWith(error => + Stream.eval( + IO.blocking( eventService.logSystem( EventType.FileScanError, - s"Erreur lors de la recherche de virus dans le fichier ${metadata.id}", + s"Erreur imprévue (bug) durant la recherche de virus dans les fichiers " + + metadataList.map { case (_, metadata) => metadata.id }, underlyingException = Some(error) ) - Files.deleteIfExists(path) - val status = FileMetadata.Status.Error - updateStatus(metadata.id, status) - .foreach(_.left.foreach(e => eventService.logErrorNoUser(e))) - notificationsService.fileUploadStatus(metadata.attached, status, uploader) - } - } - .run() - .recover { case error => - eventService.logSystem( - EventType.FileScanError, - s"Erreur imprévue (bug) durant la recherche de virus dans les fichiers " + - metadataList.map { case (_, metadata) => metadata.id }, - underlyingException = Some(error) + ) ) - Done - } + ) + .compile + .drain private val (fileMetadataRowParser, tableFields) = Macros.parserWithFields[FileMetadataRow]( "id", @@ -131,50 +267,52 @@ class FileService @Inject() ( "filesize", "status", "application_id", - "answer_id" + "answer_id", + "encryption_key_id", ) private val fieldsInSelect: String = tableFields.mkString(", ") - private def byId(fileId: UUID): Future[Either[Error, Option[FileMetadata]]] = - Future( - Try( - db.withConnection { implicit connection => - SQL(s"""SELECT $fieldsInSelect FROM file_metadata WHERE id = {fileId}::uuid""") - .on("fileId" -> fileId) - .as(fileMetadataRowParser.singleOpt) - } - ).toEither.left - .map(e => - Error.SqlException( - EventType.FileMetadataError, - s"Impossible de chercher la metadata de fichier $fileId", - e, - none + private def byId(fileId: UUID): IO[Either[Error, Option[FileMetadata]]] = + IO.blocking( + db.withConnection { implicit connection => + SQL(s"""SELECT $fieldsInSelect FROM file_metadata WHERE id = {fileId}::uuid""") + .on("fileId" -> fileId) + .as(fileMetadataRowParser.singleOpt) + } + ).attempt + .map( + _.left + .map(e => + Error.SqlException( + EventType.FileMetadataError, + s"Impossible de chercher la metadata de fichier $fileId", + e, + none + ) ) - ) - .flatMap { - case None => none.asRight - case Some(row) => - row.toFileMetadata match { - case None => - Error - .Database( - EventType.FileMetadataError, - s"Ligne invalide en BDD pour la metadata de fichier ${row.id} [" + - s"upload_date ${row.uploadDate}" + - s"filesize ${row.filesize}" + - s"status ${row.status}" + - s"application_id ${row.applicationId}" + - s"answer_id ${row.answerId}" + - "]", - none - ) - .asLeft - case Some(metadata) => metadata.some.asRight - } - } - ) + .flatMap { + case None => none.asRight + case Some(row) => + row.toFileMetadata match { + case None => + Error + .Database( + EventType.FileMetadataError, + s"Ligne invalide en BDD pour la metadata de fichier ${row.id} [" + + s"upload_date ${row.uploadDate}" + + s"filesize ${row.filesize}" + + s"status ${row.status}" + + s"application_id ${row.applicationId}" + + s"answer_id ${row.answerId}" + + "]", + none + ) + .asLeft + case Some(metadata) => metadata.some.asRight + } + } + ) def byApplicationId(applicationId: UUID): Future[Either[Error, List[FileMetadata]]] = Future( @@ -223,41 +361,69 @@ class FileService @Inject() ( SQL(s"""SELECT $fieldsInSelect FROM file_metadata""").as(fileMetadataRowParser.*) } - def deleteBefore(beforeDate: Instant): Future[Unit] = { - def logException(exception: Throwable) = - eventService.logNoRequest( - EventType.FileDeletionError, - s"Erreur lors de la suppression d'un fichier", - underlyingException = exception.some - ) + def deleteExpiredFiles(): IO[Either[Error, Unit]] = + IO.realTimeInstant + .flatMap { now => + val beforeDate = now.minus(config.filesExpirationInDays.toLong + 1, DAYS) + IO.blocking( + eventService.logNoRequest( + EventType.FilesDeletion, + s"Début de la suppression des fichiers avant $beforeDate" + ) + ) >> + deleteBefore(beforeDate).both(legacyDeleteExpiredFiles).map { case (result, _) => result } + } + private def deleteBefore(beforeDate: Instant): IO[Either[Error, Unit]] = before(beforeDate) - .map( + .flatMap( _.fold( - e => eventService.logErrorNoRequest(e), + e => IO.pure(e.asLeft), files => { - Source - .fromIterator(() => files.iterator) - .mapAsync(1) { metadata => - val path = Paths.get(s"${config.filesPath}/${metadata.id}") - Files.deleteIfExists(path) - updateStatus(metadata.id, FileMetadata.Status.Expired) - .map(_.fold(e => eventService.logErrorNoRequest(e), identity)) - } - .recover(logException _) - .runWith(Sink.ignore) - .foreach(_ => - eventService.logNoRequest( - EventType.FilesDeletion, - s"Fin de la suppression des fichiers avant $beforeDate" + Stream + .emits(files) + .evalMap { metadata => + val delete = ovhS3.delete(bucket, s3fileName(metadata.id)) + val deleteLegacy = + FsFiles[IO].deleteIfExists(FsPath(config.filesPath) / metadata.id.toString) + val deletion = delete.both(deleteLegacy) >> + updateStatus(metadata.id, FileMetadata.Status.Expired).flatMap( + _.fold( + e => IO.blocking(eventService.logErrorNoRequest(e)), + Function.const(IO.unit) + ) + ) + deletion.recoverWith(e => + IO.blocking( + eventService.logNoRequest( + EventType.FileDeletionError, + s"Erreur lors de la suppression d'un fichier", + underlyingException = e.some + ) + ) ) - ) + } + .compile + .drain + .map(_.asRight) }, ) ) - .recover { case error => - logException(error) - } + + private def legacyDeleteExpiredFiles = IO { + val dir = new java.io.File(config.filesPath) + if (dir.exists() && dir.isDirectory) { + val fileToDelete = dir + .listFiles() + .filter(_.isFile) + .filter { file => + val instant = Files.getLastModifiedTime(file.toPath).toInstant + instant + .plus(config.filesExpirationInDays.toLong + 1, DAYS) + .isBefore(Instant.now()) + } + fileToDelete.foreach(_.delete()) + } } def wipeFilenames(retentionInMonths: Long): Future[Either[Error, Int]] = @@ -283,25 +449,26 @@ class FileService @Inject() ( ) ) - private def before(beforeDate: Instant): Future[Either[Error, List[FileMetadata]]] = - Future( - Try( - db.withConnection { implicit connection => - SQL(s"""SELECT $fieldsInSelect FROM file_metadata WHERE upload_date < {beforeDate}""") - .on("beforeDate" -> beforeDate) - .as(fileMetadataRowParser.*) - } - ).toEither.left - .map(e => - Error.SqlException( - EventType.FileMetadataError, - s"Impossible de chercher les fichiers de avant $beforeDate", - e, - none + private def before(beforeDate: Instant): IO[Either[Error, List[FileMetadata]]] = + IO.blocking( + db.withConnection { implicit connection => + SQL(s"""SELECT $fieldsInSelect FROM file_metadata WHERE upload_date < {beforeDate}""") + .on("beforeDate" -> beforeDate) + .as(fileMetadataRowParser.*) + } + ).attempt + .map( + _.left + .map(e => + Error.SqlException( + EventType.FileMetadataError, + s"Impossible de chercher les fichiers de avant $beforeDate", + e, + none + ) ) - ) - .map(_.flatMap(_.toFileMetadata)) - ) + .map(_.flatMap(_.toFileMetadata)) + ) private def insertMetadata(metadata: FileMetadata): Future[Either[Error, Unit]] = Future( @@ -316,7 +483,8 @@ class FileService @Inject() ( filesize, status, application_id, - answer_id + answer_id, + encryption_key_id ) VALUES ( ${row.id}::uuid, ${row.uploadDate}, @@ -324,7 +492,8 @@ class FileService @Inject() ( ${row.filesize}, ${row.status}, ${row.applicationId}::uuid, - ${row.answerId}::uuid + ${row.answerId}::uuid, + ${row.encryptionKeyId} )""".executeUpdate() } }.toEither.left @@ -354,38 +523,39 @@ class FileService @Inject() ( } ) - private def updateStatus(id: UUID, status: FileMetadata.Status): Future[Either[Error, Unit]] = - Future( - Try { - val rawStatus = FileMetadataRow.statusFromFileMetadata(status) - db.withConnection { implicit connection => - SQL""" + private def updateStatus(id: UUID, status: FileMetadata.Status): IO[Either[Error, Unit]] = + IO.blocking { + val rawStatus = FileMetadataRow.statusFromFileMetadata(status) + db.withConnection { implicit connection => + SQL""" UPDATE file_metadata SET status = $rawStatus WHERE id = $id::uuid """.executeUpdate() - } - }.toEither.left - .map(e => - Error.SqlException( - EventType.FileMetadataError, - s"Impossible de mettre le status $status sur la metadata de fichier $id", - e, - none + } + }.attempt + .map( + _.left + .map(e => + Error.SqlException( + EventType.FileMetadataError, + s"Impossible de mettre le status $status sur la metadata de fichier $id", + e, + none + ) ) - ) - .flatMap { numOfRows => - if (numOfRows === 1) ().asRight - else - Error - .Database( - EventType.FileMetadataError, - s"Nombre incorrect de lignes modifiées ($numOfRows) " + - s"lors de la mise à jour du status $status de la metadata $id", - none - ) - .asLeft - } - ) + .flatMap { numOfRows => + if (numOfRows === 1) ().asRight + else + Error + .Database( + EventType.FileMetadataError, + s"Nombre incorrect de lignes modifiées ($numOfRows) " + + s"lors de la mise à jour du status $status de la metadata $id", + none + ) + .asLeft + } + ) } diff --git a/app/services/UserService.scala b/app/services/UserService.scala index 97ea484a3..e583a9320 100644 --- a/app/services/UserService.scala +++ b/app/services/UserService.scala @@ -644,33 +644,35 @@ class UserService @Inject() ( Column .of[String] .mapResult { - case "agent_connect" => UserSession.LoginType.AgentConnect.asRight + case "agent_connect" => UserSession.LoginType.AgentConnect.asRight + case "insecure_demo_key" => UserSession.LoginType.InsecureDemoKey.asRight + case "magic_link" => UserSession.LoginType.MagicLink.asRight case unknownType => SqlMappingError(s"Cannot parse login_type $unknownType").asLeft } - val (userSessionParser, userSessionTableFields) = Macros.parserWithFields[UserSession]( + private val userSessionTableFields = List( "id", "user_id", "creation_date", + "creation_ip_address", "last_activity", "login_type", "expires_at", - "is_revoked", + "revoked_at", ) private val qualifiedUserSessionParser = anorm.Macro.parser[UserSession]( "user_session.id", "user_session.user_id", "user_session.creation_date", + "creation_ip_address_text", "user_session.last_activity", "user_session.login_type", "user_session.expires_at", - "user_session.is_revoked", + "user_session.revoked_at", ) - val userSessionFieldsInSelect: String = userSessionTableFields.mkString(", ") - // Double the recommended minimum 64 bits of entropy private val SESSION_SIZE_BYTES = 16 @@ -680,28 +682,25 @@ class UserService @Inject() ( bytes.map("%02x".format(_)).mkString } - private def userSessionFromAgentConnect( + private def generateNewUserSession( userId: UUID, - expiresAt: Option[Instant] + loginType: UserSession.LoginType, + expiresAt: Instant, + ipAddress: String ): IO[Either[Error, UserSession]] = generateNewSessionId .flatMap(sessionId => - IO.realTimeInstant.flatMap(now => - (expiresAt match { - case None => AgentConnectService.calculateExpiresAt(now) - case Some(expiresAt) => IO.pure(expiresAt) - }) - .map(expiresAt => - UserSession( - id = sessionId, - userId = userId, - creationDate = now, - lastActivity = now, - loginType = UserSession.LoginType.AgentConnect, - expiresAt = expiresAt, - isRevoked = None, - ) - ) + IO.realTimeInstant.map(now => + UserSession( + id = sessionId, + userId = userId, + creationDate = now, + creationIpAddress = ipAddress, + lastActivity = now, + loginType = loginType, + expiresAt = expiresAt, + revokedAt = None, + ) ) ) .attempt @@ -709,7 +708,7 @@ class UserService @Inject() ( _.left.map(error => Error.SqlException( EventType.UserSessionError, - s"Impossible de générer une nouvelle session pour l'utilisateur ${userId}", + s"Impossible de générer une nouvelle session pour l'utilisateur ${userId} ($loginType)", error, none ) @@ -717,7 +716,9 @@ class UserService @Inject() ( ) private def stringifyLoginType(loginType: UserSession.LoginType): String = loginType match { - case UserSession.LoginType.AgentConnect => "agent_connect" + case UserSession.LoginType.AgentConnect => "agent_connect" + case UserSession.LoginType.InsecureDemoKey => "insecure_demo_key" + case UserSession.LoginType.MagicLink => "magic_link" } private def saveUserSession(session: UserSession): IO[Either[Error, UserSession]] = @@ -728,13 +729,15 @@ class UserService @Inject() ( id, user_id, creation_date, + creation_ip_address, last_activity, login_type, expires_at - ) VALUES( + ) VALUES ( ${session.id}, - ${session.userId}, + ${session.userId}::uuid, ${session.creationDate}, + ${session.creationIpAddress}::inet, ${session.lastActivity}, ${stringifyLoginType(session.loginType)}, ${session.expiresAt} @@ -760,12 +763,14 @@ class UserService @Inject() ( * OWASP Recommendations on session id length: * https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-length */ - def createNewUserSessionFromAgentConnect( + def createNewUserSession( userId: UUID, - expiresAt: Option[Instant] + loginType: UserSession.LoginType, + expiresAt: Instant, + ipAddress: String ): EitherT[IO, Error, UserSession] = for { - session <- EitherT(userSessionFromAgentConnect(userId, expiresAt)) + session <- EitherT(generateNewUserSession(userId, loginType, expiresAt, ipAddress)) _ <- EitherT(saveUserSession(session)) } yield session @@ -786,15 +791,17 @@ class UserService @Inject() ( WHERE id = ${sessionId} """.executeUpdate() - // This method is called at each action from a user, + // This method is called during each user action, therefore, // as an optimization, we get all the data in a single query val fields = tableFields.map(f => s"\"user\".$f").mkString(", ") + ", " + userSessionTableFields.map(f => s"user_session.$f").mkString(", ") val result: Option[(Option[UserRow], Option[UserSession])] = SQL(s""" - SELECT $fields + SELECT + $fields, + host(user_session.creation_ip_address)::text AS creation_ip_address_text FROM - (SELECT * FROM "user" WHERE id = {userId}) AS "user" + (SELECT * FROM "user" WHERE id = {userId}::uuid) AS "user" LEFT JOIN (SELECT * FROM user_session WHERE id = {sessionId}) AS user_session ON true @@ -823,6 +830,30 @@ class UserService @Inject() ( ) ) + def revokeUserSession(sessionId: String): IO[Either[Error, Unit]] = + IO.realTimeInstant.flatMap(now => + IO.blocking { + val _ = db.withConnection { implicit connection => + SQL""" + UPDATE user_session + SET revoked_at = ${now} + WHERE id = ${sessionId} + AND revoked_at is NULL + """.executeUpdate() + } + }.attempt + .map( + _.left.map[Error](error => + Error.SqlException( + EventType.UserSessionError, + s"Impossible de revoquer la session utilisateur $sessionId", + error, + none + ) + ) + ) + ) + // // AgentConnect // diff --git a/app/tasks/ExportAnonymizedDataTask.scala b/app/tasks/ExportAnonymizedDataTask.scala index f0fcd238d..e068ef377 100644 --- a/app/tasks/ExportAnonymizedDataTask.scala +++ b/app/tasks/ExportAnonymizedDataTask.scala @@ -2,7 +2,7 @@ package tasks import cats.effect.IO import cats.syntax.all._ -import helper.TasksHelpers +import helper.{TasksHelpers, Time} import java.time.{Instant, ZoneOffset} import java.time.temporal.ChronoUnit import javax.inject.Inject @@ -36,7 +36,7 @@ class ExportAnonymizedDataTask @Inject() ( }.flatTap(duration => logMessage( EventType.AnonymizedDataExportMessage, - s"Prochain export anonymisé de la BDD dans $duration" + s"Prochain export anonymisé de la BDD dans ${Time.readableDuration(duration)}" ) ) diff --git a/app/tasks/RemoveExpiredFilesTask.scala b/app/tasks/RemoveExpiredFilesTask.scala index d1dbcf58d..d138bb846 100644 --- a/app/tasks/RemoveExpiredFilesTask.scala +++ b/app/tasks/RemoveExpiredFilesTask.scala @@ -1,58 +1,55 @@ package tasks -import java.io.File -import java.nio.file.Files -import java.time.{Instant, ZonedDateTime} -import java.time.temporal.ChronoUnit.DAYS +import cats.effect.IO +import helper.{TasksHelpers, Time} +import java.time.{Instant, ZoneOffset} +import java.time.temporal.ChronoUnit import javax.inject.Inject import models.EventType -import org.apache.pekko.actor.ActorSystem -import play.api.Configuration -import scala.concurrent.ExecutionContext +import modules.AppConfig +import play.api.inject.ApplicationLifecycle +import scala.concurrent.Future import scala.concurrent.duration._ -import services.{EventService, FileService} +import services.{EventService, FileService, ServicesDependencies} class RemoveExpiredFilesTask @Inject() ( - actorSystem: ActorSystem, - configuration: Configuration, - eventService: EventService, + config: AppConfig, + dependencies: ServicesDependencies, + val eventService: EventService, fileService: FileService, -)(implicit executionContext: ExecutionContext) { - private val filesPath = configuration.underlying.getString("app.filesPath") - - private val filesExpirationInDays: Int = - configuration.underlying.getString("app.filesExpirationInDays").toInt - - val startAtHour = 5 - val now: ZonedDateTime = java.time.ZonedDateTime.now() // Machine Time - - val startDate: ZonedDateTime = - now.toLocalDate.atStartOfDay(now.getZone).plusDays(1).withHour(startAtHour) - - val initialDelay: FiniteDuration = java.time.Duration.between(now, startDate).getSeconds.seconds - - actorSystem.scheduler.scheduleWithFixedDelay(initialDelay = initialDelay, delay = 24.hours)( - new Runnable { override def run(): Unit = removeExpiredFiles() } + lifecycle: ApplicationLifecycle, +) extends TasksHelpers { + + import dependencies.ioRuntime + + def durationUntilNextTick(now: Instant): IO[FiniteDuration] = IO { + val nextInstant = now + .atZone(ZoneOffset.UTC) + .toLocalDate + .atStartOfDay(ZoneOffset.UTC) + .plusDays(1) + .withHour(5) + .toInstant + now.until(nextInstant, ChronoUnit.MILLIS).millis + }.flatTap(duration => + logMessage( + EventType.FilesDeletion, + s"Prochaine suppression des fichiers expirés dans ${Time.readableDuration(duration)}" + ) ) - def removeExpiredFiles(): Unit = { - val beforeDate = Instant.now().minus(filesExpirationInDays.toLong + 1, DAYS) - eventService.logNoRequest( + val cancelCallback: () => Future[Unit] = repeatWithDelay(durationUntilNextTick)( + loggingResult( + fileService.deleteExpiredFiles(), EventType.FilesDeletion, - s"Début de la suppression des fichiers avant $beforeDate" + "Fin de la suppression des fichiers expirés", + EventType.FileDeletionError, + "Erreur lors de la suppression des fichiers expirés", ) - val dir = new File(filesPath) - if (dir.exists() && dir.isDirectory) { - val fileToDelete = dir - .listFiles() - .filter(_.isFile) - .filter { file => - val instant = Files.getLastModifiedTime(file.toPath).toInstant - instant.plus(filesExpirationInDays.toLong + 1, DAYS).isBefore(Instant.now()) - } - fileToDelete.foreach(_.delete()) - } - val _ = fileService.deleteBefore(beforeDate) + ).unsafeRunCancelable() + + lifecycle.addStopHook { () => + cancelCallback() } } diff --git a/app/tasks/ViewsRefreshTask.scala b/app/tasks/ViewsRefreshTask.scala index 0573ae1f0..a6f54f300 100644 --- a/app/tasks/ViewsRefreshTask.scala +++ b/app/tasks/ViewsRefreshTask.scala @@ -1,7 +1,7 @@ package tasks import cats.effect.IO -import helper.TasksHelpers +import helper.{TasksHelpers, Time} import java.time.{Instant, ZoneOffset} import java.time.temporal.ChronoUnit import javax.inject.Inject @@ -33,7 +33,10 @@ class ViewsRefreshTask @Inject() ( .toInstant now.until(nextInstant, ChronoUnit.MILLIS).millis }.flatTap(duration => - logMessage(EventType.ViewsRefreshMessage, s"Prochains REFRESH MATERIALIZED VIEW dans $duration") + logMessage( + EventType.ViewsRefreshMessage, + s"Prochains REFRESH MATERIALIZED VIEW dans ${Time.readableDuration(duration)}" + ) ) val cancelCallback: () => Future[Unit] = repeatWithDelay(durationUntilNextTick)( diff --git a/app/views/allArea.scala.html b/app/views/allArea.scala.html index a3d1eb931..f77df45fc 100644 --- a/app/views/allArea.scala.html +++ b/app/views/allArea.scala.html @@ -15,7 +15,7 @@ @area.name @if(user.admin) { / @area.id / - @if(mainInfos.config.areasWithLoginByKey.contains[UUID](area.id)) { + @if(mainInfos.config.insecureAreasWithLoginByKey.contains[UUID](area.id)) { warning Login par clé possiblewarning / } diff --git a/build.sbt b/build.sbt index b8ae82a91..cca78b64f 100644 --- a/build.sbt +++ b/build.sbt @@ -73,6 +73,7 @@ scalacOptions ++= Seq( "-Wconf:cat=unused&src=twirl/.*:s", "-Wvalue-discard", "-Xsource:3", + "-Wconf:msg=method are copied from the case class constructor under Scala 3:s", ) // https://typelevel.org/cats-effect/docs/getting-started @@ -95,9 +96,10 @@ libraryDependencies ++= Seq( pipelineStages := Seq(digest, gzip) -libraryDependencies += specs2 % Test libraryDependencies += guice +val fs2Version = "3.10.2" + libraryDependencies ++= Seq( "org.postgresql" % "postgresql" % "42.7.3", "org.playframework" %% "play-mailer" % "10.0.0", @@ -109,6 +111,9 @@ libraryDependencies ++= Seq( "com.lihaoyi" %% "scalatags" % "0.13.1", "org.typelevel" %% "cats-core" % "2.12.0", "org.typelevel" %% "cats-effect" % "3.5.4", + "co.fs2" %% "fs2-core" % fs2Version, + "co.fs2" %% "fs2-io" % fs2Version, + "io.laserdisc" %% "fs2-aws-s3" % "6.1.3", ) val jjwtVersion = "0.12.6" @@ -136,7 +141,15 @@ libraryDependencies ++= Seq( ) // Crash -libraryDependencies += "io.sentry" % "sentry-logback" % "7.12.1" +libraryDependencies += "io.sentry" % "sentry-logback" % "7.14.0" + +// Test +libraryDependencies ++= Seq( + specs2 % Test, // Play Plugin + "org.specs2" %% "specs2-scalacheck" % "4.20.8" % Test, + "org.scalacheck" %% "scalacheck" % "1.18.0" % Test, + "org.typelevel" %% "cats-effect-testing-specs2" % "1.5.0" % Test, +) // Overrides dependencyOverrides += "org.apache.commons" % "commons-text" % "1.10.0" diff --git a/conf/application.conf b/conf/application.conf index bf78b9393..cba2f8bb3 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -64,12 +64,21 @@ app.areasWithLoginByKey = "" app.areasWithLoginByKey = ${?AREAS_WITH_LOGIN_BY_KEY} app.tokenExpirationInMinutes = 30 app.tokenExpirationInMinutes = ${?TOKEN_EXPIRATION_IN_MINUTES} +# Default 30 days +app.magicLinkSessionDurationInSeconds = 2592000 +app.magicLinkSessionDurationInSeconds = ${?APP_MAGIC_LINK_SESSION_DURATION_IN_SECONDS} app.filesPath = ${?FILES_PATH} app.filesExpirationInDays = 7 app.filesExpirationInDays = ${?FILES_EXPIRATION_IN_DAYS} -app.filesSecondInstanceHost = ${?FILES_SECOND_INSTANCE_HOST} app.features.agentConnectEnabled = false app.features.agentConnectEnabled = ${?FEATURE_AGENT_CONNECT_ENABLED} +app.filesOvhS3AccessKey = ${?FILES_OVH_S3_ACCESS_KEY} +app.filesOvhS3SecretKey = ${?FILES_OVH_S3_SECRET_KEY} +app.filesOvhS3Endpoint = ${?FILES_OVH_S3_ENDPOINT} +app.filesOvhS3Region = ${?FILES_OVH_S3_REGION} +app.filesOvhS3Bucket = ${?FILES_OVH_S3_BUCKET} +app.filesCurrentEncryptionKeyId = ${?FILES_CURRENT_ENCRYPTION_KEY_ID} +app.filesEncryptionKeys = ${?FILES_ENCRYPTION_KEYS} app.features.autoAddExpert = true app.features.autoAddExpert = ${?FEATURE_AUTO_ADD_EXPERT} app.features.canSendApplicationsAnywhere = false diff --git a/conf/evolutions/default/74.sql b/conf/evolutions/default/74.sql index 6fc691400..90f4542a0 100644 --- a/conf/evolutions/default/74.sql +++ b/conf/evolutions/default/74.sql @@ -1,35 +1,9 @@ --- !Ups -CREATE TABLE agent_connect_claims( - subject text NOT NULL, - email text NOT NULL, - given_name text, - usual_name text, - uid text, - siret text, - creation_date timestamptz NOT NULL, - last_auth_time timestamptz, - -- a user can be linked to multiple claims - user_id uuid -); - -CREATE UNIQUE INDEX agent_connect_claims_subject_unique_idx ON agent_connect_claims (subject); -CREATE INDEX agent_connect_claims_lower_email_idx ON agent_connect_claims (lower(email)); - - -CREATE TABLE user_session( - id text NOT NULL, - user_id uuid NOT NULL, - creation_date timestamptz NOT NULL, - last_activity timestamptz NOT NULL, - login_type text NOT NULL, - expires_at timestamptz NOT NULL, - is_revoked boolean DEFAULT NULL -); +ALTER TABLE file_metadata ADD COLUMN encryption_key_id text NULL; --- !Downs -DROP TABLE agent_connect_claims; -DROP TABLE user_session; +ALTER TABLE file_metadata DROP COLUMN encryption_key_id; diff --git a/conf/evolutions/default/75.sql b/conf/evolutions/default/75.sql new file mode 100644 index 000000000..ef737ae5a --- /dev/null +++ b/conf/evolutions/default/75.sql @@ -0,0 +1,18 @@ +--- !Ups + +CREATE TABLE user_session( + id text PRIMARY KEY, + user_id uuid NOT NULL, + creation_date timestamptz NOT NULL, + creation_ip_address inet NOT NULL, + last_activity timestamptz NOT NULL, + login_type text NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz DEFAULT NULL +); + + + +--- !Downs + +DROP TABLE user_session; diff --git a/conf/evolutions/default/76.sql b/conf/evolutions/default/76.sql new file mode 100644 index 000000000..16b42dce1 --- /dev/null +++ b/conf/evolutions/default/76.sql @@ -0,0 +1,23 @@ +--- !Ups + +CREATE TABLE agent_connect_claims( + subject text NOT NULL, + email text NOT NULL, + given_name text, + usual_name text, + uid text, + siret text, + creation_date timestamptz NOT NULL, + last_auth_time timestamptz, + -- a user can be linked to multiple claims + user_id uuid +); + +CREATE UNIQUE INDEX agent_connect_claims_subject_unique_idx ON agent_connect_claims (subject); +CREATE INDEX agent_connect_claims_lower_email_idx ON agent_connect_claims (lower(email)); + + + +--- !Downs + +DROP TABLE agent_connect_claims; diff --git a/docs/docs.md b/docs/docs.md index 0dea13653..a5e387046 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -12,6 +12,8 @@ scalafixAll -r OrganizeImports scalafmtAll ``` +Les règles scalafix sont à vérifier avec `scalafixAll --check`. + ## Organisation du code diff --git a/test/helper/CryptoHelperSpec.scala b/test/helper/CryptoHelperSpec.scala new file mode 100644 index 000000000..b4ce71a53 --- /dev/null +++ b/test/helper/CryptoHelperSpec.scala @@ -0,0 +1,79 @@ +package helper + +import helper.Crypto._ +import java.nio.charset.StandardCharsets +import org.junit.runner.RunWith +import org.scalacheck.{Arbitrary, Gen} +import org.specs2.ScalaCheck +import org.specs2.mutable.Specification +import org.specs2.runner.JUnitRunner +import scala.util.Success + +object CryptoTestValues { + val testKey = Crypto.decodeKeyBase64("zCNLgkZauiZ072v8ocC6pNZpwtwx/L4Sk9eRPQm2WA8=") + val pt = "plaintext message" + val ptBytes = pt.getBytes(StandardCharsets.UTF_8) + val ctNoAAD = "dW5pcXVlIG5vbmNlSzW1fcYTzwUIbz0hjR14HbBV9Y7blaVRQ4TAcmdJe0Og" + val ctNoAADBytes = Crypto.base64Decoder.decode(ctNoAAD) + val ctWithAAD = "dW5pcXVlIG5vbmNlSzW1fcYTzwUIbz0hjR14HbAFhFrzlFNz0cmYHt+TLzkK" + val ctWithAADBytes = Crypto.base64Decoder.decode(ctWithAAD) + + val invalidKey = Crypto.decodeKeyBase64("nhblIEuU2o4mJunzDRBqhrFncBFcpiJP6Jz6n8RxnMg=") +} + +object CryptoScalaCheck { + + implicit val keyGen: Gen[Key] = Gen.resultOf((_: Int) => generateRandomKey()) + implicit val keyArb: Arbitrary[Key] = Arbitrary(keyGen) + + case class BlobLessThan10Mb(bytes: Array[Byte]) + + implicit val blobLessThan10MbGen: Gen[BlobLessThan10Mb] = for { + size <- Gen.choose(0, 10 * 1024 * 1024) + bytes <- Gen.containerOfN[Array, Byte](size, implicitly[Arbitrary[Byte]].arbitrary) + } yield BlobLessThan10Mb(bytes) + + implicit val blobLessThan10MbArb: Arbitrary[BlobLessThan10Mb] = Arbitrary(blobLessThan10MbGen) + +} + +@RunWith(classOf[JUnitRunner]) +class CryptoHelperSpec extends Specification with ScalaCheck { + + "Given hand-picked data, crypto functions should" >> { + import CryptoTestValues._ + + "decrypt correctly ciphertexts (generated by another impl)" >> { + Crypto.decryptField(ctNoAAD, "", testKey) must equalTo(Success(pt)) + Crypto.decryptField(ctNoAAD, "something", testKey).isFailure must beTrue + + Crypto.decryptField(ctWithAAD, "some aad", testKey) must equalTo(Success(pt)) + Crypto.decryptField(ctWithAAD, "other aad", testKey).isFailure must beTrue + + Crypto.decryptField(ctNoAAD, "", invalidKey).isFailure must beTrue + Crypto.decryptField(ctWithAAD, "some aad", invalidKey).isFailure must beTrue + } + } + + "Given arbitrary values, crypto functions should" >> { + import CryptoScalaCheck._ + + "encode and decode correctly keys" >> { + prop { (key: Key) => + val encoded = encodeKeyBase64(key) + val roundtrip = decodeKeyBase64(encoded) + key === roundtrip + } + } + + "encrypt then decrypt in an idempotent way" >> { + prop { (pt: String, key: Key, aad: String) => + val ct = encryptField(pt, aad, key) + val roundtrip = ct.flatMap(ct => decryptField(ct, aad, key)) + Success(pt) === roundtrip + } + } + + } + +} diff --git a/test/helper/CryptoStreamSpec.scala.scala b/test/helper/CryptoStreamSpec.scala.scala new file mode 100644 index 000000000..c6ec9217b --- /dev/null +++ b/test/helper/CryptoStreamSpec.scala.scala @@ -0,0 +1,222 @@ +package helper + +import cats.effect.IO +import cats.effect.testing.specs2.CatsEffect +import fs2.Stream +import helper.Crypto._ +import org.junit.runner.RunWith +import org.specs2.ScalaCheck +import org.specs2.mutable.Specification +import org.specs2.runner.JUnitRunner +import org.specs2.scalacheck.Parameters + +/** Note that we decide to block on the IO effect in scalacheck property testing, this is due to the + * fact that there does not exist any integration between specs2, scalacheck and cats-effect. This + * would require some kind of wrapper. However this is unnecessary since everything tested here is + * "CPU blocking", and not I/O blocking. + * + * List of useful code in case a wrapper is needed in the future: + * - cats-effect tests uses specs2 + * https://github.com/typelevel/cats-effect/blob/2c0406fa3700c2805f976f1767e9e7f05b9cf7cd/tests/shared/src/test/scala/cats/effect/Runners.scala#L50 + * - there exists a wrapper for scalacheck + cats-effect, but it only implements munit and not + * specs2 https://github.com/typelevel/scalacheck-effect + * - specs2 integrates with scalacheck using prop + * https://github.com/etorreborre/specs2/blob/bbf99b7be990890271d76e196dda1d8e5cb940c7/scalacheck/src/main/scala/org/specs2/scalacheck/ScalaCheckPropertyCreation.scala#L20 + * - if one would want to integrate specs2 + scalacheck + cats-effect, the obvious way is to + * rewrite the specs2 integration of scalacheck for the `PropF` in + * https://github.com/typelevel/scalacheck-effect + * - cats-effect integration with specs2 can provide another useful reference + * https://github.com/typelevel/cats-effect-testing/blob/f6c9a1b8b24b7210f7293349637e58382f2e5525/specs2/shared/src/main/scala/cats/effect/testing/specs2/CatsEffect.scala#L36 + */ +@RunWith(classOf[JUnitRunner]) +class CryptoStreamSpec extends Specification with ScalaCheck with CatsEffect { + + "Given hand-picked data, crypto streams should" >> { + import CryptoTestValues._ + + "encrypt with empty AAD" >> { + for { + // Note: there is a random nonce in the cipher text + cipherText <- Stream + .emits(ptBytes) + .covary[IO] + .through(stream.encrypt("", testKey)) + .compile + .to(Array) + roundtripPlainText <- Stream + .emits(cipherText) + .covary[IO] + .through(stream.decrypt("", testKey)) + .compile + .to(Array) + } yield { + // Note: specs2 knows about Arrays and compares elements + roundtripPlainText must equalTo(ptBytes) + } + } + + "decrypt with empty AAD" >> { + Stream + .emits(ctNoAADBytes) + .covary[IO] + .through(stream.decrypt("", testKey)) + .compile + .to(Array) + .map { decryptedBytes => + decryptedBytes must equalTo(ptBytes) + } + } + + "fail if AAD is incorrect when AAD is empty" >> { + Stream + .emits(ctNoAADBytes) + .covary[IO] + .through(stream.decrypt("something", testKey)) + .compile + .drain + .attempt + .map { result => + result.isLeft must beTrue + result.left.toOption.get.isInstanceOf[javax.crypto.AEADBadTagException] must beTrue + } + } + + "decrypt with non-empty AAD" >> { + Stream + .emits(ctWithAADBytes) + .covary[IO] + .through(stream.decrypt("some aad", testKey)) + .compile + .to(Array) + .map { decryptedBytes => + decryptedBytes must equalTo(ptBytes) + } + } + + "fail if AAD is incorrect when AAD is non-empty" >> { + Stream + .emits(ctWithAADBytes) + .covary[IO] + .through(stream.decrypt("other aad", testKey)) + .compile + .drain + .attempt + .map { result => + result.isLeft must beTrue + result.left.toOption.get.isInstanceOf[javax.crypto.AEADBadTagException] must beTrue + } + } + } + + "Given arbitrary values, crypto streams should" >> { + import CryptoScalaCheck._ + + import cats.effect.unsafe.implicits.global + + "encrypt then decrypt in an idempotent way when plaintext is a String" >> { + prop { (pt: String, key: Key, aad: String) => + val ptBytes = pt.getBytes + val resultBytes = Stream + .emits(ptBytes) + .covary[IO] + .through(stream.encrypt(aad, key)) + .through(stream.decrypt(aad, key)) + .compile + .to(Array) + .unsafeRunSync() + ptBytes === resultBytes + } + } + + "encrypt then fail to decrypt with a different key" >> { + prop { (pt: String, key: Key, otherKey: Key, aad: String) => + val ptBytes = pt.getBytes + val result = Stream + .emits(ptBytes) + .covary[IO] + .through(stream.encrypt(aad, key)) + .through(stream.decrypt(aad, otherKey)) + .compile + .to(Array) + .attempt + .unsafeRunSync() + result.isLeft && + result.left.toOption.get.isInstanceOf[javax.crypto.AEADBadTagException] + } + } + + "encrypt then fail to decrypt with a different aad" >> { + prop { (pt: String, key: Key, aad: String, otherAad: String) => + val ptBytes = pt.getBytes + val result = Stream + .emits(ptBytes) + .covary[IO] + .through(stream.encrypt(aad, key)) + .through(stream.decrypt(otherAad, key)) + .compile + .to(Array) + .attempt + .unsafeRunSync() + + @SuppressWarnings(Array("scalafix:DisableSyntax.==")) + val aadsAreEqual = aad == otherAad + + aadsAreEqual || + ( + result.isLeft && + result.left.toOption.get.isInstanceOf[javax.crypto.AEADBadTagException] + ) + } + } + + "encrypt then decrypt arbitrary byte arrays of size less than 10Mb (CPU intensive)" >> { + // "overrides" the number of checks (CPU intensive) + implicit val defaultParameters = Parameters(minTestsOk = 5) + + prop { (pt: BlobLessThan10Mb, key: Key, aad: String) => + // We create chunks to mimic the way the bytes are presented to the encrypting Pipe + val chunks = pt.bytes.grouped(1024 * 1024).map(chunk => fs2.Chunk.array(chunk)).toVector + val resultBytes = Stream + .emits(chunks) + .covary[IO] + .unchunks + .through(stream.encrypt(aad, key)) + .through(stream.decrypt(aad, key)) + .compile + .to(Array) + .unsafeRunSync() + pt.bytes === resultBytes + } + } + + "output an encrypted size of (original size + nonce size + tag size)" >> { + prop { (pt: String, key: Key, aad: String) => + val ptBytes = pt.getBytes + val resultBytes = Stream + .emits(ptBytes) + .covary[IO] + .through(stream.encrypt(aad, key)) + .compile + .to(Array) + .unsafeRunSync() + resultBytes.length === ptBytes.length + NONCE_SIZE_BYTES + TAG_SIZE_BYTES + } + } + + "output an encrypted size of (original size + nonce size + tag size) (CPU intensive)" >> { + + implicit val defaultParameters = Parameters(minTestsOk = 5) + + prop { (pt: BlobLessThan10Mb, key: Key, aad: String) => + val resultBytes = Stream + .emits(pt.bytes) + .covary[IO] + .through(stream.encrypt(aad, key)) + .compile + .to(Array) + .unsafeRunSync() + resultBytes.length === pt.bytes.length + NONCE_SIZE_BYTES + TAG_SIZE_BYTES + } + } + } +}