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: OpenID based authentication for SMUI #62

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 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
78 changes: 78 additions & 0 deletions app/controllers/OpenidController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package controllers

import javax.inject.Inject
import play.api.libs.json.Json
import play.api.mvc._
import play.api.http.HttpErrorHandler
import play.api.{Configuration, Logging}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}
import scalaj.http.{Http, HttpOptions}
import pdi.jwt.{Jwt, JwtOptions, JwtAlgorithm, JwtClaim, JwtJson}



class OpenidController @Inject()(override val controllerComponents: ControllerComponents, errorHandler: HttpErrorHandler, appConfig: Configuration)(implicit ec: ExecutionContext) extends AbstractController(controllerComponents) with Logging {

private val JWT_COOKIE = getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.cookie.name", "jwt")

private def redirectToHomePage(): Future[Result] = {
Future {
Results.Redirect("http://localhost:9000/")
}
}

private def getValueFromConfigWithFallback(key: String, default: String): String = {
appConfig.getOptional[String](key) match {
case Some(value: String) => value
case None =>
logger.warn(s":: No value for $key found. Setting pass to super-default.")
default
}
}

def callback() = Action { implicit request: Request[AnyContent] =>
logger.warn("Here is the authorization code: " + request.getQueryString("code"))


val code: Option[String] = request getQueryString "code"
val upper = code map { _.trim } filter { _.length != 0 }


logger.warn("We now have a Authorization Code, and now we need to convert it to a Access Token.")

val result = Http("http://keycloak:9080/auth/realms/smui/protocol/openid-connect/token").postForm
.param("grant_type", "authorization_code")
.param("client_id", "smui")
.param("redirect_uri","http://localhost:9000/auth/openid/callback")
.param("code", upper getOrElse "")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Charset", "UTF-8")
.option(HttpOptions.readTimeout(10000)).asString

logger.warn(s"Result is $result" )

val responseJson = Json.parse(result.body)

val accessToken : String = responseJson("access_token").as[String]

val decodedAccessToken = Jwt
.decodeRawAll(
accessToken,
JwtOptions(signature = false, expiration = false, notBefore = false)
)


logger.warn("Decoded access token: " + decodedAccessToken)

// This should come from the decodedAccessToken, not from the responseJson ;-(
val scope : String = responseJson("scope").as[String]


logger.warn("Scope is " + scope)



Results.Redirect("http://localhost:9000/health").withCookies(Cookie(JWT_COOKIE, accessToken))
}
}
103 changes: 103 additions & 0 deletions app/controllers/auth/JWTOpenIdAuthenticatedAction.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package controllers.auth

import com.auth0.jwk.UrlJwkProvider
import com.jayway.jsonpath.JsonPath
import net.minidev.json.JSONArray
import pdi.jwt._
import play.api.mvc._
import play.api.{Configuration, Logging}

import java.net.URL
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Success, Try}

