1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
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
132 defaultEnv = new Hashtable();
133 defaultEnv.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
134
135
136 defaultEnv.put(Context.REFERRAL, "throw");
137
138
139 defaultEnv.put("java.naming.ldap.derefAliases", "never");
140
141
142 defaultEnv.put("java.naming.ldap.version", "3");
143
144
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
164 if ((references == null) || (references.length == 0))
165 throw new IllegalArgumentException("Reference cannot be NULL or an empty array");
166
167
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
188 if ((username == null) || (username.length() == 0))
189 return false;
190
191
192 String pattern = usernameAttribute + '=' + username;
193
194
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
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
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
213 if (ldap != null) {
214 try {
215 ldap.close();
216 } catch (NamingException e) {
217
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
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
259 if (userCredentials == null)
260 throw new IllegalArgumentException("Credentials cannot be NULL");
261
262
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
270 InitialLdapContext ldap = null;
271
272 try {
273
274
275 try {
276 ldap = connect(references[j]);
277 } catch (NamingException e) {
278
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
285 String rdn = "";
286 if (myReferences[i].isExplicitlyIndexed()) {
287
288
289 ldap.addToEnvironment(Context.SECURITY_PRINCIPAL, references[j].substring(references[j].lastIndexOf('/') + 1));
290
291 } else {
292
293
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
308 String pattern = usernameAttribute + '=' + userCredentials.getUsername();
309 rdn = ldapSearch(ldap, pattern);
310 if (rdn == null) {
311
312
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
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);
329 } catch (AuthenticationException e) {
330
331
332
333 log.logDebug("Failed to authenticate user " + userCredentials.getUsername() + " on " + references[j], mySessionTicket);
334 continue;
335
336 } catch (AuthenticationNotSupportedException e) {
337
338
339
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
352 if (ldap != null) {
353 try {
354 ldap.close();
355 } catch (NamingException e) {
356
357 log.logWarn("Unable to close the backend connection to " + references[j] + " - ignoring", mySessionTicket, e);
358 }
359 }
360 }
361
362 }
363 }
364
365
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
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
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
413 }
414 String[] parsedAttributes = (String[]) request.toArray(new String[] {});
415
416
417 String url = "unknown backend";
418 String dn = "unknown dn";
419
420
421 Attributes oldAttrs = null;
422 try {
423
424
425 final Hashtable environment = ldap.getEnvironment();
426 url = (String) environment.get(Context.PROVIDER_URL);
427 dn = (String) environment.get(Context.SECURITY_PRINCIPAL);
428
429
430 oldAttrs = ldap.getAttributes(rdn, parsedAttributes);
431
432 } catch (NameNotFoundException e) {
433
434
435
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
447 HashMap newAttrs = new HashMap();
448 for (int i = 0; i < parsedAttributes.length; i++) {
449
450
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
457 ArrayList newValues = new ArrayList(oldAttr.size());
458 for (int j = 0; j < oldAttr.size(); j++) {
459 try {
460
461
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
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
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
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
529 String url = "unknown backend";
530
531
532 long searchStart = System.currentTimeMillis();
533 NamingEnumeration results;
534 try {
535
536
537 url = (String) ldap.getEnvironment().get(Context.PROVIDER_URL);
538
539
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
547 throw new BackendException("Search on " + url + " for " + pattern + " timed out after " + (System.currentTimeMillis() - searchStart) + "ms", e);
548
549 } catch (SizeLimitExceededException e) {
550
551
552 log.logWarn("Search on " + url + " for " + pattern + " returned too many results", mySessionTicket);
553 return null;
554
555 } catch (NameNotFoundException e) {
556
557
558 log.logDebug("Could not find " + pattern + " on " + url, mySessionTicket);
559 return null;
560
561 } catch (NamingException e) {
562
563
564 throw new BackendException("Search on " + url + " for " + pattern + " failed", e);
565
566 }
567
568
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
578
579 buffer = null;
580 } catch (NamingException e) {
581 throw new BackendException("Unable to read search results", e);
582 }
583 return entry.getName();
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
600 Hashtable env = new Hashtable(defaultEnv);
601 env.put(Context.PROVIDER_URL, url);
602 return new InitialLdapContext(env, null);
603
604 }
605 }