Skip to content

Commit

Permalink
Support accepting multiple public keys
Browse files Browse the repository at this point in the history
  • Loading branch information
rtyley committed Aug 7, 2024
1 parent b9844db commit 0e72f45
Show file tree
Hide file tree
Showing 23 changed files with 313 additions and 116 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ val commonSettings =
"-deprecation",
// upgrade warnings to errors except deprecations
"-Wconf:cat=deprecation:ws,any:e",
"-release:8"
"-release:11"
),
licenses := Seq(License.Apache2),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class PanDomainAuthSettingsRefresher(
private val settingsRefresher = new Settings.Refresher[PanDomainAuthSettings](
new Settings.Loader(s3BucketLoader, settingsFileKey),
PanDomainAuthSettings.extractFrom,
(o, n) => {
for (change <- CryptoConf.Change.compare(o.cryptoConf, n.cryptoConf)) {
val message = s"PanDomainAuthSettings have changed for $domain: ${change.summary}"
if (change.isBreakingChange) logger.warn(message) else logger.info(message)
}
},
scheduler
)
settingsRefresher.start(1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package com.gu.pandomainauth.model

import com.gu.pandomainauth.SettingsFailure.SettingsResult
import com.gu.pandomainauth.service.{CryptoConf, KeyPair}
import com.gu.pandomainauth.service.CryptoConf
import com.gu.pandomainauth.service.CryptoConf.SigningAndVerification

case class PanDomainAuthSettings(
signingKeyPair: KeyPair,
cryptoConf: SigningAndVerification,
cookieSettings: CookieSettings,
oAuthSettings: OAuthSettings,
google2FAGroupSettings: Option[Google2FAGroupSettings]
Expand Down Expand Up @@ -51,9 +52,9 @@ object PanDomainAuthSettings{
) yield Google2FAGroupSettings(serviceAccountId, serviceAccountCert, adminUser, group)

for {
activeKeyPair <- CryptoConf.SettingsReader(settingMap).activeKeyPair
cryptoConf <- CryptoConf.SettingsReader(settingMap).extractFullConf
} yield PanDomainAuthSettings(
activeKeyPair,
cryptoConf,
cookieSettings,
oAuthSettings,
google2faSettings
Expand Down
5 changes: 3 additions & 2 deletions pan-domain-auth-example/app/VerifyExample.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ object VerifyExample {
// Call the start method when your application starts up to ensure the settings are kept up to date
publicSettings.start()

val publicKey = publicSettings.publicKey
// `OnlyVerification` will return None if a value has not been successfully obtained
val OnlyVerification = publicSettings.verification

// The name of this particular application
val system = "test"
Expand All @@ -41,7 +42,7 @@ object VerifyExample {
val cacheValidation = false

// To verify, call the authStatus method with the encoded cookie data
val status = PanDomain.authStatus("<<cookie data>>>", publicKey, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry = false)
val status = PanDomain.authStatus("<<cookie data>>>", OnlyVerification, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry = false)

status match {
case Authenticated(_) | GracePeriod(_) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import play.api.libs.ws.WSClient
import play.api.mvc.Results._
import play.api.mvc._

import java.net.{URLDecoder, URLEncoder}
import scala.concurrent.{ExecutionContext, Future}
import java.net.URLEncoder
import java.net.URLDecoder

class UserRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request)

Expand Down Expand Up @@ -197,8 +196,8 @@ trait AuthActions {
flushCookie(showUnauthedMessage("logged out"))
}

def readAuthenticatedUser(request: RequestHeader): Option[AuthenticatedUser] = readCookie(request) map { cookie =>
CookieUtils.parseCookieData(cookie.cookie.value, settings.signingKeyPair.publicKey)
def readAuthenticatedUser(request: RequestHeader): Option[AuthenticatedUser] = readCookie(request) flatMap { cookie =>
CookieUtils.parseCookieData(cookie.cookie.value, settings.cryptoConf).toOption
}

def readCookie(request: RequestHeader): Option[PandomainCookie] = {
Expand All @@ -208,14 +207,13 @@ trait AuthActions {
}
}

def generateCookie(authedUser: AuthenticatedUser): Cookie =
Cookie(
name = settings.cookieSettings.cookieName,
value = CookieUtils.generateCookieData(authedUser, settings.signingKeyPair.privateKey),
domain = Some(domain),
secure = true,
httpOnly = true
)
def generateCookie(authedUser: AuthenticatedUser): Cookie = Cookie(
name = settings.cookieSettings.cookieName,
value = CookieUtils.generateCookieData(authedUser, settings.cryptoConf.activeKeyPair.privateKey),
domain = Some(domain),
secure = true,
httpOnly = true
)

def includeSystemInCookie(authedUser: AuthenticatedUser)(result: Result): Result = {
val updatedAuth = authedUser.copy(authenticatedIn = authedUser.authenticatedIn + system)
Expand All @@ -237,7 +235,7 @@ trait AuthActions {
*/
def extractAuth(request: RequestHeader): AuthenticationStatus = {
readCookie(request).map { cookie =>
PanDomain.authStatus(cookie.cookie.value, settings.signingKeyPair.publicKey, validateUser, apiGracePeriod, system, cacheValidation, cookie.forceExpiry)
PanDomain.authStatus(cookie.cookie.value, settings.cryptoConf, validateUser, apiGracePeriod, system, cacheValidation, cookie.forceExpiry)
} getOrElse NotAuthenticated
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,18 @@ package com.gu.pandomainauth

import com.gu.pandomainauth.model._
import com.gu.pandomainauth.service.CookieUtils

import java.security.PublicKey
import com.gu.pandomainauth.service.CryptoConf.Verification


object PanDomain {
/**
* Check the authentication status of the provided credentials by examining the signed cookie data.
*/
def authStatus(cookieData: String, publicKey: PublicKey, validateUser: AuthenticatedUser => Boolean,
def authStatus(cookieData: String, cryptoConf: Verification, validateUser: AuthenticatedUser => Boolean,
apiGracePeriod: Long, system: String, cacheValidation: Boolean, forceExpiry: Boolean): AuthenticationStatus = {
try {
val authedUser = CookieUtils.parseCookieData(cookieData, publicKey)
CookieUtils.parseCookieData(cookieData, cryptoConf).fold(InvalidCookie, { authedUser =>
checkStatus(authedUser, validateUser, apiGracePeriod, system, cacheValidation, forceExpiry)
} catch {
case e: Exception =>
InvalidCookie(e)
}
})
}

private def checkStatus(authedUser: AuthenticatedUser, validateUser: AuthenticatedUser => Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.gu.pandomainauth

import com.gu.pandomainauth.SettingsFailure.SettingsResult
import com.gu.pandomainauth.service.CryptoConf
import com.gu.pandomainauth.service.CryptoConf.{OnlyVerification, Verification}

import java.security.PublicKey
import java.util.concurrent.{Executors, ScheduledExecutorService}
Expand All @@ -16,15 +17,24 @@ import scala.concurrent.duration._
class PublicSettings(loader: Settings.Loader,
scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1)) {

private val settingsRefresher = new Settings.Refresher[PublicKey](
private val settingsRefresher = new Settings.Refresher[Verification](
loader,
CryptoConf.SettingsReader(_).activePublicKey,
CryptoConf.SettingsReader(_).extractVerificationConf,
(o, n) => {
// for (change <- CryptoConf.Change.compare(o, n)) {
// val message = s"PanDomainAuthSettings have changed for $domain: ${change.summary}"
// if (change.isBreakingChange) logger.warn(message) else logger.info(message)
// }
},
scheduler
)

def start(interval: FiniteDuration = 60.seconds): Unit = settingsRefresher.start(interval.toMinutes.toInt)

def publicKey: PublicKey = settingsRefresher.get()
def verification: Verification = settingsRefresher.get()

@deprecated("Use OnlyVerification instead, to allow smooth transition to new public keys")
def publicKey: PublicKey = verification.activePublicKey
}

/**
Expand All @@ -39,6 +49,6 @@ object PublicSettings {
*
* @param domain the domain to fetch the public key for
*/
def getPublicKey(loader: Loader): SettingsResult[PublicKey] =
loader.loadAndParseSettingsMap().flatMap(CryptoConf.SettingsReader(_).activePublicKey)
def getOnlyVerification(loader: Loader): SettingsResult[Verification] =
loader.loadAndParseSettingsMap().flatMap(CryptoConf.SettingsReader(_).extractVerificationConf)
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ object Settings {
class Refresher[A](
loader: Settings.Loader,
settingsParser: Map[String, String] => SettingsResult[A],
changeMonitor: (A, A) => Unit,
scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1)
) {
// This is deliberately designed to throw an exception during construction if we cannot immediately read the settings
Expand All @@ -101,6 +102,7 @@ object Settings {
case Right(newSettings) =>
// logger.debug(s"Updated pan-domain settings for $domain")
val oldSettings = store.getAndSet(newSettings)
changeMonitor(oldSettings, newSettings)
case Left(err) =>
logger.error("Failed to update pan-domain settings for $domain")
err.logError(logger)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.gu.pandomainauth.model

import com.gu.pandomainauth.service.CookieUtils.CookieIntegrityFailure

sealed trait AuthenticationStatus
case class Expired(authedUser: AuthenticatedUser) extends AuthenticationStatus
case class GracePeriod(authedUser: AuthenticatedUser) extends AuthenticationStatus
case class Authenticated(authedUser: AuthenticatedUser) extends AuthenticationStatus
case class NotAuthorized(authedUser: AuthenticatedUser) extends AuthenticationStatus
case class InvalidCookie(exception: Exception) extends AuthenticationStatus
case class InvalidCookie(e: CookieIntegrityFailure) extends AuthenticationStatus
case object NotAuthenticated extends AuthenticationStatus
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.gu.pandomainauth.service

import com.gu.pandomainauth.service.CookiePayload.encodeBase64
import org.apache.commons.codec.binary.Base64
import org.apache.commons.codec.binary.Base64.isBase64

import java.nio.charset.StandardCharsets.UTF_8
import java.security.{PrivateKey, PublicKey}
import scala.util.matching.Regex

/**
* A representation of the underlying binary data (both payload & signature) in a Panda cookie.
*
* If an instance has been parsed from a cookie's text value, the existence of the instance
* *does not* imply that the signature has been verified. It only means that the cookie text was
* correctly formatted (ie two valid Base64 strings separated by '.').
*
* `CookiePayload` is designed to be the optimal representation of cookie data for checking
* signature-validity against *multiple* possible accepted public keys. It's a bridge between
* these two contexts:
*
* * cookie text: the raw cookie value - two Base64-encoded strings (payload & signature), separated by '.'
* * payload text: in Panda, a string representation of `AuthenticatedUser`
*
* To make those transformations, you need either a public or private key:
*
* * payload text -> cookie text: requires a *private* key to generate the signature
* * cookie text -> payload text: requires a *public* key to verify the signature
*/
case class CookiePayload(payloadBytes: Array[Byte], sig: Array[Byte]) {
def payloadTextVerifiedSignedWith(publicKey: PublicKey): Option[String] =
if (Crypto.verifySignature(payloadBytes, sig, publicKey)) Some(new String(payloadBytes)) else None

lazy val asCookieText: String = s"${encodeBase64(payloadBytes)}.${encodeBase64(sig)}"
}

object CookiePayload {
private val CookieRegEx: Regex = "^([\\w\\W]*)\\.([\\w\\W]*)$".r

private def encodeBase64(data: Array[Byte]): String = new String(Base64.encodeBase64(data), UTF_8)
private def decodeBase64(text: String): Array[Byte] = Base64.decodeBase64(text.getBytes(UTF_8))

/**
* @return `None` if the cookie text is incorrectly formatted (ie not "abc.xyz", with a '.' separator)
*/
def parse(cookieText: String): Option[CookiePayload] = cookieText match {
case CookieRegEx(data, sig) if isBase64(data) && isBase64(sig) =>
Some(CookiePayload(decodeBase64(data), decodeBase64(sig)))
case _ => None
}

def generateForPayloadText(payloadText: String, privateKey: PrivateKey): CookiePayload = {
val data = payloadText.getBytes("UTF-8")
CookiePayload(data, Crypto.signData(data, privateKey))
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
package com.gu.pandomainauth.service

import java.security.{PrivateKey, PublicKey, SignatureException}
import com.gu.pandomainauth.model.{AuthenticatedUser, CookieParseException, CookieSignatureInvalidException, User}
import org.apache.commons.codec.binary.Base64
import com.gu.pandomainauth.model.{AuthenticatedUser, User}
import com.gu.pandomainauth.service.CookieUtils.CookieIntegrityFailure.{MalformedCookieText, MissingUserData, SignatureNotValid}
import com.gu.pandomainauth.service.CryptoConf.Verification

import java.security.PrivateKey

object CookieUtils {
sealed trait CookieIntegrityFailure
object CookieIntegrityFailure {
case object MalformedCookieText extends CookieIntegrityFailure
case object SignatureNotValid extends CookieIntegrityFailure
case object MissingUserData extends CookieIntegrityFailure
}

type CookieResult[A] = Either[CookieIntegrityFailure, A]

private[service] def serializeAuthenticatedUser(authUser: AuthenticatedUser): String =
s"firstName=${authUser.user.firstName}" +
Expand All @@ -16,48 +26,39 @@ object CookieUtils {
s"&expires=${authUser.expires}" +
s"&multifactor=${authUser.multiFactor}"

private[service] def deserializeAuthenticatedUser(serializedForm: String): AuthenticatedUser = {
private[service] def deserializeAuthenticatedUser(serializedForm: String): Option[AuthenticatedUser] = {
val data = serializedForm
.split("&")
.map(_.split("=", 2))
.map{p => p(0) -> p(1)}
.toMap

AuthenticatedUser(
user = User(data("firstName"), data("lastName"), data("email"), data.get("avatarUrl")),
authenticatingSystem = data("system"),
authenticatedIn = Set(data("authedIn").split(",").toSeq :_*),
expires = data("expires").toLong,
multiFactor = data("multifactor").toBoolean
for {
firstName <- data.get("firstName")
lastName <- data.get("lastName")
email <- data.get("email")
system <- data.get("system")
authedIn <- data.get("authedIn")
expires <- data.get("expires")
multifactor <- data.get("multifactor")
} yield AuthenticatedUser(
user = User(firstName, lastName, email, data.get("avatarUrl")),
authenticatingSystem = system,
authenticatedIn = Set(authedIn.split(",").toSeq :_*),
expires = expires.toLong,
multiFactor = multifactor.toBoolean
)
}

def generateCookieData(authUser: AuthenticatedUser, prvKey: PrivateKey): String = {
val data = serializeAuthenticatedUser(authUser)
val encodedData = new String(Base64.encodeBase64(data.getBytes("UTF-8")))
val signature = Crypto.signData(data.getBytes("UTF-8"), prvKey)
val encodedSignature = new String(Base64.encodeBase64(signature))
def generateCookieData(authUser: AuthenticatedUser, prvKey: PrivateKey): String =
CookiePayload.generateForPayloadText(serializeAuthenticatedUser(authUser), prvKey).asCookieText

s"$encodedData.$encodedSignature"
}

lazy val CookieRegEx = "^^([\\w\\W]*)\\.([\\w\\W]*)$".r

def parseCookieData(cookieString: String, pubKey: PublicKey): AuthenticatedUser = {

cookieString match {
case CookieRegEx(data, sig) =>
try {
if (Crypto.verifySignature(Base64.decodeBase64(data.getBytes("UTF-8")), Base64.decodeBase64(sig.getBytes("UTF-8")), pubKey)) {
deserializeAuthenticatedUser(new String(Base64.decodeBase64(data.getBytes("UTF-8"))))
} else {
throw new CookieSignatureInvalidException
}
} catch {
case e: SignatureException =>
throw new CookieSignatureInvalidException
}
case _ => throw new CookieParseException
}
}
// We would quite like to know, if a user is using an old (but accepted) key, *who* that user is- or to put it another
// way, give me the authenticated user, and tell me which key they're using
def parseCookieData(cookieString: String, verification: Verification): CookieResult[AuthenticatedUser] = for {
cookiePayload <- CookiePayload.parse(cookieString).toRight(MalformedCookieText)
cookiePayloadText <- verification.decode(cookiePayload.payloadTextVerifiedSignedWith).toRight(SignatureNotValid)
authUser <- deserializeAuthenticatedUser(cookiePayloadText).toRight(MissingUserData)
} yield authUser
}

Loading

0 comments on commit 0e72f45

Please sign in to comment.