Skip to content

Commit

Permalink
#165 Add OAuth2 support
Browse files Browse the repository at this point in the history
  • Loading branch information
To-om committed Mar 13, 2019
1 parent 8e570b1 commit 42130b8
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 9 deletions.
10 changes: 10 additions & 0 deletions app/org/thp/cortex/Module.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -55,13 +56,22 @@ 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
bind[AssetCtrl].to[AssetCtrlDev]

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]
Expand Down
27 changes: 24 additions & 3 deletions app/org/thp/cortex/controllers/AuthenticationCtrl.scala
Original file line number Diff line number Diff line change
@@ -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() (
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions app/org/thp/cortex/controllers/StatusCtrl.scala
Original file line number Diff line number Diff line change
@@ -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 }

Expand Down Expand Up @@ -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)))))
}
}

Expand Down
161 changes: 161 additions & 0 deletions app/org/thp/cortex/services/OAuth2Srv.scala
Original file line number Diff line number Diff line change
@@ -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))
}
}
}

5 changes: 2 additions & 3 deletions app/org/thp/cortex/services/WorkerConfigSrv.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}

Expand Down
63 changes: 63 additions & 0 deletions app/org/thp/cortex/services/mappers/GroupUserMapper.scala
Original file line number Diff line number Diff line change
@@ -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}"))
}
}
}
}
32 changes: 32 additions & 0 deletions app/org/thp/cortex/services/mappers/MultiUserMapperSrv.scala
Original file line number Diff line number Diff line change
@@ -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)
}

}
Loading

0 comments on commit 42130b8

Please sign in to comment.