Skip to content

Commit

Permalink
Pass redirect url through OAuth state
Browse files Browse the repository at this point in the history
TokenResponse is a better name for that endpoint

More wip

More wip

WIP
  • Loading branch information
rtyley committed Oct 9, 2018
1 parent 27f46d0 commit 2d6fb72
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 96 deletions.
2 changes: 2 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ resolvers += Resolver.typesafeIvyRepo("releases")
libraryDependencies ++= Seq(
play % "provided",
playWS % "provided",
akkaHttpCore,
"com.gu.play-secret-rotation" %% "core" % "0.12",
"org.typelevel" %% "cats-core" % "1.0.1",
jose4j,
commonsCodec,
playTest % "test",
"org.scalatest" %% "scalatest" % "3.0.3" % "test"
Expand Down
71 changes: 71 additions & 0 deletions module/src/main/scala/com/gu/googleauth/GoogleOAuthService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.gu.googleauth

import play.api.libs.json.JsValue
import play.api.libs.ws.{WSClient, WSResponse}

import scala.concurrent.{ExecutionContext, Future}
import scala.language.postfixOps

case class OAuthConfig(
clientId: String,
clientSecret: String,
redirectUrl: String
)

class GoogleOAuthService(config: OAuthConfig, dd: DiscoveryDocument)(implicit context: ExecutionContext, ws: WSClient) {

def googleResponse[T](r: WSResponse)(block: JsValue => T): T = {
r.status match {
case errorCode if errorCode >= 400 =>
// try to get error if google sent us an error doc
val error = (r.json \ "error").asOpt[Error]
error.map { e =>
throw new GoogleAuthException(s"Error when calling Google: ${e.message}")
}.getOrElse {
throw new GoogleAuthException(s"Unknown error when calling Google [status=$errorCode, body=${r.body}]")
}
case normal => block(r.json)
}
}

// https://developers.google.com/identity/protocols/OpenIDConnect#exchangecode
def exchangeCodeForToken(code: String): Future[TokenResponse] = {
val requestBody = Map[String, Seq[String]](
"code" -> Seq(code),
"client_id" -> Seq(config.clientId),
"client_secret" -> Seq(config.clientSecret),
"redirect_uri" -> Seq(config.redirectUrl),
"grant_type" -> Seq("authorization_code")
)

for {
response <- ws.url(dd.token_endpoint).post(requestBody)
} yield googleResponse(response)(TokenResponse.fromJson)
}

// https://developers.google.com/identity/protocols/OpenIDConnect#obtaininguserprofileinformation
def fetchUserInfo(tr: TokenResponse): Future[UserInfo] = for {
response <- ws.url(dd.userinfo_endpoint).withHttpHeaders("Authorization" -> s"Bearer ${tr.access_token}").get()
} yield googleResponse(response)(UserInfo.fromJson)


def fetchUserIdentityForCode(code: String): Future[UserIdentity] = {
val requiredDomain: Option[String]= ???
for {
tokenResponse <- exchangeCodeForToken(code)
jwt = tokenResponse.jwt
// requiredDomain foreach { domain =>
// if (!jwt.claims.email.split("@").lastOption.contains(domain))
// throw new GoogleAuthException("Configured Google domain does not match")
// }
userInfo <- jwt.claimsJson.validate[UserInfo].asOpt.map(Future.successful).getOrElse(fetchUserInfo(tokenResponse))
} yield UserIdentity(
jwt.claims.sub,
jwt.claims.email,
userInfo.given_name,
userInfo.family_name,
jwt.claims.exp,
userInfo.picture
)
}
}
86 changes: 86 additions & 0 deletions module/src/main/scala/com/gu/googleauth/OAuthState.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.gu.googleauth

import java.nio.charset.StandardCharsets.UTF_8
import java.time.Clock
import java.util.{Base64, Date}

import com.gu.googleauth.Destination.Encryption._
import com.gu.googleauth.GoogleAuthFilters.LOGIN_ORIGIN_KEY
import com.gu.googleauth.OAuthStateSecurityConfig.SessionIdJWTClaimPropertyName
import io.jsonwebtoken.{Claims, Jws, Jwts, SignatureAlgorithm}
import org.jose4j.jwa.AlgorithmConstraints
import org.jose4j.jwa.AlgorithmConstraints.ConstraintType.WHITELIST
import org.jose4j.jwe.ContentEncryptionAlgorithmIdentifiers.AES_128_CBC_HMAC_SHA_256
import org.jose4j.jwe.JsonWebEncryption
import org.jose4j.jwe.KeyManagementAlgorithmIdentifiers.A128KW
import org.jose4j.keys.AesKey
import play.api.mvc.Session

import scala.util.{Failure, Success, Try}


case class OAuthState(sessionId: String, encryptedReturnUrl: String) {
def checkSessionIdMatches(session: Session):Try[Unit] =
if (session(SessionId.KeyName).contains(sessionId)) Success(()) else
Failure(throw new IllegalArgumentException(s"Session id does not match"))
}

object Destination {
val KeyName = "destinationUrl"

object Encryption {
val KeyManagementAlgorithm = A128KW
val ContentEncryptionAlgorithm = AES_128_CBC_HMAC_SHA_256
}

case class Encryption(secret: String) {

val key = new AesKey(secret.getBytes(UTF_8))

def encrypt(destinationUrl: String): String = {
var jwe = new JsonWebEncryption
jwe.setPayload(destinationUrl)
jwe.setAlgorithmHeaderValue(KeyManagementAlgorithm)
jwe.setEncryptionMethodHeaderParameter(ContentEncryptionAlgorithm)
jwe.setKey(key)
jwe.getCompactSerialization
}

def decrypt(encryptedDestinationUrl: String): String = {
val jwe = new JsonWebEncryption
jwe.setAlgorithmConstraints(new AlgorithmConstraints(WHITELIST, KeyManagementAlgorithm))
jwe.setContentEncryptionAlgorithmConstraints(new AlgorithmConstraints(WHITELIST, ContentEncryptionAlgorithm))
jwe.setKey(key)
jwe.setCompactSerialization(encryptedDestinationUrl)
jwe.getPayload
}
}
}

object OAuthState {

case class Encoding(secret: String, signatureAlgorithm: SignatureAlgorithm) {

private val base64EncodedSecret: String =
Base64.getEncoder.encodeToString(secret.getBytes(UTF_8))

def checkChoiceOfSigningAlgorithm(claims: Jws[Claims]): Try[Unit] =
if (claims.getHeader.getAlgorithm == signatureAlgorithm.getValue) Success(()) else
Failure(throw new IllegalArgumentException(s"the anti forgery token is not signed with $signatureAlgorithm"))

def extractOAuthStateFrom(state: String): Try[OAuthState] = for {
jwtClaims <- Try(Jwts.parser().setSigningKey(base64EncodedSecret).parseClaimsJws(state))
_ <- checkChoiceOfSigningAlgorithm(jwtClaims)
} yield OAuthState(
sessionId = jwtClaims.getBody.get(SessionIdJWTClaimPropertyName, classOf[String]),
encryptedReturnUrl = jwtClaims.getBody.get(LOGIN_ORIGIN_KEY, classOf[String])
)

def stringify(oAuthState: OAuthState)(implicit clock: Clock = Clock.systemUTC) : String = Jwts.builder()
.setExpiration(Date.from(clock.instant().plusSeconds(60)))
.claim(SessionIdJWTClaimPropertyName, oAuthState.sessionId)
.claim(LOGIN_ORIGIN_KEY, oAuthState.encryptedReturnUrl)
.signWith(signatureAlgorithm, base64EncodedSecret)
.compact()
}
}
21 changes: 21 additions & 0 deletions module/src/main/scala/com/gu/googleauth/SessionId.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.gu.googleauth

import java.math.BigInteger
import java.security.SecureRandom

import play.api.mvc.{RequestHeader, Result}

import scala.concurrent.{ExecutionContext, Future}

object SessionId {
val KeyName = "play-googleauth-session-id"

private val random = new SecureRandom()
def generateSessionId() = new BigInteger(130, random).toString(32)

def ensureUserHasSessionId(t: String => Future[Result])(implicit request: RequestHeader, ec: ExecutionContext):Future[Result] = {
val sessionId = request.session.get(KeyName).getOrElse(generateSessionId())

t(sessionId).map(_.addingToSession(KeyName -> sessionId))
}
}
5 changes: 4 additions & 1 deletion project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ object Dependencies {

//versions

val playVersion = "2.6.17"
val playVersion = "2.6.19"


//libraries

val play = "com.typesafe.play" %% "play" % playVersion
val playWS = "com.typesafe.play" %% "play-ws" % playVersion
val akkaHttpCore = "com.typesafe.akka" %% "akka-http-core" % "10.1.0"
val playTest = "com.typesafe.play" %% "play-test" % playVersion

val jose4j = "org.bitbucket.b_c" % "jose4j" % "0.6.3"

val commonsCodec = "commons-codec" % "commons-codec" % "1.9"

/** The google-api-services-admin-directory artifact has a transitive dependency on com.google.guava:guava-jdk5 - a
Expand Down
67 changes: 48 additions & 19 deletions src/main/scala/com/gu/googleauth/actions.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package com.gu.googleauth

import akka.http.scaladsl.model.Uri
import cats.data.EitherT
import cats.instances.future._
import cats.syntax.applicativeError._
import io.jsonwebtoken.ExpiredJwtException
import com.gu.googleauth.OAuthStateSecurityConfig.SessionIdJWTClaimPropertyName
import com.gu.googleauth.GoogleAuthFilters.LOGIN_ORIGIN_KEY
import com.gu.googleauth.SessionId.ensureUserHasSessionId
import io.jsonwebtoken.{ExpiredJwtException, Jwts}
import org.jose4j.jwa.AlgorithmConstraints
import org.jose4j.jwa.AlgorithmConstraints.ConstraintType.WHITELIST
import play.api.Logger
import play.api.libs.json.Json.toJson
import play.api.libs.json.{Format, JsValue, Json}
import play.api.libs.ws.WSClient
import play.api.mvc.Results._
Expand All @@ -13,13 +20,15 @@ import play.api.mvc._

import scala.concurrent.{ExecutionContext, Future}
import scala.language.{higherKinds, postfixOps}
import scala.util.Try
import AuthAction._


case class UserIdentity(sub: String, email: String, firstName: String, lastName: String, exp: Long, avatarUrl: Option[String]) {
lazy val fullName = firstName + " " + lastName
lazy val username = email.split("@").head
lazy val emailDomain = email.split("@").last
lazy val asJson = Json.stringify(Json.toJson(this))
lazy val asJson = Json.stringify(toJson(this))
lazy val isValid = System.currentTimeMillis < exp * 1000
}

Expand All @@ -38,6 +47,15 @@ object AuthenticatedRequest {
}
}

// stored in the JWT claim - everything else is just anti-forgery verification



case class OAuthConclusion(user: UserIdentity, returnUrl: String) {
def redirect(implicit req: RequestHeader) = Redirect(returnUrl).addingToSession(UserIdentity.KEY -> toJson(user).toString)

}

trait UserIdentifier {
/**
* The configuration to use for these actions
Expand All @@ -53,6 +71,13 @@ trait UserIdentifier {

object AuthAction {
type UserIdentityRequest[A] = AuthenticatedRequest[A, UserIdentity]

implicit class RichCall(call: Call) {
def withQueryParameter(keyValue: (String, String)): Call = {
val callUri = Uri(call.url)
call.copy(url = callUri.withQuery(keyValue +: callUri.query()).toString)
}
}
}

/**
Expand Down Expand Up @@ -80,12 +105,14 @@ class AuthAction[A](val authConfig: GoogleAuthConfig, loginTarget: Call, bodyPar
* Helper method that deals with sending a client for authentication. Typically this should store the target URL and
* redirect to the loginTarget. There shouldn't really be any need to override this.
*/
def sendForAuth[A](request: RequestHeader)(implicit ec: ExecutionContext) =
Redirect(loginTarget).withSession {
request.session + (GoogleAuthFilters.LOGIN_ORIGIN_KEY, request.uri)
}
def sendForAuth[A](request: RequestHeader)(implicit ec: ExecutionContext) = {
val de: Destination.Encryption = ???

Redirect(loginTarget.withQueryParameter(LOGIN_ORIGIN_KEY -> de.encrypt(request.uri)))
}

override def parser: BodyParser[A] = bodyParser

}

trait LoginSupport {
Expand All @@ -111,10 +138,8 @@ trait LoginSupport {
/**
* Redirects user to Google to start the login.
*/
def startGoogleLogin()(implicit request: RequestHeader, ec: ExecutionContext): Future[Result] = {
authConfig.antiForgeryChecker.ensureUserHasSessionId { sessionId =>
GoogleAuth.redirectToGoogle(authConfig, sessionId)
}
def startGoogleLogin()(implicit req: RequestHeader, ec: ExecutionContext): Future[Result] = ensureUserHasSessionId {
sessionId => GoogleAuth.redirectToGoogle(authConfig, sessionId)
}

/**
Expand Down Expand Up @@ -166,11 +191,17 @@ trait LoginSupport {
* Handle the OAuth2 callback, which logs the user in and redirects them appropriately.
*/
def processOauth2Callback()(implicit request: RequestHeader, ec: ExecutionContext): Future[Result] = {
(for {
identity <- checkIdentity()
} yield {
setupSessionWhenSuccessful(identity)
}).merge
val oauthStateEncoding: OAuthState.Encoding = ???
val destinationEncryption: Destination.Encryption = ???

for {
oAuthState <- Future.fromTry(oauthStateEncoding.extractOAuthStateFrom(request.getQueryString("state").get))
_ <- oAuthState.checkSessionIdMatches(request.session)
userIdentity <- GoogleAuth.validatedUserIdentity(authConfig)
} yield OAuthConclusion(
user = userIdentity,
returnUrl = destinationEncryption.decrypt(oAuthState.encryptedReturnUrl)
).redirect
}

/**
Expand All @@ -195,14 +226,12 @@ trait LoginSupport {
* Redirects user with configured play-googleauth session.
*/
def setupSessionWhenSuccessful(userIdentity: UserIdentity)(implicit request: RequestHeader): Result = {
val redirect = request.session.get(GoogleAuthFilters.LOGIN_ORIGIN_KEY) match {
val redirect = request.session.get(LOGIN_ORIGIN_KEY) match {
case Some(url) => Redirect(url)
case None => Redirect(defaultRedirectTarget)
}
// Store the JSON representation of the identity in the session - this is checked by AuthAction later
redirect.withSession {
request.session + (UserIdentity.KEY -> Json.toJson(userIdentity).toString) - GoogleAuthFilters.LOGIN_ORIGIN_KEY
}
redirect.addingToSession(UserIdentity.KEY -> toJson(userIdentity).toString)
}
}

Expand Down
Loading

0 comments on commit 2d6fb72

Please sign in to comment.