diff --git a/app/controllers/ApiController.scala b/app/controllers/ApiController.scala index 9f93a6fe..52603259 100644 --- a/app/controllers/ApiController.scala +++ b/app/controllers/ApiController.scala @@ -14,7 +14,7 @@ import java.nio.file.Paths import java.time.format.DateTimeFormatter import java.time.LocalDateTime import scala.concurrent.{ExecutionContext, Future} -import controllers.auth.{AuthActionFactory, UserRequest} +import collection.JavaConverters._ import models.FeatureToggleModel.FeatureToggleService import models._ import models.config.SmuiVersion @@ -23,21 +23,23 @@ import models.input.{InputTagId, InputValidator, ListItem, SearchInputId, Search import models.querqy.QuerqyRulesTxtGenerator import models.rules.{DeleteRule, FilterRule, RedirectRule, SynonymRule, UpDownRule} import models.spellings.{CanonicalSpellingId, CanonicalSpellingValidator, CanonicalSpellingWithAlternatives} -import models.reports.{DeploymentLog} -import org.checkerframework.checker.units.qual.A +import org.pac4j.core.profile.{ProfileManager, UserProfile} +import org.pac4j.play.PlayWebContext +import org.pac4j.play.scala.{Security, SecurityComponents} +import play.api.libs.Files import services.{RulesTxtDeploymentService, RulesTxtImportService} // TODO Make ApiController pure REST- / JSON-Controller to ensure all implicit Framework responses (e.g. 400, 500) conformity -class ApiController @Inject()(authActionFactory: AuthActionFactory, +class ApiController @Inject()(val controllerComponents: SecurityComponents, featureToggleService: FeatureToggleService, searchManagementRepository: SearchManagementRepository, querqyRulesTxtGenerator: QuerqyRulesTxtGenerator, - cc: MessagesControllerComponents, rulesTxtDeploymentService: RulesTxtDeploymentService, rulesTxtImportService: RulesTxtImportService, - targetEnvironmentConfigService: TargetEnvironmentConfigService)(implicit executionContext: ExecutionContext) - extends MessagesAbstractController(cc) with Logging { + targetEnvironmentConfigService: TargetEnvironmentConfigService) + (implicit executionContext: ExecutionContext) + extends Security[UserProfile] with play.api.i18n.I18nSupport with Logging { val API_RESULT_OK = "OK" val API_RESULT_FAIL = "KO" @@ -46,15 +48,15 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, implicit val apiResultWrites = Json.writes[ApiResult] - def getFeatureToggles = authActionFactory.getAuthenticatedAction(Action) { + def getFeatureToggles: Action[AnyContent] = Action { Ok(Json.toJson(featureToggleService.getJsFrontendToggleList)) } - def listAllSolrIndeces = authActionFactory.getAuthenticatedAction(Action) { + def listAllSolrIndeces: Action[AnyContent] = Action { Ok(Json.toJson(searchManagementRepository.listAllSolrIndexes)) } - def addNewSolrIndex = authActionFactory.getAuthenticatedAction(Action) { request: Request[AnyContent] => + def addNewSolrIndex: Action[AnyContent] = Action { request: Request[AnyContent] => val body: AnyContent = request.body val jsonBody: Option[JsValue] = body.asJson @@ -72,13 +74,13 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def getSolrIndex(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { + def getSolrIndex(solrIndexId: String): Action[AnyContent] = Action.async { Future { Ok(Json.toJson(searchManagementRepository.getSolrIndex(SolrIndexId(solrIndexId)))) } } - def deleteSolrIndex(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { + def deleteSolrIndex(solrIndexId: String): Action[AnyContent] = Action.async { Future { // TODO handle exception, give API_RESULT_FAIL try { @@ -99,7 +101,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def downloadAllRulesTxtFiles = authActionFactory.getAuthenticatedAction(Action) { req => + def downloadAllRulesTxtFiles: Action[AnyContent] = Action { Ok.chunked( createStreamResultInBackground( rulesTxtDeploymentService.writeAllRulesTxtFilesAsZipFileToStream)).as("application/zip") @@ -113,22 +115,22 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } // TODO check, if method is still in use or got substituted by listAll()? - def listAllSearchInputs(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action) { + def listAllSearchInputs(solrIndexId: String): Action[AnyContent] = Action { // TODO add error handling (database connection, other exceptions) Ok(Json.toJson(searchManagementRepository.listAllSearchInputsInclDirectedSynonyms(SolrIndexId(solrIndexId)))) } - def listAllInputTags(): Action[AnyContent] = authActionFactory.getAuthenticatedAction(Action) { + def listAllInputTags(): Action[AnyContent] = Action { Ok(Json.toJson(searchManagementRepository.listAllInputTags())) } - def getDetailedSearchInput(searchInputId: String) = authActionFactory.getAuthenticatedAction(Action) { + def getDetailedSearchInput(searchInputId: String): Action[AnyContent] = Action { // TODO add error handling (database connection, other exceptions) Ok(Json.toJson(searchManagementRepository.getDetailedSearchInput(SearchInputId(searchInputId)))) } - def addNewSearchInput(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def addNewSearchInput(solrIndexId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { val userInfo: Option[String] = lookupUserInfo(request) @@ -159,7 +161,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, - def updateSearchInput(searchInputId: String) = authActionFactory.getAuthenticatedAction(Action) { request: Request[AnyContent] => + def updateSearchInput(searchInputId: String): Action[AnyContent] = Action { request: Request[AnyContent] => val body: AnyContent = request.body val jsonBody: Option[JsValue] = body.asJson val userInfo: Option[String] = lookupUserInfo(request) @@ -195,7 +197,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def deleteSearchInput(searchInputId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def deleteSearchInput(searchInputId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { val userInfo: Option[String] = lookupUserInfo(request) searchManagementRepository.deleteSearchInput(searchInputId, userInfo) @@ -203,7 +205,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def copySearchInput(searchInputId: String, solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def copySearchInput(searchInputId: String, solrIndexId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { val userInfo: Option[String] = lookupUserInfo(request) @@ -243,13 +245,13 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def listAll(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action) { + def listAll(solrIndexId: String) : Action[AnyContent] = Action { val searchInputs = searchManagementRepository.listAllSearchInputsInclDirectedSynonyms(SolrIndexId(solrIndexId)) val spellings = searchManagementRepository.listAllSpellingsWithAlternatives(SolrIndexId(solrIndexId)) Ok(Json.toJson(ListItem.create(searchInputs, spellings))) } - def addNewSpelling(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def addNewSpelling(solrIndexId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { val userInfo: Option[String] = lookupUserInfo(request) val body: AnyContent = request.body @@ -272,14 +274,14 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def getDetailedSpelling(canonicalSpellingId: String) = authActionFactory.getAuthenticatedAction(Action).async { + def getDetailedSpelling(canonicalSpellingId: String): Action[AnyContent] = Action.async { Future { val spellingWithAlternatives = searchManagementRepository.getDetailedSpelling(canonicalSpellingId) Ok(Json.toJson(spellingWithAlternatives)) } } - def updateSpelling(solrIndexId: String, canonicalSpellingId: String) = authActionFactory.getAuthenticatedAction(Action) { request: Request[AnyContent] => + def updateSpelling(solrIndexId: String, canonicalSpellingId: String): Action[AnyContent] = Action { request: Request[AnyContent] => val userInfo: Option[String] = lookupUserInfo(request) val body: AnyContent = request.body val jsonBody: Option[JsValue] = body.asJson @@ -302,7 +304,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, BadRequest(Json.toJson(ApiResult(API_RESULT_FAIL, "Updating Canonical Spelling failed. Unexpected body data.", None))) } } - def deleteSpelling(canonicalSpellingId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def deleteSpelling(canonicalSpellingId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { val userInfo: Option[String] = lookupUserInfo(request) searchManagementRepository.deleteSpelling(canonicalSpellingId, userInfo) @@ -318,7 +320,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, * @param targetSystem "PRELIVE" vs. "LIVE" ... for reference @see evolutions/default/1.sql * @return Ok or BadRequest, if something failed. */ - def updateRulesTxtForSolrIndexAndTargetPlatform(solrIndexId: String, targetSystem: String): Action[AnyContent] = authActionFactory.getAuthenticatedAction(Action) { + def updateRulesTxtForSolrIndexAndTargetPlatform(solrIndexId: String, targetSystem: String): Action[AnyContent] = Action { logger.debug("In ApiController :: updateRulesTxtForSolrIndexAndTargetPlatform") // generate rules.txt(s) @@ -357,14 +359,14 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def listAllSuggestedSolrFields(solrIndexId: String): Action[AnyContent] = authActionFactory.getAuthenticatedAction(Action).async { + def listAllSuggestedSolrFields(solrIndexId: String): Action[AnyContent] = Action.async { Future { // TODO add error handling (database connection, other exceptions) Ok(Json.toJson(searchManagementRepository.listAllSuggestedSolrFields(solrIndexId))) } } - def addNewSuggestedSolrField(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def addNewSuggestedSolrField(solrIndexId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { val body: AnyContent = request.body val jsonBody: Option[JsValue] = body.asJson @@ -385,7 +387,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, // I am requiring the solrIndexId because it is more RESTful, but it turns out we don't need it. // Maybe validation some day? - def deleteSuggestedSolrField(solrIndexId: String, suggestedFieldId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def deleteSuggestedSolrField(solrIndexId: String, suggestedFieldId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { searchManagementRepository.deleteSuggestedSolrField(SuggestedSolrFieldId(suggestedFieldId)) Ok(Json.toJson(ApiResult(API_RESULT_OK, "Deleting Suggested Field successful", None))) @@ -393,7 +395,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } // TODO consider making method .asynch - def importFromRulesTxt(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action)(parse.multipartFormData) { request => + def importFromRulesTxt(solrIndexId: String): Action[MultipartFormData[Files.TemporaryFile]] = Action(parse.multipartFormData) { request => request.body .file("rules_txt") .map { rules_txt => @@ -431,13 +433,6 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, Ok(Json.toJson(ApiResult(API_RESULT_FAIL, "File rules_txt missing in request body.", None))) } } - private def lookupUserInfo(request: Request[AnyContent]) = { - val userInfo: Option[String] = request match { - case _: UserRequest[_] => Option(request.asInstanceOf[UserRequest[_]].username) - case _ => None - } - userInfo - } /** * Deployment info (raw or formatted) @@ -448,7 +443,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, implicit val logDeploymentInfoWrites = Json.writes[DeploymentInfo] @deprecated("The old style of retrieving a deployment log summary as plain text will be removed", "SMUI version > 3.15.1") - def getLatestDeploymentResultV1(solrIndexId: String, targetSystem: String): Action[AnyContent] = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def getLatestDeploymentResultV1(solrIndexId: String, targetSystem: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { logger.debug("In ApiController :: getLatestDeploymentResultV1") logger.debug(s"... solrIndexId = $solrIndexId") @@ -495,7 +490,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, implicit val logDeploymentDetailedInfoWrites = Json.writes[DeploymentDetailedInfo] - def getLatestDeploymentResult(solrIndexId: String): Action[AnyContent] = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => + def getLatestDeploymentResult(solrIndexId: String): Action[AnyContent] = Action.async { request: Request[AnyContent] => Future { logger.debug("In ApiController :: getLatestDeploymentResult") logger.debug(s"... solrIndexId = $solrIndexId") @@ -549,7 +544,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, implicit val smuiVersionInfoWrites = Json.writes[SmuiVersionInfo] // TODO consider outsourcing this "business logic" into the (config) model - def getLatestVersionInfo() = authActionFactory.getAuthenticatedAction(Action).async { + def getLatestVersionInfo() = Action.async { Future { // get latest version from dockerhub val latestFromDockerHub = SmuiVersion.latestVersionFromDockerHub() @@ -582,7 +577,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, // note: logical HTML structure within modal dialog begins with
"
Info
" + // TODO get maintainer from build.sbt - "
Your locally installed SMUI instance is outdated. Please consider an update. If you have issues, contact the maintainer (paulbartusch@gmx.de) or file an issue to the project: https://github.com/querqy/smui/issues
" + "
Your locally installed SMUI instance is outdated. Please consider an update. If you have issues, contact the maintainer (hello@productful.io) or file an issue to the project: https://github.com/querqy/smui/issues
" // TODO parse querqy.org/docs/smui/release-notes/ and teaser new features (optional) - might look like: // "
" + // "
What's new
" @@ -608,7 +603,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } - def getTargetEnvironment() = authActionFactory.getAuthenticatedAction(Action).async { + def getTargetEnvironment() = Action.async { Future { val targetEnvEnf = targetEnvironmentConfigService.read @@ -627,7 +622,7 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, * Activity log */ - def getActivityLog(inputId: String) = authActionFactory.getAuthenticatedAction(Action).async { + def getActivityLog(inputId: String) = Action.async { Future { val activityLog = searchManagementRepository.getInputRuleActivityLog(inputId) Ok(Json.toJson(activityLog)) @@ -638,14 +633,14 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, * Reports (for Activity log as well) */ - def getRulesReport(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { + def getRulesReport(solrIndexId: String) = Action.async { Future { val report = searchManagementRepository.getRulesReport(SolrIndexId(solrIndexId)) Ok(Json.toJson(report)) } } - def getActivityReport(solrIndexId: String) = authActionFactory.getAuthenticatedAction(Action).async { request: Request[AnyContent] => { + def getActivityReport(solrIndexId: String) = Action.async { request: Request[AnyContent] => { Future { val rawDateFrom: Option[String] = request.getQueryString("dateFrom") val rawDateTo: Option[String] = request.getQueryString("dateTo") @@ -672,4 +667,16 @@ class ApiController @Inject()(authActionFactory: AuthActionFactory, } } + private def lookupUserInfo(request: Request[AnyContent]) = { + val maybeUserId = getProfiles(request).headOption.map(_.getId) + logger.debug(s"Current user: $maybeUserId") + maybeUserId + } + + private def getProfiles(request: RequestHeader): List[UserProfile] = { + val webContext = new PlayWebContext(request) + val profileManager = new ProfileManager(webContext, controllerComponents.sessionStore) + profileManager.getProfiles.asScala.toList + } + } diff --git a/app/controllers/FrontendController.scala b/app/controllers/FrontendController.scala index 3c190651..aff8f7f8 100644 --- a/app/controllers/FrontendController.scala +++ b/app/controllers/FrontendController.scala @@ -1,29 +1,25 @@ package controllers -import javax.inject.Inject -import controllers.auth.AuthActionFactory -import play.api.{Configuration, Logging} -import play.api.mvc._ - -import scala.concurrent.{ExecutionContext, Future} -import models.FeatureToggleModel._ -import javax.inject._ -import play.api.Configuration +import org.pac4j.core.profile.UserProfile +import org.pac4j.play.scala.{Security, SecurityComponents} +import play.api.Logging import play.api.http.HttpErrorHandler -import play.api.libs.json.Json import play.api.mvc._ -class FrontendController @Inject()(cc: MessagesControllerComponents, +import javax.inject.Inject +import scala.concurrent.ExecutionContext + +class FrontendController @Inject()(val controllerComponents: SecurityComponents, assets: Assets, - errorHandler: HttpErrorHandler, - authActionFactory: AuthActionFactory)(implicit executionContext: ExecutionContext) - extends MessagesAbstractController(cc) with Logging { + errorHandler: HttpErrorHandler) + (implicit executionContext: ExecutionContext) + extends Security [UserProfile] with play.api.i18n.I18nSupport with Logging { - def index(): Action[AnyContent] = authActionFactory.getAuthenticatedAction(Action).async { request => + def index(): Action[AnyContent] = Action.async { request => assets.at("index.html")(request) } - def assetOrDefault(resource: String): Action[AnyContent] = authActionFactory.getAuthenticatedAction(Action).async { request => + def assetOrDefault(resource: String): Action[AnyContent] = Action.async { request => if (resource.startsWith("api")) { errorHandler.onClientError(request, NOT_FOUND, "Not found") } else { diff --git a/app/controllers/auth/AuthActionFactory.scala b/app/controllers/auth/AuthActionFactory.scala deleted file mode 100644 index 4e6a26fb..00000000 --- a/app/controllers/auth/AuthActionFactory.scala +++ /dev/null @@ -1,49 +0,0 @@ -package controllers.auth - -import javax.inject.Inject -import play.api.{Configuration, Logging} -import play.api.mvc._ - -import scala.concurrent.ExecutionContext - -class AuthActionFactory @Inject()(parser: BodyParsers.Default, appConfig: Configuration)(implicit ec: ExecutionContext) extends Logging { - - private def instantiateAuthAction(strClazz: String, defaultAction: ActionBuilder[Request, AnyContent]): ActionBuilder[Request, AnyContent] = { - try { - - // TODO if possible instanciate authenticatedAction only once, not with every controller call - - def instantiate(clazz: java.lang.Class[_])(args: AnyRef*): AnyRef = { - val constructor = clazz.getConstructors()(0) - constructor.newInstance(args: _*).asInstanceOf[AnyRef] - } - - val authenticatedAction = instantiate( - java.lang.Class.forName(strClazz) - )(parser, appConfig, ec) - - logger.debug(":: having instanciated " + authenticatedAction.toString) - - authenticatedAction.asInstanceOf[ActionBuilder[Request, AnyContent]] - - } catch { - case e: Throwable => - // TODO consider stop serving requests, if an expection during bootstrap of authAction happened. DO NOT return the defaultAction. - - logger.error(":: Exception during instantiation of smui.authAction :: " + e.getMessage) - logger.error(":: Authentication protection IS NOT ACTIVE!") - defaultAction - } - } - - def getAuthenticatedAction(defaultAction: ActionBuilder[Request, AnyContent]): ActionBuilder[Request, AnyContent] = { - appConfig.getOptional[String]("smui.authAction") match { - case Some(strClazz: String) => - if (strClazz.trim().equals("scala.None")) defaultAction - else instantiateAuthAction(strClazz, defaultAction) - case None => - defaultAction - } - } - -} diff --git a/app/controllers/auth/BasicAuthAuthenticatedAction.scala b/app/controllers/auth/BasicAuthAuthenticatedAction.scala deleted file mode 100644 index 8c9494cd..00000000 --- a/app/controllers/auth/BasicAuthAuthenticatedAction.scala +++ /dev/null @@ -1,72 +0,0 @@ -package controllers.auth - -import java.util.Base64 - -import play.api.{Configuration, Logging} -import play.api.mvc._ - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.control.Exception.allCatch - -// Wrap a standard request with the extracted username of the person making the request -case class UserRequest[A](username: String, request: Request[A]) extends WrappedRequest[A](request) - -@deprecated("As of v3.14. See https://github.com/querqy/smui/pull/83#issuecomment-1023284550", "27-01-2022") -class BasicAuthAuthenticatedAction(parser: BodyParsers.Default, appConfig: Configuration)(implicit ec: ExecutionContext) - extends ActionBuilderImpl(parser) with Logging { - - logger.debug("In BasicAuthAuthenticatedAction") - - val BASIC_AUTH_USER = appConfig.getOptional[String]("smui.BasicAuthAuthenticatedAction.user") match { - case Some(strUser: String) => - strUser - case None => - logger.error(":: No value for smui.BasicAuthAuthenticatedAction.user found. Setting user to super-default.") - "smui" - } - - val BASIC_AUTH_PASS = appConfig.getOptional[String]("smui.BasicAuthAuthenticatedAction.pass") match { - case Some(strUser: String) => - strUser - case None => - logger.error(":: No value for smui.BasicAuthAuthenticatedAction.pass found. Setting pass to super-default.") - "smui" - } - - /** - * Helper method to verify, that the request is basic authenticated with configured user/pass. - * Code is adopted from: https://dzone.com/articles/play-basic-authentication - * - * @param request - * @return {{true}}, for user is authenticated. - */ - override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = { - logger.debug(s":: invokeBlock :: request.path = ${request.path}") - - var extractedUsername = "" // Pulled out of the Basic Auth logic - def requestAuthenticated(request: Request[A]): Boolean = { - request.headers.get("Authorization") match { - case Some(authorization: String) => - authorization.split(" ").drop(1).headOption.exists { encoded => - val authInfo = new String(Base64.getDecoder().decode(encoded.getBytes)).split(":").toList - allCatch.opt { - val (username, password) = (authInfo.head, authInfo(1)) - extractedUsername = username - username.equals(BASIC_AUTH_USER) && password.equals(BASIC_AUTH_PASS) - - } getOrElse false - } - case None => false - } - } - - if (requestAuthenticated(request)) { - block(UserRequest(extractedUsername,request)) - } else { - Future { - // TODO return error JSON with authorization violation details, redirect target eventually (instead of empty 401 body) - Results.Unauthorized("401 Unauthorized").withHeaders(("WWW-Authenticate", "Basic realm=SMUI")) - } - } - } -} diff --git a/app/controllers/auth/JWTJsonAuthenticatedAction.scala b/app/controllers/auth/JWTJsonAuthenticatedAction.scala deleted file mode 100644 index b3d8744d..00000000 --- a/app/controllers/auth/JWTJsonAuthenticatedAction.scala +++ /dev/null @@ -1,96 +0,0 @@ -package controllers.auth - -import com.google.inject.Inject -import com.jayway.jsonpath.JsonPath -import net.minidev.json.JSONArray -import pdi.jwt.{JwtAlgorithm, JwtClaim, JwtJson} -import play.api.mvc._ -import play.api.{Configuration, Logging} - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} - -class JWTJsonAuthenticatedAction @Inject()(parser: BodyParsers.Default, appConfig: Configuration)(implicit ec: ExecutionContext) - extends ActionBuilderImpl(parser) with Logging { - - logger.debug("In JWTJsonAuthenticatedAction") - - private val JWT_LOGIN_URL = getValueFromConfigWithFallback("smui.JWTJsonAuthenticatedAction.login.url", "") - private val JWT_COOKIE = getValueFromConfigWithFallback("smui.JWTJsonAuthenticatedAction.cookie.name", "jwt") - private val JWT_PUBLIC_KEY = getValueFromConfigWithFallback("smui.JWTJsonAuthenticatedAction.public.key", "") - private val JWT_ALGORITHM = getValueFromConfigWithFallback("smui.JWTJsonAuthenticatedAction.algorithm", "rsa") - - private val JWT_AUTHORIZATION_ACTIVE = getValueFromConfigWithFallback("smui.JWTJsonAuthenticatedAction.authorization.active", "false").toBoolean - private val JWT_ROLES_JSON_PATH = getValueFromConfigWithFallback("smui.JWTJsonAuthenticatedAction.authorization.json.path", "$.roles") - private val JWT_AUTHORIZED_ROLES = getValueFromConfigWithFallback("smui.JWTJsonAuthenticatedAction.authorization.roles", "admin") - - private val authorizedRoles = JWT_AUTHORIZED_ROLES.split(",").map(_.trim()) - - private def getValueFromConfigWithFallback(key: String, default: String): String = { - appConfig.getOptional[String](key) match { - case Some(value: String) => value - case None => - logger.error(s":: No value for $key found. Setting pass to super-default.") - default - } - } - - private def decodeJwtToken(jwt: String): Try[JwtClaim] = { - JWT_ALGORITHM match { - case "hmac" => JwtJson.decode(jwt, JWT_PUBLIC_KEY, JwtAlgorithm.allHmac()) - case "asymmetric" => JwtJson.decode(jwt, JWT_PUBLIC_KEY, JwtAlgorithm.allAsymmetric()) - case "rsa" => JwtJson.decode(jwt, JWT_PUBLIC_KEY, JwtAlgorithm.allRSA()) - case "ecdsa" => JwtJson.decode(jwt, JWT_PUBLIC_KEY, JwtAlgorithm.allECDSA()) - case _ => JwtJson.decode(jwt, JWT_PUBLIC_KEY, JwtAlgorithm.allRSA()) - } - } - - private def getJwtCookie[A](request: Request[A]): Option[Cookie] = { - request.cookies.get(JWT_COOKIE) - } - - private def isAuthenticated(jwt: String): Option[JwtClaim] = { - decodeJwtToken(jwt) match { - case Success(token) => Some(token) - case Failure(_) => None - } - } - - private def isAuthorized(token: String): Boolean = { - if (JWT_AUTHORIZATION_ACTIVE) { - val rolesReadFromToken = Try(JsonPath.read[JSONArray](token, JWT_ROLES_JSON_PATH).toArray.toSeq) - - rolesReadFromToken match { - case Success(rolesInToken) => rolesInToken.exists(authorizedRoles.contains) - case _ => false - } - } else true - } - - private def getUserRequestIfAvailable[A](token: JwtClaim, request: Request[A]): Request[A] = { - token.subject match { - case Some(subject) => UserRequest(subject, request) - case None => request - } - } - - private def redirectToLoginPage(): Future[Result] = { - Future { - Results.Redirect(JWT_LOGIN_URL) - } - } - - override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = { - - logger.debug(s":: invokeBlock :: request.path = ${request.path}") - - getJwtCookie(request) match { - case Some(cookie) => - isAuthenticated(cookie.value) match { - case Some(token) if isAuthorized(token.content) => block(getUserRequestIfAvailable(token, request)) - case _ => redirectToLoginPage() - } - case None => redirectToLoginPage() - } - } -} diff --git a/app/filters/Filters.scala b/app/filters/Filters.scala new file mode 100644 index 00000000..52115add --- /dev/null +++ b/app/filters/Filters.scala @@ -0,0 +1,23 @@ +package filters + +import modules.SecurityModule +import org.pac4j.play.filters.SecurityFilter +import play.api.Configuration +import play.api.http.HttpFilters +import play.api.mvc.EssentialFilter + +import javax.inject.Inject + +class Filters @Inject()(configuration: Configuration, securityFilter: SecurityFilter) extends HttpFilters { + + override def filters: Seq[EssentialFilter] = { + // The securityFilter applies the pac4j security rules. It is only used if a non-empty authentication + // client has been configured. + if (configuration.getOptional[String](SecurityModule.ConfigKeyAuthClient).exists(_.nonEmpty)) { + Seq(securityFilter) + } else { + Seq.empty + } + } + +} diff --git a/app/models/FeatureToggleModel.scala b/app/models/FeatureToggleModel.scala index 67620d0a..0780281f 100644 --- a/app/models/FeatureToggleModel.scala +++ b/app/models/FeatureToggleModel.scala @@ -56,7 +56,6 @@ package object FeatureToggleModel extends Logging { private val FEATURE_TOGGLE_HEADLINE = "toggle.headline" private val ACTIVATE_RULE_TAGGING = "toggle.rule-tagging" private val PREDEFINED_TAGS_FILE = "toggle.predefined-tags-file" - private val SMUI_AUTH_SIMPLE_LOGOUT = "smui.auth.ui-concept.simple-logout-button-target-url" private val SMUI_VERSION = "smui.version" private val FEATURE_TOGGLE_ACTIVATE_SPELLING = "toggle.activate-spelling" private val SMUI_DEFAULT_DISPLAY_USERNAME = "toggle.display-username.default" @@ -137,8 +136,6 @@ package object FeatureToggleModel extends Logging { jsBoolFeatureToggle(ACTIVATE_RULE_TAGGING, false), JsFeatureToggle(FEATURE_TOGGLE_HEADLINE, new JsStringFeatureToggleValue( appConfig.getOptional[String](FEATURE_TOGGLE_HEADLINE).getOrElse("Search Management UI"))), - JsFeatureToggle(SMUI_AUTH_SIMPLE_LOGOUT, new JsStringFeatureToggleValue( - appConfig.getOptional[String](SMUI_AUTH_SIMPLE_LOGOUT).getOrElse(""))), JsFeatureToggle(SMUI_VERSION, new JsStringFeatureToggleValue(models.buildInfo.BuildInfo.version)), JsFeatureToggle(FEATURE_TOGGLE_UI_LIST_LIMIT_ITEMS_TO, new JsStringFeatureToggleValue( appConfig.getOptional[String](FEATURE_TOGGLE_UI_LIST_LIMIT_ITEMS_TO).getOrElse("-1"))), diff --git a/app/modules/SecurityModule.scala b/app/modules/SecurityModule.scala new file mode 100644 index 00000000..3585c3af --- /dev/null +++ b/app/modules/SecurityModule.scala @@ -0,0 +1,73 @@ +package modules + +import com.google.inject.{AbstractModule, Provides} +import org.pac4j.core.client.Clients +import org.pac4j.core.client.direct.AnonymousClient +import org.pac4j.core.config.Config +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.profile.CommonProfile +import org.pac4j.play.scala.{DefaultSecurityComponents, Pac4jScalaTemplateHelper, SecurityComponents} +import org.pac4j.play.store.{PlayCookieSessionStore, ShiroAesDataEncrypter} +import org.pac4j.play.{CallbackController, LogoutController} +import org.pac4j.saml.client.SAML2Client +import org.pac4j.saml.config.SAML2Configuration +import play.api.{Configuration, Environment} + +import java.nio.charset.StandardCharsets + +class SecurityModule(environment: Environment, configuration: Configuration) extends AbstractModule { + + import SecurityModule._ + + private val baseUrl = configuration.get[String]("smui.auth.baseUrl") + + override def configure(): Unit = { + val sKey = configuration.get[String]("play.http.secret.key").substring(0, 16) + val dataEncrypter = new ShiroAesDataEncrypter(sKey.getBytes(StandardCharsets.UTF_8)) + val playSessionStore = new PlayCookieSessionStore(dataEncrypter) + bind(classOf[SessionStore]).toInstance(playSessionStore) + bind(classOf[SecurityComponents]).to(classOf[DefaultSecurityComponents]) + bind(classOf[Pac4jScalaTemplateHelper[CommonProfile]]) + + // callback + val callbackController = new CallbackController() + callbackController.setDefaultUrl("/") + bind(classOf[CallbackController]).toInstance(callbackController) + + // logout + val logoutController = new LogoutController() + logoutController.setDefaultUrl("/") + bind(classOf[LogoutController]).toInstance(logoutController) + } + + @Provides + def provideConfig(): Config = { + val maybeConfiguredClientName = configuration.getOptional[String](ConfigKeyAuthClient).filter(_.nonEmpty) + val authClientOpt = maybeConfiguredClientName.map { + case "SAML2Client" => createSaml2Client(s"$ConfigKeyPrefixClientConfig.SAML2Client") + case other => throw new RuntimeException(s"Unsupported auth client config value: $other") + } + val allClients = authClientOpt.toSeq :+ new AnonymousClient() + // callback URL path as configured in `routes` + val clients = new Clients(s"$baseUrl/callback", allClients:_*) + new Config(clients) + } + private def createSaml2Client(keyPrefix: String): SAML2Client = { + val cfg = new SAML2Configuration( + configuration.get[String](s"$keyPrefix.keystore"), + configuration.get[String](s"$keyPrefix.keystorePassword"), + configuration.get[String](s"$keyPrefix.privateKeyPassword"), + configuration.get[String](s"$keyPrefix.identityProviderMetadataPath") + ) + cfg.setServiceProviderEntityId(configuration.get[String](s"$keyPrefix.serviceProviderEntityId")) + cfg.setServiceProviderMetadataPath(configuration.get[String](s"$keyPrefix.serviceProviderMetadataPath")) + cfg.setMaximumAuthenticationLifetime(configuration.get[Long](s"$keyPrefix.maximumAuthenticationLifetime")) + new SAML2Client(cfg) + } + +} + +object SecurityModule { + val ConfigKeyAuthClient = "smui.auth.client" + val ConfigKeyPrefixClientConfig = "smui.auth.clients" +} diff --git a/build.sbt b/build.sbt index d357d2ce..1594b7ee 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,7 @@ import com.typesafe.sbt.GitBranchPrompt name := "search-management-ui" -version := "3.17.2" +version := "4.0.0" scalaVersion := "2.12.17" @@ -34,9 +34,12 @@ lazy val dependencyCheckSettings: Seq[Setting[_]] = { resolvers ++= Seq( Resolver.jcenterRepo, - Resolver.bintrayRepo("renekrie", "maven") + Resolver.bintrayRepo("renekrie", "maven"), + "Shibboleth releases" at "https://build.shibboleth.net/nexus/content/repositories/releases/" ) +lazy val jacksonVersion = "2.15.2" + libraryDependencies ++= { Seq( guice, @@ -44,6 +47,7 @@ libraryDependencies ++= { evolutions, "com.jayway.jsonpath" % "json-path" % "2.7.0", "org.querqy" % "querqy-core" % "3.7.0", // querqy dependency + "ch.qos.logback" % "logback-classic" % "1.4.8", "net.logstash.logback" % "logstash-logback-encoder" % "5.3", // JSON logging: "org.codehaus.janino" % "janino" % "3.0.8", // For using conditions in logback.xml: "mysql" % "mysql-connector-java" % "8.0.18", // TODO verify use of mysql-connector over explicit mariaDB connector instead @@ -52,6 +56,11 @@ libraryDependencies ++= { "org.playframework.anorm" %% "anorm" % "2.7.0", "com.typesafe.play" %% "play-json" % "2.9.3", "com.pauldijou" %% "jwt-play" % "4.1.0", + "com.fasterxml.jackson.module" %% "jackson-module-scala" % jacksonVersion, + "org.apache.shiro" % "shiro-core" % "1.12.0", + "org.pac4j" % "pac4j-http" % "5.7.1" excludeAll (ExclusionRule(organization = "com.fasterxml.jackson.core")), + "org.pac4j" % "pac4j-saml" % "5.7.1" excludeAll (ExclusionRule(organization = "com.fasterxml.jackson.core")), + "org.pac4j" %% "play-pac4j" % "11.1.0-PLAY2.8", "org.scalatestplus.play" %% "scalatestplus-play" % "3.1.0" % Test, "org.mockito" % "mockito-all" % "1.10.19" % Test, "com.pauldijou" %% "jwt-play" % "4.1.0", @@ -64,7 +73,6 @@ libraryDependencies ++= { } dependencyOverrides ++= { - lazy val jacksonVersion = "2.14.1" Seq( "com.fasterxml.jackson.core" % "jackson-annotations" % jacksonVersion, "com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion, diff --git a/conf/application.conf b/conf/application.conf index 2d9acf1d..2d99c368 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -106,43 +106,6 @@ toggle.ui-concept.custom.up-down-dropdown-mappings=${?SMUI_CUSTOM_UPDOWN_MAPPING play.http.secret.key="generated application secret" play.http.secret.key=${?SMUI_PLAY_APPLICATION_SECRET} -# Authentication (technical application behaviour) -# ~~~~~ - -# For Basic Auth authentication, use SMUI's BasicAuthAuthenticatedAction (like in the example below): -# smui.authAction="controllers.auth.BasicAuthAuthenticatedAction" -# WARNING: BasicAuth is deprecated as of v3.14. See https://github.com/querqy/smui/pull/83#issuecomment-1023284550 (27-01-2022). -smui.BasicAuthAuthenticatedAction.user="smui_user" -smui.BasicAuthAuthenticatedAction.user=${?SMUI_BASIC_AUTH_USER} -smui.BasicAuthAuthenticatedAction.pass="smui_pass" -smui.BasicAuthAuthenticatedAction.pass=${?SMUI_BASIC_AUTH_PASS} - -# For JWT authentication, use SMUI's JWTJsonAuthenticatedAction (like in the example below): -# smui.authAction="controllers.auth.JWTJsonAuthenticatedAction" -smui.JWTJsonAuthenticatedAction.login.url="" -smui.JWTJsonAuthenticatedAction.login.url=${?SMUI_JWT_LOGIN_URL} -smui.JWTJsonAuthenticatedAction.cookie.name="jwt" -smui.JWTJsonAuthenticatedAction.cookie.name=${?SMUI_JWT_AUTH_COOKIE_NAME} -smui.JWTJsonAuthenticatedAction.public.key="" -smui.JWTJsonAuthenticatedAction.public.key=${?SMUI_JWT_PUBLIC_KEY} -smui.JWTJsonAuthenticatedAction.algorithm="rsa" -smui.JWTJsonAuthenticatedAction.algorithm=${?SMUI_JWT_ALGORITHMS} - -smui.JWTJsonAuthenticatedAction.authorization.active="false" -smui.JWTJsonAuthenticatedAction.authorization.active=${?SMUI_JWT_ACTIVE} -smui.JWTJsonAuthenticatedAction.authorization.json.path="$.roles" -smui.JWTJsonAuthenticatedAction.authorization.json.path=${?SMUI_JWT_ROLES_JSON_PATH} -smui.JWTJsonAuthenticatedAction.authorization.roles="admin" -smui.JWTJsonAuthenticatedAction.authorization.roles=${?SMUI_JWT_AUTHORIZED_ROLE} - -# For using no authentication, leave smui.authAction configured to scala.None -smui.authAction="scala.None" -smui.authAction=${?SMUI_AUTH_ACTION} - -smui.auth.ui-concept.simple-logout-button-target-url="" -smui.auth.ui-concept.simple-logout-button-target-url=${?SMUI_AUTH_LOGOUT_BTN_URL} - -# Remember: You can implement your own authentication behaviour (e.g. based on LDAP / JWTs) by supplying your own AuthenticatedAction (see README.md) # WARNING: A simple display-user is deprecated as of v3.14. See https://github.com/querqy/smui/pull/83#issuecomment-1023284550 (27-01-2022). toggle.display-username.default="Anonymous Search Manager" @@ -198,3 +161,72 @@ database.dispatcher { fixed-pool-size = ${fixedConnectionPool} } } + + +# Enable security module/filter +play.modules.enabled += "modules.SecurityModule" +play.http.filters = "filters.Filters" + +# Restrict Play session cookie's maxAge to 1 day +play.http.session.maxAge = 1 day +play.http.session.maxAge = ${?SMUI_SESSION_MAXAGE} + +# This defines the pac4j client to use for authentication. Uses the well-defined pac4j names (OidcClient, +# DirectBasicAuthClient...), currently supported: +# - SAML2Client +# If no or empty value is set, no authentication is used. +smui.auth.client = "" +smui.auth.client = ${?SMUI_AUTH_CLIENT} + +# The absolute URL SMUI is reachable on for callbacks +# (e.g. POSTs from external identity providers, such as in SAML authentication) +smui.auth.baseUrl = "https://localhost:2080" +smui.auth.baseUrl = ${?SMUI_AUTH_BASEURL} + +# The SAML-specific configuration which is used *only* when the SAML2Client is configured as smui.auth.client +smui.auth.clients.SAML2Client { + # the keystore file to encrypt communication with the IdP with, if the resource configured here does not exist + # a new keystore will be created + keystore = "resource:samlKeystore.jks" + keystore = ${?SMUI_SAML_KEYSTORE} + + # password of the keystore file + keystorePassword = "realPassword" + keystorePassword = ${?SMUI_SAML_KEYSTORE_PASSWORD} + + # password of the private key within the keystore + privateKeyPassword = "realPassword" + privateKeyPassword = ${?SMUI_SAML_PRIVATE_KEY_PASSWORD} + + # the path to metadata of the identity provider + identityProviderMetadataPath = "resource:azure-ad-saml.xml" + identityProviderMetadataPath = ${?SMUI_SAML_IDENTITY_PROVIDER_METADATA_PATH} + + # the ID configured at the identity provider for this service + serviceProviderEntityId = "urn:mace:saml:search-management-ui" + serviceProviderEntityId = ${?SMUI_SAML_SERVICE_PROVIDER_ENTITY_ID} + + # the path where we store the generated service-provider metadata XML temporarily + serviceProviderMetadataPath = "/tmp/sp-metadata.xml" + serviceProviderMetadataPath = ${?SMUI_SAML_SERVICE_PROVIDER_METADATA_PATH} + + # This defines the maximum age in seconds of an authentication at the IdP that is still accepted by SMUI. + # We accept any successful IdP authentication. If you set this to some positive value an old authentication + # at the IdP is rejected and will result in a (currently unhandled) "Authentication issue instant is too old + # or in the future" exception + maximumAuthenticationLifetime = -1 + maximumAuthenticationLifetime = ${?SMUI_SAML_MAXIMUM_AUTHENTICATION_LIFETIME} +} + + +# pac4j rules for configuring the pac4j SecurityFilter +pac4j.security.rules = [ + {"/" = { + authorizers = "isAuthenticated" + clients = ${?smui.auth.client} + }} + # Rules for the REST services. These don't specify a client and will return 401 when not authenticated. + {"/api/.*" = { + authorizers = "isAuthenticated" + }} +] \ No newline at end of file diff --git a/conf/routes b/conf/routes index d82c9aa8..19fc2ded 100644 --- a/conf/routes +++ b/conf/routes @@ -6,6 +6,11 @@ GET / controllers.FrontendController.index() GET /health controllers.HealthController.health +# pac4j +GET /callback @org.pac4j.play.CallbackController.callback(request: Request) +POST /callback @org.pac4j.play.CallbackController.callback(request: Request) +GET /logout @org.pac4j.play.LogoutController.logout(request: Request) + # 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 diff --git a/frontend/src/app/components/header-nav/header-nav.component.html b/frontend/src/app/components/header-nav/header-nav.component.html index 4b821455..feccbf02 100644 --- a/frontend/src/app/components/header-nav/header-nav.component.html +++ b/frontend/src/app/components/header-nav/header-nav.component.html @@ -136,17 +136,6 @@ > {{ publishToLiveButtonText() }} -
diff --git a/frontend/src/app/components/header-nav/header-nav.component.ts b/frontend/src/app/components/header-nav/header-nav.component.ts index 31794a33..8b14fbde 100644 --- a/frontend/src/app/components/header-nav/header-nav.component.ts +++ b/frontend/src/app/components/header-nav/header-nav.component.ts @@ -59,7 +59,7 @@ export class HeaderNavComponent implements OnInit { // Ignore errors } } - + hideSolrIndexSelector() { return (!this.currentSolrIndexId) || (this.currentSolrIndexId === '-1') || (this.solrService.solrIndices.length < 1) } @@ -118,11 +118,4 @@ export class HeaderNavComponent implements OnInit { this.requestPublishRulesTxtToSolr('PRELIVE'); } - public callSimpleLogoutUrl() { - console.log('In AppComponent :: callSimpleLogoutUrl'); - - // TODO redirect in a more "Angular-way" to target URL - window.location.href = this.featureToggleService.getSimpleLogoutButtonTargetUrl(); - } - } diff --git a/frontend/src/app/services/feature-toggle.service.ts b/frontend/src/app/services/feature-toggle.service.ts index 20518c88..27e942ce 100644 --- a/frontend/src/app/services/feature-toggle.service.ts +++ b/frontend/src/app/services/feature-toggle.service.ts @@ -5,7 +5,6 @@ import {HttpClient} from '@angular/common/http'; const FEATURE_TOGGLE_UI_CONCEPT_UPDOWN_RULES_COMBINED = 'toggle.ui-concept.updown-rules.combined'; const FEATURE_TOGGLE_UI_CONCEPT_ALL_RULES_WITH_SOLR_FIELDS = 'toggle.ui-concept.all-rules.with-solr-fields'; const FEATURE_TOGGLE_RULE_DEPLOYMENT_PRE_LIVE_PRESENT = 'toggle.rule-deployment.pre-live.present'; -const FEATURE_AUTH_SIMPLE_LOGOUT_BUTTON_TARGET_URL = 'smui.auth.ui-concept.simple-logout-button-target-url'; const FEATURE_TOGGLE_UI_LIST_LIMIT_ITEMS_TO = 'toggle.ui-list.limit-items-to'; const FEATURE_ACTIVATE_SPELLING = 'toggle.activate-spelling'; const FEATURE_ACTIVATE_EVENTHISTORY = 'toggle.activate-eventhistory'; @@ -66,11 +65,6 @@ export class FeatureToggleService { .getSync(FEATURE_TOGGLE_RULE_DEPLOYMENT_PRE_LIVE_PRESENT); } - getSimpleLogoutButtonTargetUrl(): any { - return this - .getSync(FEATURE_AUTH_SIMPLE_LOGOUT_BUTTON_TARGET_URL); - } - getSyncToggleUiListLimitItemsTo(): any { return this .getSync(FEATURE_TOGGLE_UI_LIST_LIMIT_ITEMS_TO); diff --git a/test/auth/JWTJsonAuthenticatedActionSpec.scala b/test/auth/JWTJsonAuthenticatedActionSpec.scala deleted file mode 100644 index e009878e..00000000 --- a/test/auth/JWTJsonAuthenticatedActionSpec.scala +++ /dev/null @@ -1,203 +0,0 @@ -package auth - -import controllers.auth.{JWTJsonAuthenticatedAction, UserRequest} -import org.scalatest.concurrent.ScalaFutures -import org.scalatest.mockito.MockitoSugar -import org.scalatestplus.play.PlaySpec -import org.scalatestplus.play.guice.GuiceOneAppPerTest -import pdi.jwt.{JwtAlgorithm, JwtJson} -import play.api.db.{Database, Databases} -import play.api.inject.guice.GuiceApplicationBuilder -import play.api.libs.json.{JsString, Json} -import play.api.mvc.{Cookie, Request, Result, Results} -import play.api.test.FakeRequest -import play.api.test.Helpers._ -import play.api.{Application, Mode} - -import java.security.{KeyPairGenerator, SecureRandom} -import java.util.Base64 -import scala.concurrent.{ExecutionContext, Future} - -class JWTJsonAuthenticatedActionSpec extends PlaySpec with MockitoSugar with GuiceOneAppPerTest with ScalaFutures { - - protected lazy val db: Database = Databases.inMemory() - - private val rsaKeyPair = generateRsaKeyPair() - - override def fakeApplication(): Application = { - GuiceApplicationBuilder() - .in(Mode.Test) - .configure(Map( - "db.default.url" -> db.url, - "db.default.driver" -> "org.h2.Driver", - "db.default.username" -> "", - "db.default.password" -> "", - "smui.authAction" -> "controllers.auth.JWTJsonAuthenticatedAction", - "smui.JWTJsonAuthenticatedAction.login.url" -> "https://redirect.com", - "smui.JWTJsonAuthenticatedAction.cookie.name" -> "test_token", - "smui.JWTJsonAuthenticatedAction.public.key" -> new String(Base64.getEncoder.encode(rsaKeyPair.getPublic.getEncoded)), - "smui.JWTJsonAuthenticatedAction.algorithm" -> "rsa", - "smui.JWTJsonAuthenticatedAction.authorization.active" -> "true", - "smui.JWTJsonAuthenticatedAction.authorization.json.path" -> "$.roles", - "smui.JWTJsonAuthenticatedAction.authorization.roles" -> "admin, search-manager, smui rules analyst" - )) - .build() - } - - "The JWTJsonAuthenticatedAction" must { - - "redirect if no jwt token is provided" in { - val request = FakeRequest(GET, "/") - val home: Future[Result] = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 303 - result.header.headers(LOCATION) must equal(getJwtConfiguration("login.url")) - } - } - - "redirect if an invalid jwt token is provided" in { - val request = FakeRequest(GET, "/") - .withCookies(buildJWTCookie(Seq("admin"), value = Some("invalid_token"))) - - val home: Future[Result] = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 303 - result.header.headers(LOCATION) must equal(getJwtConfiguration("login.url")) - } - } - - "redirect if the user has not the right permissions" in { - val request = FakeRequest(GET, "/") - .withCookies(buildJWTCookie(Seq("not_admin"))) - - val home: Future[Result] = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 303 - result.header.headers(LOCATION) must equal(getJwtConfiguration("login.url")) - } - } - - "lead user to SMUI if a valid rsa encoded token is provided" in { - val request = FakeRequest(GET, "/") - .withCookies(buildJWTCookie(Seq("search-manager"))) - - val home: Future[Result] = route(app, request).get - - // TODO test seems flaky, failed once!! - whenReady(home) { result => - result.header.status mustBe 200 - } - } - - "let users pass to SMUI if they have the right role even if they also have other roles" in { - val request = FakeRequest(GET, "/") - .withCookies(buildJWTCookie(Seq("search-manager", "barkeeper"))) - - val home: Future[Result] = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 200 - } - } - - "let users pass to SMUI if they have role containing a whitespace character" in { - val request = FakeRequest(GET, "/") - .withCookies(buildJWTCookie(Seq("smui rules analyst"))) - - val home: Future[Result] = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 200 - } - } - - "should secure API routes" in { - var request = FakeRequest(GET, "/api/v1/inputTags") - .withCookies(buildJWTCookie(Seq("search-manager"))) - - var home: Future[Result] = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 200 - } - - request = FakeRequest(GET, "/api/v1/inputTags") - home = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 303 - result.header.headers(LOCATION) must equal(getJwtConfiguration("login.url")) - } - } - - "respond correct to api call" in { - val request = FakeRequest(GET, "/api/v1/allRulesTxtFiles") - .withCookies(buildJWTCookie(Seq("search-manager"))) - - val home: Future[Result] = route(app, request).get - - whenReady(home) { result => - result.header.status mustBe 200 - } - } - - "return a UserRequest if the subject claim is present" in { - val request = FakeRequest(GET, "/api/v1/allRulesTxtFiles") - .withCookies(buildJWTCookie(Seq("search-manager"))) - val authenticator = app.injector.instanceOf[JWTJsonAuthenticatedAction] - var modifiedRequest: Request[Any] = request - - val authenticated = authenticator.invokeBlock(request, (receivedRequest: Request[Any]) => { - modifiedRequest = receivedRequest - Future.successful(Results.Ok) - }) - - whenReady(authenticated) { _ => - modifiedRequest mustBe a[UserRequest[Any]] - } - } - - "not touch the request if the subject claim is not present" in { - val request = FakeRequest(GET, "/api/v1/allRulesTxtFiles") - .withCookies(buildJWTCookie(Seq("search-manager"), optUserName = None)) - val authenticator = app.injector.instanceOf[JWTJsonAuthenticatedAction] - var modifiedRequest: Request[Any] = request - - val authenticated = authenticator.invokeBlock(request, (receivedRequest: Request[Any]) => { - modifiedRequest = receivedRequest - Future.apply(Results.Ok)(ExecutionContext.global) - }) - - whenReady(authenticated) { _ => - modifiedRequest mustBe request - modifiedRequest must not be a[UserRequest[Any]] - } - } - } - - private def generateRsaKeyPair() = { - val keyGen = KeyPairGenerator.getInstance("RSA") - keyGen.initialize(2048, new SecureRandom()) - keyGen.generateKeyPair() - } - - private def buildJWTCookie(roles: Seq[String] = Seq.empty, optUserName: Option[String] = Option("test_user"), value: Option[String] = None) = { - var token = Json.obj(("roles", roles)) - for (userName <- optUserName) { - token = token + ("sub", JsString(userName)) - } - - Cookie( - name = getJwtConfiguration("cookie.name"), - value = if (value.isEmpty) JwtJson.encode(token, rsaKeyPair.getPrivate, JwtAlgorithm.RS512) else value.get - ) - } - - private def getJwtConfiguration(key: String): String = { - app.configuration.get[String]("smui.JWTJsonAuthenticatedAction." + key) - } - -}