Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TRELLO-2572 : pro connect poc (#1749) #1773

Merged
merged 1 commit into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 37 additions & 31 deletions app/authentication/CookieAuthenticator.scala
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,6 +25,26 @@ 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
): 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,
proConnectIdToken = proConnectIdToken,
proConnectState = proConnectState
)
}

private def unserialize(str: String): Either[BrokenAuthError, CookieInfos] =
for {
data <- signer.extract(str)
Expand Down Expand Up @@ -56,39 +77,25 @@ class CookieAuthenticator(
crypted <- crypter.encrypt(Json.toJson(cookieInfos).toString())
} yield signer.sign(crypted)

private def create(userEmail: EmailAddress, impersonator: Option[EmailAddress] = None): 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
)
def initSignalConsoCookie(
userEmail: EmailAddress,
impersonator: Option[EmailAddress]
): Either[BrokenAuthError, Cookie] = {
val cookieInfos = create(userEmail, impersonator)
init(cookieInfos)
}

def init(userEmail: EmailAddress): 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
)
}
def initProConnectCookie(
userEmail: EmailAddress,
proConnectIdToken: String,
proConnectState: String
): Either[BrokenAuthError, Cookie] = {
val cookieInfos =
create(userEmail, proConnectIdToken = proConnectIdToken.some, proConnectState = proConnectState.some)
init(cookieInfos)
}

def initImpersonated(userEmail: EmailAddress, impersonator: EmailAddress): Either[BrokenAuthError, Cookie] = {
val cookieInfos = create(userEmail, Some(impersonator))
private def init(cookieInfos: CookieInfos): Either[BrokenAuthError, Cookie] =
serialize(cookieInfos).map { value =>
Cookie(
name = settings.cookieName,
Expand All @@ -103,7 +110,6 @@ class CookieAuthenticator(
sameSite = settings.sameSite
)
}
}

def embed(cookie: Cookie, result: Result): Result =
result.withCookies(cookie)
Expand All @@ -129,7 +135,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
Expand Down
5 changes: 4 additions & 1 deletion app/authentication/CookieInfos.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ case class CookieInfos(
lastUsedDateTime: OffsetDateTime,
expirationDateTime: OffsetDateTime,
idleTimeout: Option[FiniteDuration],
cookieMaxAge: Option[FiniteDuration]
cookieMaxAge: Option[FiniteDuration],
proConnectIdToken: Option[String],
proConnectState: Option[String]
)

object CookieInfos {
Expand All @@ -26,4 +28,5 @@ object CookieInfos {
def writes(o: FiniteDuration): JsValue = LongWrites.writes(o.toSeconds)
}
implicit val formatCookieInfos: OFormat[CookieInfos] = Json.format[CookieInfos]

}
18 changes: 18 additions & 0 deletions app/authentication/actions/UserAction.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
}
1 change: 1 addition & 0 deletions app/config/ApplicationConfiguration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ case class ApplicationConfiguration(
crypter: JcaCrypterSettings,
signer: JcaSignerSettings,
cookie: CookieAuthenticatorSettings,
proConnect: ProConnectConfiguration,
socialBlade: SocialBladeClientConfiguration,
websiteApi: WebsiteApiConfiguration
)
14 changes: 14 additions & 0 deletions app/config/ProConnectConfiguration.scala
Original file line number Diff line number Diff line change
@@ -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]
)
45 changes: 22 additions & 23 deletions app/controllers/AccountController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)))

}
Expand All @@ -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))
}
}
Expand Down Expand Up @@ -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)))
}

Expand All @@ -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)
Expand All @@ -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 =>
Expand Down
70 changes: 54 additions & 16 deletions app/controllers/AuthController.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package controllers

import authentication.CookieAuthenticator
import models.AuthProvider
import models.PaginatedResult
import models.UserRole
import orchestrators.AuthOrchestrator
Expand All @@ -15,8 +16,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
Expand All @@ -27,7 +36,8 @@ class AuthController(
authOrchestrator: AuthOrchestrator,
authenticator: CookieAuthenticator,
controllerComponents: ControllerComponents,
enableRateLimit: Boolean
enableRateLimit: Boolean,
proConnectOrchestrator: ProConnectOrchestrator
)(implicit val ec: ExecutionContext)
extends BaseController(authenticator, controllerComponents, enableRateLimit) {

Expand All @@ -38,27 +48,53 @@ class AuthController(
def authenticate: Action[JsValue] = IpRateLimitedAction2.async(parse.json) { implicit request =>
for {
userLogin <- request.parseBody[UserCredentials]()
userSession <- authOrchestrator.login(userLogin)
userSession <- authOrchestrator.signalConsoLogin(userLogin)
} 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) { _ =>
for {
(token_id, user) <- proConnectOrchestrator.login(code, state)
userSession <- authOrchestrator.proConnectLogin(user, 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")
userSession <- authOrchestrator.logAs(userEmail, request)
} 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)
.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)))
}
Expand All @@ -77,19 +113,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

}
}

}
Loading
Loading