diff --git a/app/authentication/CookieAuthenticator.scala b/app/authentication/CookieAuthenticator.scala index 132bea37..090add61 100644 --- a/app/authentication/CookieAuthenticator.scala +++ b/app/authentication/CookieAuthenticator.scala @@ -1,6 +1,7 @@ package authentication import authentication.CookieAuthenticator.CookieAuthenticatorSettings +import cats.implicits.catsSyntaxOptionId import controllers.error.AppError.BrokenAuthError import models.User import play.api.libs.json.Json @@ -25,6 +26,29 @@ class CookieAuthenticator( )(implicit ec: ExecutionContext) extends Authenticator[User] { + private def create( + userEmail: EmailAddress, + impersonator: Option[EmailAddress] = None, + proConnectIdToken: Option[String] = None, + proConnectState: Option[String] = None + )(implicit + request: RequestHeader + ): CookieInfos = { + val now = OffsetDateTime.now() + CookieInfos( + id = UUID.randomUUID().toString, + userEmail = userEmail, + impersonator = impersonator, + lastUsedDateTime = now, + expirationDateTime = now.plus(settings.authenticatorExpiry.toMillis, ChronoUnit.MILLIS), + idleTimeout = settings.authenticatorIdleTimeout, + cookieMaxAge = settings.cookieMaxAge, + fingerprint = if (settings.useFingerprinting) Some(fingerprintGenerator.generate(request)) else None, + proConnectIdToken = proConnectIdToken, + proConnectState = proConnectState + ) + } + private def unserialize(str: String): Either[BrokenAuthError, CookieInfos] = for { data <- signer.extract(str) @@ -65,44 +89,22 @@ class CookieAuthenticator( crypted <- crypter.encrypt(Json.toJson(cookieInfos).toString()) } yield signer.sign(crypted) - private def create(userEmail: EmailAddress, impersonator: Option[EmailAddress] = None)(implicit + def initSignalConsoCookie(userEmail: EmailAddress, impersonator: Option[EmailAddress])(implicit request: RequestHeader - ): CookieInfos = { - val now = OffsetDateTime.now() - CookieInfos( - id = UUID.randomUUID().toString, - userEmail = userEmail, - impersonator = impersonator, - lastUsedDateTime = now, - expirationDateTime = now.plus(settings.authenticatorExpiry.toMillis, ChronoUnit.MILLIS), - idleTimeout = settings.authenticatorIdleTimeout, - cookieMaxAge = settings.cookieMaxAge, - fingerprint = if (settings.useFingerprinting) Some(fingerprintGenerator.generate(request)) else None - ) - } - - def init(userEmail: EmailAddress)(implicit request: RequestHeader): Either[BrokenAuthError, Cookie] = { - val cookieInfos = create(userEmail) - serialize(cookieInfos).map { value => - Cookie( - name = settings.cookieName, - value = value, - // The maxAge` must be used from the authenticator, because it might be changed by the user - // to implement "Remember Me" functionality - maxAge = cookieInfos.cookieMaxAge.map(_.toSeconds.toInt), - path = settings.cookiePath, - domain = settings.cookieDomain, - secure = settings.secureCookie, - httpOnly = settings.httpOnlyCookie, - sameSite = settings.sameSite - ) - } + ): Either[BrokenAuthError, Cookie] = { + val cookieInfos = create(userEmail, impersonator) + init(cookieInfos) } - def initImpersonated(userEmail: EmailAddress, impersonator: EmailAddress)(implicit + def initProConnectCookie(userEmail: EmailAddress, proConnectIdToken: String, proConnectState: String)(implicit request: RequestHeader ): Either[BrokenAuthError, Cookie] = { - val cookieInfos = create(userEmail, Some(impersonator)) + val cookieInfos = + create(userEmail, proConnectIdToken = proConnectIdToken.some, proConnectState = proConnectState.some) + init(cookieInfos) + } + + private def init(cookieInfos: CookieInfos): Either[BrokenAuthError, Cookie] = serialize(cookieInfos).map { value => Cookie( name = settings.cookieName, @@ -117,7 +119,6 @@ class CookieAuthenticator( sameSite = settings.sameSite ) } - } def embed(cookie: Cookie, result: Result): Result = result.withCookies(cookie) @@ -143,7 +144,7 @@ object CookieAuthenticator { secureCookie: Boolean, httpOnlyCookie: Boolean = true, sameSite: Option[Cookie.SameSite], - useFingerprinting: Boolean = true, + useFingerprinting: Boolean = false, cookieMaxAge: Option[FiniteDuration], authenticatorIdleTimeout: Option[FiniteDuration] = None, authenticatorExpiry: FiniteDuration = 12.hours diff --git a/app/authentication/CookieInfos.scala b/app/authentication/CookieInfos.scala index b747f8a9..3fd7c574 100644 --- a/app/authentication/CookieInfos.scala +++ b/app/authentication/CookieInfos.scala @@ -17,7 +17,9 @@ case class CookieInfos( expirationDateTime: OffsetDateTime, idleTimeout: Option[FiniteDuration], cookieMaxAge: Option[FiniteDuration], - fingerprint: Option[String] + fingerprint: Option[String], + proConnectIdToken: Option[String], + proConnectState: Option[String] ) object CookieInfos { @@ -27,4 +29,5 @@ object CookieInfos { def writes(o: FiniteDuration): JsValue = LongWrites.writes(o.toSeconds) } implicit val formatCookieInfos: OFormat[CookieInfos] = Json.format[CookieInfos] + } diff --git a/app/authentication/actions/UserAction.scala b/app/authentication/actions/UserAction.scala index 13f15118..a7e1027d 100644 --- a/app/authentication/actions/UserAction.scala +++ b/app/authentication/actions/UserAction.scala @@ -4,6 +4,7 @@ import authentication.Authenticator import authentication.actions.UserAction.UserRequest import controllers.error.AppError.BrokenAuthError import controllers.error.AppErrorTransformer +import models.AuthProvider import models.User import models.UserRole import play.api.mvc.Results.Forbidden @@ -47,4 +48,21 @@ object UserAction { } } } + + def WithAuthProvider(anyAuthProvider: List[AuthProvider])(implicit ec: ExecutionContext): ActionFilter[UserRequest] = + WithAuthProvider(anyAuthProvider: _*) + + def WithAuthProvider(anyAuthProvider: AuthProvider*)(implicit ec: ExecutionContext): ActionFilter[UserRequest] = + new ActionFilter[UserRequest] { + override protected def executionContext: ExecutionContext = ec + + override protected def filter[A](request: UserRequest[A]): Future[Option[Result]] = + Future.successful { + if (anyAuthProvider.contains(request.identity.authProvider)) { + None + } else { + Some(Forbidden) + } + } + } } diff --git a/app/config/ApplicationConfiguration.scala b/app/config/ApplicationConfiguration.scala index 73c5d5ef..ebf12314 100644 --- a/app/config/ApplicationConfiguration.scala +++ b/app/config/ApplicationConfiguration.scala @@ -15,6 +15,7 @@ case class ApplicationConfiguration( crypter: JcaCrypterSettings, signer: JcaSignerSettings, cookie: CookieAuthenticatorSettings, + proConnect: ProConnectConfiguration, socialBlade: SocialBladeClientConfiguration, websiteApi: WebsiteApiConfiguration ) diff --git a/app/config/ProConnectConfiguration.scala b/app/config/ProConnectConfiguration.scala new file mode 100644 index 00000000..7c8f555e --- /dev/null +++ b/app/config/ProConnectConfiguration.scala @@ -0,0 +1,14 @@ +package config + +import java.net.URI + +case class ProConnectConfiguration( + url: URI, + clientId: String, + clientSecret: String, + tokenEndpoint: String, + userinfoEndpoint: String, + loginRedirectUri: URI, + logoutRedirectUri: URI, + allowedProviderIds: List[String] +) diff --git a/app/controllers/AccountController.scala b/app/controllers/AccountController.scala index 83a71ece..fba9495c 100644 --- a/app/controllers/AccountController.scala +++ b/app/controllers/AccountController.scala @@ -12,12 +12,14 @@ import play.api.mvc.ControllerComponents import repositories.user.UserRepositoryInterface import utils.EmailAddress import error.AppError.MalformedFileKey +import authentication.actions.UserAction.WithAuthProvider import authentication.actions.UserAction.WithRole import java.util.UUID import scala.concurrent.ExecutionContext import scala.concurrent.Future import scala.io.Source +import cats.syntax.either._ class AccountController( userOrchestrator: UserOrchestrator, @@ -43,10 +45,7 @@ class AccountController( case None => accessesOrchestrator.activateAdminOrAgentUser(activationRequest.draftUser, activationRequest.token) } - cookie <- authenticator.init(createdUser.email) match { - case Right(value) => Future.successful(value) - case Left(error) => Future.failed(error) - } + cookie <- authenticator.initSignalConsoCookie(createdUser.email, None).liftTo[Future] } yield authenticator.embed(cookie, Ok(Json.toJson(createdUser))) } @@ -56,12 +55,14 @@ class AccountController( role match { case UserRole.DGCCRF => request - .parseBody[EmailAddress](JsPath \ "email") - .flatMap(email => accessesOrchestrator.sendDGCCRFInvitation(email).map(_ => Ok)) + .parseBody[InvitationRequest]() + .flatMap { invitationRequest => + accessesOrchestrator.sendDGCCRFInvitation(invitationRequest).map(_ => Ok) + } case UserRole.DGAL => request - .parseBody[EmailAddress](JsPath \ "email") - .flatMap(email => accessesOrchestrator.sendDGALInvitation(email).map(_ => Ok)) + .parseBody[InvitationRequest]() + .flatMap(invitationRequest => accessesOrchestrator.sendDGALInvitation(invitationRequest.email).map(_ => Ok)) case _ => Future.failed(error.AppError.WrongUserRole(role)) } } @@ -137,12 +138,9 @@ class AccountController( def validateEmail() = IpRateLimitedAction2.async(parse.json) { implicit request => for { - token <- request.parseBody[String](JsPath \ "token") - user <- accessesOrchestrator.validateAgentEmail(token) - cookie <- authenticator.init(user.email) match { - case Right(value) => Future.successful(value) - case Left(error) => Future.failed(error) - } + token <- request.parseBody[String](JsPath \ "token") + user <- accessesOrchestrator.validateAgentEmail(token) + cookie <- authenticator.initSignalConsoCookie(user.email, None).liftTo[Future] } yield authenticator.embed(cookie, Ok(Json.toJson(user))) } @@ -151,7 +149,7 @@ class AccountController( accessesOrchestrator.resetLastEmailValidation(EmailAddress(email)).map(_ => NoContent) } - def edit() = SecuredAction.async(parse.json) { implicit request => + def edit() = SecuredAction.andThen(WithAuthProvider(AuthProvider.SignalConso)).async(parse.json) { implicit request => for { userUpdate <- request.parseBody[UserUpdate]() updatedUserOpt <- userOrchestrator.edit(request.identity.id, userUpdate) @@ -168,15 +166,16 @@ class AccountController( } 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) + def updateEmailAddress(token: String) = + SecuredAction + .andThen(WithAuthProvider(AuthProvider.SignalConso)) + .andThen(WithRole(UserRole.SuperAdmin, UserRole.Admin, UserRole.Professionnel, UserRole.DGAL)) + .async { implicit request => + for { + updatedUser <- accessesOrchestrator.updateEmailAddress(request.identity, token) + cookie <- authenticator.initSignalConsoCookie(updatedUser.email, None).liftTo[Future] + } yield authenticator.embed(cookie, Ok(Json.toJson(updatedUser))) } - } yield authenticator.embed(cookie, Ok(Json.toJson(updatedUser))) - } def softDelete(id: UUID) = SecuredAction.andThen(WithRole(UserRole.Admins)).async { request => diff --git a/app/controllers/AuthController.scala b/app/controllers/AuthController.scala index 1ac650ff..60c19c8c 100644 --- a/app/controllers/AuthController.scala +++ b/app/controllers/AuthController.scala @@ -1,6 +1,7 @@ package controllers import authentication.CookieAuthenticator +import models.AuthProvider import models.UserRole import orchestrators.AuthOrchestrator import play.api._ @@ -14,8 +15,16 @@ import models.auth.UserPassword import play.api.mvc.Action import play.api.mvc.AnyContent import play.api.mvc.ControllerComponents +import authentication.actions.UserAction.WithAuthProvider import authentication.actions.UserAction.WithRole +import cats.implicits.catsSyntaxOption +import cats.implicits.toFunctorOps +import orchestrators.proconnect.ProConnectOrchestrator import utils.EmailAddress +import cats.syntax.either._ +import _root_.controllers.error.AppError._ +import models.AuthProvider.ProConnect +import models.AuthProvider.SignalConso import java.util.UUID import scala.concurrent.ExecutionContext @@ -26,7 +35,8 @@ class AuthController( authOrchestrator: AuthOrchestrator, authenticator: CookieAuthenticator, controllerComponents: ControllerComponents, - enableRateLimit: Boolean + enableRateLimit: Boolean, + proConnectOrchestrator: ProConnectOrchestrator )(implicit val ec: ExecutionContext) extends BaseController(authenticator, controllerComponents, enableRateLimit) { @@ -37,10 +47,23 @@ class AuthController( def authenticate: Action[JsValue] = IpRateLimitedAction2.async(parse.json) { implicit request => for { userLogin <- request.parseBody[UserCredentials]() - userSession <- authOrchestrator.login(userLogin, request) + userSession <- authOrchestrator.signalConsoLogin(userLogin, request) } yield authenticator.embed(userSession.cookie, Ok(Json.toJson(userSession.user))) } + def startProConnectAuthentication(state: String, nonce: String) = + IpRateLimitedAction2.async(parse.empty) { _ => + proConnectOrchestrator.saveState(state, nonce).as(NoContent) + } + + def proConnectAuthenticate(code: String, state: String) = + IpRateLimitedAction2.async(parse.empty) { request => + for { + (token_id, user) <- proConnectOrchestrator.login(code, state) + userSession <- authOrchestrator.proConnectLogin(user, request, token_id, state) + } yield authenticator.embed(userSession.cookie, Ok(Json.toJson(userSession.user))) + } + def logAs() = SecuredAction.andThen(WithRole(UserRole.SuperAdmin)).async(parse.json) { implicit request => for { userEmail <- request.parseBody[EmailAddress](JsPath \ "email") @@ -48,16 +71,29 @@ class AuthController( } yield authenticator.embed(userSession.cookie, Ok(Json.toJson(userSession.user))) } - def logout(): Action[AnyContent] = SecuredAction.async { implicit request => + def logout(): Action[AnyContent] = SecuredAction.andThen(WithAuthProvider(SignalConso)).async { implicit request => request.identity.impersonator match { case Some(impersonator) => authOrchestrator .logoutAs(impersonator, request) .map(userSession => authenticator.embed(userSession.cookie, Ok(Json.toJson(userSession.user)))) - case None => Future.successful(authenticator.discard(NoContent)) + case None => + Future.successful(authenticator.discard(NoContent)) } } + def logoutProConnect(): Action[AnyContent] = + SecuredAction.andThen(WithAuthProvider(ProConnect)).async { implicit request => + for { + cookiesInfo <- authenticator.extract(request).liftTo[Future] + tokenId <- cookiesInfo.proConnectIdToken.liftTo[Future](MissingProConnectTokenId) + state <- cookiesInfo.proConnectState.liftTo[Future](MissingProConnectState) + redirectUrl <- proConnectOrchestrator.endSessionUrl(tokenId, state) + result = Ok(redirectUrl) + } yield authenticator.discard(result) + + } + def getUser(): Action[AnyContent] = SecuredAction.async { implicit request => Future.successful(Ok(Json.toJson(request.identity))) } @@ -76,19 +112,21 @@ class AuthController( } yield Ok } - def resetPassword(token: UUID): Action[JsValue] = IpRateLimitedAction2.async(parse.json) { implicit request => - for { - userPassword <- request.parseBody[UserPassword]() - _ <- authOrchestrator.resetPassword(token, userPassword) - } yield NoContent - } + def resetPassword(token: UUID): Action[JsValue] = + IpRateLimitedAction2.async(parse.json) { implicit request => + for { + userPassword <- request.parseBody[UserPassword]() + _ <- authOrchestrator.resetPassword(token, userPassword) + } yield NoContent + } - def changePassword = SecuredAction.async(parse.json) { implicit request => - for { - updatePassword <- request.parseBody[PasswordChange]() - _ <- authOrchestrator.changePassword(request.identity, updatePassword) - } yield NoContent + def changePassword = + SecuredAction.andThen(WithAuthProvider(AuthProvider.SignalConso)).async(parse.json) { implicit request => + for { + updatePassword <- request.parseBody[PasswordChange]() + _ <- authOrchestrator.changePassword(request.identity, updatePassword) + } yield NoContent - } + } } diff --git a/app/controllers/error/AppError.scala b/app/controllers/error/AppError.scala index c6a823df..29d6c14b 100644 --- a/app/controllers/error/AppError.scala +++ b/app/controllers/error/AppError.scala @@ -585,4 +585,52 @@ object AppError { override val titleForLogs: String = "user_not_found" } + final case class ProConnectSessionNotFound(state: String) extends FailedAuthenticationError { + override val scErrorCode: String = "SC-0063" + override val title: String = "Cannot find pro connect state" + override val details: String = + s"State $state ProConnect introuvable " + override val titleForLogs: String = "state_not_found" + } + + final case class ProConnectSessionInvalidJwt(message: String) extends FailedAuthenticationError { + override val scErrorCode: String = "SC-0064" + override val title: String = "Malformed request body" + override val details: String = + s"Le corps de la réponse (claim )ProConnect ne correspond pas à ce qui est attendu par l'API : $message" + override val titleForLogs: String = "malformed_claim" + } + + final case object MissingProConnectTokenId extends FailedAuthenticationError { + override val scErrorCode: String = "SC-0065" + override val title: String = "Cannot find pro connect token id" + override val details: String = + s"Token id ProConnect introuvable dans le cookie " + override val titleForLogs: String = "token_id_not_found" + } + + final case object MissingProConnectState extends FailedAuthenticationError { + override val scErrorCode: String = "SC-0066" + override val title: String = "Cannot find pro connect state" + override val details: String = + s"State ProConnect introuvable dans le cookie " + override val titleForLogs: String = "state_not_found" + } + + final case class UserNotInvited(login: String) extends ForbiddenError { + override val scErrorCode: String = "SC-0067" + override val title: String = "Cannot perform action on user" + override val details: String = + s"Utilisateur $login n'a pas accès à SignalConso, demandez une invitation via le support" + override val titleForLogs: String = "user_not_invited" + } + + final case class UserNotAllowedToAccessSignalConso(login: String) extends ForbiddenError { + override val scErrorCode: String = "SC-0068" + override val title: String = "Not allowed to user signal conso" + override val details: String = + s"Utilisateur $login ne fait pas parti de la DGCCRF et n'a pas accès à SignalConso." + override val titleForLogs: String = "user_not_allowed" + } + } diff --git a/app/loader/SignalConsoApplicationLoader.scala b/app/loader/SignalConsoApplicationLoader.scala index 87b533e7..cbf0c002 100644 --- a/app/loader/SignalConsoApplicationLoader.scala +++ b/app/loader/SignalConsoApplicationLoader.scala @@ -17,6 +17,8 @@ import com.typesafe.config.ConfigFactory import config._ import models.report.sampledata.SampleDataService import orchestrators._ +import orchestrators.proconnect.ProConnectClient +import orchestrators.proconnect.ProConnectOrchestrator import orchestrators.socialmedia.InfluencerOrchestrator import orchestrators.socialmedia.SocialBladeClient import org.flywaydb.core.Flyway @@ -68,6 +70,8 @@ import repositories.influencer.InfluencerRepository import repositories.influencer.InfluencerRepositoryInterface import repositories.ipblacklist.IpBlackListRepository import repositories.probe.ProbeRepository +import repositories.proconnect.ProConnectSessionRepository +import repositories.proconnect.ProConnectSessionRepositoryInterface import repositories.rating.RatingRepository import repositories.rating.RatingRepositoryInterface import repositories.report.ReportRepository @@ -205,10 +209,11 @@ class SignalConsoComponents( val companyAccessRepository: CompanyAccessRepositoryInterface = new CompanyAccessRepository(dbConfig) val accessTokenRepository: AccessTokenRepositoryInterface = new AccessTokenRepository(dbConfig, companyAccessRepository) - val asyncFileRepository: AsyncFileRepositoryInterface = new AsyncFileRepository(dbConfig) - val authAttemptRepository: AuthAttemptRepositoryInterface = new AuthAttemptRepository(dbConfig) - val authTokenRepository: AuthTokenRepositoryInterface = new AuthTokenRepository(dbConfig) - def companyRepository: CompanyRepositoryInterface = new CompanyRepository(dbConfig) + val asyncFileRepository: AsyncFileRepositoryInterface = new AsyncFileRepository(dbConfig) + val authAttemptRepository: AuthAttemptRepositoryInterface = new AuthAttemptRepository(dbConfig) + val authTokenRepository: AuthTokenRepositoryInterface = new AuthTokenRepository(dbConfig) + val proConnectSessionRepository: ProConnectSessionRepositoryInterface = new ProConnectSessionRepository(dbConfig) + def companyRepository: CompanyRepositoryInterface = new CompanyRepository(dbConfig) val companyActivationAttemptRepository: CompanyActivationAttemptRepositoryInterface = new CompanyActivationAttemptRepository(dbConfig) val consumerRepository: ConsumerRepositoryInterface = new ConsumerRepository(dbConfig) @@ -666,11 +671,21 @@ class SignalConsoComponents( val asyncFileController = new AsyncFileController(asyncFileRepository, s3Service, cookieAuthenticator, controllerComponents) + val proConnectClient = new ProConnectClient(applicationConfiguration.proConnect) + val proConnectOrchestrator = + new ProConnectOrchestrator( + proConnectClient, + proConnectSessionRepository, + userOrchestrator, + applicationConfiguration.proConnect.allowedProviderIds + ) + val authController = new AuthController( authOrchestrator, cookieAuthenticator, controllerComponents, - applicationConfiguration.app.enableRateLimit + applicationConfiguration.app.enableRateLimit, + proConnectOrchestrator ) val companyAccessController = diff --git a/app/models/AccessToken.scala b/app/models/AccessToken.scala index 849dd77b..68af40c9 100644 --- a/app/models/AccessToken.scala +++ b/app/models/AccessToken.scala @@ -58,3 +58,11 @@ case class ActivationRequest( object ActivationRequest { implicit val ActivationRequestFormat: OFormat[ActivationRequest] = Json.format[ActivationRequest] } + +case class InvitationRequest( + email: EmailAddress, + authProvider: Option[AuthProvider] +) +object InvitationRequest { + implicit val InvitationRequestFormat: OFormat[InvitationRequest] = Json.format[InvitationRequest] +} diff --git a/app/models/AuthProvider.scala b/app/models/AuthProvider.scala new file mode 100644 index 00000000..f58eb6ea --- /dev/null +++ b/app/models/AuthProvider.scala @@ -0,0 +1,20 @@ +package models + +import enumeratum._ +import repositories.PostgresProfile.api._ +import slick.ast.BaseTypedType +import slick.jdbc.JdbcType + +sealed trait AuthProvider extends EnumEntry +object AuthProvider extends PlayEnum[AuthProvider] { + val values = findValues + + case object ProConnect extends AuthProvider + case object SignalConso extends AuthProvider + + implicit val AuthProviderColumnType: JdbcType[AuthProvider] with BaseTypedType[AuthProvider] = + MappedColumnType.base[AuthProvider, String]( + _.entryName, + AuthProvider.namesToValuesMap + ) +} diff --git a/app/models/User.scala b/app/models/User.scala index c951279d..529f55aa 100644 --- a/app/models/User.scala +++ b/app/models/User.scala @@ -25,6 +25,8 @@ case class User( lastName: String, userRole: UserRole, lastEmailValidation: Option[OffsetDateTime], + authProvider: AuthProvider, + authProviderId: Option[String], deletionDate: Option[OffsetDateTime] = None, impersonator: Option[EmailAddress] = None ) { @@ -43,7 +45,8 @@ object User { "role" -> user.userRole.entryName, "lastEmailValidation" -> user.lastEmailValidation, "deletionDate" -> user.deletionDate, - "impersonator" -> user.impersonator + "impersonator" -> user.impersonator, + "authProvider" -> user.authProvider ) } diff --git a/app/models/proconnect/ProConnectClaim.scala b/app/models/proconnect/ProConnectClaim.scala new file mode 100644 index 00000000..7fbf4a6f --- /dev/null +++ b/app/models/proconnect/ProConnectClaim.scala @@ -0,0 +1,32 @@ +package models.proconnect + +import play.api.libs.json.Json +import play.api.libs.json.JsonConfiguration +import play.api.libs.json.JsonNaming +import play.api.libs.json.Reads + +case class ProConnectClaim( + sub: String, + custom: CustomField, + email: String, + givenName: String, + usualName: String, + aud: String, + exp: Long, + iat: Long, + iss: String, + idp_id: String +) + +case class CustomField( + profession: String +) + +object ProConnectClaim { + + implicit val config: JsonConfiguration.Aux[Json.MacroOptions] = JsonConfiguration(JsonNaming.SnakeCase) + + implicit val customFieldReads: Reads[CustomField] = Json.reads[CustomField] + implicit val proConnectClaimReads: Reads[ProConnectClaim] = Json.reads[ProConnectClaim] + +} diff --git a/app/models/proconnect/ProConnectNonce.scala b/app/models/proconnect/ProConnectNonce.scala new file mode 100644 index 00000000..ab48149c --- /dev/null +++ b/app/models/proconnect/ProConnectNonce.scala @@ -0,0 +1,12 @@ +package models.proconnect + +import play.api.libs.json.Json +import play.api.libs.json.Reads + +case class ProConnectNonce(nonce: String) + +object ProConnectNonce { + + implicit val proConnectNonceReads: Reads[ProConnectNonce] = Json.reads[ProConnectNonce] + +} diff --git a/app/models/report/sampledata/UserGenerator.scala b/app/models/report/sampledata/UserGenerator.scala index 1f3414b5..ba105b3d 100644 --- a/app/models/report/sampledata/UserGenerator.scala +++ b/app/models/report/sampledata/UserGenerator.scala @@ -1,5 +1,6 @@ package models.report.sampledata +import models.AuthProvider.SignalConso import models.User import models.UserRole import utils.EmailAddress @@ -24,7 +25,9 @@ object UserGenerator { lastName = lastName, userRole = userRole, lastEmailValidation = Some(OffsetDateTime.now()), - deletionDate = None + deletionDate = None, + authProvider = SignalConso, + authProviderId = None ) } diff --git a/app/orchestrators/AccessesOrchestrator.scala b/app/orchestrators/AccessesOrchestrator.scala index 6d050fb9..f34d6763 100644 --- a/app/orchestrators/AccessesOrchestrator.scala +++ b/app/orchestrators/AccessesOrchestrator.scala @@ -6,6 +6,7 @@ import cats.implicits.toTraverseOps import config.TokenConfiguration import controllers.error.AppError._ import io.scalaland.chimney.dsl._ +import models.AuthProvider.ProConnect import models._ import models.token.AdminOrDgccrfTokenKind import models.token.AgentAccessToken @@ -23,6 +24,7 @@ import repositories.accesstoken.AccessTokenRepositoryInterface import services.EmailAddressService import services.emails.EmailDefinitionsAdmin.AdminAccessLink import services.emails.EmailDefinitionsDggcrf.DgccrfAgentAccessLink +import services.emails.EmailDefinitionsDggcrf.DgccrfAgentInvitation import services.emails.EmailDefinitionsDggcrf.DgccrfValidateEmail import services.emails.EmailDefinitionsVarious.UpdateEmailAddress import services.emails.MailServiceInterface @@ -171,7 +173,7 @@ class AccessesOrchestrator( } .liftTo[Future](AccountActivationTokenNotFoundOrInvalid(token)) _ = logger.debug(s"Token $token found, creating user with role $userRole") - user <- userOrchestrator.createUser(draftUser, accessToken, userRole) + user <- userOrchestrator.createSignalConsoUser(draftUser, accessToken, userRole) _ = logger.debug(s"User created successfully, invalidating token") _ <- accessTokenRepository.invalidateToken(accessToken) _ = logger.debug(s"Token has been revoked") @@ -219,7 +221,7 @@ class AccessesOrchestrator( val parsedEmails = parseEmails(emails) for { _ <- validateEmails(parsedEmails, EmailAddressService.isEmailAcceptableForDgccrfAccount) - res <- Future.sequence(parsedEmails.map(sendDGCCRFInvitation)) + res <- Future.sequence(parsedEmails.map(email => sendAdminOrAgentInvitation(email, TokenKind.DGCCRFAccount))) } yield res case UserRole.DGAL => val parsedEmails = parseEmails(emails) @@ -230,8 +232,17 @@ class AccessesOrchestrator( case _ => Future.failed(WrongUserRole(role)) } - def sendDGCCRFInvitation(email: EmailAddress): Future[Unit] = - sendAdminOrAgentInvitation(email, TokenKind.DGCCRFAccount) + def sendDGCCRFInvitation(invitationRequest: InvitationRequest): Future[Unit] = + invitationRequest.authProvider match { + case Some(ProConnect) => + for { + _ <- userOrchestrator.createProConnectUser(invitationRequest.email, UserRole.DGCCRF) + _ <- mailService.send( + DgccrfAgentInvitation.Email("DGCCRF")(invitationRequest.email, frontRoute.dashboard.welcome) + ) + } yield () + case _ => sendAdminOrAgentInvitation(invitationRequest.email, TokenKind.DGCCRFAccount) + } def sendDGALInvitation(email: EmailAddress): Future[Unit] = sendAdminOrAgentInvitation(email, TokenKind.DGALAccount) @@ -245,7 +256,7 @@ class AccessesOrchestrator( def sendReadOnlyAdminInvitation(email: EmailAddress): Future[Unit] = sendAdminOrAgentInvitation(email, TokenKind.ReadOnlyAdminAccount) - def sendAdminOrAgentInvitation(email: EmailAddress, kind: AdminOrDgccrfTokenKind): Future[Unit] = { + private def sendAdminOrAgentInvitation(email: EmailAddress, kind: AdminOrDgccrfTokenKind): Future[Unit] = { val (emailValidationFunction, joinDuration, emailTemplate, invitationUrlFunction) = kind match { case DGCCRFAccount => ( diff --git a/app/orchestrators/AuthOrchestrator.scala b/app/orchestrators/AuthOrchestrator.scala index 7e00d7d7..d9a08a28 100644 --- a/app/orchestrators/AuthOrchestrator.scala +++ b/app/orchestrators/AuthOrchestrator.scala @@ -10,6 +10,7 @@ import cats.syntax.option._ import config.TokenConfiguration import controllers.error.AppError import controllers.error.AppError._ +import models.AuthProvider import models.User import models.UserRole import models.auth._ @@ -17,7 +18,6 @@ import orchestrators.AuthOrchestrator.AuthAttemptPeriod import orchestrators.AuthOrchestrator.MaxAllowedAuthAttempts import orchestrators.AuthOrchestrator.authTokenExpiration import play.api.Logger -import play.api.mvc.Cookie import play.api.mvc.Request import repositories.authattempt.AuthAttemptRepositoryInterface import repositories.authtoken.AuthTokenRepositoryInterface @@ -27,6 +27,8 @@ import services.emails.MailService import utils.Logs.RichLogger import utils.EmailAddress import utils.PasswordComplexityHelper +import cats.syntax.either._ +import models.AuthProvider.SignalConso import java.time.OffsetDateTime import java.time.Period @@ -72,48 +74,73 @@ class AuthOrchestrator( Future.successful(()) } - def login(userLogin: UserCredentials, request: Request[_]): Future[UserSession] = { - logger.debug(s"Validate auth attempts count") - val eventualUserSession: Future[UserSession] = for { - _ <- validateAuthenticationAttempts(userLogin.login) - maybeUser <- userRepository.findByEmailIncludingDeleted(userLogin.login) - user <- maybeUser.liftTo[Future](UserNotFound(userLogin.login)) + def signalConsoLogin(userCredentials: UserCredentials, request: Request[_]): Future[UserSession] = { + + val eventualUserSession = for { + _ <- validateAuthenticationAttempts(userCredentials.login) + user <- getStrictUser(userCredentials.login) _ = logger.debug(s"Found user (maybe deleted)") - _ <- handleDeletedUser(user, userLogin) + _ <- handleDeletedUser(user, userCredentials) _ = logger.debug(s"Check last validation email for DGCCRF users") _ <- validateAgentAccountLastEmailValidation(user) _ = logger.debug(s"Successful login for user") - cookie <- getCookie(userLogin)(request) - _ = logger.debug(s"Successful generated token for user") + _ <- credentialsProvider.authenticate(userCredentials.login, userCredentials.password) + cookie <- authenticator.initSignalConsoCookie(EmailAddress(userCredentials.login), None)(request).liftTo[Future] + _ = logger.debug(s"Successful generated token for user") } yield UserSession(cookie, user) - eventualUserSession + + saveAuthAttemptWithRecovery(userCredentials.login, eventualUserSession) + + } + + def proConnectLogin( + user: User, + request: Request[_], + proConnectIdToken: String, + proConnectState: String + ): Future[UserSession] = { + val eventualUserSession =for { + cookie <- authenticator + .initProConnectCookie(user.email, proConnectIdToken, proConnectState)(request) + .liftTo[Future] + _ = logger.debug(s"Successful generated token for user ${user.email.value}") + } yield UserSession(cookie, user) + + saveAuthAttemptWithRecovery(user.email.value, eventualUserSession) + } + + private def saveAuthAttemptWithRecovery[T](login: String, eventualSession: Future[T]): Future[T] = { + eventualSession .flatMap { session => logger.debug(s"Saving auth attempts for user") - authAttemptRepository.create(AuthAttempt.build(userLogin.login, isSuccess = true)).map(_ => session) + authAttemptRepository.create(AuthAttempt.build(login, isSuccess = true)).map(_ => session) } .recoverWith { case error: AppError => logger.debug(s"Saving failed auth attempt for user") authAttemptRepository - .create(AuthAttempt.build(userLogin.login, isSuccess = false, failureCause = Some(error.details))) + .create(AuthAttempt.build(login, isSuccess = false, failureCause = Some(error.details))) .flatMap(_ => Future.failed(error)) case error => logger.debug(s"Saving failed auth attempt for user") authAttemptRepository .create( AuthAttempt.build( - userLogin.login, + login, isSuccess = false, failureCause = Some(s"Unexpected error : ${error.getMessage}") ) ) .flatMap(_ => Future.failed(error)) - } - } + private def getStrictUser(login: String) = for { + maybeUser <- userRepository.findByEmailIncludingDeleted(login) + user <- maybeUser.liftTo[Future](UserNotFound(login)) + } yield user + def logAs(userEmail: EmailAddress, request: IdentifiedRequest[User, _]) = for { maybeUserToImpersonate <- userRepository.findByEmail(userEmail.value) userToImpersonate <- maybeUserToImpersonate.liftTo[Future](UserNotFound(userEmail.value)) @@ -121,23 +148,20 @@ class AuthOrchestrator( case UserRole.Professionnel => Future.unit case _ => Future.failed(BrokenAuthError("Not a pro")) } - cookie <- authenticator.initImpersonated(userEmail, request.identity.email)(request) match { - case Right(value) => Future.successful(value) - case Left(error) => Future.failed(error) - } + cookie <- authenticator.initSignalConsoCookie(userEmail, Some(request.identity.email))(request).liftTo[Future] } yield UserSession(cookie, userToImpersonate.copy(impersonator = Some(request.identity.email))) def logoutAs(userEmail: EmailAddress, request: Request[_]) = for { maybeUser <- userRepository.findByEmail(userEmail.value) user <- maybeUser.liftTo[Future](UserNotFound(userEmail.value)) - cookie <- authenticator.init(userEmail)(request) match { - case Right(value) => Future.successful(value) - case Left(error) => Future.failed(error) - } + cookie <- authenticator.initSignalConsoCookie(userEmail, None)(request).liftTo[Future] } yield UserSession(cookie, user) + private def findSignalConsoUserByEmail(emailAddress: String) = + userRepository.findByEmail(emailAddress).map(_.filter(_.authProvider == AuthProvider.SignalConso)) + def forgotPassword(resetPasswordLogin: UserLogin): Future[Unit] = - userRepository.findByEmail(resetPasswordLogin.login).flatMap { + findSignalConsoUserByEmail(resetPasswordLogin.login).flatMap { case Some(user) => for { _ <- authTokenRepository.deleteForUserId(user.id) @@ -182,15 +206,6 @@ class AuthOrchestrator( _ = logger.debug(s"Password updated for user id ${user.id}") } yield () - private def getCookie(userLogin: UserCredentials)(implicit req: Request[_]): Future[Cookie] = - for { - _ <- credentialsProvider.authenticate(userLogin.login, userLogin.password) - cookie <- authenticator.init(EmailAddress(userLogin.login)) match { - case Right(value) => Future.successful(value) - case Left(error) => Future.failed(error) - } - } yield cookie - private def validateAgentAccountLastEmailValidation(user: User): Future[User] = user.userRole match { case UserRole.DGCCRF | UserRole.DGAL if needsEmailRevalidation(user) => accessesOrchestrator @@ -209,7 +224,7 @@ class AuthOrchestrator( .now() .minus(dgccrfDelayBeforeRevalidation) ) - ) + ) && user.authProvider == SignalConso private def validateAuthenticationAttempts(login: String): Future[Unit] = for { _ <- authAttemptRepository diff --git a/app/orchestrators/ProAccessTokenOrchestrator.scala b/app/orchestrators/ProAccessTokenOrchestrator.scala index 328e27bc..fc324327 100644 --- a/app/orchestrators/ProAccessTokenOrchestrator.scala +++ b/app/orchestrators/ProAccessTokenOrchestrator.scala @@ -86,7 +86,7 @@ class ProAccessTokenOrchestrator( def activateProUser(draftUser: DraftUser, token: String, siret: SIRET): Future[User] = for { _ <- PasswordComplexityHelper.validatePasswordComplexity(draftUser.password) token <- fetchCompanyToken(token, siret) - user <- userOrchestrator.createUser(draftUser, token, UserRole.Professionnel) + user <- userOrchestrator.createSignalConsoUser(draftUser, token, UserRole.Professionnel) _ <- bindPendingTokens(user) _ <- eventRepository.create( Event( diff --git a/app/orchestrators/UserOrchestrator.scala b/app/orchestrators/UserOrchestrator.scala index 0e0b3800..d6564944 100644 --- a/app/orchestrators/UserOrchestrator.scala +++ b/app/orchestrators/UserOrchestrator.scala @@ -1,8 +1,11 @@ package orchestrators +import cats.data.OptionT import cats.implicits.catsSyntaxMonadError import controllers.error.AppError.EmailAlreadyExist +import controllers.error.AppError.UserAccountEmailAlreadyExist import controllers.error.AppError.UserNotFound +import controllers.error.AppError.UserNotInvited import models.AccessToken import models.DraftUser import models.User @@ -11,10 +14,14 @@ import models.UserUpdate import play.api.Logger import repositories.user.UserRepositoryInterface import utils.EmailAddress + import java.time.OffsetDateTime import cats.syntax.option._ +import models.AuthProvider.ProConnect +import models.AuthProvider.SignalConso import models.event.Event import models.event.Event.stringToDetailsJsValue +import models.proconnect.ProConnectClaim import repositories.event.EventRepositoryInterface import utils.Constants.ActionEvent.USER_DELETION import utils.Constants.EventType.ADMIN @@ -24,7 +31,7 @@ import scala.concurrent.ExecutionContext import scala.concurrent.Future trait UserOrchestratorInterface { - def createUser(draftUser: DraftUser, accessToken: AccessToken, role: UserRole): Future[User] + def createSignalConsoUser(draftUser: DraftUser, accessToken: AccessToken, role: UserRole): Future[User] def findOrError(emailAddress: EmailAddress): Future[User] @@ -37,6 +44,10 @@ trait UserOrchestratorInterface { def softDelete(targetUserId: UUID, currentUserId: UUID): Future[Unit] def updateEmail(user: User, newEmail: EmailAddress): Future[User] + + def getProConnectUser(claim: ProConnectClaim, role: UserRole): Future[User] + + def createProConnectUser(emailAddress: EmailAddress, role: UserRole): Future[User] } class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository: EventRepositoryInterface)(implicit @@ -55,7 +66,7 @@ class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository: 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] = { + override def createSignalConsoUser(draftUser: DraftUser, accessToken: AccessToken, role: UserRole): Future[User] = { val email: EmailAddress = accessToken.emailedTo.getOrElse(draftUser.email) val user = User( id = UUID.randomUUID, @@ -64,6 +75,8 @@ class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository: firstName = draftUser.firstName, lastName = draftUser.lastName, userRole = role, + authProvider = SignalConso, + authProviderId = None, lastEmailValidation = Some(OffsetDateTime.now()) ) for { @@ -72,6 +85,47 @@ class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository: } yield user } + override def getProConnectUser(claim: ProConnectClaim, role: UserRole): Future[User] = + OptionT(userRepository.findByAuthProviderId(claim.sub)) + .orElseF(userRepository.findByEmail(claim.email)) + .semiflatMap { user => + val updated = user.copy( + email = EmailAddress(claim.email), + firstName = claim.givenName, + lastName = claim.usualName, + authProvider = ProConnect, + authProviderId = claim.sub.some, + lastEmailValidation = Some(OffsetDateTime.now()) + ) + userRepository.update(user.id, updated) + } + .getOrRaise( + UserNotInvited(claim.email) + ) + + override def createProConnectUser(emailAddress: EmailAddress, role: UserRole): Future[User] = + userRepository + .findByEmailIncludingDeleted(emailAddress.value) + .flatMap { + case Some(user) if user.deletionDate.isDefined => + // Reactivating user + userRepository.restore(user) + case Some(_) => Future.failed(UserAccountEmailAlreadyExist) + case None => + val user = User( + id = UUID.randomUUID, + password = "", + email = emailAddress, + firstName = "", + lastName = "", + userRole = role, + authProvider = ProConnect, + authProviderId = None, + lastEmailValidation = Some(OffsetDateTime.now()) + ) + userRepository.create(user) + } + override def findOrError(emailAddress: EmailAddress): Future[User] = userRepository .findByEmail(emailAddress.value) diff --git a/app/orchestrators/proconnect/ProConnectAccessToken.scala b/app/orchestrators/proconnect/ProConnectAccessToken.scala new file mode 100644 index 00000000..54b321e9 --- /dev/null +++ b/app/orchestrators/proconnect/ProConnectAccessToken.scala @@ -0,0 +1,14 @@ +package orchestrators.proconnect + +import play.api.libs.json.Json +import play.api.libs.json.OFormat + +case class ProConnectAccessToken( + access_token: String, + expires_in: Int, + id_token: String +) + +object ProConnectAccessToken { + implicit val ProConnectAccessTokenFormat: OFormat[ProConnectAccessToken] = Json.format[ProConnectAccessToken] +} diff --git a/app/orchestrators/proconnect/ProConnectClient.scala b/app/orchestrators/proconnect/ProConnectClient.scala new file mode 100644 index 00000000..e96d0ad8 --- /dev/null +++ b/app/orchestrators/proconnect/ProConnectClient.scala @@ -0,0 +1,88 @@ +package orchestrators.proconnect + +import config.ProConnectConfiguration +import orchestrators.proconnect.ProConnectClient.ProConnectError +import play.api.Logger +import sttp.client3.asynchttpclient.future.AsyncHttpClientFutureBackend +import sttp.client3.UriContext +import sttp.client3.asString +import sttp.client3.basicRequest +import sttp.client3.playJson.asJson +import sttp.model.Header + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +class ProConnectClient(config: ProConnectConfiguration)(implicit ec: ExecutionContext) { + + val logger = Logger(this.getClass) + val backend = AsyncHttpClientFutureBackend() + + def getToken(code: String): Future[ProConnectAccessToken] = { + val url = s"${config.url}/${config.tokenEndpoint}" + val request = basicRequest + .body( + Map( + "client_id" -> config.clientId, + "client_secret" -> config.clientSecret, + "grant_type" -> "authorization_code", + "redirect_uri" -> config.loginRedirectUri.toString, + "code" -> code + ) + ) + .post(uri"${url}") + .response(asJson[ProConnectAccessToken]) + + request + .send(backend) + .flatMap { response => + if (response.code.isSuccess) { + response.body match { + case Right(token) => + Future.successful(token) + case Left(error) => + logger.error(s"Error while parsing oauth token from ProConnect", error) + Future.failed(ProConnectError("Error while parsing oauth token from ProConnect")) + } + } else { + logger.error(response.toString()) + Future.failed(ProConnectError(s"Error while fetching oauth token from ProConnect, code: ${response.code}")) + } + } + } + + def userInfo(token: ProConnectAccessToken): Future[String] = { + val url = uri"${config.url}/api/v2/userinfo" + val request = basicRequest + .get(url) + .headers(Header.authorization("Bearer", token.access_token)) + .response(asString) + request + .send(backend) + .flatMap { response => + if (response.code.isSuccess) { + response.body match { + case Right(token) => + Future.successful(token) + case Left(error) => + logger.error(s"Error while parsing oauth token from ProConnect : $error") + Future.failed(ProConnectError("Error while parsing oauth token from ProConnect")) + } + } else { + Future.failed(ProConnectError(s"Error while fetching oauth token from ProConnect, code: ${response.code}")) + } + } + } + + def endSessionUrl(id_token: String, state: String) = +// val logoutRedirectUri= URLEncoder.encode(config.logoutRedirectUri.toString, StandardCharsets.UTF_8.toString) + uri"${config.url}/api/v2/session/end?id_token_hint=${id_token}&state=${state}&post_logout_redirect_uri=${config.logoutRedirectUri.toString}" + .toString() + +} + +object ProConnectClient { + case object TokenExpired + + case class ProConnectError(message: String) extends Throwable +} diff --git a/app/orchestrators/proconnect/ProConnectOrchestrator.scala b/app/orchestrators/proconnect/ProConnectOrchestrator.scala new file mode 100644 index 00000000..364a8b5e --- /dev/null +++ b/app/orchestrators/proconnect/ProConnectOrchestrator.scala @@ -0,0 +1,110 @@ +package orchestrators.proconnect + +import cats.implicits.catsSyntaxOption +import cats.syntax.either._ +import controllers.error.AppError +import controllers.error.AppError.ProConnectSessionInvalidJwt +import controllers.error.AppError.UserNotAllowedToAccessSignalConso +import models.UserRole.DGCCRF +import models.proconnect.ProConnectClaim +import models.proconnect.ProConnectNonce +import orchestrators.UserOrchestratorInterface +import play.api.Logger +import play.api.libs.json.JsError +import play.api.libs.json.JsValue +import play.api.libs.json.Json +import repositories.proconnect.ProConnectSession +import repositories.proconnect.ProConnectSessionRepositoryInterface +import utils.Logs.RichLogger + +import java.util.Base64 +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +class ProConnectOrchestrator( + proConnectClient: ProConnectClient, + proConnectSessionRepository: ProConnectSessionRepositoryInterface, + userOrchestrator: UserOrchestratorInterface, + allowedProvidersIds: List[String] +)(implicit + val executionContext: ExecutionContext +) { + val logger: Logger = Logger(this.getClass) + + def saveState(state: String, nonce: String): Future[ProConnectSession] = + proConnectSessionRepository.create(ProConnectSession(state, nonce)) + + def login(code: String, state: String) = + for { + session <- validateState(state) + token <- proConnectClient.getToken(code) + _ <- validateNonce(session, token.id_token) + jwtRaw <- proConnectClient.userInfo(token) + claim <- decodeClaim(jwtRaw).liftTo[Future] + _ <- allowedProvidersIds.find(_ == claim.idp_id).liftTo[Future](UserNotAllowedToAccessSignalConso(claim.email)) + user <- userOrchestrator.getProConnectUser(claim, DGCCRF) + } yield (token.id_token, user) + + private def validateState(state: String) = for { + maybeStoredState <- proConnectSessionRepository.find(state) + proConnectSession <- maybeStoredState match { + case Some(session) => Future.successful(session) + case None => + logger.errorWithTitle( + "csrf_state_mismatch", + s"State ${state} not found, is this the result of a csrf attack ?" + ) + Future.failed(AppError.ProConnectSessionNotFound(state)) + } + } yield proConnectSession + + private def validateNonce(session: ProConnectSession, idToken: String) = + decodeNonce(idToken) match { + case Right(tokenNonce) if tokenNonce.nonce.toLowerCase().trim == session.nonce.toLowerCase().trim => Future.unit + case _ => + logger.errorWithTitle( + "csrf_nonce_mismatch_or_not_found", + s"Nonce ${session.nonce} not found in idtoken $idToken" + ) + Future.failed(AppError.ProConnectSessionNotFound(session.nonce)) + } + + private def base64Decode(input: String): String = { + val decoder = Base64.getUrlDecoder + // Fix missing padding (Base64 strings should have length multiple of 4) + val paddedInput = input + ("=" * ((4 - input.length % 4) % 4)) + new String(decoder.decode(paddedInput)) + } + + private def decodeClaim(token: String): Either[ProConnectSessionInvalidJwt, ProConnectClaim] = { + val payloadJson: JsValue = decodeJwt(token) + payloadJson.validate[ProConnectClaim].asEither.leftMap { err => + ProConnectSessionInvalidJwt(Json.stringify(JsError.toJson(err))) + } + } + + private def decodeNonce(token: String): Either[ProConnectSessionInvalidJwt, ProConnectNonce] = { + val payloadJson: JsValue = decodeJwt(token) + payloadJson.validate[ProConnectNonce].asEither.leftMap { err => + ProConnectSessionInvalidJwt(Json.stringify(JsError.toJson(err))) + } + } + + private def decodeJwt(token: String) = { + // Split the token into parts (header, payload, signature) + val parts = token.split("\\.") + if (parts.length != 3) { + throw new IllegalArgumentException("Invalid JWT token") + } + val payloadDecoded = base64Decode(parts(1)) + + val payloadJson = Json.parse(payloadDecoded) + payloadJson + } + + def endSessionUrl(id_token: String, state: String) = + for { + _ <- validateState(state) + } yield proConnectClient.endSessionUrl(id_token, state) + +} diff --git a/app/repositories/proconnect/ProConnectSession.scala b/app/repositories/proconnect/ProConnectSession.scala new file mode 100644 index 00000000..b609572d --- /dev/null +++ b/app/repositories/proconnect/ProConnectSession.scala @@ -0,0 +1,15 @@ +package repositories.proconnect + +import java.time.OffsetDateTime +import java.util.UUID + +case class ProConnectSession( + id: UUID, + state: String, + nonce: String, + creationDate: OffsetDateTime +) + +object ProConnectSession { + def apply(state: String, nonce: String) = new ProConnectSession(UUID.randomUUID(), state, nonce, OffsetDateTime.now()) +} diff --git a/app/repositories/proconnect/ProConnectSessionRepository.scala b/app/repositories/proconnect/ProConnectSessionRepository.scala new file mode 100644 index 00000000..56e76cc6 --- /dev/null +++ b/app/repositories/proconnect/ProConnectSessionRepository.scala @@ -0,0 +1,38 @@ +package repositories.proconnect + +import repositories.CRUDRepository +import repositories.PostgresProfile.api._ +import slick.basic.DatabaseConfig +import slick.jdbc.JdbcProfile +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +/** A repository for authToken. + */ +class ProConnectSessionRepository( + override val dbConfig: DatabaseConfig[JdbcProfile] +)(implicit override val ec: ExecutionContext) + extends CRUDRepository[ProConnectSessionTable, ProConnectSession] + with ProConnectSessionRepositoryInterface { + + override val table: TableQuery[ProConnectSessionTable] = ProConnectSessionTable.table + import dbConfig._ + + override def find(state: String): Future[Option[ProConnectSession]] = db + .run( + table + .filter(_.state === state) + .to[List] + .result + .headOption + ) + + override def delete(state: String): Future[Int] = db.run { + table + .filter(_.state === state) + .delete + } + +} diff --git a/app/repositories/proconnect/ProConnectSessionRepositoryInterface.scala b/app/repositories/proconnect/ProConnectSessionRepositoryInterface.scala new file mode 100644 index 00000000..8cd2e08f --- /dev/null +++ b/app/repositories/proconnect/ProConnectSessionRepositoryInterface.scala @@ -0,0 +1,13 @@ +package repositories.proconnect + +import repositories.CRUDRepositoryInterface + +import scala.concurrent.Future + +trait ProConnectSessionRepositoryInterface extends CRUDRepositoryInterface[ProConnectSession] { + + def find(state: String): Future[Option[ProConnectSession]] + + def delete(state: String): Future[Int] + +} diff --git a/app/repositories/proconnect/ProConnectSessionTable.scala b/app/repositories/proconnect/ProConnectSessionTable.scala new file mode 100644 index 00000000..4a40a419 --- /dev/null +++ b/app/repositories/proconnect/ProConnectSessionTable.scala @@ -0,0 +1,32 @@ +package repositories.proconnect + +import repositories.DatabaseTable +import repositories.PostgresProfile.api._ + +import java.time.OffsetDateTime +import java.util.UUID + +class ProConnectSessionTable(tag: Tag) extends DatabaseTable[ProConnectSession](tag, "pro_connect_session") { + + def state = column[String]("state") + def nonce = column[String]("nonce") + def creationDate = column[OffsetDateTime]("creation_date") + + type ProConnectSessionData = (UUID, String, String, OffsetDateTime) + + def constructProConnectSession: ProConnectSessionData => ProConnectSession = { + case (id, state, nonce, creationDate) => + ProConnectSession(id, state, nonce, creationDate) + } + + def extractProConnectSession: PartialFunction[ProConnectSession, ProConnectSessionData] = { + case ProConnectSession(id, state, nonce, creationDate) => + (id, state, nonce, creationDate) + } + + def * = (id, state, nonce, creationDate) <> (constructProConnectSession, extractProConnectSession.lift) +} + +object ProConnectSessionTable { + val table = TableQuery[ProConnectSessionTable] +} diff --git a/app/repositories/user/UserRepository.scala b/app/repositories/user/UserRepository.scala index d6d3a52f..2ffb30ca 100644 --- a/app/repositories/user/UserRepository.scala +++ b/app/repositories/user/UserRepository.scala @@ -119,6 +119,14 @@ class UserRepository( .headOption ) + override def findByAuthProviderId(authProviderId: String): Future[Option[User]] = + db.run( + table + .filter(_.authProviderId === authProviderId) + .result + .headOption + ) + override def findByEmailIncludingDeleted(email: String): Future[Option[User]] = db.run( fullTableIncludingDeleted @@ -130,6 +138,13 @@ class UserRepository( .headOption ) + override def restore(user: User): Future[User] = { + val restoredUser = user.copy(deletionDate = None) + db.run( + fullTableIncludingDeleted.filter(_.id === user.id).update(restoredUser) + ).map(_ => restoredUser) + } + // Override the base method to avoid accidental delete override def delete(id: UUID): Future[Int] = softDelete(id) diff --git a/app/repositories/user/UserRepositoryInterface.scala b/app/repositories/user/UserRepositoryInterface.scala index 630d73fc..5c98104f 100644 --- a/app/repositories/user/UserRepositoryInterface.scala +++ b/app/repositories/user/UserRepositoryInterface.scala @@ -28,6 +28,8 @@ trait UserRepositoryInterface extends CRUDRepositoryInterface[User] { def findByEmail(email: String): Future[Option[User]] + def findByAuthProviderId(authProviderId: String): Future[Option[User]] + def findByEmailIncludingDeleted(email: String): Future[Option[User]] def softDelete(id: UUID): Future[Int] @@ -37,4 +39,6 @@ trait UserRepositoryInterface extends CRUDRepositoryInterface[User] { def findByIds(ids: Seq[UUID]): Future[Seq[User]] def hardDelete(id: UUID): Future[Int] + def restore(user: User): Future[User] + } diff --git a/app/repositories/user/UserTable.scala b/app/repositories/user/UserTable.scala index db97a208..f2833a09 100644 --- a/app/repositories/user/UserTable.scala +++ b/app/repositories/user/UserTable.scala @@ -1,5 +1,6 @@ package repositories.user +import models.AuthProvider import models.User import models.UserRole import repositories.DatabaseTable @@ -17,18 +18,78 @@ class UserTable(tag: Tag) extends DatabaseTable[User](tag, "users") { def lastName = column[String]("lastname") def role = column[String]("role") def lastEmailValidation = column[Option[OffsetDateTime]]("last_email_validation") + def authProvider = column[AuthProvider]("auth_provider") + def authProviderId = column[Option[String]]("auth_provider_id") def deletionDate = column[Option[OffsetDateTime]]("deletion_date") - type UserData = (UUID, String, EmailAddress, String, String, String, Option[OffsetDateTime], Option[OffsetDateTime]) + type UserData = + ( + UUID, + String, + EmailAddress, + String, + String, + String, + Option[OffsetDateTime], + AuthProvider, + Option[String], + Option[OffsetDateTime] + ) def constructUser: UserData => User = { - case (id, password, email, firstName, lastName, role, lastEmailValidation, deletionDate) => - User(id, password, email, firstName, lastName, UserRole.withName(role), lastEmailValidation, deletionDate) + case ( + id, + password, + email, + firstName, + lastName, + role, + lastEmailValidation, + authProvider, + authProviderId, + deletionDate + ) => + User( + id, + password, + email, + firstName, + lastName, + UserRole.withName(role), + lastEmailValidation, + authProvider, + authProviderId, + deletionDate = deletionDate, + impersonator = None + ) } def extractUser: PartialFunction[User, UserData] = { - case User(id, password, email, firstName, lastName, role, lastEmailValidation, deletionDate, _) => - (id, password, email, firstName, lastName, role.entryName, lastEmailValidation, deletionDate) + case User( + id, + password, + email, + firstName, + lastName, + role, + lastEmailValidation, + authProvider, + authProviderId, + deletionDate, + _ + ) => + ( + id, + password, + email, + firstName, + lastName, + role.entryName, + lastEmailValidation, + authProvider, + authProviderId, + deletionDate + ) } def * = ( @@ -39,6 +100,8 @@ class UserTable(tag: Tag) extends DatabaseTable[User](tag, "users") { lastName, role, lastEmailValidation, + authProvider, + authProviderId, deletionDate ) <> (constructUser, extractUser.lift) } diff --git a/app/services/emails/EmailDefinitionsDggcrf.scala b/app/services/emails/EmailDefinitionsDggcrf.scala index 5d6e773f..f2d25e9d 100644 --- a/app/services/emails/EmailDefinitionsDggcrf.scala +++ b/app/services/emails/EmailDefinitionsDggcrf.scala @@ -16,6 +16,23 @@ import java.time.LocalDate object EmailDefinitionsDggcrf { + case object DgccrfAgentInvitation extends EmailDefinition { + override val category = Dgccrf + + override def examples = + Seq("access_link" -> ((recipient, _) => Email("DGCCRF")(recipient, dummyURL))) + + final case class Email(role: String)(recipient: EmailAddress, connectionUrl: URI) extends BaseEmail { + override val subject: String = EmailSubjects.DGCCRF_ACCESS_LINK + + override def getBody: (FrontRoute, EmailAddress) => String = (_, _) => + views.html.mails.dgccrf.proConnectInvite(connectionUrl, role).toString + + override val recipients: List[EmailAddress] = List(recipient) + } + + } + case object DgccrfAgentAccessLink extends EmailDefinition { override val category = Dgccrf diff --git a/app/services/emails/EmailsExamplesUtils.scala b/app/services/emails/EmailsExamplesUtils.scala index 628ac353..ea7da8bf 100644 --- a/app/services/emails/EmailsExamplesUtils.scala +++ b/app/services/emails/EmailsExamplesUtils.scala @@ -1,5 +1,6 @@ package services.emails +import models.AuthProvider.SignalConso import models.Subscription import models.User import models.UserRole @@ -117,7 +118,9 @@ object EmailsExamplesUtils { firstName = "Jeanne", lastName = "Dupont", userRole = UserRole.Admin, - lastEmailValidation = None + lastEmailValidation = None, + authProvider = SignalConso, + authProviderId = None ) def genEvent = diff --git a/app/utils/FrontRoute.scala b/app/utils/FrontRoute.scala index 6bcc3e7c..1ba64b1c 100644 --- a/app/utils/FrontRoute.scala +++ b/app/utils/FrontRoute.scala @@ -40,9 +40,9 @@ class FrontRoute(signalConsoConfiguration: SignalConsoConfiguration) { def validateEmail(token: String) = url(s"/connexion/validation-email?token=${token}") 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") +// 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/dgccrf/proConnectInvite.scala.html b/app/views/mails/dgccrf/proConnectInvite.scala.html new file mode 100644 index 00000000..a1b520f6 --- /dev/null +++ b/app/views/mails/dgccrf/proConnectInvite.scala.html @@ -0,0 +1,34 @@ +@import java.net.URI +@import utils.FrontRoute +@(connectionUrl: URI, role: String) + +@views.html.mails.layout("Accédez à SignalConso") { +

+ Bonjour, +

+ +

+ Vous avez été invité à accéder à l'interface @role sur SignalConso. +
Afin de procéder à l'activation de votre compte, veuillez vous connecter depuis le lien ci-dessous : +

+ +

+ Accéder à Signal Conso +

+ +

+ Si le lien ne fonctionne pas, merci de : +

+

+ +

Cordialement,

+ +

+ L'équipe SignalConso +

+} \ No newline at end of file diff --git a/conf/common/auth.conf b/conf/common/auth.conf index 8fd2464d..28da544c 100644 --- a/conf/common/auth.conf +++ b/conf/common/auth.conf @@ -1,6 +1,6 @@ cookie { cookie-name = "sc-api" - cookie-domain=${?COOKIE_DOMAIN} + cookie-domain = ${?COOKIE_DOMAIN} same-site = "strict" same-site = ${?COOKIE_SAME_SITE} cookie-max-age = 12 hours @@ -13,3 +13,16 @@ crypter.key = ${?CRYPTER_KEY} signer.key = "test-lZzJ9n4kVttkCJcJjpS+Aii7kGl5RVddVgGl2" signer.key = ${?SIGNER_KEY} + + +pro-connect { + url: ${?PRO_CONNECT_URL} + client-id: ${?PRO_CONNECT_CLIENT_ID} + client-secret: ${?PRO_CONNECT_CLIENT_SECRET} + token-endpoint: "api/v2/token" + userinfo-endpoint: "api/v2/userinfo" + login-redirect-uri: ${?PRO_CONNECT_LOGIN_REDIRECT_URI} + logout-redirect-uri: ${?PRO_CONNECT_LOGOUT_REDIRECT_URI} + //See https://grist.numerique.gouv.fr/o/docs/3kQ829mp7bTy/AgentConnect-Configuration-des-Fournisseurs-dIdentite/p/1 for ids and allow only DGCCRF + allowed-provider-ids: ${?PRO_CONNECT_IDENTITY_PROVIDER_IDS} +} \ No newline at end of file diff --git a/conf/db/migration/default/V38__proconnect_session.sql b/conf/db/migration/default/V38__proconnect_session.sql new file mode 100644 index 00000000..6661c23d --- /dev/null +++ b/conf/db/migration/default/V38__proconnect_session.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS pro_connect_session +( + id UUID NOT NULL PRIMARY KEY, + state VARCHAR NOT NULL UNIQUE, + nonce VARCHAR NOT NULL, + creation_date TIMESTAMP WITH TIME ZONE NOT NULL +); + diff --git a/conf/db/migration/default/V39__user_auth_provider.sql b/conf/db/migration/default/V39__user_auth_provider.sql new file mode 100644 index 00000000..a7886562 --- /dev/null +++ b/conf/db/migration/default/V39__user_auth_provider.sql @@ -0,0 +1,5 @@ +ALTER TABLE users + ADD auth_provider VARCHAR default 'SignalConso' not null; + +ALTER TABLE users + ADD auth_provider_id VARCHAR; \ No newline at end of file diff --git a/conf/routes b/conf/routes index 84acc5f6..13372a40 100644 --- a/conf/routes +++ b/conf/routes @@ -98,8 +98,11 @@ GET /api/mobileapp/requirements controller # Authentication API POST /api/authenticate controllers.AuthController.authenticate() +GET /api/authenticate/proconnect/start controllers.AuthController.startProConnectAuthentication(state: String, nonce:String) +GET /api/authenticate/proconnect controllers.AuthController.proConnectAuthenticate(code: String, state: String) POST /api/log-as controllers.AuthController.logAs() POST /api/logout controllers.AuthController.logout() +POST /api/logout/proconnect controllers.AuthController.logoutProConnect() GET /api/current-user controllers.AuthController.getUser() POST /api/authenticate/password/forgot controllers.AuthController.forgotPassword() POST /api/authenticate/password/reset controllers.AuthController.resetPassword(token: java.util.UUID) diff --git a/test/controllers/AccountControllerSpec.scala b/test/controllers/AccountControllerSpec.scala index 6eda636e..c11d5828 100644 --- a/test/controllers/AccountControllerSpec.scala +++ b/test/controllers/AccountControllerSpec.scala @@ -152,7 +152,7 @@ class AccountControllerSpec(implicit ee: ExecutionEnv) "send an invalid DGCCRF invitation" in { val request = FakeRequest(POST, routes.AccountController.sendAgentInvitation(UserRole.DGCCRF).toString) - .withJsonBody(Json.obj("email" -> "user@example.com")) + .withJsonBody(Json.obj("email" -> "user@example.com", "authProvider" -> AuthProvider.SignalConso )) .withAuthCookie(identity.email, components.cookieAuthenticator) val result = route(app, request).get @@ -162,7 +162,7 @@ class AccountControllerSpec(implicit ee: ExecutionEnv) "send a DGCCRF invitation" in { val request = FakeRequest(POST, routes.AccountController.sendAgentInvitation(UserRole.DGCCRF).toString) .withAuthCookie(identity.email, components.cookieAuthenticator) - .withJsonBody(Json.obj("email" -> "user@dgccrf.gouv.fr")) + .withJsonBody(Json.obj("email" -> "user@dgccrf.gouv.fr", "authProvider" -> AuthProvider.SignalConso )) val result = route(app, request).get Helpers.status(result) must beEqualTo(200) @@ -177,6 +177,7 @@ class AccountControllerSpec(implicit ee: ExecutionEnv) Helpers.status(result) must beEqualTo(403) } + "send a DGAL invitation" in { val request = FakeRequest(POST, routes.AccountController.sendAgentInvitation(UserRole.DGAL).toString) .withAuthCookie(identity.email, components.cookieAuthenticator) diff --git a/test/controllers/AuthControllerSpec.scala b/test/controllers/AuthControllerSpec.scala index 44484516..e8fae6a9 100644 --- a/test/controllers/AuthControllerSpec.scala +++ b/test/controllers/AuthControllerSpec.scala @@ -172,6 +172,7 @@ class AuthControllerSpec(implicit ee: ExecutionEnv) ) val authAttempts = Await.result(result.map(_._2), Duration.Inf) + println(s"------------------ authAttempts = ${authAttempts} ------------------") authAttempts.length shouldEqual 1 authAttempts.headOption.map(_.login) shouldEqual Some(login) authAttempts.headOption.flatMap(_.isSuccess) shouldEqual (Some(false)) diff --git a/test/repositories/UserRepositorySpec.scala b/test/repositories/UserRepositorySpec.scala index 033cde3a..65e51b40 100644 --- a/test/repositories/UserRepositorySpec.scala +++ b/test/repositories/UserRepositorySpec.scala @@ -114,8 +114,8 @@ class UserRepositorySpec(implicit ee: ExecutionEnv) extends Specification with A def e6 = userRepository .listInactiveAgentsWithSentEmailCount(now.minusMonths(1), now.minusYears(1)) - .map(_.map { case (user, count) => (user.id, count) }) must beEqualTo( - List(inactiveDgccrfUser.id -> None, inactiveDgccrfUserWithEmails.id -> Some(2)) + .map(_.map { case (user, count) => (user.id, count) }.toSet) must beEqualTo( + List(inactiveDgccrfUserWithEmails.id -> Some(2),inactiveDgccrfUser.id -> None).toSet ).await def e7 = userRepository .listInactiveAgentsWithSentEmailCount(now.minusMonths(1), now.minusMonths(2)) must beEmpty[List[ diff --git a/test/tasks/account/InactiveAccountTaskSpec.scala b/test/tasks/account/InactiveAccountTaskSpec.scala index ac886c11..315b8826 100644 --- a/test/tasks/account/InactiveAccountTaskSpec.scala +++ b/test/tasks/account/InactiveAccountTaskSpec.scala @@ -140,7 +140,7 @@ class InactiveAccountTaskSpec(implicit ee: ExecutionEnv) ) // Validating user - userList.map(_.id).containsSlice(expectedUsers.map(_.id)) shouldEqual true + userList.map(_.id).toSet shouldEqual(expectedUsers.map(_.id).toSet) userList.map(_.id).contains(inactiveDGCCRFUser.id) shouldEqual false deletedUsersList.map(_.id).contains(inactiveDGCCRFUser.id) shouldEqual true diff --git a/test/utils/AuthHelpers.scala b/test/utils/AuthHelpers.scala index 27fedc1d..4ff39fbb 100644 --- a/test/utils/AuthHelpers.scala +++ b/test/utils/AuthHelpers.scala @@ -8,7 +8,7 @@ import play.api.test.FakeRequest object AuthHelpers { implicit class FakeRequestOps[A](request: FakeRequest[A]) { def withAuthCookie(userEmail: EmailAddress, cookieAuthenticator: CookieAuthenticator): FakeRequest[A] = { - val cookie = cookieAuthenticator.init(userEmail)(request).toOption.get + val cookie = cookieAuthenticator.initSignalConsoCookie(userEmail, None)(request).toOption.get request.withCookies(cookie) } } diff --git a/test/utils/Fixtures.scala b/test/utils/Fixtures.scala index b87f7d90..64d42b44 100644 --- a/test/utils/Fixtures.scala +++ b/test/utils/Fixtures.scala @@ -1,5 +1,6 @@ package utils +import models.AuthProvider.SignalConso import models._ import models.barcode.BarcodeProduct import models.company.Address @@ -55,7 +56,9 @@ object Fixtures { firstName = firstName, lastName = lastName, userRole = userRole, - lastEmailValidation = None + lastEmailValidation = None, + authProvider = SignalConso, + authProviderId = None ) val genFirstName = Gen.oneOf("Alice", "Bob", "Charles", "Danièle", "Émilien", "Fanny", "Gérard") diff --git a/zip.http b/zip.http index abc34d23..baea4a16 100644 --- a/zip.http +++ b/zip.http @@ -8,3 +8,10 @@ GET localhost:9000/api/reports/files?reportId=d894fe80-935b-45f8-bfab-ea1c8933f5 GET localhost:9000/api/certified-influencer?name=eeeee&socialNetwork=LinkedIn + + +### +GET localhost:9000/api/authenticate/proconnect/callback?code=Y000000&state=ioio&id_token=059e41a6-bd10-4cb1-97f8-b03884df63de + +### +GET localhost:9009/api/authenticate/proconnect/start?state=hksdgfkhdsfkjdsh \ No newline at end of file