Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pluggable authentication in the grid #3058

Merged
merged 25 commits into from
Jan 18, 2021
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5b9d728
Prototype pluggable authentication in the grid
sihil Dec 8, 2020
b7665d3
Migrate code codebase to use auth providers
sihil Jan 8, 2021
d09031b
Complete the processAuth callback
sihil Jan 8, 2021
230cad9
Make the image processing SPI loader generic
sihil Jan 11, 2021
4679fd7
Fixup the ImageLoaderConfig to use generic provider loader
sihil Jan 11, 2021
3433af8
Update the core stack if it exists
sihil Jan 12, 2021
0fafaef
Changes to get the pan domain auth provider working locally
sihil Jan 12, 2021
8e02033
Initialise and shutdown authentication providers
sihil Jan 12, 2021
7a27a54
Move validateUser function into PandaAuthenticationProvider
sihil Jan 12, 2021
9f9bee9
Fix validateUser test
sihil Jan 13, 2021
b0922b9
Catch exceptions thrown in provider constructors
sihil Jan 13, 2021
5c9387d
Rename test file
sihil Jan 13, 2021
bfe508d
Change signature of flushToken to take initial result
sihil Jan 14, 2021
be32c5b
Access providers via member
sihil Jan 14, 2021
81e3a8b
Migrate to scalatest's play integration
sihil Jan 14, 2021
32f8fe5
Add set of tests for authenticationStatus
sihil Jan 14, 2021
eb4af3a
Add set of tests for on behalf of functions
sihil Jan 14, 2021
c9580f7
Rename process authentication callback as a result of review
sihil Jan 14, 2021
5af22a0
Correct/improve docs
sihil Jan 14, 2021
1f09667
Allow the login link to be disabled or overridden if appropriate
sihil Jan 14, 2021
f3bef97
Make it possible to cancel scheduled updates in stores
sihil Jan 14, 2021
e33c0c2
Fix typo in provider trait
sihil Jan 14, 2021
d20d608
Change names of Principal implementations for consistency
sihil Jan 15, 2021
2841623
Remove GracePeriod auth status from API
sihil Jan 18, 2021
98db717
Add a test suite for default api key auth provider
sihil Jan 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package com.gu.mediaservice

import com.amazonaws.services.lambda.runtime.Context
import com.amazonaws.services.lambda.runtime.events.{APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent}
import com.gu.mediaservice.lib.auth.Authentication
import com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider
import com.gu.mediaservice.model.Image
import com.typesafe.scalalogging.LazyLogging
import play.api.libs.json.{JsString, Json}
import play.api.libs.json.Json

