diff --git a/README.md b/README.md index e5edb1b7..50eb858b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,56 @@ You can run cerebro listening on a different host and port(defaults to 0.0.0.0:9 bin/cerebro -Dhttp.port=1234 -Dhttp.address=127.0.0.1 ``` +#### LDAP config + +LDAP can be configured using environment variables. If you typically run cerebro using docker, +you can pass a file with all the env vars. The file would look like: + +```bash +# Set it to ldap to activate ldap authorization +AUTH_TYPE=ldap + +# Your ldap url +LDAP_URL=ldap://exammple.com:389 + +LDAP_BASE_DN=OU=users,DC=example,DC=com + +# Usually method should be "simple" otherwise, set it to the SASL mechanisms +LDAP_METHOD=simple + +# user-template executes a string.format() operation where +# username is passed in first, followed by base-dn. Some examples +# - %s => leave user untouched +# - %s@domain.com => append "@domain.com" to username +# - uid=%s,%s => usual case of OpenLDAP +LDAP_USER_TEMPLATE=%s@example.com + +# User identifier that can perform searches +LDAP_BIND_DN=admin@example.com +LDAP_BIND_PWD=adminpass + +# Group membership settings (optional) + +# If left unset LDAP_BASE_DN will be used +# LDAP_GROUP_BASE_DN=OU=users,DC=example,DC=com + +# Attribute that represent the user, for example uid or mail +# LDAP_USER_ATTR=mail + +# Filter that tests membership of the group. If this property is empty then there is no group membership check +# AD example => memberOf=CN=mygroup,ou=ouofthegroup,DC=domain,DC=com +# OpenLDAP example => CN=mygroup +# LDAP_GROUP=memberOf=memberOf=CN=mygroup,ou=ouofthegroup,DC=domain,DC=com + +``` + +You can the pass this file as argument using: + +```bash + docker run -p 9000:9000 --env-file env-ldap lmenezes/cerebro +``` + + #### Other settings Other settings are exposed through the **conf/application.conf** file found on the application directory. diff --git a/app/controllers/auth/ldap/LDAPAuthConfig.scala b/app/controllers/auth/ldap/LDAPAuthConfig.scala index 07083eb0..1ac809bb 100644 --- a/app/controllers/auth/ldap/LDAPAuthConfig.scala +++ b/app/controllers/auth/ldap/LDAPAuthConfig.scala @@ -7,10 +7,24 @@ class LDAPAuthConfig(config: Configuration) extends AuthConfig { implicit val conf = config - final val domain = getSetting("user-domain") - final val userformat = getSetting("user-format") + final val userTemplate = getSetting("user-template") final val method = getSetting("method") final val url = getSetting("url") final val baseDN = getSetting("base-dn") + final val bindDN = getSetting("bind-dn") + final val bindPwd = getSetting("bind-pw") + + final val groupMembership: Option[LDAPGroupSearchConfig] = { + val groupAuthConfig = config.get[Configuration]("group-search") + groupAuthConfig.getOptional[String]("group").map { group => + LDAPGroupSearchConfig( + groupAuthConfig.getOptional[String]("base-dn").getOrElse(baseDN), + getSetting("user-attr")(groupAuthConfig), + group + ) + } + } } + +case class LDAPGroupSearchConfig(baseDN: String, userAttr: String, group: String) diff --git a/app/controllers/auth/ldap/LDAPAuthService.scala b/app/controllers/auth/ldap/LDAPAuthService.scala index dba51065..ece5e2e1 100644 --- a/app/controllers/auth/ldap/LDAPAuthService.scala +++ b/app/controllers/auth/ldap/LDAPAuthService.scala @@ -1,49 +1,69 @@ package controllers.auth.ldap import java.util.Hashtable -import javax.naming._ -import javax.naming.directory._ import com.google.inject.Inject +import com.sun.jndi.ldap.LdapCtxFactory import controllers.auth.AuthService -import play.api.Configuration +import javax.naming._ +import javax.naming.directory.SearchControls +import play.api.{Configuration, Logger} import scala.util.control.NonFatal class LDAPAuthService @Inject()(globalConfig: Configuration) extends AuthService { - private val log = org.slf4j.LoggerFactory.getLogger(classOf[LDAPAuthService]) + private val log = Logger(this.getClass) private final val config = new LDAPAuthConfig(globalConfig.get[Configuration]("auth.settings")) - def auth(username: String, password: String): Option[String] = { - val env = new Hashtable[String, String](11) - env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory") - env.put(Context.PROVIDER_URL, s"${config.url}/${config.baseDN}") - env.put(Context.SECURITY_AUTHENTICATION, config.method) - - if (username.contains("@")) { - env.put(Context.SECURITY_PRINCIPAL, username) - } else if (!config.domain.isEmpty()){ - env.put(Context.SECURITY_PRINCIPAL, s"$username@${config.domain}") - } else { - env.put(Context.SECURITY_PRINCIPAL, config.userformat.format(username,config.baseDN)) - } - log.debug(s"Logging into LDAP with user ${env.get(Context.SECURITY_PRINCIPAL)}") - env.put(Context.SECURITY_CREDENTIALS, password) + def checkUserAuth(username: String, password: String): Boolean = { + val props = new Hashtable[String, String]() + props.put(Context.SECURITY_PRINCIPAL, config.userTemplate.format(username, config.baseDN)) + props.put(Context.SECURITY_CREDENTIALS, password) try { - val ctx = new InitialDirContext(env) - ctx.close() - Some(username) + LdapCtxFactory.getLdapCtxInstance(config.url, props) + true } catch { - case ex: AuthenticationException => - log.info(s"login of $username failed with: ${ex.getMessage}") - None + case e: AuthenticationException => + log.info(s"login of $username failed with: ${e.getMessage}") + false case NonFatal(e) => log.error(s"login of $username failed", e) - None + false + } + } + + def checkGroupMembership(username: String, groupConfig: LDAPGroupSearchConfig): Boolean = { + val props = new Hashtable[String, String]() + props.put(Context.SECURITY_PRINCIPAL, config.bindDN) + props.put(Context.SECURITY_CREDENTIALS, config.bindPwd) + props.put(Context.REFERRAL, "follow") + val user = config.userTemplate.format(username, config.baseDN) + val controls = new SearchControls() + controls.setSearchScope(SearchControls.SUBTREE_SCOPE) + try { + val context = LdapCtxFactory.getLdapCtxInstance(config.url, props) + val search = context.search(groupConfig.baseDN,s"(& (${groupConfig.userAttr}=$user)(${groupConfig.group}))", controls) + context.close() + search.hasMore() + } catch { + case e: AuthenticationException => + log.info(s"User $username doesn't fulfill condition (${groupConfig.group}) : ${e.getMessage}") + false + case NonFatal(e) => + log.error(s"Unexpected error while checking group membership of $username", e) + false + } + } + + def auth(username: String, password: String): Option[String] = { + val isValidUser = config.groupMembership match { + case Some(groupConfig) => checkGroupMembership(username, groupConfig) && checkUserAuth(username, password) + case None => checkUserAuth(username, password) } + if (isValidUser) Some(username) else None } } diff --git a/conf/application.conf b/conf/application.conf index a16d1297..61640a9a 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -23,36 +23,39 @@ es = { # Authentication auth = { - # Example of LDAP authentication - #type: ldap - #settings: { - #url = "ldap://host:port" - #base-dn = "ou=active,ou=Employee" - # OpenLDAP might be something like - #base-dn = "ou=People,dc=domain,dc=com" - # Usually method should be left as simple - # Otherwise, set it to the SASL mechanisms to try - #method = "simple" - # Usernames in the form of email addresses (containing @) are passed through unchanged - # Set user-domain to append @user-domain to bare usernames - #user-domain = "domain.com" - # Or leave empty to use user-format formatting - #user-domain = "" - # user-format executes a string.format() operation where - # username is passed in first, followed by base-dn - # Leave username unchanged - #user-format = "%s" - # Like setting user-domain - #user-format = "%s@domain.com" - # Common for OpenLDAP - #user-format = "uid=%s,%s" - #} - # Example of simple username/password authentication - #type: basic - #settings: { - #username = "admin" - #password = "1234" - #} + # either basic or ldap + type: ${?AUTH_TYPE} + settings { + # LDAP + url = ${?LDAP_URL} + # OpenLDAP might be something like "ou=People,dc=domain,dc=com" + base-dn = ${?LDAP_BASE_DN} + # Usually method should be "simple" otherwise, set it to the SASL mechanisms to try + method = ${?LDAP_METHOD} + # user-template executes a string.format() operation where + # username is passed in first, followed by base-dn. Some examples + # - %s => leave user untouched + # - %s@domain.com => append "@domain.com" to username + # - uid=%s,%s => usual case of OpenLDAP + user-template = ${?LDAP_USER_TEMPLATE} + // User identifier that can perform searches + bind-dn = ${?LDAP_BIND_DN} + bind-pw = ${?LDAP_BIND_PWD} + group-search { + // If left unset parent's base-dn will be used + base-dn = ${?LDAP_GROUP_BASE_DN} + // Attribute that represent the user, for example uid or mail + user-attr = ${?LDAP_USER_ATTR} + // Filter that tests membership of the group. If this property is empty then there is no group membership check + // AD example => memberOf=CN=mygroup,ou=ouofthegroup,DC=domain,DC=com + // OpenLDAP example => CN=mygroup + group = ${?LDAP_GROUP} + } + + # Basic auth + username = ${?BASIC_AUTH_USER} + password = ${?BASIC_AUTH_PWD} + } } # A list of known hosts