From ea9019e5ac924104ba768326381fdc5640468466 Mon Sep 17 00:00:00 2001 From: Derek Chen-Becker Date: Wed, 24 Nov 2010 10:06:27 -0700 Subject: [PATCH] Overhaul of the LDAP module Closes #738 Major overhaul of the LDAP module to streamline configuration and error handling. Major new functionality is: 1. Deprecation of old "parameter" var in favor of configure methods and Inject pattern for all configurable values 2. Automated testing and retry behavior for iffy connections 3. A unit test that utilizes ApacheDS to run an internal test LDAP server --- lift-ldap/.gitignore | 1 + lift-ldap/pom.xml | 38 +- .../main/scala/net/liftweb/ldap/LDAP.scala | 498 ++++++++++++++---- .../net/liftweb/ldap/LDAPProtoUser.scala | 2 +- .../scala/net/liftweb/ldap/LdapSpec.scala | 144 +++++ 5 files changed, 577 insertions(+), 106 deletions(-) create mode 100644 lift-ldap/.gitignore create mode 100644 lift-ldap/src/test/scala/net/liftweb/ldap/LdapSpec.scala diff --git a/lift-ldap/.gitignore b/lift-ldap/.gitignore new file mode 100644 index 0000000..f78c92a --- /dev/null +++ b/lift-ldap/.gitignore @@ -0,0 +1 @@ +server-work/ diff --git a/lift-ldap/pom.xml b/lift-ldap/pom.xml index 1c39361..6a1c16a 100644 --- a/lift-ldap/pom.xml +++ b/lift-ldap/pom.xml @@ -61,12 +61,48 @@ junit junit + + org.apache.directory.server + apacheds-core + 1.5.5 + test + + + org.apache.directory.server + apacheds-server-integ + 1.5.5 + test + + + org.apache.directory.server + apacheds-core-integ + 1.5.5 + test + - + + + + + maven-clean-plugin + + + + . + + server-work + + false + + + + + + LDAPSearch.search: Searching for '%s'".format(filter)) + def attributesFromDn(dn: String): Attributes = + initialContext.getAttributes(dn) - var list = List[String]() + /** + * Searches the base DN for entities matching the given filter. + */ + def search(filter: String): List[String] = { + logger.debug("Searching for '%s'".format(filter)) - val ctx = initialContext + val resultList = new ListBuffer[String]() - if (!ctx.isEmpty) { - val result = ctx.get.search(parameters().getOrElse("ldap.base", DEFAULT_BASE_DN), - filter, - getSearchControls()) + val searchResults = initialContext.search(ldapBaseDn.vend, + filter, + searchControls.vend) - while(result.hasMore()) { - var r = result.next() - list = list ::: List(r.getName) - } - } - - return list + while(searchResults.hasMore()) { + resultList += searchResults.next().getName } - - def bindUser(dn: String, password: String) : Boolean = { - logger.debug("--> LDAPSearch.bindUser: Try to bind user '%s'".format(dn)) - - var result = false - - try { - val username = dn + "," + parameters().getOrElse("ldap.base", DEFAULT_BASE_DN) - var ctx = getInitialContext(parameters() ++ Map("ldap.userName" -> username, - "ldap.password" -> password)) - result = !ctx.isEmpty - ctx.get.close - } - catch { - case e: Exception => println(e) + + return resultList.reverse.toList + } + + /** + * Attempts to authenticate the given DN against the configured + * LDAP provider. + */ + def bindUser(dn: String, password: String) : Boolean = { + logger.debug("Attempting to bind user '%s'".format(dn)) + + try { + val username = dn + "," + ldapBaseDn.vend + var ctx = + ldapUser.doWith(username) { + ldapPassword.doWith(password) { + openInitialContext() + } } - logger.debug("--> LDAPSearch.bindUser: Bind successfull ? %s".format(result)) + ctx.close - return result + logger.info("Successfully authenticated " + dn) + true + } catch { + case ae : AuthenticationException => { + logger.warn("Authentication failed for '%s' : %s".format(dn, ae.getMessage)) + false + } } + } + + // This caches the context for the current thread + private[this] final val currentInitialContext = new ThreadGlobal[InitialLdapContext]() + + /** + * This method attempts to fetch the cached InitialLdapContext for the + * current thread. If there isn't a current context, open a new one. If a + * test DN is configured, the connection (cached or new) will be validated + * by performing a lookup on the test DN. + */ + protected def getInitialContext() : InitialLdapContext = { + val maxAttempts = retryMaxCount.vend + var attempts = 0 + + var context : Box[InitialLdapContext] = Empty + + while (context.isEmpty && attempts < maxAttempts) { + try { + context = (currentInitialContext.box, testLookup.vend) match { + // If we don't want to test an existing context, just return it + case (Full(ctxt), Empty) => Full(ctxt) + case (Full(ctxt), Full(test)) => { + logger.debug("Testing InitialContext prior to returning") + ctxt.lookup(test) + Full(ctxt) + } + case (Empty,_) => { + // We'll just allocate a new InitialContext to the thread + currentInitialContext(openInitialContext()) + + // Setting context to Empty here forces one more iteration in case a test + // DN has been configured + Empty + } + } + } catch { + case commE : CommunicationException => { + logger.error("Communcations failure on attempt %d while " + + "verifying InitialContext: %s".format(attempts + 1, commE.getMessage)) - // TODO : Allow search controls && attributes without override method ? - def getSearchControls() : SearchControls = { - val searchAttributes = new Array[String](1) - searchAttributes(0) = "cn" + // The current context failed, so clear it + currentInitialContext(null) - val constraints = new SearchControls() - constraints.setSearchScope(SearchControls.SUBTREE_SCOPE) - constraints.setReturningAttributes(searchAttributes) - return constraints + // We sleep before retrying + Thread.sleep(retryInterval.vend) + attempts += 1 + } + } } - private def getInitialContext(props: StringMap) : Option[InitialLdapContext] = { - logger.debug("--> LDAPSearch.getInitialContext: Get initial context from '%s'".format(props.get("ldap.url"))) - - var env = new Hashtable[String, String]() - env.put(Context.PROVIDER_URL, props.getOrElse("ldap.url", DEFAULT_URL)) - env.put(Context.SECURITY_AUTHENTICATION, "simple") - env.put(Context.SECURITY_PRINCIPAL, props.getOrElse("ldap.userName", DEFAULT_USER)) - env.put(Context.SECURITY_CREDENTIALS, props.getOrElse("ldap.password", DEFAULT_PASSWORD)) - env.put(Context.INITIAL_CONTEXT_FACTORY, parameters().getOrElse("ldap.initial_context_factory", - DEFAULT_INITIAL_CONTEXT_FACTORY)) - return Some(new InitialLdapContext(env, null)) + // We have a final check on the context before returning + context match { + case Full(ctxt) => ctxt + case Empty => throw new CommunicationException("Failed to connect to '%s' after %d attempts". + format(ldapUrl.vend, attempts)) } + } + + /** + * This method does the actual work of setting up the environment and constructing + * the InitialLdapContext. + */ + protected def openInitialContext () : InitialLdapContext = { + logger.debug("Obtaining an initial context from '%s'".format(ldapUrl.vend)) + + var env = new Hashtable[String, String]() + env.put(Context.PROVIDER_URL, ldapUrl.vend) + env.put(Context.SECURITY_AUTHENTICATION, ldapAuthType.vend) + env.put(Context.SECURITY_PRINCIPAL, ldapUser.vend) + env.put(Context.SECURITY_CREDENTIALS, ldapPassword.vend) + env.put(Context.INITIAL_CONTEXT_FACTORY, ldapFactory.vend) + new InitialLdapContext(env, null) + } } -} -} +}} // Close nested packages + diff --git a/lift-ldap/src/main/scala/net/liftweb/ldap/LDAPProtoUser.scala b/lift-ldap/src/main/scala/net/liftweb/ldap/LDAPProtoUser.scala index 719e129..b53c056 100644 --- a/lift-ldap/src/main/scala/net/liftweb/ldap/LDAPProtoUser.scala +++ b/lift-ldap/src/main/scala/net/liftweb/ldap/LDAPProtoUser.scala @@ -103,7 +103,7 @@ trait MetaLDAPProtoUser[ModelType <: LDAPProtoUser[ModelType]] extends MetaMegaP } - def ldapVendor: SimpleLDAPVendor = SimpleLDAPVendor + def ldapVendor: LDAPVendor = SimpleLDAPVendor override def login : NodeSeq = { if (S.post_?) { diff --git a/lift-ldap/src/test/scala/net/liftweb/ldap/LdapSpec.scala b/lift-ldap/src/test/scala/net/liftweb/ldap/LdapSpec.scala new file mode 100644 index 0000000..310c93c --- /dev/null +++ b/lift-ldap/src/test/scala/net/liftweb/ldap/LdapSpec.scala @@ -0,0 +1,144 @@ +/* + * Copyright 2010 WorldWide Conferencing, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.liftweb { +package ldap { + +import _root_.org.specs._ +import _root_.org.specs.runner.JUnit3 +import _root_.org.specs.runner.ConsoleRunner + +import javax.naming.CommunicationException + +import org.apache.mina.util.AvailablePortFinder +import org.apache.directory.server.core.DefaultDirectoryService +import org.apache.directory.server.core.partition.impl.btree.jdbm.{JdbmIndex,JdbmPartition} +import org.apache.directory.server.ldap.LdapServer +import org.apache.directory.server.protocol.shared.transport.TcpTransport +import org.apache.directory.server.xdbm.Index +import org.apache.directory.server.core.entry.ServerEntry +import org.apache.directory.shared.ldap.name.LdapDN + +class LdapSpecsAsTest extends JUnit3(LdapSpecs) +object LdapSpecsRunner extends ConsoleRunner(LdapSpecs) + +object LdapSpecs extends Specification { + val ROOT_DN = "dc=ldap,dc=liftweb,dc=net" + + // Thanks to Francois Armand for pointing this utility out! + val service_port = AvailablePortFinder.getNextAvailable(40000) + val service = new DefaultDirectoryService + val ldap = new LdapServer + + + /* + * The following is taken from: + * http://directory.apache.org/apacheds/1.5/41-embedding-apacheds-into-an-application.html + * http://stackoverflow.com/questions/1560230/running-apache-ds-embedded-in-my-application + */ + doBeforeSpec { + try { + // Disable changelog + service.getChangeLog.setEnabled(false) + + // Set up a partition + val partition = new JdbmPartition + partition.setId("lift-ldap") + partition.setSuffix(ROOT_DN) + service.addPartition(partition) + + // Index attributes (gnarly type due to poor type inferencing) + val indices : java.util.Set[Index[_,ServerEntry]] = new java.util.HashSet() + + List("objectClass", "ou", "uid", "sn").foreach { + attr : String => indices.add(new JdbmIndex(attr)) + } + + partition.setIndexedAttributes(indices) + + // Set up the transport to use our "available" port + ldap.setTransports(new TcpTransport(service_port)) + ldap.setDirectoryService(service) + + service.startup() + + // Inject the root entry if it does not already exist + if ( !service.getAdminSession().exists(partition.getSuffixDn)) { + println("Adding root entry") + val rootEntry = service.newEntry(new LdapDN(ROOT_DN)) + rootEntry.add( "objectClass", "top", "domain", "extensibleObject" ); + rootEntry.add( "dc", "ldap" ); + service.getAdminSession().add( rootEntry ); + } + + addTestData() + + ldap.start() + + println("Started LDAP server on port " + service_port) + } catch { + case e => e.printStackTrace + } + } + + "LDAPVendor" should { + shareVariables() + + object myLdap extends LDAPVendor + + myLdap.configure(Map("ldap.url" -> "ldap://localhost:%d/".format(service_port), + "ldap.base" -> "dc=ldap,dc=liftweb,dc=net")) + + "handle simple lookups" in { + myLdap.search("objectClass=person") must_== List("cn=Test User") + } + + "handle simple authentication" in { + myLdap.bindUser("cn=Test User", "letmein") + } + + "attempt reconnects" in { + object badLdap extends LDAPVendor + badLdap.configure() + + badLdap.search("objectClass=person") must throwA[CommunicationException] + } + } + + + doAfterSpec { + ldap.stop() + service.shutdown() + println("Stopped server") + } + + def addTestData() { + val username = new LdapDN("cn=Test User," + ROOT_DN) + if (! service.getAdminSession().exists(username)) { + println("Adding test user") + // Add a test user. This will be used for searching and binding + val entry = service.newEntry(username) + entry.add("objectClass", "person", "organizationalPerson") + entry.add("cn", "Test User") + entry.add("sn", "User") + entry.add("userpassword", "letmein") + service.getAdminSession.add(entry) + } + } +} + + +}} // Close nested packages