From 7d08c00ac43eabdc44d09ee7067c5ca8d93474c3 Mon Sep 17 00:00:00 2001 From: Charles Dufour Date: Tue, 23 Jan 2024 09:55:43 +0100 Subject: [PATCH] [TRELLO-2160] Implement routes to update the email address (#1527) * [TRELLO-2160] Implement routes to update the email address * [TRELLO-2160] Implement update my email address * [TRELLO-2160] Implement update my email address * [TRELLO-2160] Handle token correctly --- app/config/SignalConsoConfiguration.scala | 3 +- app/controllers/AccountController.scala | 17 ++++ app/controllers/error/AppError.scala | 14 +++ app/models/AccessToken.scala | 9 +- app/models/token/TokenKind.scala | 1 + app/orchestrators/AccessesOrchestrator.scala | 86 +++++++++++++++++++ app/orchestrators/CompanyOrchestrator.scala | 2 +- app/orchestrators/UserOrchestrator.scala | 5 ++ .../accesstoken/AccessTokenRepository.scala | 9 ++ .../AccessTokenRepositoryInterface.scala | 2 + .../accesstoken/AccessTokenTable.scala | 4 +- app/services/Email.scala | 10 +++ app/utils/EmailSubjects.scala | 1 + app/utils/FrontRoute.scala | 1 + app/views/mails/updateEmailAddress.scala.html | 22 +++++ conf/common/app.conf | 1 + .../V18__add_user_id_to_access_tokens.sql | 3 + conf/routes | 2 + test/controllers/ReportControllerSpec.scala | 2 +- 19 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 app/views/mails/updateEmailAddress.scala.html create mode 100644 conf/db/migration/default/V18__add_user_id_to_access_tokens.sql diff --git a/app/config/SignalConsoConfiguration.scala b/app/config/SignalConsoConfiguration.scala index 8971592f1..c0aa6e6b1 100644 --- a/app/config/SignalConsoConfiguration.scala +++ b/app/config/SignalConsoConfiguration.scala @@ -25,7 +25,8 @@ case class TokenConfiguration( adminJoinDuration: FiniteDuration, dgccrfJoinDuration: Period, dgccrfDelayBeforeRevalidation: Period, - dgccrfRevalidationTokenDuration: Option[Period] + dgccrfRevalidationTokenDuration: Option[Period], + updateEmailAddressDuration: Period ) case class MobileAppConfiguration( diff --git a/app/controllers/AccountController.scala b/app/controllers/AccountController.scala index 1d8a45623..f87092f7b 100644 --- a/app/controllers/AccountController.scala +++ b/app/controllers/AccountController.scala @@ -151,6 +151,23 @@ class AccountController( } } + def sendEmailAddressUpdateValidation() = SecuredAction.async(parse.json) { implicit request => + for { + emailAddress <- request.parseBody[EmailAddress](JsPath \ "email") + _ <- accessesOrchestrator.sendEmailAddressUpdateValidation(request.identity, emailAddress) + } yield NoContent + } + + def updateEmailAddress(token: String) = SecuredAction.async { implicit request => + for { + updatedUser <- accessesOrchestrator.updateEmailAddress(request.identity, token) + cookie <- authenticator.init(updatedUser.email) match { + case Right(value) => Future.successful(value) + case Left(error) => Future.failed(error) + } + } yield authenticator.embed(cookie, Ok(Json.toJson(updatedUser))) + } + def softDelete(id: UUID) = SecuredAction.andThen(WithPermission(UserPermission.softDeleteUsers)).async { request => userOrchestrator.softDelete(targetUserId = id, currentUserId = request.identity.id).map(_ => NoContent) diff --git a/app/controllers/error/AppError.scala b/app/controllers/error/AppError.scala index b221d8d44..116ae3bbf 100644 --- a/app/controllers/error/AppError.scala +++ b/app/controllers/error/AppError.scala @@ -493,4 +493,18 @@ object AppError { override val titleForLogs: String = "no_report_files" } + final case class UpdateEmailTokenNotFound(token: String) extends NotFoundError { + override val `type`: String = "SC-0052" + override val title: String = s"Update email token $token not found" + override val details: String = s"Le lien de modification d'email n'est pas valide ($token)." + override val titleForLogs: String = "update_email_token_not_found" + } + + final case class DifferentUserFromRequest(userId: UUID, initialUserId: Option[UUID]) extends BadRequestError { + override val `type`: String = "SC-0053" + override val title: String = s"The user that initiated the update request is different" + override val details: String = s"The user that initiated ($initialUserId) the update request is different ($userId)" + override val titleForLogs: String = "different_user_from_request" + } + } diff --git a/app/models/AccessToken.scala b/app/models/AccessToken.scala index 07922db50..849dd77bc 100644 --- a/app/models/AccessToken.scala +++ b/app/models/AccessToken.scala @@ -18,7 +18,8 @@ case class AccessToken( companyId: Option[UUID], companyLevel: Option[AccessLevel], emailedTo: Option[EmailAddress], - expirationDate: Option[OffsetDateTime] + expirationDate: Option[OffsetDateTime], + userId: Option[UUID] ) object AccessToken { @@ -30,7 +31,8 @@ object AccessToken { companyId: Option[UUID], level: Option[AccessLevel], emailedTo: Option[EmailAddress] = None, - creationDate: OffsetDateTime = OffsetDateTime.now() + creationDate: OffsetDateTime = OffsetDateTime.now(), + userId: Option[UUID] = None ): AccessToken = AccessToken( creationDate = creationDate, kind = kind, @@ -39,7 +41,8 @@ object AccessToken { companyId = companyId, companyLevel = level, emailedTo = emailedTo, - expirationDate = validity.map(OffsetDateTime.now().plus(_)) + expirationDate = validity.map(OffsetDateTime.now().plus(_)), + userId = userId ) def resetExpirationDate(accessToken: AccessToken, validity: java.time.temporal.TemporalAmount) = diff --git a/app/models/token/TokenKind.scala b/app/models/token/TokenKind.scala index f9a1408df..ee115477a 100644 --- a/app/models/token/TokenKind.scala +++ b/app/models/token/TokenKind.scala @@ -16,6 +16,7 @@ object TokenKind extends PlayEnum[TokenKind] { case object CompanyFollowUp extends TokenKind case object CompanyJoin extends TokenKind case object ValidateEmail extends TokenKind + case object UpdateEmail extends TokenKind case object DGCCRFAccount extends AdminOrDgccrfTokenKind case object DGALAccount extends AdminOrDgccrfTokenKind case object AdminAccount extends AdminOrDgccrfTokenKind diff --git a/app/orchestrators/AccessesOrchestrator.scala b/app/orchestrators/AccessesOrchestrator.scala index 454db9b03..570f91d68 100644 --- a/app/orchestrators/AccessesOrchestrator.scala +++ b/app/orchestrators/AccessesOrchestrator.scala @@ -12,6 +12,7 @@ import models.token.AgentAccessToken import models.token.DGCCRFUserActivationToken import models.token.TokenKind import models.token.TokenKind.AdminAccount +import models.token.TokenKind.UpdateEmail import models.token.TokenKind.DGALAccount import models.token.TokenKind.DGCCRFAccount import models.token.TokenKind.ValidateEmail @@ -19,6 +20,7 @@ import play.api.Logger import repositories.accesstoken.AccessTokenRepositoryInterface import services.Email.AdminAccessLink import services.Email.AgentAccessLink +import services.Email.UpdateEmailAddress import services.Email import services.EmailAddressService import services.MailServiceInterface @@ -49,6 +51,90 @@ class AccessesOrchestrator( case _ => None } + def updateEmailAddress(user: User, token: String): Future[User] = + for { + accessToken <- accessTokenRepository.findToken(token) + updateEmailToken <- accessToken.filter(_.kind == UpdateEmail).liftTo[Future](UpdateEmailTokenNotFound(token)) + isSameUser = updateEmailToken.userId.contains(user.id) + emailedTo <- updateEmailToken.emailedTo.liftTo[Future]( + ServerError(s"Email should be defined for access token $token") + ) + _ <- user.userRole match { + case UserRole.DGAL | UserRole.DGCCRF => + accessTokenRepository.validateEmail(updateEmailToken, user) + case UserRole.Admin | UserRole.Professionnel => + accessTokenRepository.invalidateToken(updateEmailToken) + } + updatedUser <- + if (isSameUser) userOrchestrator.updateEmail(user, emailedTo) + else Future.failed(DifferentUserFromRequest(user.id, updateEmailToken.userId)) + } yield updatedUser + + def sendEmailAddressUpdateValidation(user: User, newEmail: EmailAddress): Future[Unit] = { + val emailValidationFunction = user.userRole match { + case UserRole.Admin => + EmailAddressService.isEmailAcceptableForAdminAccount _ + case UserRole.DGCCRF => + EmailAddressService.isEmailAcceptableForDgccrfAccount _ + case UserRole.DGAL => + EmailAddressService.isEmailAcceptableForDgalAccount _ + case UserRole.Professionnel => (_: String) => true + } + + for { + _ <- + if (emailValidationFunction(newEmail.value)) Future.unit + else Future.failed(InvalidDGCCRFOrAdminEmail(List(newEmail))) + existingTokens <- accessTokenRepository.fetchPendingTokens(user) + existingToken = existingTokens.headOption + token <- + existingToken match { + case Some(token) if token.emailedTo.contains(newEmail) => + logger.debug("reseting token validity and email") + accessTokenRepository.update( + token.id, + AccessToken.resetExpirationDate(token, tokenConfiguration.updateEmailAddressDuration) + ) + case Some(token) => + logger.debug("invalidating old token and create new one") + for { + _ <- accessTokenRepository.invalidateToken(token) + createdToken <- accessTokenRepository.create( + AccessToken.build( + kind = UpdateEmail, + token = UUID.randomUUID.toString, + validity = Some(tokenConfiguration.updateEmailAddressDuration), + companyId = None, + level = None, + emailedTo = Some(newEmail), + userId = Some(user.id) + ) + ) + } yield createdToken + case None => + logger.debug("creating token") + accessTokenRepository.create( + AccessToken.build( + kind = UpdateEmail, + token = UUID.randomUUID.toString, + validity = Some(tokenConfiguration.updateEmailAddressDuration), + companyId = None, + level = None, + emailedTo = Some(newEmail), + userId = Some(user.id) + ) + ) + } + _ <- mailService.send( + UpdateEmailAddress( + newEmail, + frontRoute.dashboard.updateEmail(token.token), + tokenConfiguration.updateEmailAddressDuration.getDays + ) + ) + } yield () + } + def listAgentPendingTokens(user: User, maybeRequestedRole: Option[UserRole]): Future[List[AgentAccessToken]] = user.userRole match { case UserRole.Admin => diff --git a/app/orchestrators/CompanyOrchestrator.scala b/app/orchestrators/CompanyOrchestrator.scala index 171e73678..00ec6a427 100644 --- a/app/orchestrators/CompanyOrchestrator.scala +++ b/app/orchestrators/CompanyOrchestrator.scala @@ -235,7 +235,7 @@ class CompanyOrchestrator( for { companies <- companyRepository.fetchCompanies(companyIds) followUpTokens <- companies.traverse(getFollowUpToken(_, userId)) - tokenMap = followUpTokens.collect { case token @ AccessToken(_, _, _, _, _, Some(companyId), _, _, _) => + tokenMap = followUpTokens.collect { case token @ AccessToken(_, _, _, _, _, Some(companyId), _, _, _, _) => (companyId, token) }.toMap htmlDocuments = companies.flatMap(company => diff --git a/app/orchestrators/UserOrchestrator.scala b/app/orchestrators/UserOrchestrator.scala index b46ea25f6..b92065190 100644 --- a/app/orchestrators/UserOrchestrator.scala +++ b/app/orchestrators/UserOrchestrator.scala @@ -35,6 +35,8 @@ trait UserOrchestratorInterface { def edit(userId: UUID, update: UserUpdate): Future[Option[User]] def softDelete(targetUserId: UUID, currentUserId: UUID): Future[Unit] + + def updateEmail(user: User, newEmail: EmailAddress): Future[User] } class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository: EventRepositoryInterface)(implicit @@ -50,6 +52,9 @@ class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository: .getOrElse(Future(None)) } yield updatedUser + def updateEmail(user: User, newEmail: EmailAddress): Future[User] = + userRepository.update(user.id, user.copy(email = newEmail)) + override def createUser(draftUser: DraftUser, accessToken: AccessToken, role: UserRole): Future[User] = { val email: EmailAddress = accessToken.emailedTo.getOrElse(draftUser.email) val user = User( diff --git a/app/repositories/accesstoken/AccessTokenRepository.scala b/app/repositories/accesstoken/AccessTokenRepository.scala index 11e7372a8..20beee977 100644 --- a/app/repositories/accesstoken/AccessTokenRepository.scala +++ b/app/repositories/accesstoken/AccessTokenRepository.scala @@ -9,6 +9,7 @@ import models.token.TokenKind.CompanyFollowUp import models.token.TokenKind.CompanyInit import models.token.TokenKind.DGALAccount import models.token.TokenKind.DGCCRFAccount +import models.token.TokenKind.UpdateEmail import repositories.accesstoken.AccessTokenColumnType._ import repositories.company.CompanyTable import repositories.companyaccess.CompanyAccessColumnType._ @@ -123,6 +124,14 @@ class AccessTokenRepository( fetchCompanyValidTokens(company).delete ) + override def fetchPendingTokens(user: User): Future[List[AccessToken]] = db.run( + fetchValidTokens + .filter(_.userId === user.id) + .filter(_.kind === (UpdateEmail: TokenKind)) + .to[List] + .result + ) + override def fetchPendingTokens(emailedTo: EmailAddress): Future[List[AccessToken]] = db.run( table diff --git a/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala b/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala index 825359be6..aebbaa520 100644 --- a/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala +++ b/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala @@ -28,6 +28,8 @@ trait AccessTokenRepositoryInterface extends CRUDRepositoryInterface[AccessToken def findValidToken(company: Company, token: String): Future[Option[AccessToken]] + def fetchPendingTokens(user: User): Future[List[AccessToken]] + def fetchPendingTokens(company: Company): Future[List[AccessToken]] def removePendingTokens(company: Company): Future[Int] diff --git a/app/repositories/accesstoken/AccessTokenTable.scala b/app/repositories/accesstoken/AccessTokenTable.scala index b9624241c..0eef27bab 100644 --- a/app/repositories/accesstoken/AccessTokenTable.scala +++ b/app/repositories/accesstoken/AccessTokenTable.scala @@ -21,6 +21,7 @@ class AccessTokenTable(tag: Tag) extends DatabaseTable[AccessToken](tag, "access def level = column[Option[AccessLevel]]("level") def emailedTo = column[Option[EmailAddress]]("emailed_to") def expirationDate = column[Option[OffsetDateTime]]("expiration_date") + def userId = column[Option[UUID]]("user_id") def * = ( id, creationDate, @@ -30,7 +31,8 @@ class AccessTokenTable(tag: Tag) extends DatabaseTable[AccessToken](tag, "access companyId, level, emailedTo, - expirationDate + expirationDate, + userId ) <> ((AccessToken.apply _).tupled, AccessToken.unapply) } diff --git a/app/services/Email.scala b/app/services/Email.scala index a77cf4b98..ab6787c9f 100644 --- a/app/services/Email.scala +++ b/app/services/Email.scala @@ -395,5 +395,15 @@ object Email { _.attachmentSeqForWorkflowStepN(3, report.lang.getOrElse(Locale.FRENCH)) } + final case class UpdateEmailAddress(recipient: EmailAddress, invitationUrl: URI, daysBeforeExpiry: Int) + extends Email { + + override val recipients: Seq[EmailAddress] = List(recipient) + override val subject: String = EmailSubjects.UPDATE_EMAIL_ADDRESS + + override def getBody: (FrontRoute, EmailAddress) => String = (_, _) => + views.html.mails.updateEmailAddress(invitationUrl, daysBeforeExpiry).toString() + } + private def getLocaleOrDefault(locale: Option[Locale]): Locale = locale.getOrElse(Locale.FRENCH) } diff --git a/app/utils/EmailSubjects.scala b/app/utils/EmailSubjects.scala index e3d4a6d5e..6901ce684 100644 --- a/app/utils/EmailSubjects.scala +++ b/app/utils/EmailSubjects.scala @@ -21,4 +21,5 @@ object EmailSubjects { val INACTIVE_DGCCRF_ACCOUNT_REMINDER = "Votre compte SignalConso est inactif" val PRO_NEW_COMPANIES_ACCESSES = (siren: SIREN) => s"Vous avez maintenant accès à l'entreprise $siren sur SignalConso" val PRO_COMPANIES_ACCESSES_INVITATIONS = (siren: SIREN) => s"Rejoignez l'entreprise $siren sur SignalConso" + val UPDATE_EMAIL_ADDRESS = "Validez votre nouvelle adresse email SignalConso" } diff --git a/app/utils/FrontRoute.scala b/app/utils/FrontRoute.scala index 7eb75085e..018edf8b7 100644 --- a/app/utils/FrontRoute.scala +++ b/app/utils/FrontRoute.scala @@ -29,6 +29,7 @@ class FrontRoute(signalConsoConfiguration: SignalConsoConfiguration) { def resetPassword(authToken: AuthToken) = url(s"/connexion/nouveau-mot-de-passe/${authToken.id}") def activation = url("/activation") def welcome = url("/") + def updateEmail(token: String) = url(s"/parametres/update-email/$token") object Admin { def register(token: String) = url(s"/admin/rejoindre/?token=$token") diff --git a/app/views/mails/updateEmailAddress.scala.html b/app/views/mails/updateEmailAddress.scala.html new file mode 100644 index 000000000..55c28983f --- /dev/null +++ b/app/views/mails/updateEmailAddress.scala.html @@ -0,0 +1,22 @@ +@import java.net.URI +@(validationUrl: URI, daysBeforeExpiry: Int) + +@views.html.mails.layout("Validez votre nouvelle adresse email SignalConso") { +

+ Bonjour, +

+

+ Pour valider votre nouvelle adresse email sur SignalConso veuillez cliquer sur lien suivant : + +

+ Validez votre nouvelle adresse email +

+

+

+ ATTENTION : Ce lien n'est valable que @daysBeforeExpiry jours après l'envoi de cet e-mail +

+ +

+ L'équipe SignalConso +

+} \ No newline at end of file diff --git a/conf/common/app.conf b/conf/common/app.conf index d023a71cc..502b795e6 100644 --- a/conf/common/app.conf +++ b/conf/common/app.conf @@ -29,6 +29,7 @@ app { dgccrf-join-duration = "P60D" dgccrf-delay-before-revalidation = "P90D" dgccrf-revalidation-token-duration = "P7D" + update-email-address-duration = "P2D" } mobile-app { diff --git a/conf/db/migration/default/V18__add_user_id_to_access_tokens.sql b/conf/db/migration/default/V18__add_user_id_to_access_tokens.sql new file mode 100644 index 000000000..5e7136f62 --- /dev/null +++ b/conf/db/migration/default/V18__add_user_id_to_access_tokens.sql @@ -0,0 +1,3 @@ +ALTER TABLE access_tokens + ADD user_id UUID, + ADD CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users(id); \ No newline at end of file diff --git a/conf/routes b/conf/routes index 22d517ddc..5cee2e78a 100644 --- a/conf/routes +++ b/conf/routes @@ -125,6 +125,8 @@ POST /api/account/validate-email controll POST /api/account/validate-email/:email controllers.AccountController.forceValidateEmail(email : String) PUT /api/account controllers.AccountController.edit() DELETE /api/account/:id controllers.AccountController.softDelete(id: java.util.UUID) +POST /api/account/send-email-update-validation controllers.AccountController.sendEmailAddressUpdateValidation() +PUT /api/account/update-email/:token controllers.AccountController.updateEmailAddress(token: String) # EmailValidation API POST /api/email-validation/check controllers.EmailValidationController.check() diff --git a/test/controllers/ReportControllerSpec.scala b/test/controllers/ReportControllerSpec.scala index da4d8184c..65b47c32a 100644 --- a/test/controllers/ReportControllerSpec.scala +++ b/test/controllers/ReportControllerSpec.scala @@ -304,7 +304,7 @@ class ReportControllerSpec(implicit ee: ExecutionEnv) extends Specification with override def s3Service: S3ServiceInterface = mockS3Service override def tokenConfiguration = - TokenConfiguration(None, None, 12.hours, Period.ofDays(60), Period.ZERO, None) + TokenConfiguration(None, None, 12.hours, Period.ofDays(60), Period.ZERO, None, Period.ZERO) override def uploadConfiguration = UploadConfiguration(Seq.empty, false, "/tmp") override def mobileAppConfiguration = MobileAppConfiguration( minimumAppVersionIos = "1.0.0",