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

WIP: Pass redirect url through OAuth state #59

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
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