import scala.collection.JavaConverters._
import scala.concurrent.ExecutionContext.Implicits.global
Expand Down Expand Up @@ -94,7 +94,7 @@ class ImageProjectionLambdaHandler extends LazyLogging {
private def getAuthKeyFrom(headers: Map[String, String]) = {
// clients like curl or API gateway may lowerCases custom header names, yay!
headers.find {
case (k, _) => k.equalsIgnoreCase(Authentication.apiKeyHeaderName)
case (k, _) => k.equalsIgnoreCase(ApiKeyAuthenticationProvider.apiKeyHeaderName)
}.map(_._2)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ package com.gu.mediaservice

import java.io.IOException
import java.net.URL

import com.gu.mediaservice.lib.auth.Authentication
import com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider
import com.typesafe.scalalogging.LazyLogging
import okhttp3._
import play.api.libs.json.{JsValue, Json}
import scala.util.{Try, Success, Failure}

import scala.util.{Failure, Success, Try}
import scala.concurrent.{ExecutionContext, Future, Promise}

object ClientResponse {
Expand Down Expand Up @@ -52,7 +51,7 @@ class GridClient(maxIdleConnections: Int, debugHttpResponse: Boolean) extends La
.build()

def makeGetRequestSync(url: URL, apiKey: String): ResponseWrapper = {
val request = new Request.Builder().url(url).header(Authentication.apiKeyHeaderName, apiKey).build
val request = new Request.Builder().url(url).header(ApiKeyAuthenticationProvider.apiKeyHeaderName, apiKey).build
val response = httpClient.newCall(request).execute
processResponse(response, url)
}
Expand Down Expand Up @@ -128,7 +127,7 @@ class GridClient(maxIdleConnections: Int, debugHttpResponse: Boolean) extends La
}

private def makeRequestAsync(url: URL, apiKey: String): Future[Response] = {
val request = new Request.Builder().url(url).header(Authentication.apiKeyHeaderName, apiKey).build
val request = new Request.Builder().url(url).header(ApiKeyAuthenticationProvider.apiKeyHeaderName, apiKey).build
val promise = Promise[Response]()
httpClient.newCall(request).enqueue(new Callback {
override def onFailure(call: Call, e: IOException): Unit = promise.failure(e)
Expand Down
2 changes: 1 addition & 1 deletion auth/app/auth/AuthComponents.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class AuthComponents(context: Context) extends GridComponents(context, new AuthC

final override val buildInfo = utils.buildinfo.BuildInfo

val controller = new AuthController(auth, config, controllerComponents)
val controller = new AuthController(auth, providers, config, controllerComponents)
val permissionsAwareManagement = new ManagementWithPermissions(controllerComponents, controller, buildInfo)

override val router = new Routes(httpErrorHandler, controller, permissionsAwareManagement)
Expand Down
103 changes: 58 additions & 45 deletions auth/app/auth/AuthController.scala
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
package auth

import java.net.URI

import com.gu.mediaservice.lib.argo.ArgoHelpers
import com.gu.mediaservice.lib.argo.model.Link
import com.gu.mediaservice.lib.auth.Authentication.PandaUser
import com.gu.mediaservice.lib.auth.Authentication.{MachinePrincipal, UserPrincipal}
import com.gu.mediaservice.lib.auth.provider.AuthenticationProviders
import com.gu.mediaservice.lib.auth.{Authentication, Permissions, PermissionsHandler}
import com.gu.mediaservice.lib.logging.GridLogging
import com.gu.pandomainauth.service.OAuthException
import play.api.libs.json.Json
import play.api.mvc.{BaseController, ControllerComponents}
import play.api.mvc.{BaseController, ControllerComponents, Result}

import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try

class AuthController(auth: Authentication, val config: AuthConfig,
class AuthController(auth: Authentication, providers: AuthenticationProviders, val config: AuthConfig,
override val controllerComponents: ControllerComponents)(implicit ec: ExecutionContext)
extends BaseController
with ArgoHelpers
Expand All @@ -31,70 +29,85 @@ class AuthController(auth: Authentication, val config: AuthConfig,
respond(indexData, indexLinks)
}

def index = auth.AuthAction { indexResponse }

def session = auth.AuthAction { request =>
val user = request.user
val firstName = user.firstName
val lastName = user.lastName
def index = auth { indexResponse }

val showPaid = hasPermission(PandaUser(request.user), Permissions.ShowPaid)
def session = auth { request =>
val showPaid = hasPermission(request.user, Permissions.ShowPaid)
request.user match {
case UserPrincipal(firstName, lastName, email, _) =>

respond(
Json.obj("user" ->
Json.obj(
"name" -> s"$firstName $lastName",
"firstName" -> firstName,
"lastName" -> lastName,
"email" -> user.email,
"avatarUrl" -> user.avatarUrl,
"permissions" ->
respond(
Json.obj("user" ->
Json.obj(
"showPaid" -> showPaid
"name" -> s"$firstName $lastName",
"firstName" -> firstName,
"lastName" -> lastName,
"email" -> email,
"permissions" ->
Json.obj(
"showPaid" -> showPaid
)
)
)
)
case MachinePrincipal(accessor, _) => respond(
Json.obj("api-key" ->
Json.obj(
"name" -> accessor.identity,
"tier" -> accessor.tier.toString,
"permissions" ->
Json.obj(
"showPaid" -> showPaid
)
)
)
)
)
}
}


def isOwnDomainAndSecure(uri: URI): Boolean = {
uri.getHost.endsWith(config.domainRoot) && uri.getScheme == "https"
}
def isValidDomain(inputUri: String): Boolean = {
Try(URI.create(inputUri)).filter(isOwnDomainAndSecure).isSuccess
val success = Try(URI.create(inputUri)).filter(isOwnDomainAndSecure).isSuccess
if (!success) logger.warn(s"Provided login redirect URI is invalid: $inputUri")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

success
}

// Play session key used to store the URI to redirect to during login
val REDIRECT_SESSION_KEY = "gridRedirectUri"

// Trigger the auth cycle
// If a redirectUri is provided, redirect the browser there once auth'd,
// else return a dummy page (e.g. for automatically re-auth'ing in the background)
// FIXME: validate redirectUri before doing the auth
def doLogin(redirectUri: Option[String] = None) = auth.AuthAction { req =>
redirectUri map {
case uri if isValidDomain(uri) => Redirect(uri)
case _ => Ok("logged in (not redirecting to external redirectUri)")
} getOrElse Ok("logged in")
def doLogin(redirectUri: Option[String] = None) = Action.async { implicit req =>
val checkedRedirectUri = redirectUri collect {
case uri if isValidDomain(uri) => uri
}
providers.userProvider.sendForAuthentication match {
case Some(authCallback) =>
authCallback(req).map(_.addingToSession(checkedRedirectUri.map(REDIRECT_SESSION_KEY -> _).toSeq:_*))
case None =>
Future.successful(InternalServerError("Login not supported by configured authentication provider"))
}
}

def oauthCallback = Action.async { implicit request =>
// We use the `Try` here as the `GoogleAuthException` are thrown before we
// get to the asynchronicity of the `Future` it returns.
// We then have to flatten the Future[Future[T]]. Fiddly...
Future.fromTry(Try(auth.processOAuthCallback())).flatten.recover {
// This is when session session args are missing
case e: OAuthException => respondError(BadRequest, "google-auth-exception", e.getMessage, auth.loginLinks)

// Class `missing anti forgery token` as a 4XX
// see https://github.com/guardian/pan-domain-authentication/blob/master/pan-domain-auth-play_2-6/src/main/scala/com/gu/pandomainauth/service/GoogleAuth.scala#L63
case e: IllegalArgumentException if e.getMessage == "The anti forgery token did not match" => {
logger.error(e.getMessage)
respondError(BadRequest, "google-auth-exception", e.getMessage, auth.loginLinks)
}
providers.userProvider.sendForAuthenticationCallback match {
case Some(callback) =>
val maybeRedirectUri = request.session.get(REDIRECT_SESSION_KEY)
callback(request, maybeRedirectUri).map(_.removingFromSession(REDIRECT_SESSION_KEY))
case None =>
Future.successful(InternalServerError("No callback for configured authentication provider"))
}
}

def logout = Action { implicit request =>
auth.processLogout
val result: Result = providers.userProvider.flushToken match {
case Some(callback) => callback(request, Ok("Logged out"))
case None => InternalServerError("Logout not supported by configured authentication provider")
}
result.withNewSession
}
}
4 changes: 2 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ val commonSettings = Seq(

testOptions in Test ++= Seq(Tests.Argument(TestFrameworks.ScalaTest, "-o"), Tests.Argument(TestFrameworks.ScalaTest, "-u", "logs/test-reports")),
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.0.5",
"org.mockito" % "mockito-core" % "2.18.0"
"org.scalatestplus.play" %% "scalatestplus-play" % "3.1.3" % Test,
"org.mockito" % "mockito-core" % "2.18.0" % Test
)
)

Expand Down
19 changes: 19 additions & 0 deletions common-lib/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,22 @@ image.processors = [
"com.gu.mediaservice.lib.cleanup.GuardianMetadataCleaners",
"com.gu.mediaservice.lib.cleanup.SupplierProcessors$"
]

authentication.providers {
machine {
className = "com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider"
config {
# authKeyStoreBucket = <s3-bucket-with-api-keys>
}
}
# TODO: short term we put panda here for backwards compatibility but the default provider should be something better
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor point: lets say something like "the default provider should be something that involves zero configuration for a simple test setup", just to help any future archaeologists infer what we mean by "better" here

user {
className = "com.gu.mediaservice.lib.guardian.auth.PandaAuthenticationProvider"
config {
# all of the things relating to pan domain auth (these are currently sensibly defaulted in code)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth saying something like "sensibly defaulted for Guardian usage"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of figured that's implicit in that the provider is Guardian specific, but I will make that clearer.

# panda.system = media-service
# panda.bucketName = <s3-bucket-with-config>
# panda.settingsFileKey = <s3-key-with-config>
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package com.gu.mediaservice.lib

import java.io.InputStream

import akka.actor.Scheduler
import akka.actor.{Cancellable, Scheduler}
import com.gu.Box
import com.gu.mediaservice.lib.aws.S3
import com.gu.mediaservice.lib.config.CommonConfig
Expand Down Expand Up @@ -42,8 +41,14 @@ abstract class BaseStore[TStoreKey, TStoreVal](bucket: String, config: CommonCon
}
}

private var cancellable: Option[Cancellable] = None

def scheduleUpdates(scheduler: Scheduler) {
scheduler.schedule(0.seconds, 10.minutes)(update())
cancellable = Some(scheduler.schedule(0.seconds, 10.minutes)(update()))
}

def stopUpdates(): Unit = {
cancellable.foreach(_.cancel())
}

def update(): Unit
Expand Down
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
Loading