diff --git a/app/org/thp/cortex/Module.scala b/app/org/thp/cortex/Module.scala index 154a7c1ac..96927958c 100644 --- a/app/org/thp/cortex/Module.scala +++ b/app/org/thp/cortex/Module.scala @@ -17,6 +17,7 @@ import org.elastic4play.models.BaseModelDef import org.elastic4play.services.auth.MultiAuthSrv import org.elastic4play.services.{ AuthSrv, MigrationOperations } import org.thp.cortex.controllers.{ AssetCtrl, AssetCtrlDev, AssetCtrlProd } +import services.mappers.{ MultiUserMapperSrv, UserMapper } class Module(environment: Environment, configuration: Configuration) extends AbstractModule with ScalaModule with AkkaGuiceSupport { @@ -55,6 +56,14 @@ class Module(environment: Environment, configuration: Configuration) extends Abs authBindings.addBinding.to(authSrvClass) } + val ssoMapperBindings = ScalaMultibinder.newSetBinder[UserMapper](binder) + reflectionClasses + .getSubTypesOf(classOf[UserMapper]) + .asScala + .filterNot(c ⇒ java.lang.reflect.Modifier.isAbstract(c.getModifiers) || c.isMemberClass) + .filterNot(c ⇒ c == classOf[MultiUserMapperSrv]) + .foreach(mapperCls ⇒ ssoMapperBindings.addBinding.to(mapperCls)) + if (environment.mode == Mode.Prod) bind[AssetCtrl].to[AssetCtrlProd] else @@ -62,6 +71,7 @@ class Module(environment: Environment, configuration: Configuration) extends Abs bind[org.elastic4play.services.UserSrv].to[UserSrv] bind[Int].annotatedWith(Names.named("databaseVersion")).toInstance(models.modelVersion) + bind[UserMapper].to[MultiUserMapperSrv] bind[AuthSrv].to[CortexAuthSrv] bind[MigrationOperations].to[Migration] diff --git a/app/org/thp/cortex/controllers/AuthenticationCtrl.scala b/app/org/thp/cortex/controllers/AuthenticationCtrl.scala index accca52d8..6eb37af00 100644 --- a/app/org/thp/cortex/controllers/AuthenticationCtrl.scala +++ b/app/org/thp/cortex/controllers/AuthenticationCtrl.scala @@ -1,18 +1,18 @@ package org.thp.cortex.controllers -import javax.inject.{ Inject, Singleton } - import scala.concurrent.{ ExecutionContext, Future } import play.api.mvc._ +import javax.inject.{ Inject, Singleton } +import org.thp.cortex.models.UserStatus import org.thp.cortex.services.UserSrv import org.elastic4play.controllers.{ Authenticated, Fields, FieldsBodyParser, Renderer } import org.elastic4play.database.DBIndex import org.elastic4play.services.AuthSrv -import org.elastic4play.{ MissingAttributeError, Timed } import org.elastic4play.services.JsonFormat.authContextWrites +import org.elastic4play.{ AuthorizationError, MissingAttributeError, OAuth2Redirect, Timed } @Singleton class AuthenticationCtrl @Inject() ( @@ -38,6 +38,27 @@ class AuthenticationCtrl @Inject() ( } } + @Timed + def ssoLogin: Action[AnyContent] = Action.async { implicit request ⇒ + dbIndex.getIndexStatus.flatMap { + case false ⇒ Future.successful(Results.Status(520)) + case _ ⇒ + (for { + authContext ← authSrv.authenticate() + user ← userSrv.get(authContext.userId) + } yield { + if (user.status() == UserStatus.Ok) + authenticated.setSessingUser(Ok, authContext) + else + throw AuthorizationError("Your account is locked") + }) recover { + // A bit of a hack with the status code, so that Angular doesn't reject the origin + case OAuth2Redirect(redirectUrl, qp) ⇒ Redirect(redirectUrl, qp, status = OK) + case e ⇒ throw e + } + } + } + @Timed def logout = Action { Ok.withNewSession diff --git a/app/org/thp/cortex/controllers/StatusCtrl.scala b/app/org/thp/cortex/controllers/StatusCtrl.scala index 0c0e25be1..165a1b492 100644 --- a/app/org/thp/cortex/controllers/StatusCtrl.scala +++ b/app/org/thp/cortex/controllers/StatusCtrl.scala @@ -1,12 +1,11 @@ package org.thp.cortex.controllers import javax.inject.{ Inject, Singleton } - import scala.concurrent.ExecutionContext import play.api.Configuration import play.api.http.Status -import play.api.libs.json.{ JsString, Json } +import play.api.libs.json.{ JsBoolean, JsString, Json } import play.api.libs.json.Json.toJsFieldJsValueWrapper import play.api.mvc.{ AbstractController, Action, AnyContent, ControllerComponents } @@ -42,7 +41,8 @@ class StatusCtrl @Inject() ( case multiAuthSrv: MultiAuthSrv ⇒ multiAuthSrv.authProviders.map { a ⇒ JsString(a.name) } case _ ⇒ JsString(authSrv.name) }), - "capabilities" → authSrv.capabilities.map(c ⇒ JsString(c.toString))))) + "capabilities" → authSrv.capabilities.map(c ⇒ JsString(c.toString)), + "ssoAutoLogin" → JsBoolean(configuration.getOptional[Boolean]("auth.sso.autologin").getOrElse(false))))) } } diff --git a/app/org/thp/cortex/services/OAuth2Srv.scala b/app/org/thp/cortex/services/OAuth2Srv.scala new file mode 100644 index 000000000..33b3d1780 --- /dev/null +++ b/app/org/thp/cortex/services/OAuth2Srv.scala @@ -0,0 +1,161 @@ +package org.thp.cortex.services + +import scala.concurrent.{ ExecutionContext, Future } + +import play.api.http.Status +import play.api.libs.json.{ JsObject, JsValue } +import play.api.libs.ws.WSClient +import play.api.mvc.RequestHeader +import play.api.{ Configuration, Logger } + +import akka.stream.Materializer +import javax.inject.{ Inject, Singleton } +import org.thp.cortex.services.mappers.UserMapper + +import org.elastic4play.services.{ AuthContext, AuthSrv } +import org.elastic4play.{ AuthenticationError, AuthorizationError, OAuth2Redirect } + +case class OAuth2Config( + clientId: String, + clientSecret: String, + redirectUri: String, + responseType: String, + grantType: String, + authorizationUrl: String, + tokenUrl: String, + userUrl: String, + scope: String, + autocreate: Boolean) + +object OAuth2Config { + def apply(configuration: Configuration): Option[OAuth2Config] = { + for { + clientId ← configuration.getOptional[String]("auth.oauth2.clientId") + clientSecret ← configuration.getOptional[String]("auth.oauth2.clientSecret") + redirectUri ← configuration.getOptional[String]("auth.oauth2.redirectUri") + responseType ← configuration.getOptional[String]("auth.oauth2.responseType") + grantType ← configuration.getOptional[String]("auth.oauth2.grantType") + authorizationUrl ← configuration.getOptional[String]("auth.oauth2.authorizationUrl") + userUrl ← configuration.getOptional[String]("auth.oauth2.userUrl") + tokenUrl ← configuration.getOptional[String]("auth.oauth2.tokenUrl") + scope ← configuration.getOptional[String]("auth.oauth2.scope") + autocreate = configuration.getOptional[Boolean]("auth.sso.autocreate").getOrElse(false) + } yield OAuth2Config(clientId, clientSecret, redirectUri, responseType, grantType, authorizationUrl, tokenUrl, userUrl, scope, autocreate) + } +} + +@Singleton +class OAuth2Srv( + ws: WSClient, + userSrv: UserSrv, + ssoMapper: UserMapper, + oauth2Config: Option[OAuth2Config], + implicit val ec: ExecutionContext, + implicit val mat: Materializer) + extends AuthSrv { + + @Inject() def this( + ws: WSClient, + ssoMapper: UserMapper, + userSrv: UserSrv, + configuration: Configuration, + ec: ExecutionContext, + mat: Materializer) = this( + ws, + userSrv, + ssoMapper, + OAuth2Config(configuration), + ec, + mat) + + override val name: String = "oauth2" + private val logger = Logger(getClass) + + val Oauth2TokenQueryString = "code" + + private def withOAuth2Config[A](body: OAuth2Config ⇒ Future[A]): Future[A] = { + oauth2Config.fold[Future[A]](Future.failed(AuthenticationError("OAuth2 not configured properly")))(body) + } + + override def authenticate()(implicit request: RequestHeader): Future[AuthContext] = { + withOAuth2Config { cfg ⇒ + request.queryString + .get(Oauth2TokenQueryString) + .flatMap(_.headOption) + .fold(createOauth2Redirect(cfg.clientId)) { code ⇒ + getAuthTokenAndAuthenticate(cfg.clientId, code) + } + } + } + + private def getAuthTokenAndAuthenticate(clientId: String, code: String)(implicit request: RequestHeader): Future[AuthContext] = { + logger.debug("Getting user token with the code from the response!") + withOAuth2Config { cfg ⇒ + ws.url(cfg.tokenUrl) + .post(Map( + "code" → code, + "grant_type" → cfg.grantType, + "client_secret" → cfg.clientSecret, + "redirect_uri" → cfg.redirectUri, + "client_id" → clientId)) + .recoverWith { + case error ⇒ + logger.error(s"Token verification failure", error) + Future.failed(AuthenticationError("Token verification failure")) + } + .flatMap { r ⇒ + r.status match { + case Status.OK ⇒ + val accessToken = (r.json \ "access_token").asOpt[String].getOrElse("") + val authHeader = "Authorization" → s"bearer $accessToken" + ws.url(cfg.userUrl) + .addHttpHeaders(authHeader) + .get().flatMap { userResponse ⇒ + if (userResponse.status != Status.OK) { + Future.failed(AuthenticationError(s"unexpected response from server: ${userResponse.status} ${userResponse.body}")) + } + else { + val response = userResponse.json.asInstanceOf[JsObject] + getOrCreateUser(response, authHeader) + } + } + case _ ⇒ + logger.error(s"unexpected response from server: ${r.status} ${r.body}") + Future.failed(AuthenticationError("unexpected response from server")) + } + } + } + } + + private def getOrCreateUser(response: JsValue, authHeader: (String, String))(implicit request: RequestHeader): Future[AuthContext] = { + withOAuth2Config { cfg ⇒ + ssoMapper.getUserFields(response, Some(authHeader)).flatMap { + userFields ⇒ + val userId = userFields.getString("login").getOrElse("") + userSrv.get(userId).flatMap(user ⇒ { + userSrv.getFromUser(request, user, name) + }).recoverWith { + case authErr: AuthorizationError ⇒ Future.failed(authErr) + case _ if cfg.autocreate ⇒ + userSrv.inInitAuthContext { implicit authContext ⇒ + userSrv.create(userFields).flatMap(user ⇒ { + userSrv.getFromUser(request, user, name) + }) + } + } + } + } + } + + private def createOauth2Redirect(clientId: String): Future[AuthContext] = { + withOAuth2Config { cfg ⇒ + val queryStringParams = Map[String, Seq[String]]( + "scope" → Seq(cfg.scope), + "response_type" → Seq(cfg.responseType), + "redirect_uri" → Seq(cfg.redirectUri), + "client_id" → Seq(clientId)) + Future.failed(OAuth2Redirect(cfg.authorizationUrl, queryStringParams)) + } + } +} + diff --git a/app/org/thp/cortex/services/WorkerConfigSrv.scala b/app/org/thp/cortex/services/WorkerConfigSrv.scala index f09906433..6dc4b9478 100644 --- a/app/org/thp/cortex/services/WorkerConfigSrv.scala +++ b/app/org/thp/cortex/services/WorkerConfigSrv.scala @@ -44,7 +44,7 @@ trait WorkerConfigSrv { .runWith(Sink.seq) .map { baseConfigs ⇒ (BaseConfig.global(workerType, configuration) +: baseConfigs) - .map(c ⇒ c.name → c) + .map(c ⇒ c.name → (c + BaseConfig.global(workerType, configuration))) .toMap } } @@ -96,10 +96,9 @@ trait WorkerConfigSrv { import org.elastic4play.services.QueryDSL._ for { configItems ← definitions - workerConfigItems = configItems workerConfigs ← findForUser(userId, any, Some("all"), Nil) ._1 - .runFold(workerConfigItems) { (definitionConfig, workerConfig) ⇒ updateDefinitionConfig(definitionConfig, workerConfig) } + .runFold(configItems) { (definitionConfig, workerConfig) ⇒ updateDefinitionConfig(definitionConfig, workerConfig) } } yield workerConfigs.values.toSeq } diff --git a/app/org/thp/cortex/services/mappers/GroupUserMapper.scala b/app/org/thp/cortex/services/mappers/GroupUserMapper.scala new file mode 100644 index 000000000..2ead6580c --- /dev/null +++ b/app/org/thp/cortex/services/mappers/GroupUserMapper.scala @@ -0,0 +1,63 @@ +package org.thp.cortex.services.mappers + +import scala.concurrent.{ ExecutionContext, Future } + +import play.api.Configuration +import play.api.libs.json._ +import play.api.libs.ws.WSClient + +import javax.inject.Inject + +import org.elastic4play.AuthenticationError +import org.elastic4play.controllers.Fields + +class GroupUserMapper( + loginAttrName: String, + nameAttrName: String, + rolesAttrName: Option[String], + groupAttrName: String, + defaultRoles: Seq[String], + groupsUrl: String, + mappings: Map[String, Seq[String]], + ws: WSClient, + implicit val ec: ExecutionContext) extends UserMapper { + + @Inject() def this( + + configuration: Configuration, + ws: WSClient, + ec: ExecutionContext) = this( + configuration.getOptional[String]("auth.sso.attributes.login").getOrElse("name"), + configuration.getOptional[String]("auth.sso.attributes.name").getOrElse("username"), + configuration.getOptional[String]("auth.sso.attributes.roles"), + configuration.getOptional[String]("auth.sso.attributes.groups").getOrElse(""), + configuration.getOptional[Seq[String]]("auth.sso.defaultRoles").getOrElse(Seq()), + configuration.getOptional[String]("auth.sso.groups.url").getOrElse(""), + configuration.getOptional[Map[String, Seq[String]]]("auth.sso.groups.mappings").getOrElse(Map()), + ws, + ec) + + override val name: String = "group" + + override def getUserFields(jsValue: JsValue, authHeader: Option[(String, String)]): Future[Fields] = { + + val apiCall = authHeader.fold(ws.url(groupsUrl))(headers ⇒ ws.url(groupsUrl).addHttpHeaders(headers)) + apiCall.get.flatMap { r ⇒ + val jsonGroups = (r.json \ groupAttrName).as[Seq[String]] + val mappedRoles = jsonGroups.flatMap(mappings.get).maxBy(_.length) + val roles = if (mappedRoles.nonEmpty) mappedRoles else defaultRoles + + val fields = for { + login ← (jsValue \ loginAttrName).validate[String] + name ← (jsValue \ nameAttrName).validate[String] + } yield Fields(Json.obj( + "login" → login, + "name" → name, + "roles" → roles)) + fields match { + case JsSuccess(f, _) ⇒ Future.successful(f) + case JsError(errors) ⇒ Future.failed(AuthenticationError(s"User info fails: ${errors.map(_._1).mkString}")) + } + } + } +} diff --git a/app/org/thp/cortex/services/mappers/MultiUserMapperSrv.scala b/app/org/thp/cortex/services/mappers/MultiUserMapperSrv.scala new file mode 100644 index 000000000..238605b2f --- /dev/null +++ b/app/org/thp/cortex/services/mappers/MultiUserMapperSrv.scala @@ -0,0 +1,32 @@ +package org.thp.cortex.services.mappers + +import scala.collection.immutable +import scala.concurrent.Future + +import play.api.Configuration +import play.api.libs.json.JsValue + +import javax.inject.{ Inject, Singleton } + +import org.elastic4play.controllers.Fields + +object MultiUserMapperSrv { + def getMapper(configuration: Configuration, ssoMapperModules: immutable.Set[UserMapper]): UserMapper = { + val name = configuration.getOptional[String]("auth.sso.mapper").getOrElse("simple") + ssoMapperModules.find(_.name == name).get + } +} + +@Singleton +class MultiUserMapperSrv @Inject() ( + configuration: Configuration, + ssoMapperModules: immutable.Set[UserMapper]) extends UserMapper { + + override val name: String = "usermapper" + private lazy val mapper: UserMapper = MultiUserMapperSrv.getMapper(configuration, ssoMapperModules) + + override def getUserFields(jsValue: JsValue, authHeader: Option[(String, String)]): Future[Fields] = { + mapper.getUserFields(jsValue, authHeader) + } + +} diff --git a/app/org/thp/cortex/services/mappers/SimpleUserMapper.scala b/app/org/thp/cortex/services/mappers/SimpleUserMapper.scala new file mode 100644 index 000000000..03ffc4588 --- /dev/null +++ b/app/org/thp/cortex/services/mappers/SimpleUserMapper.scala @@ -0,0 +1,43 @@ +package org.thp.cortex.services.mappers + +import scala.concurrent.{ ExecutionContext, Future } + +import play.api.Configuration +import play.api.libs.json.{ JsError, JsSuccess, JsValue, Json } + +import javax.inject.Inject + +import org.elastic4play.AuthenticationError +import org.elastic4play.controllers.Fields + +class SimpleUserMapper( + loginAttrName: String, + nameAttrName: String, + rolesAttrName: Option[String], + defaultRoles: Seq[String], + implicit val ec: ExecutionContext) extends UserMapper { + + @Inject() def this(configuration: Configuration, ec: ExecutionContext) = this( + configuration.getOptional[String]("auth.sso.attributes.login").getOrElse("name"), + configuration.getOptional[String]("auth.sso.attributes.name").getOrElse("username"), + configuration.getOptional[String]("auth.sso.attributes.roles"), + configuration.getOptional[Seq[String]]("auth.sso.defaultRoles").getOrElse(Seq()), + ec) + + override val name: String = "simple" + + override def getUserFields(jsValue: JsValue, authHeader: Option[(String, String)]): Future[Fields] = { + val fields = for { + login ← (jsValue \ loginAttrName).validate[String] + name ← (jsValue \ nameAttrName).validate[String] + roles = rolesAttrName.fold(defaultRoles)(r ⇒ (jsValue \ r).asOpt[Seq[String]].getOrElse(defaultRoles)) + } yield Fields(Json.obj( + "login" → login, + "name" → name, + "roles" → roles)) + fields match { + case JsSuccess(f, _) ⇒ Future.successful(f) + case JsError(errors) ⇒ Future.failed(AuthenticationError(s"User info fails: ${errors.map(_._1).mkString}")) + } + } +} diff --git a/app/org/thp/cortex/services/mappers/UserMapper.scala b/app/org/thp/cortex/services/mappers/UserMapper.scala new file mode 100644 index 000000000..02bf2ae61 --- /dev/null +++ b/app/org/thp/cortex/services/mappers/UserMapper.scala @@ -0,0 +1,16 @@ +package org.thp.cortex.services.mappers + +import scala.concurrent.Future + +import play.api.libs.json.JsValue + +import org.elastic4play.controllers.Fields + +/** + * User mapper trait to be used when converting a JS response from a third party API to a valid Fields object. Used in + * the SSO process to create new users if the option is selected. + */ +trait UserMapper { + val name: String + def getUserFields(jsValue: JsValue, authHeader: Option[(String, String)] = None): Future[Fields] +} diff --git a/conf/routes b/conf/routes index 4145627fd..300dd360d 100644 --- a/conf/routes +++ b/conf/routes @@ -7,6 +7,7 @@ GET / org.thp.cort GET /api/health org.thp.cortex.controllers.StatusCtrl.health GET /api/logout org.thp.cortex.controllers.AuthenticationCtrl.logout() POST /api/login org.thp.cortex.controllers.AuthenticationCtrl.login() +POST /api/ssoLogin org.thp.cortex.controllers.AuthenticationCtrl.ssoLogin() ################### # API used by TheHive