All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.gu.pandomainauth.action.Actions.scala Maven / Gradle / Ivy

The newest version!
package com.gu.pandomainauth.action

import com.gu.pandomainauth.model._
import com.gu.pandomainauth.service._
import com.gu.pandomainauth.{PanDomain, PanDomainAuthSettingsRefresher}
import org.slf4j.LoggerFactory
import play.api.libs.ws.WSClient
import play.api.mvc.Results._
import play.api.mvc._

import java.net.{URLDecoder, URLEncoder}
import scala.concurrent.{ExecutionContext, Future}

class UserRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request)

case class PandomainCookie(cookie: Cookie, forceExpiry: Boolean)

trait AuthActions {
  private val logger = LoggerFactory.getLogger(this.getClass)

  trait AuthenticationAction extends ActionBuilder[UserRequest, AnyContent] {

    def authenticateRequest(request: RequestHeader)(produceResultGivenAuthedUser: User => Future[Result]): Future[Result]

    override def invokeBlock[A](request: Request[A], block: (UserRequest[A]) => Future[Result]): Future[Result] =
      authenticateRequest(request)(user => block(new UserRequest(user, request)))

  }

  /**
    * Play application components that you must provide in order to use AuthActions
    */
  def wsClient: WSClient
  def controllerComponents: ControllerComponents
  val panDomainSettings: PanDomainAuthSettingsRefresher

  private lazy val system: String = panDomainSettings.system
  private lazy val domain: String = panDomainSettings.domain
  private def settings: PanDomainAuthSettings = panDomainSettings.settings

  private implicit val ec: ExecutionContext = controllerComponents.executionContext

  /**
    * Returns true if the authed user is valid in the implementing system (meets your multifactor requirements, you recognise the email etc.).
    *
    * If your implementing application needs to audit logins / register new users etc then this ia also the place to do it (although in this case
    * you should strongly consider setting cacheValidation to true).
    *
    * @param authedUser
    * @return true if the user is valid in your app
    */
  def validateUser(authedUser: AuthenticatedUser): Boolean

  /**
    * By default the validity of the user is checked every request. If your validateUser implementation is expensive or has side effects you
    * can override this to true and validity will only be checked the first time the user visits your app after their login is established.
    *
    * Note the the cache is invalidated after the user's session is re-established with the OAuth provider.
    *
    * @return true if you want to only check the validity of the user once for the lifetime of the user's auth session
    */
  def cacheValidation: Boolean = false

  /**
    * Adding an expiry extension to `APIAuthAction`s allows for a delay between an applications authentication and their
    * respective API XHR calls expiring.
    *
    * By default this is 0 and thus disabled.
    *
    * This is particularly useful for SPAs where users have third party cookies disabled.
    *
    * @return the amount of delay between App and API expiry in milliseconds
    */
  def apiGracePeriod: Long = 0 // ms

  /**
    * The auth callback url. This is where the OAuth provider will send the user after authentication.
    * This action on should invoke processOAuthCallback
    *
    * @return
    */
  def authCallbackUrl: String

  lazy val OAuth = new OAuth(settings.oAuthSettings, system, authCallbackUrl)(ec, wsClient)

  /**
    * Application name used for initialising Google API clients for directory group checking
    */
  lazy val applicationName: String = s"pan-domain-authentication-$system"

  lazy val multifactorChecker: Option[Google2FAGroupChecker] = settings.google2FAGroupSettings.map {
    new Google2FAGroupChecker(_, panDomainSettings.s3BucketLoader, applicationName)
  }

  /**
    * A cookie key that stores the target URL that was being accessed when redirected for authentication
    */
  val LOGIN_ORIGIN_KEY = "panda-loginOriginUrl"
  /*
   * Cookie key containing an anti-forgery token; helps to validate that the oauth callback arrived in response to the correct oauth request
   */
  val ANTI_FORGERY_KEY = "panda-antiForgeryToken"
  /*
   * Cookie that will make panda behave as if the cookie has expired.
   * NOTE: This cookie is for debugging only! It should _not_ be set by any application code to expire the cookie!! Use the `processLogout` action instead!!
   */
  private val FORCE_EXPIRY_KEY = "panda-forceExpiry"

  private def cookie(name: String, value: String): Cookie =
    Cookie(
      name,
      value = URLEncoder.encode(value, "UTF-8"),
      secure = true,
      httpOnly = true,
      // Chrome will pass back SameSite=Lax cookies, but Firefox requires
      // SameSite=None, since the cookies are to be returned on a redirect
      // from a 3rd party
      sameSite = Some(Cookie.SameSite.None)
    )
  private lazy val discardCookies = Seq(
    DiscardingCookie(LOGIN_ORIGIN_KEY, secure = true),
    DiscardingCookie(ANTI_FORGERY_KEY, secure = true),
    DiscardingCookie(FORCE_EXPIRY_KEY, secure = true)
  )

