Skip to content

Commit

Permalink
Merge pull request #124 from guardian/an/import-panda-hmac
Browse files Browse the repository at this point in the history
Pull panda-hmac into repo
  • Loading branch information
andrew-nowak committed Nov 21, 2023
2 parents 46c6cde + c10b409 commit 0a27d3d
Show file tree
Hide file tree
Showing 15 changed files with 409 additions and 4 deletions.
86 changes: 84 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ 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.

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
Expand All @@ -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
Expand Down Expand Up @@ -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.

<details>
<summary>Pre-request script</summary>

```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 });
```

</details>


### 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.
Expand Down
34 changes: 33 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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))
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions hmac-examples/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions hmac-examples/js/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
14.17.3
37 changes: 37 additions & 0 deletions hmac-examples/js/hmac-client.js
Original file line number Diff line number Diff line change
@@ -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 :(');
});
6 changes: 6 additions & 0 deletions hmac-examples/js/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"dependencies": {
"reqwest": "2.0.5",
"xhr2": "0.1.3"
}
}
51 changes: 51 additions & 0 deletions hmac-examples/python/hmac-client.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions hmac-examples/python/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
requests==2.18.4
21 changes: 21 additions & 0 deletions hmac-examples/scala/HMACClient.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions hmac-examples/scala/build.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name := "scala-hmac-client-example"

scalaVersion := "2.11.8"

libraryDependencies += "com.gu" %% "panda-hmac" % "1.1"
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 0a27d3d

Please sign in to comment.