View Javadoc
1   /*
2    * Copyright 2012–2019 Michael Osipov
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package net.sf.michaelo.dirctxsrc;
17  
18  import java.io.OutputStream;
19  import java.security.PrivilegedActionException;
20  import java.security.PrivilegedExceptionAction;
21  import java.util.ArrayList;
22  import java.util.Hashtable;
23  import java.util.List;
24  import java.util.logging.Level;
25  import java.util.logging.Logger;
26  
27  import javax.naming.Context;
28  import javax.naming.NamingException;
29  import javax.naming.directory.DirContext;
30  import javax.naming.directory.InitialDirContext;
31  import javax.security.auth.Subject;
32  import javax.security.auth.login.LoginContext;
33  import javax.security.auth.login.LoginException;
34  import javax.security.sasl.Sasl;
35  
36  import org.apache.commons.lang3.StringUtils;
37  import static org.apache.commons.lang3.Validate.*;
38  import org.ietf.jgss.GSSCredential;
39  import org.ietf.jgss.GSSException;
40  import org.ietf.jgss.GSSManager;
41  import org.ietf.jgss.Oid;
42  
43  /**
44   * A JNDI directory context factory returning ready-to-use {@link DirContext} objects. The basic
45   * idea is borrowed from {@link javax.sql.DataSource} where you get a database connection. Same does
46   * this class with directory contexts. This directory context source has built-in support for
47   * anonymous and GSS-API with Kerberos 5 authentication. If you intend to use the latter, make sure
48   * that your environment is properly configured.
49   * <p>
50   * Here is a minimal example how to create a {@code DirContextSource} with the supplied builder:
51   *
52   * <pre>
53   * DirContextSource.Builder builder = new DirContextSource.Builder(&quot;ldap://hostname&quot;);
54   * DirContextSource contextSource = builder.build();
55   * // try and catch block omitted for the sake of brevity, handle NamingException appropriately
56   * DirContext context = contextSource.getDirContext();
57   * // Perform operations
58   * context.close();
59   * </pre>
60   *
61   * Before returning a {@code DirContext} the source will loop several times until a connection has
62   * been established or the number of retries are exhausted, which ever comes first.
63   *
64   * <p>
65   * A {@code DirContextSource} object will be initially preconfigured by its builder for you:
66   * <ol>
67   * <li>The context factory is set by default to {@code com.sun.jndi.ldap.LdapCtxFactory}.</li>
68   * <li>The default authentication scheme is set to none/anonymous.</li>
69   * <li>If GSS-API authentication is used the login entry name defaults to {@code DirContextSource}.
70   * </li>
71   * <li>By default a context source will try once to connect and will wait for 2000 ms between
72   * retries.</li>
73   * </ol>
74   *
75   * <p>
76   * A complete overview of all {@code DirContext} properties can be found
77   * <a href= "https://docs.oracle.com/javase/7/docs/technotes/guides/jndi/jndi-ldap.html">here</a>.
78   * Make sure that you pass reasonable/valid values only otherwise the behavior is undefined.
79   *
80   * @version $Id: DirContextSource.java 240 2019-03-01 10:42:41Z michael-o $
81   */
82  public class DirContextSource {
83  
84  	/**
85  	 * Enum containing all supported authentication mechanisms.
86  	 */
87  	public enum Auth {
88  
89  		NONE("none"), GSSAPI("GSSAPI");
90  
91  		private String securityAuthName;
92  
93  		Auth(String securityAuthName) {
94  			this.securityAuthName = securityAuthName;
95  		}
96  
97  		String getSecurityAuthName() {
98  			return securityAuthName;
99  		}
100 	}
101 
102 	protected final static Oid KRB5_MECHANISM;
103 
104 	static {
105 		try {
106 			KRB5_MECHANISM = new Oid("1.2.840.113554.1.2.2");
107 		} catch (GSSException e) {
108 			throw new IllegalStateException("failed to create OID for Kerberos 5 mechanism");
109 		}
110 	}
111 
112 	private static class GSSInitialDirContext extends InitialDirContext {
113 
114 		public GSSInitialDirContext(Hashtable<?, ?> environment) throws NamingException {
115 			super(environment);
116 		}
117 
118 		@Override
119 		public void close() throws NamingException {
120 			GSSCredential credential = null;
121 
122 			try {
123 				credential = (GSSCredential) getEnvironment().get(Sasl.CREDENTIALS);
124 			} finally {
125 				super.close();
126 			}
127 
128 			if (credential != null) {
129 				try {
130 					credential.dispose();
131 				} catch (GSSException e) {
132 					// ignore
133 				}
134 			}
135 		}
136 
137 	}
138 
139 	private static final Logger logger = Logger.getLogger(DirContextSource.class.getName());
140 	private final Hashtable<String, Object> env;
141 	private final String loginEntryName;
142 	private final int retries;
143 	private final int retryWait;
144 	private final Auth auth;
145 
146 	private DirContextSource(Builder builder) {
147 		env = new Hashtable<String, Object>();
148 
149 		env.put(Context.INITIAL_CONTEXT_FACTORY, builder.contextFactory);
150 		env.put(Context.PROVIDER_URL, StringUtils.join(builder.urls, ' '));
151 		env.put(Context.SECURITY_AUTHENTICATION, builder.auth.getSecurityAuthName());
152 		auth = builder.auth;
153 		loginEntryName = builder.loginEntryName;
154 		if (builder.objectFactories != null)
155 			env.put(Context.OBJECT_FACTORIES, StringUtils.join(builder.objectFactories, ':'));
156 		env.put(Sasl.SERVER_AUTH, Boolean.toString(builder.mutualAuth));
157 		if (builder.qop != null)
158 			env.put(Sasl.QOP, StringUtils.join(builder.qop, ','));
159 		if (builder.debug)
160 			env.put("com.sun.jndi.ldap.trace.ber", builder.debugStream);
161 		retries = builder.retries;
162 		retryWait = builder.retryWait;
163 		if (builder.referral != null)
164 			env.put(Context.REFERRAL, builder.referral);
165 		if (builder.binaryAttributes != null)
166 			env.put("java.naming.ldap.attributes.binary",
167 					StringUtils.join(builder.binaryAttributes, ' '));
168 		env.putAll(builder.additionalProperties);
169 	}
170 
171 	/**
172 	 * A builder to construct a {@link DirContextSource} with a fluent interface.
173 	 *
174 	 * <p>
175 	 * <strong>Notes:</strong>
176 	 * <ol>
177 	 * <li>This class is not thread-safe. Configure the builder in your main thread, build the
178 	 * object and pass it on to your forked threads.</li>
179 	 * <li>An {@code IllegalStateException} is thrown if a property is modified after this builder
180 	 * has already been used to build a {@code DirContextSource}, simply create a new builder in
181 	 * this case.</li>
182 	 * <li>All passed arrays will be defensively copied and null/empty values will be skipped except
183 	 * when all elements are invalid, an exception will be raised.</li>
184 	 * </ol>
185 	 */
186 	public static final class Builder {
187 
188 		// Builder properties
189 		private String contextFactory;
190 		private String[] urls;
191 		private Auth auth;
192 		private String loginEntryName;
193 		private String[] objectFactories;
194 		private boolean mutualAuth;
195 		private String[] qop;
196 		private boolean debug;
197 		private OutputStream debugStream;
198 		private int retries;
199 		private int retryWait;
200 		private String[] binaryAttributes;
201 		private String referral;
202 		private Hashtable<String, Object> additionalProperties;
203 
204 		private boolean done;
205 
206 		/**
207 		 * Constructs a new builder for {@link DirContextSource} with anonymous authentication.
208 		 *
209 		 * <p>
210 		 * <strong>Note:</strong> The default context factory
211 		 * {@code com.sun.jndi.ldap.LdapCtxFactory} will iterate through all URLs/servers until the
212 		 * first one is reachable/available.
213 		 *
214 		 * @param urls
215 		 *            The URL(s) of a directory server. It/they may contain root DNs.
216 		 * @throws NullPointerException
217 		 *             if {@code urls} is null
218 		 * @throws IllegalArgumentException
219 		 *             if {@code urls} is empty
220 		 */
221 		public Builder(String... urls) {
222 			// Initialize default values first as mentioned in the class' Javadoc
223 			contextFactory("com.sun.jndi.ldap.LdapCtxFactory");
224 			auth(Auth.NONE);
225 			retries(1);
226 			retryWait(2000);
227 			additionalProperties = new Hashtable<String, Object>();
228 
229 			urls(urls);
230 		}
231 
232 		/**
233 		 * Sets the context factory for this directory context.
234 		 *
235 		 * @param contextFactory
236 		 *            the context factory class name
237 		 * @throws NullPointerException
238 		 *             if {@code contextFactory} is null
239 		 * @throws IllegalArgumentException
240 		 *             if {@code contextFactory} is empty
241 		 * @return this builder
242 		 */
243 		public Builder contextFactory(String contextFactory) {
244 			check();
245 			this.contextFactory = validateAndReturnString("contextFactory", contextFactory);
246 			return this;
247 		}
248 
249 		private String[] validateAndReturnStringArray(String name, String[] value) {
250 			notEmpty(value, "property '%s' cannot be null or empty", name);
251 
252 			List<String> validatedElements = new ArrayList<String>();
253 			for (String elem : value)
254 				if (StringUtils.isNotEmpty(elem))
255 					validatedElements.add(elem);
256 
257 			notEmpty(validatedElements, "property '%s' cannot be null or empty", name);
258 
259 			return validatedElements.toArray(new String[validatedElements.size()]);
260 		}
261 
262 		private String validateAndReturnString(String name, String value) {
263 			return notEmpty(value, "property '%s' cannot be null or empty", name);
264 		}
265 
266 		private <T> T validateAndReturnObject(String name, T value) {
267 			return notNull(value, "property '%s' cannot be null", name);
268 		}
269 
270 		private Builder urls(String... urls) {
271 			check();
272 			this.urls = validateAndReturnStringArray("urls", urls);
273 			return this;
274 		}
275 
276 		/**
277 		 * Sets the authentication scheme.
278 		 *
279 		 * @param auth
280 		 *            the auth to be used
281 		 * @throws NullPointerException
282 		 *             if {@code auth} is null
283 		 * @return this builder
284 		 */
285 		public Builder auth(Auth auth) {
286 			check();
287 			this.auth = validateAndReturnObject("auth", auth);
288 
289 			// Workaround for a bug in the SASL GSSAPI plugin where RFC 4752 is violated
290 			// https://bugs.openjdk.java.net/browse/JDK-8160818
291 			if (auth == Auth.GSSAPI)
292 				mutualAuth().qop("auth-int");
293 
294 			return this;
295 		}
296 
297 		/**
298 		 * Sets the login entry name for GSS-API authentication.
299 		 *
300 		 * @param loginEntryName
301 		 *            the login entry name which retrieves the GSS-API credential
302 		 * @throws NullPointerException
303 		 *             if {@code loginEntryName} is null
304 		 * @throws IllegalArgumentException
305 		 *             if {@code loginEntryName} is empty
306 		 * @return this builder
307 		 */
308 		public Builder loginEntryName(String loginEntryName) {
309 			check();
310 			this.loginEntryName = validateAndReturnString("loginEntryName", loginEntryName);
311 			return this;
312 		}
313 
314 		/**
315 		 * Enables anonymous authentication.
316 		 *
317 		 * @return this builder
318 		 */
319 		public Builder anonymousAuth() {
320 			return auth(Auth.NONE);
321 		}
322 
323 		/**
324 		 * Enables GSS-API authentication with a default login entry name.
325 		 *
326 		 * @return this builder
327 		 */
328 		public Builder gssApiAuth() {
329 			return gssApiAuth("DirContextSource");
330 		}
331 
332 		/**
333 		 * Enables GSS-API authentication with a custom login entry name.
334 		 *
335 		 * @param loginEntryName
336 		 *            the login entry name which retrieves the GSS-API credential
337 		 * @throws NullPointerException
338 		 *             if {@code loginEntryName} is null
339 		 * @throws IllegalArgumentException
340 		 *             if {@code loginEntryName} is empty
341 		 * @see #loginEntryName(String)
342 		 * @return this builder
343 		 */
344 
345 		public Builder gssApiAuth(String loginEntryName) {
346 			auth(Auth.GSSAPI).loginEntryName(loginEntryName);
347 			return this;
348 		}
349 
350 		/**
351 		 * Sets the object factories for this directory context.
352 		 *
353 		 * @param objectFactories
354 		 *            the objectFactories class names
355 		 * @throws NullPointerException
356 		 *             if {@code objectFactories} is null
357 		 * @throws IllegalArgumentException
358 		 *             if {@code objectFactories} is empty
359 		 * @return this builder
360 		 */
361 		public Builder objectFactories(String... objectFactories) {
362 			check();
363 			this.objectFactories = validateAndReturnStringArray("objectFactories", objectFactories);
364 			return this;
365 		}
366 
367 		/**
368 		 * Enables the mutual authentication between client and directory server. This only works
369 		 * with SASL mechanisms which support this feature, e.g., GSS-API.
370 		 *
371 		 * @return this builder
372 		 */
373 		public Builder mutualAuth() {
374 			return mutualAuth(true);
375 		}
376 
377 		/**
378 		 * Enables or disables the mutual authentication between client and directory server. This
379 		 * only works with SASL mechanisms which support this feature, e.g., GSS-API.
380 		 *
381 		 * @param mutualAuth
382 		 *            the mutual authentication flag
383 		 * @return this builder
384 		 */
385 		public Builder mutualAuth(boolean mutualAuth) {
386 			check();
387 			this.mutualAuth = mutualAuth;
388 			return this;
389 		}
390 
391 		/**
392 		 * Sets the quality of protection in preference order with which the connection to the
393 		 * directory server is secured. The first negotiated quality is used. Valid values are
394 		 * {@code auth}, {@code auth-int}, and {@code auth-conf}. This only works with SASL
395 		 * mechanisms which support this feature, e.g., Digest MD5 or GSS-API. See
396 		 * <a href="https://docs.oracle.com/javase/7/docs/technotes/guides/jndi/jndi-ldap-gl.html#qop">here
397 		 * </a> for details.
398 		 *
399 		 * @param qop
400 		 *            the quality of protection for this directory context connection
401 		 * @throws NullPointerException
402 		 *             if {@code qop} is null
403 		 * @throws IllegalArgumentException
404 		 *             if {@code qop} is empty
405 		 * @return this builder
406 		 */
407 		public Builder qop(String... qop) {
408 			check();
409 			this.qop = validateAndReturnStringArray("qop", qop);
410 			return this;
411 		}
412 
413 		/**
414 		 * Enables the redirection of the LDAP debug output to {@code System.err}.
415 		 *
416 		 * @see #debug(boolean)
417 		 * @return this builder
418 		 */
419 		public Builder debug() {
420 			return debug(true);
421 		}
422 
423 		/**
424 		 * Enables or disables the redirection of the LDAP debug output to {@code System.err}.
425 		 *
426 		 * @param debug
427 		 *            the debug flag
428 		 * @return this builder
429 		 */
430 		public Builder debug(boolean debug) {
431 			check();
432 			this.debug = debug;
433 			this.debugStream = debug ? System.err : null;
434 			return this;
435 		}
436 
437 		/**
438 		 * Redirects the LDAP debug output to an {@link OutputStream}.
439 		 *
440 		 * @param stream
441 		 *            an {@code OutputStream} where debug output will be written to
442 		 * @throws NullPointerException
443 		 *             if {@code stream} is null
444 		 * @return this builder
445 		 */
446 		public Builder debug(OutputStream stream) {
447 			check();
448 			this.debugStream = validateAndReturnObject("stream", stream);
449 			this.debug = true;
450 			return this;
451 		}
452 
453 		/**
454 		 * Sets the number or connection retries.
455 		 *
456 		 * @param retries
457 		 *            The number of retries. This value must be a positive integer.
458 		 * @throws IllegalArgumentException
459 		 *             if {@code retries} is not a positive integer
460 		 * @return this builder
461 		 */
462 		public Builder retries(int retries) {
463 			check();
464 			isTrue(retries > 0, "property 'retries' must be greater than zero but is %d", retries);
465 			this.retries = retries;
466 			return this;
467 		}
468 
469 		/**
470 		 * Sets the wait interval between reconnections.
471 		 *
472 		 * @param retryWait
473 		 *            The wait time in milliseconds. This value must be a positive integer.
474 		 * @throws IllegalArgumentException
475 		 *             if {@code retryWait} is not a positive integer
476 		 * @return this builder
477 		 */
478 		public Builder retryWait(int retryWait) {
479 			check();
480 			isTrue(retryWait > 0, "property 'retryWait' must be greater than zero but is %d",
481 					retryWait);
482 			this.retryWait = retryWait;
483 			return this;
484 		}
485 
486 		/**
487 		 * Sets those attributes which will be returned as {@code byte[]} instead of {@code String}.
488 		 * See <a href=
489 		 * "https://docs.oracle.com/javase/7/docs/technotes/guides/jndi/jndi-ldap-gl.html#binary">here
490 		 * </a> for details.
491 		 *
492 		 * @param attributes
493 		 *            the attributes to be returned as byte array
494 		 * @throws NullPointerException
495 		 *             if {@code attributes} is null
496 		 * @throws IllegalArgumentException
497 		 *             if {@code attributes} is empty
498 		 * @return this builder
499 		 */
500 		public Builder binaryAttributes(String... attributes) {
501 			check();
502 			this.binaryAttributes = validateAndReturnStringArray("binaryAttributes", attributes);
503 			return this;
504 		}
505 
506 		/**
507 		 * Sets the referral handling strategy. Valid values are {@code ignore}, {@code follow}, and
508 		 * {@code throw}. See
509 		 * <a href="https://docs.oracle.com/javase/7/docs/technotes/guides/jndi/jndi-ldap-gl.html#referral">here </a>
510 		 * for details.
511 		 *
512 		 * @param referral
513 		 *            the referral handling strate
514 		 * @throws NullPointerException
515 		 *             if {@code referral} is null
516 		 * @throws IllegalArgumentException
517 		 *             if {@code referral} is empty
518 		 * @return this builder
519 		 */
520 		public Builder referral(String referral) {
521 			check();
522 			this.referral = validateAndReturnString("referral", referral);
523 			return this;
524 		}
525 
526 		/**
527 		 * Sets an additional property not available through the builder interface.
528 		 *
529 		 * @param name
530 		 *            name of the property
531 		 * @param value
532 		 *            value of the property
533 		 * @throws NullPointerException
534 		 *             if {@code name} is null
535 		 * @throws IllegalArgumentException
536 		 *             if {@code value} is empty
537 		 * @return this builder
538 		 */
539 		public Builder additionalProperty(String name, Object value) {
540 			check();
541 			notEmpty(name, "additional property's name cannot be null or empty");
542 			this.additionalProperties.put(name, value);
543 			return this;
544 		}
545 
546 		/**
547 		 * Builds a {@code DirContextSource} and marks this builder as non-modifiable for future
548 		 * use. You may call this method as often as you like, it will return a new
549 		 * {@code DirContextSource} instance on every call.
550 		 *
551 		 * @throws IllegalStateException
552 		 *             if a combination of necessary attributes is not set
553 		 * @return a {@code DirContextSource} object
554 		 */
555 		public DirContextSource build() {
556 
557 			if (auth == Auth.GSSAPI && StringUtils.isEmpty(loginEntryName))
558 				throw new IllegalStateException(
559 						"auth 'GSS-API' is set but no login entry name configured");
560 
561 			DirContextSource contextSource = new DirContextSource(this);
562 			done = true;
563 
564 			return contextSource;
565 		}
566 
567 		private void check() {
568 			if (done)
569 				throw new IllegalStateException("cannot modify an already used builder");
570 		}
571 
572 	}
573 
574 	protected DirContext getGssApiDirContext() throws NamingException {
575 
576 		DirContext context = null;
577 
578 		try {
579 
580 			LoginContext lc = new LoginContext(loginEntryName);
581 			lc.login();
582 
583 			context = Subject.doAs(lc.getSubject(), new PrivilegedExceptionAction<DirContext>() {
584 
585 				public DirContext run() throws NamingException {
586 
587 					GSSManager manager = GSSManager.getInstance();
588 					GSSCredential credential;
589 					try {
590 						credential = manager.createCredential(null,
591 								GSSCredential.INDEFINITE_LIFETIME, KRB5_MECHANISM,
592 								GSSCredential.INITIATE_ONLY);
593 					} catch (GSSException e) {
594 						NamingException ne = new NamingException("failed to obtain GSS credential");
595 						ne.setRootCause(e);
596 						throw ne;
597 					}
598 
599 					int r = retries;
600 					InitialDirContext idc = null;
601 
602 					while (r-- > 0) {
603 
604 						try {
605 							env.put(Sasl.CREDENTIALS, credential);
606 							idc = new GSSInitialDirContext(env);
607 							break;
608 						} catch (NamingException e) {
609 							if (r == 0)
610 								throw e;
611 
612 							logger.log(Level.WARNING,
613 									String.format(
614 											"Connecting to [%s] failed, remaining retries: %d",
615 											env.get(Context.PROVIDER_URL), r), e);
616 
617 							try {
618 								Thread.sleep(retryWait);
619 							} catch (InterruptedException e1) {
620 								throw new NamingException(e1.getMessage());
621 							}
622 						}
623 
624 					}
625 
626 					return idc;
627 				}
628 			});
629 
630 			lc.logout();
631 		} catch (LoginException e) {
632 			NamingException ne = new NamingException(e.getMessage());
633 			ne.initCause(e);
634 			throw ne;
635 		} catch (SecurityException e) {
636 			NamingException ne = new NamingException(e.getMessage());
637 			ne.initCause(e);
638 			throw ne;
639 		} catch (PrivilegedActionException e) {
640 			throw (NamingException) e.getException();
641 		}
642 
643 		return context;
644 	}
645 
646 	protected DirContext getAnonymousDirContext() throws NamingException {
647 
648 		DirContext context = null;
649 
650 		int r = retries;
651 
652 		while (r-- > 0) {
653 
654 			try {
655 				context = new InitialDirContext(env);
656 				break;
657 			} catch (NamingException e) {
658 				if (r == 0)
659 					throw e;
660 
661 				logger.log(Level.WARNING,
662 						String.format(
663 								"Connecting to [%s] failed, remaining retries: %d",
664 								env.get(Context.PROVIDER_URL), r), e);
665 
666 				try {
667 					Thread.sleep(retryWait);
668 				} catch (InterruptedException e1) {
669 					throw new NamingException(e1.getMessage());
670 				}
671 			}
672 
673 		}
674 
675 		return context;
676 	}
677 
678 	/**
679 	 * Returns a ready-to-use {@code DirContext}. Do not forget to close the context after all
680 	 * operations.
681 	 *
682 	 * @return a {@code DirContext}
683 	 * @throws javax.naming.NamingException
684 	 *             thrown if a problem with the creation arises
685 	 */
686 	public DirContext getDirContext() throws NamingException {
687 
688 		switch (auth) {
689 		case NONE:
690 			return getAnonymousDirContext();
691 		case GSSAPI:
692 			return getGssApiDirContext();
693 		default:
694 			throw new AssertionError(auth);
695 		}
696 
697 	}
698 
699 }