  /**
    * starts the authentication process for a user. By default this just sends the user off to the OAuth provider for auth
    * but if you want to show welcome page with a button on it then override.
    */
  def sendForAuth(implicit request: RequestHeader, email: Option[String] = None) = {
    val antiForgeryToken = OAuth.generateAntiForgeryToken()
    OAuth.redirectToOAuthProvider(antiForgeryToken, email)(ec) map { res =>
      val originUrl = request.uri
      res.withCookies(cookie(ANTI_FORGERY_KEY, antiForgeryToken), cookie(LOGIN_ORIGIN_KEY, originUrl))
    }
  }

  def checkMultifactor(authedUser: AuthenticatedUser) = multifactorChecker.exists(_.checkMultifactor(authedUser))

  /**
    * invoked when the user is not logged in a can't be authed - this may be when the user is not valid in yur system
    * or when they have exoplicitly logged out.
    *
    * Override this to add a logged out screen and display maeesages for your app. The default implementation is
    * to ust return a 403 response
    *
    * @param message
    * @param request
    * @return
    */
  def showUnauthedMessage(message: String)(implicit request: RequestHeader): Result = {
    logger.info(message)
    Forbidden
  }

  /**
    * Generates the message shown to the user when user validation fails. override this to add a custom error message
    *
    * @param claimedAuth
    * @return
    */
  def invalidUserMessage(claimedAuth: AuthenticatedUser) = s"user ${claimedAuth.user.email} not valid for $system"

  private def decodeCookie(name: String)(implicit request: RequestHeader) =
    request.cookies.get(name).map(cookie => URLDecoder.decode(cookie.value, "UTF-8"))

  def processOAuthCallback()(implicit request: RequestHeader): Future[Result] = {
    (for {
      token <- decodeCookie(ANTI_FORGERY_KEY)
      originalUrl <- decodeCookie(LOGIN_ORIGIN_KEY)
    } yield {
      OAuth.validatedUserIdentity(token)(request, ec, wsClient).map { claimedAuth =>
        val existingAuthenticatedIn = readAuthenticatedUser(request).map(_.authenticatedIn)
        val authedUserData =
          claimedAuth.copy(
            authenticatingSystem = system,
            authenticatedIn = existingAuthenticatedIn.fold(Set(system))(_ + system),
            multiFactor = checkMultifactor(claimedAuth)
          )

        if (validateUser(authedUserData)) {
          val updatedCookie = generateCookie(authedUserData)
          Redirect(originalUrl)
            .withCookies(updatedCookie)
            .discardingCookies(discardCookies:_*)
        } else {
          showUnauthedMessage(invalidUserMessage(claimedAuth))
        }
      }
    }) getOrElse {
      Future.successful(BadRequest("Missing cookies"))
    }
  }

  def processLogout(implicit request: RequestHeader) = {
    flushCookie(showUnauthedMessage("logged out"))
  }

  def readAuthenticatedUser(request: RequestHeader): Option[AuthenticatedUser] = readCookie(request) flatMap { cookie =>
    CookieUtils.parseCookieData(cookie.cookie.value, settings.signingAndVerification).toOption
  }

  def readCookie(request: RequestHeader): Option[PandomainCookie] = {
    request.cookies.get(settings.cookieSettings.cookieName).map { cookie =>
      val forceExpiry = request.cookies.get(FORCE_EXPIRY_KEY).exists(_.value != "0")
      PandomainCookie(cookie, forceExpiry)
    }
  }

  def generateCookie(authedUser: AuthenticatedUser): Cookie = Cookie(
    name = settings.cookieSettings.cookieName,
    value = CookieUtils.generateCookieData(authedUser, settings.signingAndVerification),
    domain = Some(domain),
    secure = true,
    httpOnly = true
  )

  def includeSystemInCookie(authedUser: AuthenticatedUser)(result: Result): Result = {
    val updatedAuth    = authedUser.copy(authenticatedIn = authedUser.authenticatedIn + system)
    val updatedCookie = generateCookie(updatedAuth)
    result.withCookies(updatedCookie)
  }

  def flushCookie(result: Result): Result = {
    val clearCookie = DiscardingCookie(
      name = settings.cookieSettings.cookieName,
      domain = Some(domain),
      secure = true
    )
    result.discardingCookies(clearCookie)
  }

  /**
    * Extract the authentication status from the request.
    */
  def extractAuth(request: RequestHeader): AuthenticationStatus = {
    readCookie(request).map { cookie =>
      PanDomain.authStatus(cookie.cookie.value, settings.signingAndVerification, validateUser, apiGracePeriod, system, cacheValidation, cookie.forceExpiry)
    } getOrElse NotAuthenticated
  }

