Skip to content

Commit

Permalink
TRELLO-2572: pro connect authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
ssedoudbgouv committed Nov 10, 2024
1 parent f3ce2d3 commit c413c35
Show file tree
Hide file tree
Showing 44 changed files with 924 additions and 142 deletions.
71 changes: 36 additions & 35 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 @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -117,7 +119,6 @@ class CookieAuthenticator(
sameSite = settings.sameSite
)
}
}

def embed(cookie: Cookie, result: Result): Result =
result.withCookies(cookie)
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion app/authentication/CookieInfos.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,4 +29,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
Loading

0 comments on commit c413c35

Please sign in to comment.