-
Notifications
You must be signed in to change notification settings - Fork 123
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Prototype pluggable authentication in the grid
- Loading branch information
Showing
7 changed files
with
392 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
68 changes: 68 additions & 0 deletions
68
...b/src/main/scala/com/gu/mediaservice/lib/auth/provider/ApiKeyAuthenticationProvider.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}") | ||
} | ||
} | ||
} |
111 changes: 111 additions & 0 deletions
111
common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/Authentication.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
82 changes: 82 additions & 0 deletions
82
common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProvider.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
3 changes: 3 additions & 0 deletions
3
...on-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationProviders.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
27 changes: 27 additions & 0 deletions
27
common-lib/src/main/scala/com/gu/mediaservice/lib/auth/provider/AuthenticationStatus.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
Oops, something went wrong.