Skip to content

Commit

Permalink
TRELLO-2572 : pro connect poc (#1749)
Browse files Browse the repository at this point in the history
* TRELLO-2572: pro connect authentication
  • Loading branch information
ssedoudbgouv authored Nov 14, 2024
1 parent e94b356 commit f91188a
Show file tree
Hide file tree
Showing 45 changed files with 933 additions and 139 deletions.
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

0 comments on commit f91188a

Please sign in to comment.