Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OpenID Connect and OAuth2 sign-on improvement #1112

Merged
merged 7 commits into from
Apr 6, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 99 additions & 47 deletions conf/application.sample
Original file line number Diff line number Diff line change
Expand Up @@ -45,57 +45,109 @@ search {

# Authentication
auth {
# "provider" parameter contains authentication provider. It can be multi-valued (useful for migration)
# available auth types are:
# services.LocalAuthSrv : passwords are stored in user entity (in Elasticsearch). No configuration is required.
# ad : use ActiveDirectory to authenticate users. Configuration is under "auth.ad" key
# ldap : use LDAP to authenticate users. Configuration is under "auth.ldap" key
provider = [local]
# "provider" parameter contains authentication provider. It can be multi-valued (useful for migration)
# available auth types are:
# services.LocalAuthSrv : passwords are stored in user entity (in Elasticsearch). No configuration is required.
# ad : use ActiveDirectory to authenticate users. Configuration is under "auth.ad" key
# ldap : use LDAP to authenticate users. Configuration is under "auth.ldap" key
# oauth2 : use OAuth/OIDC to authenticate users. Configuration is under "auth.oauth2" and "auth.sso" keys
provider = [local]

# By default, basic authentication is disabled. You can enable it by setting "method.basic" to true.
#method.basic = true


ad {
# The Windows domain name in DNS format. This parameter is required if you do not use
# 'serverNames' below.
#domainFQDN = "mydomain.local"

# Optionally you can specify the host names of the domain controllers instead of using 'domainFQDN
# above. If this parameter is not set, TheHive uses 'domainFQDN'.
#serverNames = [ad1.mydomain.local, ad2.mydomain.local]

# The Windows domain name using short format. This parameter is required.
#domainName = "MYDOMAIN"

# If 'true', use SSL to connect to the domain controller.
#useSSL = true
}

ldap {
# The LDAP server name or address. The port can be specified using the 'host:port'
# syntax. This parameter is required if you don't use 'serverNames' below.
#serverName = "ldap.mydomain.local:389"

# If you have multiple LDAP servers, use the multi-valued setting 'serverNames' instead.
#serverNames = [ldap1.mydomain.local, ldap2.mydomain.local]

# Account to use to bind to the LDAP server. This parameter is required.
#bindDN = "cn=thehive,ou=services,dc=mydomain,dc=local"

# Password of the binding account. This parameter is required.
#bindPW = "***secret*password***"

# Base DN to search users. This parameter is required.
#baseDN = "ou=users,dc=mydomain,dc=local"

# Filter to search user in the directory server. Please note that {0} is replaced
# by the actual user name. This parameter is required.
#filter = "(cn={0})"

# If 'true', use SSL to connect to the LDAP directory server.
#useSSL = true
}
ad {
# The Windows domain name in DNS format. This parameter is required if you do not use
# 'serverNames' below.
#domainFQDN = "mydomain.local"

# Optionally you can specify the host names of the domain controllers instead of using 'domainFQDN
# above. If this parameter is not set, TheHive uses 'domainFQDN'.
#serverNames = [ad1.mydomain.local, ad2.mydomain.local]

# The Windows domain name using short format. This parameter is required.
#domainName = "MYDOMAIN"

# If 'true', use SSL to connect to the domain controller.
#useSSL = true
}

ldap {
# The LDAP server name or address. The port can be specified using the 'host:port'
# syntax. This parameter is required if you don't use 'serverNames' below.
#serverName = "ldap.mydomain.local:389"

# If you have multiple LDAP servers, use the multi-valued setting 'serverNames' instead.
#serverNames = [ldap1.mydomain.local, ldap2.mydomain.local]

# Account to use to bind to the LDAP server. This parameter is required.
#bindDN = "cn=thehive,ou=services,dc=mydomain,dc=local"

# Password of the binding account. This parameter is required.
#bindPW = "***secret*password***"

# Base DN to search users. This parameter is required.
#baseDN = "ou=users,dc=mydomain,dc=local"

# Filter to search user in the directory server. Please note that {0} is replaced
# by the actual user name. This parameter is required.
#filter = "(cn={0})"

# If 'true', use SSL to connect to the LDAP directory server.
#useSSL = true
}

oauth2 {
# URL of the authorization server
#clientId = "client-id"
#clientSecret = "client-secret"
#redirectUri = "https://my-thehive-instance.example/index.html#!/login"
#responseType = "code"
#grantType = "authorization_code"

# URL from where to get the access token
#authorizationUrl = "https://auth-site.com/OAuth/Authorize"
#tokenUrl = "https://auth-site.com/OAuth/Token"

# The endpoint from which to obtain user details using the OAuth token, after successful login
#userUrl = "https://auth-site.com/api/User"
#scope = "openid profile"
}

# Single-Sign On
sso {
# Autocreate user in database?
#autocreate = false

# Autoupdate its profile and roles?
#autoupdate = false

# Autologin user using SSO?
#autologin = false
# Attributes mappings
#attributes {
# login = "sub"
# name = "name"
# groups = "groups"
# #roles = "roles"
#}

# Name of mapping class from user resource to backend user ('simple' or 'group')
#mapper = group
# Default roles for users with no groups mapped ("read", "write", "admin")
#defaultRoles = []

#groups {
# # URL to retreive groups (leave empty if you are using OIDC)
# #url = "https://auth-site.com/api/Groups"
# # Group mappings, you can have multiple roles for each group: they are merged
# mappings {
# admin-profile-name = ["admin"]
# editor-profile-name = ["write"]
# reader-profile-name = ["read"]
# }
#}
}
}

# Maximum time between two requests without requesting authentication
Expand Down
21 changes: 18 additions & 3 deletions thehive-backend/app/services/OAuth2Srv.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ case class OAuth2Config(
tokenUrl: String,
userUrl: String,
scope: String,
autocreate: Boolean
autocreate: Boolean,
autoupdate: Boolean
)

object OAuth2Config {
Expand All @@ -41,7 +42,8 @@ object OAuth2Config {
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)
autoupdate = configuration.getOptional[Boolean]("auth.sso.autoupdate").getOrElse(false)
} yield OAuth2Config(clientId, clientSecret, redirectUri, responseType, grantType, authorizationUrl, tokenUrl, userUrl, scope, autocreate, autoupdate)
}