class JWTOpenIdAuthenticatedAction(parser: BodyParsers.Default, appConfig: Configuration)(implicit ec: ExecutionContext)
extends ActionBuilderImpl(parser) with Logging {

logger.warn("In JWTOpenIdAuthenticatedAction")

private val JWT_LOGIN_URL = getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.login.url", "")
private val JWT_COOKIE = getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.cookie.name", "jwt")
private val JWT_AUTHORIZED_ROLES = getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.authorization.roles", "admin")

private val JWKS_URL = new URL("http://localhost:9080/auth/realms/smui/protocol/openid-connect/certs")
private val JWT_ROLES_JSON_PATH = "resource_access.smui.roles"

private lazy val authorizedRoles = JWT_AUTHORIZED_ROLES.replaceAll("\\s", "").split(",").toSeq

private def getValueFromConfigWithFallback(key: String, default: String): String = {
appConfig.getOptional[String](key) match {
case Some(value: String) => value
case None =>
logger.warn(s":: No value for $key found. Setting pass to super-default.")
default
}
}

def decodeRawAll(jwt: String): Try[(String, String, String)] = {
Jwt
.decodeRawAll(
jwt,
JwtOptions(signature = false, expiration = false, notBefore = false)
)
}

private def isAuthenticated(jwt: String): Option[JwtClaim] = {
logger.info(s"Authenticating using $jwt")

// get the pub key of the signing key to verify signature
val maybeJwk = for {
// decode without verifying as we only need the header
//(header, _, _) <- JwtJson.decodeRawAll(jwt, JwtOptions(signature = false)).toOption

// decode without any verification as the token is most likely already expired
(header, _, _) <- JwtJson.decodeRawAll(jwt, JwtOptions(false, false, false)).toOption

keyId <- JwtJson.parseHeader(header).keyId
jwk <- Try(new UrlJwkProvider(JWKS_URL).get(keyId)).toOption
} yield jwk

for {
jwk <- maybeJwk
// claims <- JwtJson.decode(jwt, jwk.getPublicKey, Seq(JwtAlgorithm.RS256)).toOption

// decode without any verification as the token is most likely already expired
claims <- JwtJson.decode(jwt, jwk.getPublicKey, Seq(JwtAlgorithm.RS256), JwtOptions(false, false, false)).toOption
} yield claims
}

private def isAuthorized(claim: JwtClaim): Boolean = {
val rolesInToken = Try(JsonPath.read[JSONArray](claim.content, JWT_ROLES_JSON_PATH).toArray.toSeq)

rolesInToken match {
case Success(roles) => roles.forall(authorizedRoles.contains)
case _ => false
}
}

private def redirectToLoginPage(): Future[Result] = {
Future {
Results.Redirect(JWT_LOGIN_URL)
}
}

private def getJwtCookie[A](request: Request[A]): Option[Cookie] = {
request.cookies.get(JWT_COOKIE)
}



override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = {

logger.warn(s":: invokeBlock :: request.path = ${request.path}")

getJwtCookie(request) match {
case Some(cookie) =>
isAuthenticated(cookie.value) match {
case Some(token) if isAuthorized(token) => block(request)
case _ => redirectToLoginPage()
}
case None => redirectToLoginPage()
}
}
}
5 changes: 4 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ version := "3.12.1"

scalaVersion := "2.12.11"


lazy val root = (project in file("."))
.enablePlugins(PlayScala)
.enablePlugins(BuildInfoPlugin)
Expand All @@ -19,7 +20,7 @@ lazy val root = (project in file("."))
)
.settings(dependencyCheckSettings: _*)

updateOptions := updateOptions.value.withCachedResolution(cachedResoluton = true)
updateOptions := updateOptions.value.withCachedResolution(true)

lazy val dependencyCheckSettings: Seq[Setting[_]] = {
import DependencyCheckPlugin.autoImport._
Expand Down Expand Up @@ -49,6 +50,8 @@ libraryDependencies ++= {
"org.playframework.anorm" %% "anorm" % "2.6.4",
"com.typesafe.play" %% "play-json" % "2.6.12",
"com.pauldijou" %% "jwt-play" % "4.1.0",
"com.auth0" % "jwks-rsa" % "0.17.0",
"org.scalaj" %% "scalaj-http" % "2.3.0",
"org.scalatestplus.play" %% "scalatestplus-play" % "3.1.0" % Test,
"org.mockito" % "mockito-all" % "1.10.19" % Test,
"com.pauldijou" %% "jwt-play" % "4.1.0",
Expand Down
2 changes: 2 additions & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
GET / controllers.FrontendController.index()
GET /health controllers.HealthController.health

GET /auth/openid/callback controllers.OpenidController.callback

# serve the API v1 Specification
# TODO search-input URL path partially "behind" solrIndexId path component and partially not
GET /api/v1/featureToggles controllers.ApiController.getFeatureToggles
Expand Down