From 88579c0360fa38fd2e12fdcc535731b0856213b7 Mon Sep 17 00:00:00 2001 From: Andrew Nowak Date: Mon, 20 Nov 2023 15:10:27 +0000 Subject: [PATCH 1/2] import panda hmac libs, and build play 2.9 and 3.0 compat releases --- build.sbt | 34 +++++++- .../com/gu/pandahmac/HmacAuthActions.scala | 81 +++++++++++++++++++ .../scala/com/gu/pandahmac/HmacSecrets.scala | 21 +++++ .../com/gu/pandahmac/HmacSecretsTest.scala | 60 ++++++++++++++ project/Dependencies.scala | 4 + version.sbt | 2 +- 6 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacAuthActions.scala create mode 100644 pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacSecrets.scala create mode 100644 pan-domain-auth-hmac/src/test/scala/com/gu/pandahmac/HmacSecretsTest.scala diff --git a/build.sbt b/build.sbt index 94b6db3..24121c7 100644 --- a/build.sbt +++ b/build.sbt @@ -35,7 +35,12 @@ val commonSettings = crossScalaVersions := List(scala212, scala213), organization := "com.gu", Test / fork := false, - scalacOptions ++= Seq("-feature", "-deprecation", "-Xfatal-warnings"), + scalacOptions ++= Seq( + "-feature", + "-deprecation", + // upgrade warnings to errors except deprecations + "-Wconf:cat=deprecation:ws,any:e" + ), publishArtifact := false ) @@ -138,6 +143,30 @@ lazy val panDomainAuthPlay_3_0 = project("pan-domain-auth-play_3-0") publishArtifact := true ).dependsOn(panDomainAuthCore) +lazy val panDomainAuthHmac_2_8 = project("panda-hmac-play_2-8") + .settings(sourceDirectory := (ThisBuild / baseDirectory).value / "pan-domain-auth-hmac" / "src") + .settings(sonatypeReleaseSettings: _*) + .settings( + libraryDependencies ++= hmacLibs ++ playLibs_2_8 ++ testDependencies, + publishArtifact := true + ).dependsOn(panDomainAuthPlay_2_8) +lazy val panDomainAuthHmac_2_9 = project("panda-hmac-play_2-9") + .settings(sourceDirectory := (ThisBuild / baseDirectory).value / "pan-domain-auth-hmac" / "src") + .settings(sonatypeReleaseSettings: _*) + .settings( + crossScalaVersions := Seq(scala213), + libraryDependencies ++= hmacLibs ++ playLibs_2_9 ++ testDependencies, + publishArtifact := true + ).dependsOn(panDomainAuthPlay_2_9) +lazy val panDomainAuthHmac_3_0 = project("panda-hmac-play_3-0") + .settings(sourceDirectory := (ThisBuild / baseDirectory).value / "pan-domain-auth-hmac" / "src") + .settings(sonatypeReleaseSettings: _*) + .settings( + crossScalaVersions := Seq(scala213), + libraryDependencies ++= hmacLibs ++ playLibs_3_0 ++ testDependencies, + publishArtifact := true + ).dependsOn(panDomainAuthPlay_3_0) + lazy val exampleApp = project("pan-domain-auth-example") .enablePlugins(PlayScala) .settings(libraryDependencies ++= (awsDependencies :+ ws)) @@ -155,6 +184,9 @@ lazy val root = Project("pan-domain-auth-root", file(".")).aggregate( panDomainAuthPlay_2_8, panDomainAuthPlay_2_9, panDomainAuthPlay_3_0, + panDomainAuthHmac_2_8, + panDomainAuthHmac_2_9, + panDomainAuthHmac_3_0, exampleApp ).settings(sonatypeReleaseSettings) .settings( diff --git a/pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacAuthActions.scala b/pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacAuthActions.scala new file mode 100644 index 0000000..3d016c5 --- /dev/null +++ b/pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacAuthActions.scala @@ -0,0 +1,81 @@ +package com.gu.pandahmac +import com.gu.hmac.{HMACHeaders, ValidateHMACHeader} +import com.gu.pandomainauth.action.{AuthActions, UserRequest} +import com.gu.pandomainauth.model.User +import org.slf4j.LoggerFactory +import play.api.libs.ws.WSClient +import play.api.mvc.Results._ +import play.api.mvc._ + +import java.net.URI +import scala.concurrent.{ExecutionContext, Future} + + +object HMACHeaderNames { + val hmacKey = "X-Gu-Tools-HMAC-Token" + val dateKey = "X-Gu-Tools-HMAC-Date" + // Optional header to give the emulated user a nice name, if this isn't present we default to 'hmac-authed-service' + val serviceNameKey = "X-Gu-Tools-Service-Name" +} + +trait HMACAuthActions extends AuthActions with HMACSecrets { + /** + * Play application + * Play application components that you must provide in order to use AuthActions + */ + def wsClient: WSClient + def controllerComponents: ControllerComponents + + private implicit val ec: ExecutionContext = controllerComponents.executionContext + + private def authByKeyOrPanda[A](request: Request[A], block: RequestHandler[A], useApiAuth: Boolean): Future[Result] = { + val oHmac: Option[String] = request.headers.get(HMACHeaderNames.hmacKey) + val oDate: Option[String] = request.headers.get(HMACHeaderNames.dateKey) + val oServiceName: Option[String] = request.headers.get(HMACHeaderNames.serviceNameKey) + val uri = new URI(request.uri) + + (oHmac, oDate) match { + case (Some(hmac), Some(date)) => { + if (validateHMACHeaders(date, hmac, uri)) { + val user = User(oServiceName.getOrElse("hmac-authed-service"), "", "", None) + block(new UserRequest(user, request)) + } else { + Future.successful(Unauthorized) + } + } + case _ => if(useApiAuth) apiAuthByPanda(request, block) else authByPanda(request, block) + } + } + + type RequestHandler[A] = UserRequest[A] => Future[Result] + + def authByPanda[A](request: Request[A], block: RequestHandler[A]): Future[Result] = + AuthAction.invokeBlock(request, (request: UserRequest[A]) => { + block(new UserRequest(request.user, request)) + }) + + def apiAuthByPanda[A](request: Request[A], block: RequestHandler[A]): Future[Result] = + APIAuthAction.invokeBlock(request, (request: UserRequest[A]) => { + block(new UserRequest(request.user, request)) + }) + + + /* as per https://www.playframework.com/documentation/2.6.x/ScalaActionsComposition */ + object HMACAuthAction extends ActionBuilder[UserRequest, AnyContent] { + override def parser: BodyParser[AnyContent] = HMACAuthActions.this.controllerComponents.parsers.default + override protected def executionContext: ExecutionContext = HMACAuthActions.this.controllerComponents.executionContext + + override def invokeBlock[A](request: Request[A], block: RequestHandler[A]): Future[Result] = { + authByKeyOrPanda(request, block, useApiAuth = false) + } + } + + + object APIHMACAuthAction extends ActionBuilder[UserRequest, AnyContent] { + override def parser: BodyParser[AnyContent] = HMACAuthActions.this.controllerComponents.parsers.default + override protected def executionContext: ExecutionContext = HMACAuthActions.this.controllerComponents.executionContext + + override def invokeBlock[A](request: Request[A], block: RequestHandler[A]): Future[Result] = + authByKeyOrPanda(request, block, useApiAuth = true) + } +} diff --git a/pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacSecrets.scala b/pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacSecrets.scala new file mode 100644 index 0000000..912c57c --- /dev/null +++ b/pan-domain-auth-hmac/src/main/scala/com/gu/pandahmac/HmacSecrets.scala @@ -0,0 +1,21 @@ +package com.gu.pandahmac + +import com.gu.hmac.ValidateHMACHeader + +import java.net.URI + +trait HMACSecrets extends ValidateHMACHeader { + @deprecated("In an upcoming release consumers will expected to provide a list of secrets to `secretKeys` to allow for secret rotation.") + def secret: String = "" + def secretKeys: List[String] = List.empty + + protected def gatherSecrets: List[String] = + if(!secretKeys.isEmpty) secretKeys + else if(!secret.isEmpty) List(secret) + else throw new Exception( + "Please set either 'secret' or 'secretKeys', no secret available for HMACAuthActions!" + ) + + def validateHMACHeaders(date: String, hmac: String, uri: URI): Boolean = + gatherSecrets.exists(validateHMACHeadersWithSecret(_, date, hmac, uri)) +} \ No newline at end of file diff --git a/pan-domain-auth-hmac/src/test/scala/com/gu/pandahmac/HmacSecretsTest.scala b/pan-domain-auth-hmac/src/test/scala/com/gu/pandahmac/HmacSecretsTest.scala new file mode 100644 index 0000000..b9a13f6 --- /dev/null +++ b/pan-domain-auth-hmac/src/test/scala/com/gu/pandahmac/HmacSecretsTest.scala @@ -0,0 +1,60 @@ +package com.gu.pandahmac + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.net.URI +import java.time.format.DateTimeFormatter +import java.time.{Clock, Instant, ZoneId} + +class HMACHeadersTest extends AnyWordSpec with Matchers { + + val uri = new URI("http:///www.theguardian.com/signin?query=someData") + val validSecret = "secret" + val expectedHMAC = "3AQ08uT4ToOISOXWMr68UvzrgrqIx3KK/pKEenwVES8=" + val dateHeaderValue = "Tue, 15 Nov 1994 08:12:00 GMT" + val someTimeInThePast = Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(dateHeaderValue)) + + val fixedClock = + Clock.fixed(someTimeInThePast, ZoneId.systemDefault) + + "HMACSecrets" when { + "validateHMACHeaders" should { + "throw an exception if called without secret or secretKeys set" in { + val hmacSecrets = new HMACSecrets {} + + intercept[Exception] { + hmacSecrets.validateHMACHeaders(dateHeaderValue, s"HMAC $expectedHMAC", uri) + } + } + + "return true if a valid secret is set" in { + val hmacSecrets = new HMACSecrets { + override val secret = validSecret + override val clock: Clock = fixedClock + } + + hmacSecrets.validateHMACHeaders(dateHeaderValue, s"HMAC $expectedHMAC", uri) should be(true) + } + + "return true if any valid secret is in secretKeys" in { + val hmacSecrets = new HMACSecrets { + override val secretKeys = List("invalid", validSecret, "invalid") + override val clock: Clock = fixedClock + } + + hmacSecrets.validateHMACHeaders(dateHeaderValue, s"HMAC $expectedHMAC", uri) should be(true) + } + + "preferentially use secretKeys over secret if both are provided" in { + val hmacSecrets = new HMACSecrets { + override val secret = "invalid" + override val secretKeys = List("invalid", validSecret, "invalid") + override val clock: Clock = fixedClock + } + + hmacSecrets.validateHMACHeaders(dateHeaderValue, s"HMAC $expectedHMAC", uri) should be(true) + } + } + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a80ef78..cc58cda 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -28,6 +28,10 @@ object Dependencies { ) } + val hmacLibs = Seq( + "com.gu" %% "hmac-headers" % "2.0.0" + ) + val googleDirectoryApiDependencies = Seq( "com.google.apis" % "google-api-services-admin-directory" % "directory_v1-rev20230124-2.0.0", "com.google.auth" % "google-auth-library-credentials" % "1.16.0", diff --git a/version.sbt b/version.sbt index 630f493..5051b75 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "1.4.1-SNAPSHOT" +version in ThisBuild := "3.0.0-SNAPSHOT" From c10b409a4d707467302c63cef42ada9c09d7b642 Mon Sep 17 00:00:00 2001 From: Andrew Nowak Date: Mon, 20 Nov 2023 15:34:16 +0000 Subject: [PATCH 2/2] include README info from old panda-hmac repo --- README.md | 86 ++++++++++++++++++++++++++- hmac-examples/README.md | 3 + hmac-examples/js/.nvmrc | 1 + hmac-examples/js/hmac-client.js | 37 ++++++++++++ hmac-examples/js/package.json | 6 ++ hmac-examples/python/hmac-client.py | 51 ++++++++++++++++ hmac-examples/python/requirements.txt | 1 + hmac-examples/scala/HMACClient.scala | 21 +++++++ hmac-examples/scala/build.sbt | 5 ++ 9 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 hmac-examples/README.md create mode 100644 hmac-examples/js/.nvmrc create mode 100644 hmac-examples/js/hmac-client.js create mode 100644 hmac-examples/js/package.json create mode 100644 hmac-examples/python/hmac-client.py create mode 100644 hmac-examples/python/requirements.txt create mode 100644 hmac-examples/scala/HMACClient.scala create mode 100644 hmac-examples/scala/build.sbt diff --git a/README.md b/README.md index afaa581..2b720ff 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ On their return the existing cookie is updated with the new expiry time. ## What's provided -Pan domain auth is split into 5 modules. +Pan domain auth is split into 6 modules. The [pan-domain-auth-verification](###-to-verify-logins) library provides the basic functionality for sigining and verifying login cookies in Scala. For JVM applications that only need to *VERIFY* an existing login (rather than issue logins themselves) this is the library to use. @@ -52,7 +52,7 @@ For JVM applications that only need to *VERIFY* an existing login (rather than i The `pan-domain-auth-core` library provides the core utilities to load settings, create and validate the cookie and check if the user has mutli-factor auth turned on when usng Google as the provider. -The [pan-domain-auth-play_2-6](###if-your-application-needs-to-issue-logins) library provide an implementation for play apps. There is an auth action +The [pan-domain-auth-play_2-8, 2-9 and 3-0](###if-your-application-needs-to-issue-logins) libraries provide an implementation for play apps. There is an auth action that can be applied to the endpoints in your application that will do checking and setting of the cookie and will give you the OAuth authentication mechanism and callback. This is the only framework specific implementation currently (due to play being the framework predominantly used at The Guardian), this can be used as reference if you need to implement another framework implementation. This library is for applications @@ -64,6 +64,9 @@ The `pan-domain-auth-example` provides an example Play 2.9 app with authenticati of how to set up an nginx configuration to allow you to run multiple authenticated apps locally as if they were all on the same domain which is useful during development. +The [panda-hmac](###to-verify-machines) libraries build on pan-domain-auth-play to also verify machine clients, +who cannot perform OAuth authentication, by using HMAC-SHA-256. + ## Requirements If you are adding a new application to an existing deployment of pan-domain-authentication then you can skip to @@ -321,6 +324,85 @@ function(request) { ``` +### To verify machines + +Add a dependency on the correct version of `pan-domain-auth-play` and configure to allow authentication of users using OAuth 2. Then, adding support should be as simple as adding a dependency on the relevant panda-hmac-play library, and mixing `HMACAuthActions` into your controllers. + +Example: + +```scala +import com.gu.pandahmac.HMACAuthActions + +// ... + +@Singleton +class MyController @Inject() ( + override val config: Configuration, + override val controllerComponents: ControllerComponents, + override val wsClient: WSClient, + override val refresher: InjectableRefresher +) extends AbstractController(controllerComponents) + with PanDomainAuthActions + with HMACAuthActions { + + override def secretKeys = List("currentSecret") // You're likely to get your secret from configuration or a cloud service like AWS Secrets Manager + + def myApiActionWithBody = APIHMACAuthAction.async(circe.json(2048)) { request => + // ... do something with the request + } + + def myRegularAction = HMACAuthAction {} + + def myRegularAsyncAction = HMACAuthAction.async {} +} +``` + +#### Setting up a machine client + +There are example clients for Scala, Javascript and Python in the `hmac-examples/` directory. + +Each client needs a copy of the shared secret, defined as "currentSecret" in the controller example above. +Each request needs a standard (RFC-7231) HTTP Date header, and an authorization digest that is calculated like this: + +1. Make a "string to sign" consisting of the HTTP Date and the Path part of the URI you're trying to access, +seperated by a literal newline (unix-style, not CRLF) +2. Calculate the HMAC digest of the "string to sign" using the shared secret as a key and the HMAC-SHA-256 algorithm +3. Base64 encode the binary output of the HMAC digest to get a random-looking string +4. Add the HTTP date to the request headers with the header name **'X-Gu-Tools-HMAC-Date'** +5. Add another header called **'X-Gu-Tools-HMAC-Token'** and set its value to the literal string **HMAC** followed by a + space and the digest, like this: `X-Gu-Tools-HMAC-Token: HMAC boXSTNumKWRX3eQk/BBeHYk` +6. Send the request and the server should respond with a success. +7. The default allowable clock skew is 5 minutes, if you have problems then this is the first thing to check. + +#### Testing HMAC-authenticated endpoints in isolation + +[Postman](https://www.postman.com/) is a common environment for testing HTTP requests. We can add a [pre-request script](https://learning.postman.com/docs/writing-scripts/pre-request-scripts/) that automatically adds HMAC headers when we hit send. + +
+Pre-request script + +```js +const URL = require("url"); + +const uri = pm.request.url.toString(); +const secret = "Secret goes here :)"; + +const httpDate = new Date().toUTCString(); +const path = new URL.parse(uri).path; +const stringToSign = `${httpDate}\n${path}`; +const stringToSignBytes = CryptoJS.enc.Utf8.parse(stringToSign); +const secretBytes = CryptoJS.enc.Utf8.parse(secret); + +const signature = CryptoJS.enc.Base64.stringify(CryptoJS.HmacSHA256(stringToSignBytes, secretBytes)); +const authToken = `HMAC ${signature}`; + +pm.request.headers.add({ key: 'X-Gu-Tools-HMAC-Date', value: httpDate }); +pm.request.headers.add({ key: 'X-Gu-Tools-HMAC-Token', value: authToken }); +``` + +
+ + ### Dealing with auth expiry in a single page webapp In a single page webapp there will typically be an initial page load and then all communication with the server will be initiated by JavaScript. diff --git a/hmac-examples/README.md b/hmac-examples/README.md new file mode 100644 index 0000000..49c0c16 --- /dev/null +++ b/hmac-examples/README.md @@ -0,0 +1,3 @@ +# Client Examples + +Sometimes the best way to learn is by example. This folder contains a few example client implementations for various languages to show how you might call a HMAC'd service. If you've recently worked against `play-hmac` in a language without an example it would be great if you could add a snippet here. diff --git a/hmac-examples/js/.nvmrc b/hmac-examples/js/.nvmrc new file mode 100644 index 0000000..c6244cd --- /dev/null +++ b/hmac-examples/js/.nvmrc @@ -0,0 +1 @@ +14.17.3 diff --git a/hmac-examples/js/hmac-client.js b/hmac-examples/js/hmac-client.js new file mode 100644 index 0000000..20b5a4c --- /dev/null +++ b/hmac-examples/js/hmac-client.js @@ -0,0 +1,37 @@ +const crypto = require('crypto'); +const reqwest = require('reqwest'); + +// The secret you share with the remote service. +// Should *NOT* be hard coded, put it somewhere private (S3, Dynamo, properties file, etc.) +const sharedSecret = "Sanguine, my brother."; + +// Make a hmac token from the required components. You probably want to copy this :) +function makeHMACToken(secret, date, uri) { + const hmac = crypto.createHmac('sha256', secret); + + const content = date + '\n' + uri; + + hmac.update(content, 'utf-8'); + + return "HMAC " + hmac.digest('base64'); +} + +// It's important to remember the leading / +const uri = "/api/examples"; +const date = (new Date()).toUTCString(); +const token = makeHMACToken(sharedSecret, date, uri); + +// Make a request to our example API with the generated HMAC +reqwest({ + url: "http://example.com" + uri, + method: 'GET', + headers: { + 'X-Gu-Tools-HMAC-Date': date, + 'X-Gu-Tools-HMAC-Token': token, + 'X-Gu-Tools-Service-Name': 'example-service-name' + } +}).then(function(resp) { + console.log('We did it!'); +}, function(err, msg) { + console.error('Something went wrong :('); +}); diff --git a/hmac-examples/js/package.json b/hmac-examples/js/package.json new file mode 100644 index 0000000..f325860 --- /dev/null +++ b/hmac-examples/js/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "reqwest": "2.0.5", + "xhr2": "0.1.3" + } +} diff --git a/hmac-examples/python/hmac-client.py b/hmac-examples/python/hmac-client.py new file mode 100644 index 0000000..5154ef4 --- /dev/null +++ b/hmac-examples/python/hmac-client.py @@ -0,0 +1,51 @@ +#!/usr/bin/python + +import hashlib +import hmac +from optparse import OptionParser +from datetime import datetime +import base64 +from email.utils import formatdate +import requests +from time import mktime +from urlparse import urlparse +from pprint import pprint + +def get_token(uri, secret): + httpdate = formatdate(timeval=mktime(datetime.now().timetuple()),localtime=False,usegmt=True) + url_parts = urlparse(uri) + + string_to_sign = "{0}\n{1}".format(httpdate, url_parts.path) + print "string_to_sign: " + string_to_sign + hm = hmac.new(secret, string_to_sign,hashlib.sha256) + return "HMAC {0}".format(base64.b64encode(hm.digest())), httpdate + +#START MAIN +parser = OptionParser() +parser.add_option("--host", dest="host", help="host to access", default="video.local.dev-gutools.co.uk") +parser.add_option("-a", "--atom", dest="atom", help="uuid of the atom to request") +parser.add_option("-s", "--secret", dest="secret", help="shared secret to use") +(options, args) = parser.parse_args() + +if options.secret is None: + print "You must supply the password in --secret" + exit(1) + +uri = "https://{host}/pluto/resend/{id}".format(host=options.host, id=options.atom) +print "uri is " + uri +authtoken, httpdate = get_token(uri, options.secret) +print authtoken + +headers = { + 'X-Gu-Tools-HMAC-Date': httpdate, + 'X-Gu-Tools-HMAC-Token': authtoken +} + +print headers +response = requests.post(uri,headers=headers) +print "Server returned {0}".format(response.status_code) +pprint(response.headers) +if response.status_code==200: + pprint(response.json()) +else: + print response.text diff --git a/hmac-examples/python/requirements.txt b/hmac-examples/python/requirements.txt new file mode 100644 index 0000000..271baf7 --- /dev/null +++ b/hmac-examples/python/requirements.txt @@ -0,0 +1 @@ +requests==2.18.4 diff --git a/hmac-examples/scala/HMACClient.scala b/hmac-examples/scala/HMACClient.scala new file mode 100644 index 0000000..9aa6c15 --- /dev/null +++ b/hmac-examples/scala/HMACClient.scala @@ -0,0 +1,21 @@ +package example + +import java.net.URI +import com.gu.hmac.HMACHeaders + +// When using scala you get the `hmac-headers` library and use it directly to generate your HMAC tokens +object HMACClient extends HMACHeaders { + val secret = "Sanguine, my brother." + + // Unlike the javascript example, with the hmac-headers library you don't provide it a date, it generates one for you + def makeHMACToken(uri: String): HMACHeaderValues = { + createHMACHeaderValues(new URI(uri)) + } +} + +object ExampleRequestSender { + def sendRequest = { + val uri = "/api/examples" + ws.url("example.com" + uri) + } +} diff --git a/hmac-examples/scala/build.sbt b/hmac-examples/scala/build.sbt new file mode 100644 index 0000000..87c6cea --- /dev/null +++ b/hmac-examples/scala/build.sbt @@ -0,0 +1,5 @@ +name := "scala-hmac-client-example" + +scalaVersion := "2.11.8" + +libraryDependencies += "com.gu" %% "panda-hmac" % "1.1"