@Singleton
Expand Down Expand Up @@ -125,11 +127,24 @@ class OAuth2Srv(
userSrv
.get(userId)
.flatMap(user ⇒ {
userSrv.getFromUser(request, user, name)
if (cfg.autoupdate) {
logger.debug(s"Updating OAuth/OIDC user")
userSrv.inInitAuthContext { implicit authContext ⇒
// Only update name and roles, not login (can't change it)
userSrv
.update(user, userFields.unset("login"))
.flatMap(user ⇒ {
userSrv.getFromUser(request, user, name)
})
}
} else {
userSrv.getFromUser(request, user, name)
}
})
.recoverWith {
case authErr: AuthorizationError ⇒ Future.failed(authErr)
case _ if cfg.autocreate ⇒
logger.debug(s"Creating OAuth/OIDC user")
userSrv.inInitAuthContext { implicit authContext ⇒
userSrv
.create(userFields)
Expand Down
79 changes: 69 additions & 10 deletions thehive-backend/app/services/mappers/GroupUserMapper.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package services.mappers
import javax.inject.Inject

import scala.concurrent.{ExecutionContext, Future}
import scala.util.parsing.combinator._

import play.api.Configuration
import play.api.{Configuration, Logger}
import play.api.libs.json._
import play.api.libs.ws.WSClient

Expand All @@ -14,7 +15,6 @@ import org.elastic4play.controllers.Fields
class GroupUserMapper(
loginAttrName: String,
nameAttrName: String,
rolesAttrName: Option[String],
groupAttrName: String,
defaultRoles: Seq[String],
groupsUrl: String,
Expand All @@ -25,9 +25,8 @@ class GroupUserMapper(

@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.login").getOrElse("sub"),
configuration.getOptional[String]("auth.sso.attributes.name").getOrElse("name"),
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(""),
Expand All @@ -38,13 +37,73 @@ class GroupUserMapper(

override val name: String = "group"

private[GroupUserMapper] lazy val logger = Logger(getClass)

private class RoleListParser extends RegexParsers {
val str = "[a-zA-Z0-9_]+".r
val strSpc = "[a-zA-Z0-9_ ]+".r
val realStr = ("\""~>strSpc<~"\"" | "'"~>strSpc<~"'" | str)

def expr: Parser[Seq[String]] = {
"[" ~ opt(realStr ~ rep(("," | ",") ~ realStr)) ~ "]" ^^ {
case _ ~ Some(firstRole ~ list) ~ _ => list.foldLeft(Seq(firstRole)) {
case (queue, _ ~ role) => role +: queue
}
case _ ~ _ => Seq.empty[String]
} | opt(realStr) ^^ {
case Some(role) => Seq(role)
case None => Seq.empty[String]
}
}

def apply(input: String, logger: Logger): Seq[String] = parseAll(expr, input) match {
case Success(result, _) => result
case failure : NoSuccess => {
logger.error(failure.msg)
Seq.empty[String]
}
}
}

override def getUserFields(jsValue: JsValue, authHeader: Option[(String, String)]): Future[Fields] = {
if (groupsUrl == "") {
logger.debug(s"No groups endpoint provided (auth.sso.groups.url). Assuming groups were already received.")

val jsonGroups: Seq[String] = {
val groups: JsValue = (jsValue \ groupAttrName).get
// Groups received as valid JSON array
if (groups.isInstanceOf[JsArray]) {
groups.as[Seq[String]]
// Group list received as string (invalid JSON, for example: "ROLE" or "['Role 1', ROLE2, 'Role_3']")
} else if (groups.isInstanceOf[JsString]) {
(new RoleListParser).apply(groups.as[String], logger)
// Invalid group list
} else {
logger.error(s"Invalid group list (${groupAttrName} attribute, value '${groups}', type ${groups.getClass})")
Seq.empty[String]
}
}

mapGroupsAndBuildUserFields(jsValue, jsonGroups)

} else {
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]]
mapGroupsAndBuildUserFields(jsValue, jsonGroups)
}
}
}

private def mapGroupsAndBuildUserFields(jsValue: JsValue, jsonGroups: Seq[String]): Future[Fields] = {
val mappedRoles = jsonGroups.flatMap(mappings.get).flatten.toSet
val roles = if (mappedRoles.nonEmpty) mappedRoles else defaultRoles

if (roles.isEmpty) {
Future.failed(AuthenticationError(s"No matched roles for user."))

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
} else {
logger.debug(s"Computed roles : ${roles}")

val fields = for {
login ← (jsValue \ loginAttrName).validate[String]
Expand Down
4 changes: 2 additions & 2 deletions thehive-backend/app/services/mappers/SimpleUserMapper.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ class SimpleUserMapper(

@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.login").getOrElse("sub"),
configuration.getOptional[String]("auth.sso.attributes.name").getOrElse("name"),
configuration.getOptional[String]("auth.sso.attributes.roles"),
configuration.getOptional[Seq[String]]("auth.sso.defaultRoles").getOrElse(Seq()),
ec
Expand Down
2 changes: 1 addition & 1 deletion ui/app/scripts/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ angular.module('thehive', [
}
},
params: {
autoLogin: false
disableSsoAutoLogin: false
},
title: 'Login'
})
Expand Down
10 changes: 7 additions & 3 deletions ui/app/scripts/controllers/AuthenticationCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
(function() {
'use strict';
angular.module('theHiveControllers')
.controller('AuthenticationCtrl', function($scope, $state, $location, $uibModalStack, $stateParams, AuthenticationSrv, NotificationSrv, UtilsSrv, UrlParser, appConfig) {
.controller('AuthenticationCtrl', function($scope, $state, $location, $uibModalStack, $stateParams, AuthenticationSrv, NotificationSrv, appConfig) {
$scope.params = {};
$scope.ssoLogingIn = false;

$uibModalStack.dismissAll();

$scope.ssoLogin = function (code) {
$scope.ssoLogingIn = true;
AuthenticationSrv.ssoLogin(code)
.then(function(response) {
var redirectLocation = response.headers().location;
if(angular.isDefined(redirectLocation)) {
window.location = redirectLocation;
} else {
$location.search('code', null);
$state.go('app.cases');
}
})
Expand All @@ -25,6 +28,7 @@
} else {
NotificationSrv.log(err.data.message, 'error');
}
$scope.ssoLogingIn = false;
$location.url($location.path());
});
};
Expand All @@ -49,8 +53,8 @@
});
};

var code = UtilsSrv.extractQueryParam('code', UrlParser('query', $location.absUrl()));
if(angular.isDefined(code) || $stateParams.autoLogin) {
var code = $location.search().code;
if(angular.isDefined(code) || (appConfig.config.ssoAutoLogin && !$stateParams.disableSsoAutoLogin)) {
$scope.ssoLogin(code);
}
});
Expand Down
4 changes: 2 additions & 2 deletions ui/app/scripts/controllers/RootCtrl.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ angular.module('theHiveControllers').controller('RootCtrl',
$state.go('maintenance');
return;
}else if(!currentUser || !currentUser.id) {
$state.go('login', {autoLogin: appConfig.config.ssoAutoLogin });
$state.go('login');
return;
}

Expand Down Expand Up @@ -141,7 +141,7 @@ angular.module('theHiveControllers').controller('RootCtrl',

$scope.logout = function() {
AuthenticationSrv.logout(function() {
$state.go('login');
$state.go('login', {disableSsoAutoLogin: true});
}, function(data, status) {
NotificationSrv.error('RootCtrl', data, status);
});
Expand Down
Loading