diff --git a/app/controllers/OpenidController.scala b/app/controllers/OpenidController.scala new file mode 100644 index 00000000..8b722925 --- /dev/null +++ b/app/controllers/OpenidController.scala @@ -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)) + } +} diff --git a/app/controllers/auth/JWTOpenIdAuthenticatedAction.scala b/app/controllers/auth/JWTOpenIdAuthenticatedAction.scala new file mode 100644 index 00000000..9624c3e6 --- /dev/null +++ b/app/controllers/auth/JWTOpenIdAuthenticatedAction.scala @@ -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() + } + } +} diff --git a/build.sbt b/build.sbt index 9645241a..adc79fbc 100644 --- a/build.sbt +++ b/build.sbt @@ -5,6 +5,7 @@ version := "3.12.1" scalaVersion := "2.12.11" + lazy val root = (project in file(".")) .enablePlugins(PlayScala) .enablePlugins(BuildInfoPlugin) @@ -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._ @@ -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", diff --git a/conf/application.conf b/conf/application.conf index 94d9fe5d..42ad8cc6 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -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} diff --git a/conf/routes b/conf/routes index 8949bd10..c43658b4 100644 --- a/conf/routes +++ b/conf/routes @@ -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