  /**
    * Action that ensures the user is logged in and validated.
    *
    * This action is for page load type requests where it is possible to send the user for auth
    * and for them to interact with the auth provider. For API / XHR type requests use the APIAuthAction
    *
    * if the user is not authed or the auth has expired they are sent for authentication
    */
  object AuthAction extends AuthenticationAction {

    override def parser: BodyParser[AnyContent]               = AuthActions.this.controllerComponents.parsers.default
    override protected def executionContext: ExecutionContext = AuthActions.this.controllerComponents.executionContext

    def authenticateRequest(request: RequestHeader)(produceResultGivenAuthedUser: User => Future[Result]): Future[Result] = {
      extractAuth(request) match {
        case NotAuthenticated =>
          logger.debug(s"user not authed against $domain, authing")
          sendForAuth(request)

        case InvalidCookie(e) =>
          logger.warn("error checking user's auth, clear cookie and re-auth", e)
          // remove the invalid cookie data
          sendForAuth(request).map(flushCookie)

        case Expired(authedUser) =>
          logger.debug(s"user ${authedUser.user.email} login expired, sending to re-auth")
          sendForAuth(request, Some(authedUser.user.email))

        case GracePeriod(authedUser) =>
          logger.debug(s"user ${authedUser.user.email} login expired, in grace period, sending to re-auth")
          sendForAuth(request, Some(authedUser.user.email))

        case NotAuthorized(authedUser) =>
          logger.debug(s"user not authorized, show error")
          Future(showUnauthedMessage(invalidUserMessage(authedUser))(request))

        case Authenticated(authedUser) =>
          val response = produceResultGivenAuthedUser(authedUser.user)
          if (authedUser.authenticatedIn(system)) {
            response
          } else {
            logger.debug(s"user ${authedUser.user.email} from other system valid: adding validity in $system.")
            response.map(includeSystemInCookie(authedUser))
          }
      }
    }
  }

  /**
    * Action that ensures the user is logged in and validated.
    *
    * This action is for API / XHR type requests where the user can't be sent to the auth provider for auth. In the
    * cases where the auth is not valid response codes are sent to the requesting app and the javascript that initiated
    * the request should handle these appropriately
    *
    * If the user is not authed then a 401 response is sent, if the auth has expired then a 419 response is sent, if
    * the user is authed but not allowed to perform the action a 403 is sent
    *
    * If the user is authed or has an expiry extension, a 200 is sent
    *
    */
  object APIAuthAction extends AbstractApiAuthAction with PlainErrorResponses

  trait PlainErrorResponses {
    val notAuthenticatedResult = Unauthorized
    val invalidCookieResult    = Unauthorized
    val expiredResult          = new Status(419)
    val notAuthorizedResult    = Forbidden
  }

  /**
    * Abstraction for API auth actions allowing to mix in custom results for each of the different error scenarios.
    */
  trait AbstractApiAuthAction extends AuthenticationAction {

    override def parser: BodyParser[AnyContent]               = AuthActions.this.controllerComponents.parsers.default
    override protected def executionContext: ExecutionContext = AuthActions.this.controllerComponents.executionContext

    val notAuthenticatedResult: Result
    val invalidCookieResult: Result
    val expiredResult: Result
    val notAuthorizedResult: Result

    def authenticateRequest(request: RequestHeader)(produceResultGivenAuthedUser: User => Future[Result]): Future[Result] = {
      extractAuth(request) match {
        case NotAuthenticated =>
          logger.debug(s"user not authed against $domain, return 401")
          Future(notAuthenticatedResult)

        case InvalidCookie(e) =>
          logger.warn("error checking user's auth, clear cookie and return 401", e)
          // remove the invalid cookie data
          Future(invalidCookieResult).map(flushCookie)

        case Expired(authedUser) =>
          logger.debug(s"user ${authedUser.user.email} login expired, return 419")
          Future(expiredResult)

        case GracePeriod(authedUser) =>
          logger.debug(s"user ${authedUser.user.email} login expired but is in grace period.")
          val response = produceResultGivenAuthedUser(authedUser.user)
          responseWithSystemCookie(response, authedUser)

        case NotAuthorized(authedUser) =>
          logger.debug(s"user not authorized, return 403")
          logger.debug(invalidUserMessage(authedUser))
          Future(notAuthorizedResult)

        case Authenticated(authedUser) =>
          val response = produceResultGivenAuthedUser(authedUser.user)
          responseWithSystemCookie(response, authedUser)
      }
    }

    def responseWithSystemCookie(response: Future[Result], authedUser: AuthenticatedUser): Future[Result] =
      if (authedUser.authenticatedIn(system)) {
        response
      } else {
        logger.debug(s"user ${authedUser.user.email} from other system valid: adding validity in $system.")
        response.map(includeSystemInCookie(authedUser))
      }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy