View Javadoc

1   /*
2    * Copyright (c) 2004 UNINETT FAS
3    *
4    * This program is free software; you can redistribute it and/or modify it
5    * under the terms of the GNU General Public License as published by the Free
6    * Software Foundation; either version 2 of the License, or (at your option)
7    * any later version.
8    *
9    * This program is distributed in the hope that it will be useful, but WITHOUT
10   * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11   * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12   * more details.
13   *
14   * You should have received a copy of the GNU General Public License along with
15   * this program; if not, write to the Free Software Foundation, Inc., 59 Temple
16   * Place - Suite 330, Boston, MA 02111-1307, USA.
17   *
18   */
19  
20  package no.feide.moria.directory.backend;
21  
22  import java.io.UnsupportedEncodingException;
23  import java.util.ArrayList;
24  import java.util.Arrays;
25  import java.util.HashMap;
26  import java.util.Hashtable;
27  import java.util.Vector;
28  
29  import javax.naming.AuthenticationException;
30  import javax.naming.AuthenticationNotSupportedException;
31  import javax.naming.ConfigurationException;
32  import javax.naming.Context;
33  import javax.naming.NameNotFoundException;
34  import javax.naming.NamingEnumeration;
35  import javax.naming.NamingException;
36  import javax.naming.SizeLimitExceededException;
37  import javax.naming.TimeLimitExceededException;
38  import javax.naming.directory.Attribute;
39  import javax.naming.directory.Attributes;
40  import javax.naming.directory.BasicAttributes;
41  import javax.naming.directory.SearchControls;
42  import javax.naming.directory.SearchResult;
43  import javax.naming.ldap.InitialLdapContext;
44  
45  import no.feide.moria.directory.Credentials;
46  import no.feide.moria.directory.index.IndexedReference;
47  import no.feide.moria.log.MessageLogger;
48  
49  import org.apache.commons.codec.binary.Base64;
50  
51  /***
52   * Java Naming and Directory Interface (JNDI) backend. Used to authenticate
53   * users and retrieve the associated attributes.
54   */
55  public class JNDIBackend
56  implements DirectoryManagerBackend {
57  
58      /*** The message logger. */
59      private final MessageLogger log = new MessageLogger(JNDIBackend.class);
60  
61      /*** The external reference of this backend. */
62      private IndexedReference[] myReferences;
63  
64      /*** The connection timeout used. */
65      private final int myTimeout;
66  
67      /*** Default initial LDAP context environment. */
68      private Hashtable defaultEnv;
69  
70      /*** The name of the attribute holding the username. */
71      private String usernameAttribute;
72  
73      /*** The name of the attribute used to guess a user's (R)DN. */
74      private String guessedAttribute;
75  
76      /*** The session ticket used when logging from this instance. */
77      private String mySessionTicket;
78  
79  
80      /***
81       * Protected constructor. Creates an initial default context environment and
82       * adds support for referrals, a fix for OpenSSL aliases, and enables SSL as
83       * default.
84       * @param sessionTicket
85       *            The session ticket for this instance, used when logging. May
86       *            be <code>null</code> (which is treated as an empty string)
87       *            or an empty string.
88       * @param timeout
89       *            The number of seconds before a connection attempt through this
90       *            backend times out.
91       * @param ssl
92       *            <code>true</code> if SSL is to be used, otherwise
93       *            <code>false</code>.
94       * @param usernameAttributeName
95       *            The name of the attribute holding the username. Cannot be
96       *            <code>null</code>.
97       * @param guessedAttributeName
98       *            If we search but cannot find a user element (for example, if
99       *            it is not searchable), we will guess that the (R)DN starts
100      *            with the substring
101      *            <code><i>guessedAttributeName</i>=<i>usernamePrefix</i></code>,
102      *            where <code><i>usernamePrefix</i></code> is the part of the
103      *            username preceding the 'at' character. Cannot be
104      *            <code>null</code>.
105      * @throws IllegalArgumentException
106      *             If <code>timeout</code> is less than zero.
107      * @throws NullPointerException
108      *             If <code>guessedAttributeName</code> or
109      *             <code>usernameAttribute</code> is <code>null</code>.
110      */
111     protected JNDIBackend(final String sessionTicket, final int timeout,
112                           final boolean ssl,
113                           final String usernameAttributeName,
114                           final String guessedAttributeName)
115     throws IllegalArgumentException, NullPointerException {
116 
117         // Assignments, with sanity checks.
118         if (usernameAttributeName == null)
119             throw new NullPointerException("Username attribute name cannot be NULL");
120         usernameAttribute = usernameAttributeName;
121         if (guessedAttributeName == null)
122             throw new NullPointerException("Guessed attribute name cannot be NULL");
123         guessedAttribute = guessedAttributeName;
124         if (timeout < 0)
125             throw new IllegalArgumentException("Timeout must be greater than zero");
126         myTimeout = timeout;
127         mySessionTicket = sessionTicket;
128         if (mySessionTicket == null)
129             mySessionTicket = "";
130 
131         // Create initial context environment.
132         defaultEnv = new Hashtable();
133         defaultEnv.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
134 
135         // To catch referrals.
136         defaultEnv.put(Context.REFERRAL, "throw");
137 
138         // Due to OpenSSL problems.
139         defaultEnv.put("java.naming.ldap.derefAliases", "never");
140 
141         // Use LDAP v3.
142         defaultEnv.put("java.naming.ldap.version", "3");
143 
144         // Should we enable SSL?
145         if (ssl)
146             defaultEnv.put(Context.SECURITY_PROTOCOL, "ssl");
147 
148     }
149 
150 
151     /***
152      * Opens this backend. Does not actually initialize the network connection
153      * to the external LDAP.
154      * @param references
155      *            The external reference to the LDAP server. Cannot be
156      *            <code>null</code>, and must contain at least one reference.
157      * @throws IllegalArgumentException
158      *             If <code>reference</code> is <code>null</code>, or an
159      *             empty array.
160      */
161     public final void open(final IndexedReference[] references) {
162 
163         // Sanity check.
164         if ((references == null) || (references.length == 0))
165             throw new IllegalArgumentException("Reference cannot be NULL or an empty array");
166 
167         // Create a local copy of the references.
168         ArrayList newReferences = new ArrayList(references.length);
169         for (int i = 0; i < references.length; i++)
170             newReferences.add(references[i]);
171         myReferences = (IndexedReference[]) newReferences.toArray(new IndexedReference[] {});
172 
173     }
174 
175 
176     /***
177      * Checks whether a user element exists, based on its username value.
178      * @param username
179      *            User name.
180      * @return <code>true</code> if the user can be looked up through JNDI,
181      *         otherwise <code>false</code>.
182      * @throws BackendException
183      *             If there is a problem accessing the backend.
184      */
185     public final boolean userExists(final String username) throws BackendException {
186 
187         // Sanity checks.
188         if ((username == null) || (username.length() == 0))
189             return false;
190 
191         // The search pattern.
192         String pattern = usernameAttribute + '=' + username;
193 
194         // Go through all references.
195         for (int i = 0; i < myReferences.length; i++) {
196             String[] references = myReferences[i].getReferences();
197             for (int j = 0; j < references.length; j++) {
198 
199                 // Search this reference.
200                 InitialLdapContext ldap = null;
201                 try {
202                     ldap = connect(references[j]);
203                     if (ldapSearch(ldap, pattern) != null)
204                         return true;
205                 } catch (NamingException e) {
206                     // Unable to connect, but we might have other sources.
207                     log.logWarn("Unable to access the backend on '" + references[j] + "': " + e.getClass().getName());
208                     log.logDebug("Stack trace:", e);
209                     continue;
210                 } finally {
211 
212                     // Close the LDAP connection.
213                     if (ldap != null) {
214                         try {
215                             ldap.close();
216                         } catch (NamingException e) {
217                             // Ignored.
218                             log.logWarn("Unable to close the backend connection to '" + references[j] + "': " + e.getClass().getName(), mySessionTicket);
219                             log.logDebug("Stack trace:", e);
220                         }
221                     }
222                 }
223 
224             }
225         }
226 
227         // Still no match.
228         return false;
229 
230     }
231 
232 
233     /***
234      * Authenticates the user using the supplied credentials and retrieves the
235      * requested attributes.
236      * @param userCredentials
237      *            User's credentials. Cannot be <code>null</code>.
238      * @param attributeRequest
239      *            Requested attributes.
240      * @return The requested attributes (<code>String</code> names and
241      *         <code>String[]</code> values), if they did exist in the
242      *         external backend. Otherwise returns those attributes that could
243      *         actually be read, this may be an empty <code>HashMap</code>.
244      *         Returns an empty <code>HashMap</code> if
245      *         <code>attributeRequest</code> is <code>null</code> or an
246      *         empty array.
247      * @throws AuthenticationFailedException
248      *             If the authentication fails.
249      * @throws BackendException
250      *             If there is a problem accessing the backend.
251      * @throws IllegalArgumentException
252      *             If <code>userCredentials</code> is <code>null</code>.
253      */
254     public final HashMap authenticate(final Credentials userCredentials,
255                                       final String[] attributeRequest) throws AuthenticationFailedException,
256                                                                       BackendException {
257 
258         // Sanity check.
259         if (userCredentials == null)
260             throw new IllegalArgumentException("Credentials cannot be NULL");
261 
262         // Go through all references.
263         for (int i = 0; i < myReferences.length; i++) {
264             final String[] references = myReferences[i].getReferences();
265             final String[] usernames = myReferences[i].getUsernames();
266             final String[] passwords = myReferences[i].getPasswords();
267             for (int j = 0; j < references.length; j++) {
268 
269                 // For the benefit of the finally block below.
270                 InitialLdapContext ldap = null;
271 
272                 try {
273 
274                     // Context for this reference.
275                     try {
276                         ldap = connect(references[j]);
277                     } catch (NamingException e) {
278                         // Connection failed, but we might have other sources.
279                         log.logWarn("Unable to access the backend on '" + references[j] + "': " + e.getClass().getName());
280                         log.logDebug("Stack trace:", e);
281                         continue;
282                     }
283 
284                     // Skip search phase if the reference(s) are explicit.
285                     String rdn = "";
286                     if (myReferences[i].isExplicitlyIndexed()) {
287 
288                         // Add the explicit reference; no search phase, no RDN.
289                         ldap.addToEnvironment(Context.SECURITY_PRINCIPAL, references[j].substring(references[j].lastIndexOf('/') + 1));
290 
291                     } else {
292 
293                         // Anonymous search or not?
294                         ldap.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple");
295                         if ((usernames[j].length() == 0) && (passwords[j].length() > 0))
296                             log.logWarn("Search username is empty but search password is not - possible index problem");
297                         else if ((passwords[j].length() == 0) && (usernames[j].length() > 0))
298                             log.logWarn("Search password is empty but search username is not - possible index problem");
299                         else if ((passwords[j].length() == 0) && (usernames[j].length() == 0)) {
300                             log.logDebug("Anonymous search for user element DN");
301                             ldap.removeFromEnvironment(Context.SECURITY_AUTHENTICATION);
302                         } else
303                             log.logDebug("Non-anonymous search for user element DN");
304                         ldap.addToEnvironment(Context.SECURITY_PRINCIPAL, usernames[j]);
305                         ldap.addToEnvironment(Context.SECURITY_CREDENTIALS, passwords[j]);
306 
307                         // Search using the implicit reference.
308                         String pattern = usernameAttribute + '=' + userCredentials.getUsername();
309                         rdn = ldapSearch(ldap, pattern);
310                         if (rdn == null) {
311 
312                             // No user element found. Try to guess the RDN.
313                             rdn = userCredentials.getUsername();
314                             rdn = guessedAttribute + '=' + rdn.substring(0, rdn.indexOf('@'));
315                             log.logDebug("No subtree match for " + pattern + " on " + references[j] + " - guessing on RDN " + rdn, mySessionTicket);
316 
317                         }
318                         log.logDebug("Matched " + pattern + " to " + rdn + ',' + ldap.getNameInNamespace());
319                         ldap.addToEnvironment(Context.SECURITY_PRINCIPAL, rdn + ',' + ldap.getNameInNamespace());
320                     }
321 
322                     // Authenticate and get attributes.
323                     ldap.addToEnvironment(Context.SECURITY_AUTHENTICATION, "simple");
324                     ldap.addToEnvironment(Context.SECURITY_CREDENTIALS, userCredentials.getPassword());
325                     try {
326                         ldap.reconnect(null);
327                         log.logDebug("Successfully authenticated " + userCredentials.getUsername() + " on " + references[j], mySessionTicket);
328                         return getAttributes(ldap, rdn, attributeRequest); // Success.
329                     } catch (AuthenticationException e) {
330 
331                         // Authentication failed, but we may have other
332                         // references.
333                         log.logDebug("Failed to authenticate user " + userCredentials.getUsername() + " on " + references[j], mySessionTicket);
334                         continue;
335 
336                     } catch (AuthenticationNotSupportedException e) {
337 
338                         // Password authentication not supported for the DN.
339                         // We may still have other references.
340                         log.logDebug("Failed to authenticate user " + userCredentials.getUsername() + " on " + references[j], mySessionTicket);
341                         continue;
342 
343                     }
344 
345                 } catch (ConfigurationException e) {
346                     throw new BackendException("Backend configuration problem with " + references[j], e);
347                 } catch (NamingException e) {
348                     throw new BackendException("Unable to access the backend on " + references[j], e);
349                 } finally {
350 
351                     // Close the LDAP connection.
352                     if (ldap != null) {
353                         try {
354                             ldap.close();
355                         } catch (NamingException e) {
356                             // Ignored.
357                             log.logWarn("Unable to close the backend connection to " + references[j] + " - ignoring", mySessionTicket, e);
358                         }
359                     }
360                 }
361 
362             }
363         }
364 
365         // No user was found.
366         throw new AuthenticationFailedException("Failed to authenticate user " + userCredentials.getUsername());
367 
368     }
369 
370 
371     /***
372      * Retrieves a list of attributes from an element.
373      * @param ldap
374      *            A prepared LDAP context. Cannot be <code>null</code>.
375      * @param rdn
376      *            The relative DN (to the DN in the LDAP context
377      *            <code>ldap</code>). Cannot be <code>null</code>.
378      * @param attributes
379      *            The requested attribute's names.
380      * @return The requested attributes (<code>String</code> names and
381      *         <code>String[]</code> values), if they did exist in the
382      *         external backend. Otherwise returns those attributes that could
383      *         actually be read, this may be an empty <code>HashMap</code>.
384      *         Returns an empty <code>HashMap</code> if
385      *         <code>attributes</code> is <code>null</code> or an empty
386      *         array. Note that attribute values are mapped to
387      *         <code>String</code> using ISO-8859-1.
388      * @throws BackendException
389      *             If unable to read the attributes from the backend.
390      * @throws NullPointerException
391      *             If <code>ldap</code> or <code>rdn</code> is
392      *             <code>null</code>.
393      * @see javax.naming.directory.InitialDirContext#getAttributes(java.lang.String,
394      *      java.lang.String[])
395      */
396     private HashMap getAttributes(final InitialLdapContext ldap,
397                                   final String rdn,
398                                   final String[] attributes) throws BackendException {
399 
400         // Sanity checks.
401         if (ldap == null)
402             throw new NullPointerException("LDAP context cannot be NULL");
403         if (rdn == null)
404             throw new NullPointerException("RDN cannot be NULL");
405         if ((attributes == null) || (attributes.length == 0))
406             return new HashMap();
407 
408         // Eliminate all occurrences of so-called "virtual" attributes.
409         Vector request = new Vector(Arrays.asList(attributes));
410         for (int i = 0; i < DirectoryManagerBackend.VIRTUAL_ATTRIBUTES.length; i++)
411             while (request.remove(DirectoryManagerBackend.VIRTUAL_ATTRIBUTES[i])) {
412                 // Repeat until done...
413             }
414         String[] parsedAttributes = (String[]) request.toArray(new String[] {});
415 
416         // The context provider URL and DN, for later logging.
417         String url = "unknown backend";
418         String dn = "unknown dn";
419 
420         // Get the attributes from an already initialized LDAP connection.
421         Attributes oldAttrs = null;
422         try {
423 
424             // Remember the URL and bind DN, for later logging.
425             final Hashtable environment = ldap.getEnvironment();
426             url = (String) environment.get(Context.PROVIDER_URL);
427             dn = (String) environment.get(Context.SECURITY_PRINCIPAL);
428 
429             // Get the attributes.
430             oldAttrs = ldap.getAttributes(rdn, parsedAttributes);
431 
432         } catch (NameNotFoundException e) {
433 
434             // Successful authentication but missing user element; no attributes
435             // returned and the event is logged.
436             log.logWarn("No user element found (DN was '" + dn + "')");
437             oldAttrs = new BasicAttributes();
438 
439         } catch (NamingException e) {
440             String a = new String();
441             for (int i = 0; i < attributes.length; i++)
442                 a = a + attributes[i] + ", ";
443             throw new BackendException("Unable to read attribute(s) '" + a.substring(0, a.length() - 2) + "' from '" + rdn + "' on '" + url + "'", e);
444         }
445 
446         // Translate retrieved attributes from Attributes to HashMap.
447         HashMap newAttrs = new HashMap();
448         for (int i = 0; i < parsedAttributes.length; i++) {
449 
450             // Did we get an attribute back at all?
451             Attribute oldAttr = oldAttrs.get(parsedAttributes[i]);
452             if (oldAttr == null) {
453                 log.logDebug("Requested attribute '" + parsedAttributes[i] + "' not found on '" + url + "'", mySessionTicket);
454             } else {
455 
456                 // Map the attribute values to String[].
457                 ArrayList newValues = new ArrayList(oldAttr.size());
458                 for (int j = 0; j < oldAttr.size(); j++) {
459                     try {
460 
461                         // We either have a String or a byte[].
462                         String newValue = null;
463                         try {
464                             newValue = new String(((String) oldAttr.get(j)).getBytes(), DirectoryManagerBackend.ATTRIBUTE_VALUE_CHARSET);
465                         } catch (ClassCastException e) {
466 
467                             // Map byte[] to String, using ISO-8859-1 encoding.
468                             newValue = new String(Base64.encodeBase64((byte[]) oldAttr.get(j)), DirectoryManagerBackend.ATTRIBUTE_VALUE_CHARSET);
469 
470                         }
471                         newValues.add(newValue);
472 
473                     } catch (NamingException e) {
474                         throw new BackendException("Unable to read attribute value of '" + oldAttr.getID() + "' from '" + url + "'", e);
475                     } catch (UnsupportedEncodingException e) {
476                         throw new BackendException("Unable to use ISO-8859-1 encoding", e);
477                     }
478                 }
479                 newAttrs.put(parsedAttributes[i], newValues.toArray(new String[] {}));
480 
481             }
482 
483         }
484         return newAttrs;
485 
486     }
487 
488 
489     /***
490      * Does nothing, but needed to fulfill the
491      * <code>DirectoryManagerBackend</code> interface. Actual backend
492      * connections are closed after each use.
493      * @see DirectoryManagerBackend#close()
494      */
495     public void close() {
496 
497         // Does nothing.
498 
499     }
500 
501 
502     /***
503      * Does a subtree search for an element given a pattern. Only the first
504      * element found is considered, and all references are searched in order
505      * until either a match is found or no more references are left to search.
506      * @param ldap
507      *            A prepared LDAP context.
508      * @param pattern
509      *            The search pattern. Must not include the character '*' or the
510      *            substring '\2a' to prevent possible LDAP exploits.
511      * @return The element's relative DN, or <code>null</code> if none was
512      *         found. <code>null</code> is also returned if the search pattern
513      *         contains an illegal character or substring.
514      * @throws BackendException
515      *             If there was a problem accessing the backend. Typical causes
516      *             include timeouts.
517      */
518     private String ldapSearch(final InitialLdapContext ldap,
519                               final String pattern) throws BackendException {
520 
521         // Check pattern for illegal content.
522         String[] illegals = {"*", "//2a"};
523         for (int i = 0; i < illegals.length; i++) {
524             if (pattern.indexOf(illegals[i]) > -1)
525                 return null;
526         }
527 
528         // The context provider URL, for later logging.
529         String url = "unknown backend";
530 
531         // Start counting the (milli)seconds.
532         long searchStart = System.currentTimeMillis();
533         NamingEnumeration results;
534         try {
535 
536             // Remember the URL, for later logging.
537             url = (String) ldap.getEnvironment().get(Context.PROVIDER_URL);
538 
539             // Perform the search.
540             results = ldap.search("", pattern, new SearchControls(SearchControls.SUBTREE_SCOPE, 0, 1000 * myTimeout, new String[] {}, false, false));
541             if (!results.hasMore())
542                 return null;
543 
544         } catch (TimeLimitExceededException e) {
545 
546             // The search timed out.
547             throw new BackendException("Search on " + url + " for " + pattern + " timed out after " + (System.currentTimeMillis() - searchStart) + "ms", e);
548 
549         } catch (SizeLimitExceededException e) {
550 
551             // The search returned too many results.
552             log.logWarn("Search on " + url + " for " + pattern + " returned too many results", mySessionTicket);
553             return null;
554 
555         } catch (NameNotFoundException e) {
556 
557             // Element not found. Possibly non-existing reference.
558             log.logDebug("Could not find " + pattern + " on " + url, mySessionTicket); // Necessary?
559             return null;
560 
561         } catch (NamingException e) {
562 
563             // All other exceptions.
564             throw new BackendException("Search on " + url + " for " + pattern + " failed", e);
565 
566         }
567 
568         // We just found at least one element. Did we get an ambigious result?
569         SearchResult entry = null;
570         try {
571             entry = (SearchResult) results.next();
572             String buffer = new String();
573             while (results.hasMoreElements())
574                 buffer = buffer + ", " + ((SearchResult) results.next()).getName();
575             if (!buffer.equals(""))
576                 log.logWarn("Search on " + url + " for " + pattern + " gave ambiguous result: [" + entry.getName() + buffer + "]", mySessionTicket);
577             // TODO: Throw BackendException, or a subclass, or just (as now)
578             // pick the first and hope for the best?
579             buffer = null;
580         } catch (NamingException e) {
581             throw new BackendException("Unable to read search results", e);
582         }
583         return entry.getName(); // Relative DN (to the reference).
584 
585     }
586 
587 
588     /***
589      * Creates a new connection to a given backend provider URL.
590      * @param url
591      *            The backend provider URL.
592      * @return The opened backend connection.
593      * @throws NamingException
594      *             If unable to connect to the provider given by
595      *             <code>url</code>.
596      */
597     private InitialLdapContext connect(final String url) throws NamingException {
598 
599         // Prepare connection.
600         Hashtable env = new Hashtable(defaultEnv);
601         env.put(Context.PROVIDER_URL, url);
602         return new InitialLdapContext(env, null);
603 
604     }
605 }