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 all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
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))
}
}
108 changes: 108 additions & 0 deletions app/controllers/auth/JWTOpenIdAuthenticatedAction.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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 JWKS_URL = new URL(getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.jwks.url", ""))
private val JWT_COOKIE = getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.cookie.name", "jwt")
private val JWT_AUTHORIZED_ROLES = getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.authorization.roles", "admin")

private val JWT_ROLES_JSON_PATH = getValueFromConfigWithFallback("smui.JWTOpenIdAuthenticatedAction.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 = {
logger.warn("ERIC HERE, claim content is: " + claim.content)
logger.warn("ERIC HERE, JWT_ROLES_JSON_PATH is " + JWT_ROLES_JSON_PATH)
//val rolesInToken = Try(JsonPath.read[JSONArray](claim.content, JWT_ROLES_JSON_PATH).toArray.toSeq)
// I could get a {"scope"="smui:searchandizer"} in my claim, but not a {"resource_access":{"smui":{"roles":["smui-user"]}}}
// So changing this code just to get it to work.
val rolesInToken = Try(JsonPath.read[String](claim.content, JWT_ROLES_JSON_PATH).split(" ").toArray.toSeq)
logger.warn("ERIC HERE " + rolesInToken)
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
12 changes: 12 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ smui.JWTJsonAuthenticatedAction.authorization.json.path=${?SMUI_JWT_ROLES_JSON_P
smui.JWTJsonAuthenticatedAction.authorization.roles="admin"
smui.JWTJsonAuthenticatedAction.authorization.roles=${?SMUI_JWT_AUTHORIZED_ROLE}

smui.JWTOpenIdAuthenticatedAction.login.url=""
smui.JWTOpenIdAuthenticatedAction.login.url=${?SMUI_JWT_LOGIN_URL}
smui.JWTOpenIdAuthenticatedAction.jwks.url=""
smui.JWTOpenIdAuthenticatedAction.jwks.url=${?SMUI_JWKS_URL}
smui.JWTOpenIdAuthenticatedAction.cookie.name="jwt"
smui.JWTOpenIdAuthenticatedAction.cookie.name=${?SMUI_JWT_COOKIE}
smui.JWTOpenIdAuthenticatedAction.authorization.roles="admin"
smui.JWTOpenIdAuthenticatedAction.authorization.roles=${?SMUIJWT_AUTHORIZED_ROLES}
smui.JWTOpenIdAuthenticatedAction.roles.json.path=""
smui.JWTOpenIdAuthenticatedAction.roles.json.path=${?SMUI_JWT_ROLES_JSON_PATH}


# For using no authentication, leave smui.authAction configured to scala.None
smui.authAction="scala.None"
smui.authAction=${?SMUI_AUTH_ACTION}
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