From 64703d53684341232f74c976ebaeaa98b824a53d Mon Sep 17 00:00:00 2001 From: Simon Hildrew Date: Tue, 8 Dec 2020 15:42:59 +0000 Subject: [PATCH] Prototype pluggable authentication in the grid --- .../mediaservice/lib/auth/ApiAccessor.scala | 4 +- .../ApiKeyAuthenticationProvider.scala | 68 +++++++++++ .../lib/auth/provider/Authentication.scala | 111 ++++++++++++++++++ .../provider/AuthenticationProvider.scala | 82 +++++++++++++ .../provider/AuthenticationProviders.scala | 3 + .../auth/provider/AuthenticationStatus.scala | 27 +++++ .../PandaAuthenticationProvider.scala | 99 ++++++++++++++++ 7 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala create mode 100644 common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/Authentication.scala create mode 100644 common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala create mode 100644 common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProviders.scala create mode 100644 common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationStatus.scala create mode 100644 common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/PandaAuthenticationProvider.scala diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala index 37d802f676..54e489af8c 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/ApiAccessor.scala @@ -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 @@ -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") diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala new file mode 100644 index 0000000000..2b439efd46 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala @@ -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}") + } + } +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/Authentication.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/Authentication.scala new file mode 100644 index 0000000000..66bc574e93 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/Authentication.scala @@ -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 + } +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala new file mode 100644 index 0000000000..01758d8a25 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala @@ -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 +} diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProviders.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProviders.scala new file mode 100644 index 0000000000..72e79da8bb --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProviders.scala @@ -0,0 +1,3 @@ +package com.gu.mediaservice.lib.auth.provider + +case class AuthenticationProviders(userProvider: UserAuthenticationProvider, apiProvider: ApiAuthenticationProvider) diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationStatus.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationStatus.scala new file mode 100644 index 0000000000..5dcbe453d3 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationStatus.scala @@ -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 + diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/PandaAuthenticationProvider.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/PandaAuthenticationProvider.scala new file mode 100644 index 0000000000..224ee537b8 --- /dev/null +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/PandaAuthenticationProvider.scala @@ -0,0 +1,99 @@ +package com.gu.mediaservice.lib.auth.provider +import com.gu.mediaservice.lib.auth.provider.Authentication.{PandaUser, Principal} +import com.gu.mediaservice.lib.aws.S3Ops +import com.gu.pandomainauth.PanDomainAuthSettingsRefresher +import com.gu.pandomainauth.action.AuthActions +import com.gu.pandomainauth.model.{AuthenticatedUser, Authenticated => PandaAuthenticated, Expired => PandaExpired, GracePeriod => PandaGracePeriod, InvalidCookie => PandaInvalidCookie, NotAuthenticated => PandaNotAuthenticated, NotAuthorized => PandaNotAuthorised} +import play.api.libs.ws.{DefaultWSCookie, WSClient, WSRequest} +import play.api.mvc.{ControllerComponents, RequestHeader, Result} + +import scala.concurrent.Future + +class PandaAuthenticationProvider(resources: AuthenticationProviderResources) extends UserAuthenticationProvider with AuthActions { + + final override def authCallbackUrl: String = s"${resources.commonConfig.services.authBaseUri}/oauthCallback" + override lazy val panDomainSettings: PanDomainAuthSettingsRefresher = buildPandaSettings() + override def wsClient: WSClient = resources.wsClient + override def controllerComponents: ControllerComponents = resources.controllerComponents + + /** + * 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): AuthenticationStatus = { + extractAuth(request) match { + case PandaNotAuthenticated => NotAuthenticated + case PandaInvalidCookie(e) => Invalid("error checking user's auth, clear cookie and re-auth", Some(e)) + case PandaExpired(authedUser) => Expired(PandaUser(authedUser.user)) + case PandaGracePeriod(authedUser) => GracePeriod(PandaUser(authedUser.user)) + case PandaNotAuthorised(authedUser) => NotAuthorised(s"${authedUser.user.email} not authorised to use application") + case PandaAuthenticated(authedUser) => Authenticated(PandaUser(authedUser.user)) + } + } + + /** + * 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. + */ + override def sendForAuthentication: Option[(RequestHeader, Option[Principal]) => Future[Result]] = Some( + { (requestHeader: RequestHeader, principal: Option[Principal]) => + val email = principal.collect{ + case PandaUser(user) => user.email + } + sendForAuth(requestHeader, email) + } + ) + + /** + * 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. + */ + override def processAuthentication: Option[RequestHeader => Future[Result]] = Some( + processOAuthCallback()(_) + ) + + /** + * 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 + */ + override def flushToken: Option[RequestHeader => Result] = Some(processLogout(_)) + + /** + * 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 => + val cookieName = panDomainSettings.settings.cookieSettings.cookieName + request.cookies.get(cookieName) match { + case Some(cookie) => Right(wsRequest.addCookies(DefaultWSCookie(cookieName, cookie.value))) + case None => Left(s"Pan domain cookie $cookieName is missing in original request.") + } + } + + private def buildPandaSettings() = { + new PanDomainAuthSettingsRefresher( + domain = resources.commonConfig.services.domainRoot, + system = resources.commonConfig.stringOpt("panda.system").getOrElse("media-service"), + bucketName = resources.commonConfig.stringOpt("panda.bucketName").getOrElse("pan-domain-auth-settings"), + settingsFileKey = resources.commonConfig.stringOpt("panda.settingsFileKey").getOrElse(s"${resources.commonConfig.services.domainRoot}.settings"), + s3Client = S3Ops.buildS3Client(resources.commonConfig, localstackAware=resources.commonConfig.useLocalAuth) + ) + } + + private val userValidationEmailDomain = resources.commonConfig.stringOpt("panda.userDomain").getOrElse("guardian.co.uk") + + final override def validateUser(authedUser: AuthenticatedUser): Boolean = { + Authentication.validateUser(authedUser, userValidationEmailDomain, multifactorChecker) + } + +}