Skip to content

Commit

Permalink
Prototype pluggable authentication in the grid
Browse files Browse the repository at this point in the history
  • Loading branch information
sihil committed Dec 8, 2020
1 parent 10bcee0 commit 64703d5
Show file tree
Hide file tree
Showing 7 changed files with 392 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.gu.mediaservice.lib.auth

import com.gu.mediaservice.lib.argo.ArgoHelpers
import com.gu.mediaservice.lib.config.Services
import play.api.mvc.{Request, Result}
import play.api.mvc.{RequestHeader, Result}

sealed trait Tier
case object Internal extends Tier
Expand All @@ -28,7 +28,7 @@ object ApiAccessor extends ArgoHelpers {
ApiAccessor(name, tier)
}

def hasAccess(apiKey: ApiAccessor, request: Request[Any], services: Services): Boolean = apiKey.tier match {
def hasAccess(apiKey: ApiAccessor, request: RequestHeader, services: Services): Boolean = apiKey.tier match {
case Internal => true
case ReadOnly => request.method == "GET"
case Syndication => request.method == "GET" && request.host == services.apiHost && request.path.startsWith("/images")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.gu.mediaservice.lib.auth.provider
import com.gu.mediaservice.lib.auth.provider.Authentication.ApiKeyAccessor
import com.gu.mediaservice.lib.auth.{ApiAccessor, KeyStore}
import com.typesafe.scalalogging.StrictLogging
import play.api.libs.ws.WSRequest
import play.api.mvc.RequestHeader

object ApiKeyAuthenticationProvider {
val apiKeyHeaderName = "X-Gu-Media-Key"
}

class ApiKeyAuthenticationProvider(resources: AuthenticationProviderResources) extends ApiAuthenticationProvider with StrictLogging {
var keyStorePlaceholder: Option[KeyStore] = _

// TODO: we should also shutdown the keystore but there isn't currently a hook
override def initialise(): Unit = {
val store = new KeyStore(resources.config.get[String]("authKeyStoreBucket"), resources.commonConfig)(resources.context)
store.scheduleUpdates(resources.actorSystem.scheduler)
keyStorePlaceholder = Some(store)
}

def keyStore: KeyStore = keyStorePlaceholder.getOrElse(throw new IllegalStateException("Not initialised"))

/**
* Establish the authentication status of the given request header. This can return an authenticated user or a number
* of reasons why a user is not authenticated.
*
* @param request The request header containing cookies and other request headers that can be used to establish the
* authentication status of a request.
* @return An authentication status expressing whether the
*/
override def authenticateRequest(request: RequestHeader): ApiAuthenticationStatus = {
request.headers.get(ApiKeyAuthenticationProvider.apiKeyHeaderName) match {
case Some(key) =>
keyStore.lookupIdentity(key) match {
// api key provided
case Some(apiKey) =>
// valid api key
val accessor = ApiKeyAccessor(apiKey)
logger.info(s"Using api key with name ${apiKey.identity} and tier ${apiKey.tier}", apiKey)
if (ApiAccessor.hasAccess(apiKey, request, resources.commonConfig.services)) {
// valid api key which has access
Authenticated(accessor)
} else {
// valid api key which doesn't have access
NotAuthorised("API key valid but not authorised")
}
// provided api key not known
case None => Invalid("API key not valid")
}
// no api key found
case None => NotAuthenticated
}
}

/**
* A function that allows downstream API calls to be made using the credentials of the inflight request
*
* @param request The request header of the inflight call
* @return A function that adds appropriate data to a WSRequest
*/
override def onBehalfOf(request: RequestHeader): WSRequest => Either[String, WSRequest] = { wsRequest: WSRequest =>
request.headers.get(ApiKeyAuthenticationProvider.apiKeyHeaderName) match {
case Some(key) => Right(wsRequest.addHttpHeaders(ApiKeyAuthenticationProvider.apiKeyHeaderName -> key))
case None => Left(s"API key not found in request, no header ${ApiKeyAuthenticationProvider.apiKeyHeaderName}")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.gu.mediaservice.lib.auth.provider

import com.gu.mediaservice.lib.argo.ArgoHelpers
import com.gu.mediaservice.lib.argo.model.Link
import com.gu.mediaservice.lib.auth.provider.Authentication.{ApiKeyAccessor, OnBehalfOfPrincipal, PandaUser, Principal}
import com.gu.mediaservice.lib.auth.{ApiAccessor, Internal}
import com.gu.mediaservice.lib.config.CommonConfig
import com.gu.pandomainauth.model.{AuthenticatedUser, User}
import com.gu.pandomainauth.service.Google2FAGroupChecker
import play.api.libs.ws.WSRequest
import play.api.mvc.Security.AuthenticatedRequest
import play.api.mvc._

import scala.concurrent.{ExecutionContext, Future}

class Authentication(config: CommonConfig,
providers: AuthenticationProviders,
override val parser: BodyParser[AnyContent],
override val executionContext: ExecutionContext)
extends ActionBuilder[Authentication.Request, AnyContent] with ArgoHelpers {

// make the execution context implicit so it will be picked up appropriately
implicit val ec: ExecutionContext = executionContext

val loginLinks = List(
Link("login", config.services.loginUriTemplate)
)

def unauthorised(errorMessage: String, throwable: Option[Throwable] = None): Future[Result] = {
logger.info(s"Authentication failure $errorMessage", throwable.orNull)
Future.successful(respondError(Unauthorized, "authentication-failure", "Authentication failure", loginLinks))
}

def forbidden(errorMessage: String): Future[Result] = {
logger.info(s"User not authorised: $errorMessage")
Future.successful(respondError(Forbidden, "principal-not-authorised", "Principal not authorised", loginLinks))
}

def authenticationStatus(requestHeader: RequestHeader, providers: AuthenticationProviders) = {
def sendForAuth(maybePrincipal: Option[Principal]): Future[Result] = {
providers.userProvider.sendForAuthentication.fold(unauthorised("No path to authenticate user"))(_(requestHeader, maybePrincipal))
}

def flushToken(resultWhenAbsent: Result): Result = {
providers.userProvider.flushToken.fold(resultWhenAbsent)(_(requestHeader))
}

providers.apiProvider.authenticateRequest(requestHeader) match {
case Authenticated(authedUser) => Right(authedUser)
case Invalid(message, throwable) => Left(unauthorised(message, throwable))
case NotAuthorised(message) => Left(forbidden(s"Principal not authorised: $message"))
case NotAuthenticated =>
providers.userProvider.authenticateRequest(requestHeader) match {
case NotAuthenticated => Left(sendForAuth(None))
case Expired(principal) => Left(sendForAuth(Some(principal)))
case GracePeriod(authedUser) => Right(authedUser)
case Authenticated(authedUser) => Right(authedUser)
case Invalid(message, throwable) => Left(unauthorised(message, throwable).map(flushToken))
case NotAuthorised(message) => Left(forbidden(s"Principal not authorised: $message"))
}
}
}

override def invokeBlock[A](request: Request[A], block: Authentication.Request[A] => Future[Result]): Future[Result] = {
// Authenticate request. Try with API authenticator first and then with user authenticator
authenticationStatus(request, providers) match {
// we have a principal, so process the block
case Right(principal) => block(new AuthenticatedRequest(principal, request))
// no principal so return a result which will either be an error or a form of redirect
case Left(result) => result
}
}

def getOnBehalfOfPrincipal(principal: Principal, originalRequest: RequestHeader): OnBehalfOfPrincipal = {
def failureToException[T](result: Either[String, T]): T = result.fold(error => throw new IllegalStateException(error), identity)
val enrichWithAuth = principal match {
case _:ApiKeyAccessor => providers.apiProvider.onBehalfOf(originalRequest) andThen failureToException
case _:PandaUser => providers.userProvider.onBehalfOf(originalRequest) andThen failureToException
}
new OnBehalfOfPrincipal {
override def enrich: WSRequest => WSRequest = enrichWithAuth
}
}
}

object Authentication {
sealed trait Principal {
def accessor: ApiAccessor
}
case class PandaUser(user: User) extends Principal {
def accessor: ApiAccessor = ApiAccessor(identity = user.email, tier = Internal)
}
case class ApiKeyAccessor(accessor: ApiAccessor) extends Principal

type Request[A] = AuthenticatedRequest[A, Principal]

trait OnBehalfOfPrincipal {
def enrich: WSRequest => WSRequest
}

val originalServiceHeaderName = "X-Gu-Original-Service"

def getIdentity(principal: Principal): String = principal.accessor.identity

def validateUser(authedUser: AuthenticatedUser, userValidationEmailDomain: String, multifactorChecker: Option[Google2FAGroupChecker]): Boolean = {
val isValidDomain = authedUser.user.email.endsWith("@" + userValidationEmailDomain)
val passesMultifactor = if(multifactorChecker.nonEmpty) { authedUser.multiFactor } else { true }

isValidDomain && passesMultifactor
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.gu.mediaservice.lib.auth.provider

import akka.actor.ActorSystem
import com.gu.mediaservice.lib.auth.provider.Authentication.Principal
import com.gu.mediaservice.lib.config.CommonConfig
import play.api.Configuration
import play.api.libs.ws.{WSClient, WSRequest}
import play.api.mvc.{ControllerComponents, RequestHeader, Result}

import scala.concurrent.{ExecutionContext, Future}

/**
* Case class containing useful resources for authentication providers to allow concurrent processing and external
* API calls to be conducted.
* @param config the tree of configuration for this provider
* @param commonConfig the Grid common config object
* @param context an execution context
* @param actorSystem an actor system
* @param wsClient a play WSClient for making API calls
*/
case class AuthenticationProviderResources(config: Configuration,
commonConfig: CommonConfig,
context: ExecutionContext,
actorSystem: ActorSystem,
wsClient: WSClient,
controllerComponents: ControllerComponents)

sealed trait AuthenticationProvider {
def initialise(): Unit = {}
def shutdown(): Future[Unit] = Future.successful(())

/**
* A function that allows downstream API calls to be made using the credentials of the inflight request
* @param request The request header of the inflight call
* @return A function that adds appropriate data to a WSRequest
*/
def onBehalfOf(request: RequestHeader): WSRequest => Either[String, WSRequest]
}

trait UserAuthenticationProvider extends AuthenticationProvider {
/**
* Establish the authentication status of the given request header. This can return an authenticated user or a number
* of reasons why a user is not authenticated.
* @param request The request header containing cookies and other request headers that can be used to establish the
* authentication status of a request.
* @return An authentication status expressing whether the
*/
def authenticateRequest(request: RequestHeader): AuthenticationStatus

/**
* If this provider supports sending a user that is not authorised to a federated auth provider then it should
* provide a function here to redirect the user. The function signature takes the applications callback URL as
* well as the request and should return a result.
*/
def sendForAuthentication: Option[(RequestHeader, Option[Principal]) => Future[Result]]

/**
* If this provider supports sending a user that is not authorised to a federated auth provider then it should
* provide an Play action here that deals with the return of a user from a federated provider. This should be
* used to set a cookie or similar to ensure that a subsequent call to authenticateRequest will succeed. If
* authentication failed then this should return an appropriate 4xx result.
*/
def processAuthentication: Option[RequestHeader => Future[Result]]

/**
* If this provider is able to clear user tokens (i.e. by clearing cookies) then it should provide a function to
* do that here which will be used to log users out and also if the token is invalid.
* @return
*/
def flushToken: Option[RequestHeader => Result]
}

trait ApiAuthenticationProvider extends AuthenticationProvider {
/**
* Establish the authentication status of the given request header. This can return an authenticated user or a number
* of reasons why a user is not authenticated.
* @param request The request header containing cookies and other request headers that can be used to establish the
* authentication status of a request.
* @return An authentication status expressing whether the
*/
def authenticateRequest(request: RequestHeader): ApiAuthenticationStatus
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.gu.mediaservice.lib.auth.provider

case class AuthenticationProviders(userProvider: UserAuthenticationProvider, apiProvider: ApiAuthenticationProvider)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.gu.mediaservice.lib.auth.provider

import com.gu.mediaservice.lib.auth.provider.Authentication.Principal

// statuses that directly extend this are for users only
/** Status of a client's authentication */
sealed trait AuthenticationStatus

/** User authentication is valid but expired */
case class Expired(authedUser: Principal) extends AuthenticationStatus
/** User authentication is valid and expired but the expiry is within a grace period */
case class GracePeriod(authedUser: Principal) extends AuthenticationStatus

// statuses that extend this can be used by both users and machines
/** Status of an API client's authentication */
sealed trait ApiAuthenticationStatus extends AuthenticationStatus

/** User authentication is valid */
case class Authenticated(authedUser: Principal) extends ApiAuthenticationStatus
/** User authentication is OK but the user is not authorised to use this system - might be a group or 2FA check failure */
case class NotAuthorised(message: String) extends ApiAuthenticationStatus
/** User authentication token or key (cookie, header, query param) exists but isn't valid -
* the message and exception will be logged but not leaked to user */
case class Invalid(message: String, throwable: Option[Throwable] = None) extends ApiAuthenticationStatus
/** User authentication token doesn't exist */
case object NotAuthenticated extends ApiAuthenticationStatus

Loading

0 comments on commit 64703d5

Please sign in to comment.