View Javadoc
1   /*
2    * Copyright 2013–2026 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.tomcat.realm;
17  
18  import java.net.URI;
19  import java.net.URISyntaxException;
20  import java.security.Principal;
21  import java.security.cert.CertificateParsingException;
22  import java.security.cert.X509Certificate;
23  import java.util.ArrayList;
24  import java.util.Arrays;
25  import java.util.Base64;
26  import java.util.Collection;
27  import java.util.Collections;
28  import java.util.HashMap;
29  import java.util.LinkedList;
30  import java.util.List;
31  import java.util.Locale;
32  import java.util.Map;
33  import java.util.concurrent.atomic.AtomicLong;
34  
35  import javax.naming.CommunicationException;
36  import javax.naming.CompositeName;
37  import javax.naming.InvalidNameException;
38  import javax.naming.Name;
39  import javax.naming.NameNotFoundException;
40  import javax.naming.NameParser;
41  import javax.naming.NamingEnumeration;
42  import javax.naming.NamingException;
43  import javax.naming.PartialResultException;
44  import javax.naming.ReferralException;
45  import javax.naming.ServiceUnavailableException;
46  import javax.naming.directory.Attribute;
47  import javax.naming.directory.Attributes;
48  import javax.naming.directory.DirContext;
49  import javax.naming.directory.SearchControls;
50  import javax.naming.directory.SearchResult;
51  import javax.naming.ldap.LdapName;
52  import javax.naming.ldap.ManageReferralControl;
53  import javax.naming.ldap.Rdn;
54  import javax.security.auth.x500.X500Principal;
55  
56  import net.sf.michaelo.dirctxsrc.DirContextSource;
57  import net.sf.michaelo.tomcat.StringUtils;
58  import net.sf.michaelo.tomcat.authenticator.SpnegoAuthenticator;
59  import net.sf.michaelo.tomcat.realm.asn1.OtherNameAsn1Parser;
60  import net.sf.michaelo.tomcat.realm.asn1.OtherNameParseResult;
61  import net.sf.michaelo.tomcat.realm.mapper.SamAccountNameRfc2247Mapper;
62  import net.sf.michaelo.tomcat.realm.mapper.UserPrincipalNameSearchMapper;
63  import net.sf.michaelo.tomcat.realm.mapper.UsernameSearchMapper;
64  import net.sf.michaelo.tomcat.realm.mapper.UsernameSearchMapper.MappedValues;
65  
66  import org.apache.catalina.LifecycleException;
67  import org.apache.catalina.Server;
68  import org.apache.catalina.authenticator.SSLAuthenticator;
69  import org.apache.catalina.realm.CombinedRealm;
70  import org.apache.naming.ContextBindings;
71  import org.apache.tomcat.util.buf.Asn1Parser;
72  import org.apache.tomcat.util.collections.SynchronizedStack;
73  import org.ietf.jgss.GSSCredential;
74  import org.ietf.jgss.GSSException;
75  import org.ietf.jgss.GSSManager;
76  import org.ietf.jgss.GSSName;
77  import org.ietf.jgss.Oid;
78  
79  /**
80   * A realm which retrieves <em>already authenticated</em> users from Active Directory via LDAP.
81   *
82   * <h2 id="configuration">Configuration</h2>
83   *
84   * Following options can be configured:
85   *
86   * <ul>
87   *   <li>{@code dirContextSourceName}: the name of the {@link DirContextSource} in JNDI with which principals will be
88   *       retrieved.
89   *   <li>{@code localDirContextSource}: whether this {@code DirContextSource} is locally configured in the
90   *       {@code context.xml} or globally configured in the {@code server.xml} (optional). Default value is
91   *       {@code false}.
92   *   <li>{@code roleFormats}: comma-separated list of role formats to be applied to user security groups. The following
93   *       values are possible: {@code sid} retrieves the {@code objectSid} and {@code sIDHistory} attribute values,
94   *       {@code name} retrieves the {@code msDS-PrincipalName} attribute value representing the down-level logon name
95   *       format: <code>{netbiosDomain}\{samAccountName}</code>, and {@code nameEx} retrieves the
96   *       {@code distinguishedName} and {@code sAMAccountName} attribute values and converts the DC RDNs from the DN to
97   *       the Kerberos realm and appends the {@code sAMAccountName} (reversed RFC 2247) with format <code>
98   *       {realm}\{samAccountName}</code>. Default is {@code sid}.
99   *   <li>{@code prependRoleFormat}: whether the role format is prepended to the role as <code>{roleFormat}:{role}</code>
100  *       . Default is {@code false}.
101  *   <li>{@code additionalAttributes}: comma-separated list of attributes to be retrieved for the principal. Binary
102  *       attributes must end with {@code ;binary} and will be stored as {@code byte[]}, ordinary attributes will be
103  *       stored as {@code String}. If an attribute is multivalued, it will be stored as {@code List}.
104  *   <li>{@code connectionPoolSize}: the maximum amount of directory server connections the pool will hold. Default is
105  *       zero which means no connections will be pooled.
106  *   <li>{@code maxIdleTime}: the maximum amount of time in milliseconds a directory server connection should remain
107  *       idle before it is closed. Default value is 15 minutes.
108  * </ul>
109  *
110  * <h2>Connection Pooling</h2>
111  *
112  * This realm offers a poor man's directory server connection pooling which can drastically improve access performance
113  * for non-session (stateless) applications. It utilizes a LIFO structure based on {@link SynchronizedStack}. No
114  * background thread is managing the connections. They are acquired, validated, eventually closed and opened when
115  * {@link #getPrincipal(GSSName, GSSCredential)} is invoked. Validation involves a minimal and limited query with at
116  * most 500 ms of wait time just to verify the connection is alive and healthy. If this query fails, the connection is
117  * closed immediately. If the amount of requested connections exceeds the ones available in the pool, new ones are
118  * opened and pushed onto the pool. If the pool does not accept any addtional connections they are closed immediately.
119  *
120  * <p><strong>Note:</strong> This connection pool feature has to be explicitly enabled by setting
121  * {@code connectionPoolSize} to greater than zero.
122  *
123  * <h2 id="on-usernames">On Usernames</h2>
124  *
125  * This realm processes supplied usernames with different types.
126  *
127  * <h3>Supported Types</h3>
128  *
129  * Only a subset of username types are accepted in contrast to other realm implementations. Namely, this realm must know
130  * what type is passed to properly map it into Active Directory search space with a {@link UsernameSearchMapper}
131  * implementation. The supported username types are:
132  *
133  * <ul>
134  *   <li>{@link GSSName} by inspecting the string name type,
135  *   <li>{@link X509Certificate} by extracting the {@code SAN:otherName} field and matching for MS UPN type id
136  *       (1.3.6.1.4.1.311.20.2.3).
137  * </ul>
138  *
139  * <p><strong>Note:</strong> Both types represent <em>already authenticated</em> users by means of a GSS and/or
140  * TLScontext.
141  *
142  * <h3>Canonicalization</h3>
143  *
144  * This realm will always try to canonicalize a given username type to a real {@link GSSName} with the string name type
145  * of {@code KRB5_NT_PRINCIPAL} (1.2.840.113554.1.2.2.1) similar to the {@code canonicalize} flag in the <a
146  * href="https://web.mit.edu/kerberos/krb5-1.19/doc/admin/conf_files/krb5_conf.html">{@code krb5.conf}</a> file. This
147  * makes the final {@link GSSName} fully usable in subsequent GSS-API calls.
148  *
149  * <h2 id="referral-handling">Referral Handling</h2>
150  *
151  * Active Directory uses two type of responses when it cannot complete a search request: referrals and search result
152  * references. Both are different in nature, read more about them <a href=
153  * "https://documentation.avaya.com/en-us/home/bundle/avaya-device-services/deployingavayadeviceservices_r10-3-x/examples-of-microsoft-active-directory-ldap-proper/ldap-search-results-and-referrals.html">here</a>.
154  * For this section I will use the term <i>referral</i> for both types synomously as does the <a
155  * href="https://docs.oracle.com/javase/jndi/tutorial/ldap/referral/jndi.html">JNDI/LDAP Provider documentation</a>.
156  * <br>
157  * When working with the default LDAP ports (not GC) or in a multi-forest environment, it is highly likely to receive
158  * referrals (either subordinate or cross) during a search or lookup. Sun's JNDI/LDAP Provider takes the following
159  * approach to handle referrals with the {@code java.naming.referral} property and its values: {@code ignore},
160  * {@code throw}, and {@code follow}. You can ignore referrals altogether, but the provider will still signal a
161  * {@link PartialResultException} when a {@link NamingEnumeration} is iterated. The reason is because it adds a
162  * {@link ManageReferralControl} when {@code ignore} is set and assumes that the target server will ignore referrals,
163  * but this is a misconception in this provider implementation, see <a
164  * href="https://openldap-software.0penldap.narkive.com/cuImLMRw/managedsait#post2">here</a> and <a
165  * href="https://bugs.openjdk.org/browse/JDK-5109452">here</a>. It is also unclear whether Microsoft Active Directory
166  * supports this control. <br>
167  * This realm will catch this exception and continue to process the enumeration. If the {@code DirContextSource} is set
168  * to {@code throw}, this realm will catch the {@link ReferralException} also, but avoid following referrals manually
169  * (for several reasons) and will continue with the process. Following referrals automatically is a completely opaque
170  * operation to the application, no {@code ReferralException} is thrown, but the referrals are handled internally and
171  * referral contexts are queried and closed. If you choose to {@code follow} referrals you <strong>must</strong> use my
172  * <a href="https://michael-o.github.io/active-directory-dc-locator/">Active Directory DC Locator</a> otherwise the
173  * queries <strong>will</strong> fail and you will suffer from <a
174  * href="https://bugs.java.com/bugdatabase/JDK-8161361">JDK-8161361</a> and <a
175  * href="https://bugs.java.com/bugdatabase/JDK-8160768">JDK-8160768</a>!
176  *
177  * <p><em>Why do you need to use my Active Directory DC Locator?</em> Microsoft takes a very sophisticated approach on
178  * not to rely on hostnames because servers can be provisioned and decommissioned any time. Instead, they heavily rely
179  * on DNS domain names and DNS SRV records at runtime. I.e., an initial or a referral URL does not contain a hostname,
180  * but only a domain name. While you can connect to the service with this name, you cannot easily authenticate against
181  * it with Kerberos because one cannot bind the same SPN {@code ldap/<dnsDomainName>@<REALM>}, e.g.,
182  * {@code ldap/example.com@EXAMPLE.COM} to more than one account. If you try authenticate anyway, you will receive a
183  * "Server not found in Kerberos database (7)" error. Therefore, one has to perform a DNS SRV query
184  * ({@code _ldap._tcp.<dnsDomainName>}) to test whether this name is a hostname or a domain name served by one or more
185  * servers. If it turns out to be a domain name, you have to select one target host from the query response (according
186  * to RFC 2782), construct a domain-based SPN {@code ldap/<targetHost>/<dnsDomainName>@<REALM>} or a host-based one
187  * {@code ldap/<targetHost>@<REALM>}, obtain a service ticket for and connect to that target host.
188  *
189  * <p><em>How to handle referrals?</em> There are several ways depending on your setup: Use the Global Catalog (port
190  * 3268) with a single forest and set referrals to {@code ignore}, or with multiple forests and set referrals to either
191  *
192  * <ul>
193  *   <li>{@code follow} with a {@link DirContextSource} in your home forest and use my Active Directory DC Locator, or
194  *   <li>{@code ignore} with multiple {@code DirContextSources}, and create a {@link CombinedRealm} with one
195  *       {@code ActiveDirectoryRealm} per forest.
196  * </ul>
197  *
198  * <p>You will then have the principal properly looked up in Active Directory.
199  *
200  * <p>Further references: <a href=
201  * "https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2003/cc759550(v=ws.10)">How DNS
202  * Support for Active Directory Works</a> is a good read on the DNS topic as well as <a href=
203  * "https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc978012(v=technet.10)">Global
204  * Catalog and LDAP Searches</a> and <a href=
205  * "https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc978014(v=technet.10)">LDAP
206  * Referrals</a>.
207  *
208  * <p><strong>Note:</strong> Always remember, referrals incur an amplification in time and space and make the entire
209  * process slower. <br>
210  * <strong>Tip:</strong> Consider using the {@link PacDataActiveDirectoryRealm} if you don't need all features and use
211  * {@link SpnegoAuthenticator SPNEGO authentication} only since it is orders of magnitude faster, but remember though
212  * that {@link SSLAuthenticator X.509 authentication} still requires this realm.
213  *
214  * @see ActiveDirectoryPrincipal
215  */
216 public class ActiveDirectoryRealm extends ActiveDirectoryRealmBase {
217 
218 	// A mere holder class for directory server connections
219 	protected static class DirContextConnection {
220 		protected String id;
221 		protected long lastReturnTime;
222 		protected DirContext context;
223 	}
224 
225 	private static final AtomicLong COUNT = new AtomicLong(0);
226 
227 	// 1.3.6.1.4.1.311.20.2.3
228 	private static final byte[] MS_UPN_OID_BYTES = {
229 		(byte) 0x2B,
230 		(byte) 0x06,
231 		(byte) 0x01,
232 		(byte) 0x04,
233 		(byte) 0x01,
234 		(byte) 0x82,
235 		(byte) 0x37,
236 		(byte) 0x14,
237 		(byte) 0x02,
238 		(byte) 0x03
239 	};
240 
241 	private static final long UF_ACCOUNT_DISABLE = 0x00000002L;
242 	private static final long UF_NORMAL_ACCOUNT = 0x00000200L;
243 	private static final long UF_WORKSTATION_TRUST_ACCOUNT = 0x00001000L;
244 
245 	private static final Oid MS_UPN;
246 	private static final Oid KRB5_NT_PRINCIPAL;
247 
248 	private static final Map<String, String> X500_PRINCIPAL_OID_MAP = new HashMap<String, String>();
249 
250 	private static final UsernameSearchMapper[] USERNAME_SEARCH_MAPPERS = {
251 		new SamAccountNameRfc2247Mapper(), new UserPrincipalNameSearchMapper()
252 	};
253 
254 	private static final String[] DEFAULT_USER_ATTRIBUTES =
255 			new String[] {"userAccountControl", "memberOf", "objectSid;binary", "sAMAccountName"};
256 
257 	private static final String[] DEFAULT_ROLE_ATTRIBUTES = new String[] {"groupType"};
258 
259 	private static final String DEFAULT_ROLE_FORMAT = "sid";
260 
261 	private static final Map<String, String[]> ROLE_FORMAT_ATTRIBUTES = new HashMap<>();
262 
263 	static {
264 		try {
265 			MS_UPN = new Oid("1.3.6.1.4.1.311.20.2.3");
266 		} catch (GSSException e) {
267 			throw new IllegalStateException("Failed to create OID for MS_UPN");
268 		}
269 
270 		try {
271 			KRB5_NT_PRINCIPAL = new Oid("1.2.840.113554.1.2.2.1");
272 		} catch (GSSException e) {
273 			throw new IllegalStateException("Failed to create OID for KRB5_NT_PRINCIPAL");
274 		}
275 
276 		X500_PRINCIPAL_OID_MAP.put("1.2.840.113549.1.9.1", "emailAddress");
277 		X500_PRINCIPAL_OID_MAP.put("2.5.4.5", "serialNumber");
278 		// surname
279 		X500_PRINCIPAL_OID_MAP.put("2.5.4.4", "SN");
280 		// givenName
281 		X500_PRINCIPAL_OID_MAP.put("2.5.4.42", "GN");
282 
283 		ROLE_FORMAT_ATTRIBUTES.put("sid", new String[] {"objectSid;binary", "sIDHistory;binary"});
284 		ROLE_FORMAT_ATTRIBUTES.put("name", new String[] {"msDS-PrincipalName"});
285 		ROLE_FORMAT_ATTRIBUTES.put("nameEx", new String[] {"distinguishedName", "sAMAccountName"});
286 	}
287 
288 	protected boolean localDirContextSource;
289 	protected String dirContextSourceName;
290 
291 	protected String[] attributes;
292 	protected String[] additionalAttributes;
293 
294 	protected String[] roleFormats;
295 	protected String[] roleAttributes;
296 
297 	protected boolean prependRoleFormat;
298 
299 	protected int connectionPoolSize = 0;
300 	protected long maxIdleTime = 900_000L;
301 
302 	// Poor man's connection pool
303 	protected SynchronizedStack<DirContextConnection> connectionPool;
304 
305 	protected static String getNextConnectionId() {
306 		return String.format("conn-%06d", COUNT.incrementAndGet());
307 	}
308 
309 	/**
310 	 * Sets whether the {@code DirContextSource} is locally ({@code context.xml} defined or globally {@code server.xml}.
311 	 *
312 	 * @param localDirContextSource the local directory context source indication
313 	 */
314 	public void setLocalDirContextSource(boolean localDirContextSource) {
315 		this.localDirContextSource = localDirContextSource;
316 	}
317 
318 	/**
319 	 * Sets the name of the {@code DirContextSource}
320 	 *
321 	 * @param dirContextSourceName the directory context source name
322 	 */
323 	public void setDirContextSourceName(String dirContextSourceName) {
324 		this.dirContextSourceName = dirContextSourceName;
325 	}
326 
327 	/**
328 	 * Sets a comma-separated list of Active Directory attributes retreived and stored for the user principal.
329 	 *
330 	 * @param additionalAttributes the additional attributes
331 	 */
332 	public void setAdditionalAttributes(String additionalAttributes) {
333 		this.additionalAttributes = additionalAttributes.split(",");
334 
335 		this.attributes = new String[DEFAULT_USER_ATTRIBUTES.length + this.additionalAttributes.length];
336 		System.arraycopy(DEFAULT_USER_ATTRIBUTES, 0, this.attributes, 0, DEFAULT_USER_ATTRIBUTES.length);
337 		System.arraycopy(
338 				this.additionalAttributes,
339 				0,
340 				this.attributes,
341 				DEFAULT_USER_ATTRIBUTES.length,
342 				this.additionalAttributes.length);
343 	}
344 
345 	/**
346 	 * Sets a comma-separated list of role formats to be applied to user security groups from Active Directory.
347 	 *
348 	 * @param roleFormats the role formats
349 	 */
350 	public void setRoleFormats(String roleFormats) {
351 		this.roleFormats = roleFormats.split(",");
352 		List<String> attributes = new ArrayList<>(Arrays.asList(DEFAULT_ROLE_ATTRIBUTES));
353 		for (String roleFormat : this.roleFormats) {
354 			if (ROLE_FORMAT_ATTRIBUTES.get(roleFormat) != null)
355 				attributes.addAll(Arrays.asList(ROLE_FORMAT_ATTRIBUTES.get(roleFormat)));
356 		}
357 
358 		this.roleAttributes = attributes.toArray(new String[0]);
359 	}
360 
361 	/**
362 	 * Sets whether the role format is prepended to the role.
363 	 *
364 	 * @param prependRoleFormat the prepend role format indication
365 	 */
366 	public void setPrependRoleFormat(boolean prependRoleFormat) {
367 		this.prependRoleFormat = prependRoleFormat;
368 	}
369 
370 	/**
371 	 * Sets the maximum amount of directory server connections the pool will hold.
372 	 *
373 	 * @param connectionPoolSize the connection pool size
374 	 */
375 	public void setConnectionPoolSize(int connectionPoolSize) {
376 		this.connectionPoolSize = connectionPoolSize;
377 	}
378 
379 	/**
380 	 * Sets the maximum amount of time in milliseconds a directory server connection should remain idle before it is
381 	 * closed.
382 	 *
383 	 * @param maxIdleTime the maximum idle time
384 	 */
385 	public void setMaxIdleTime(long maxIdleTime) {
386 		this.maxIdleTime = maxIdleTime;
387 	}
388 
389 	@Override
390 	protected Principal getPrincipal(X509Certificate userCert) {
391 		try {
392 			Collection<List<?>> san = userCert.getSubjectAlternativeNames();
393 			if (san == null || san.isEmpty()) return null;
394 
395 			String dn = userCert.getSubjectX500Principal().getName(X500Principal.RFC2253, X500_PRINCIPAL_OID_MAP);
396 			for (List<?> sanField : san) {
397 				Integer nameType = (Integer) sanField.get(0);
398 				// SAN's OtherName, see X509Certificate#getSubjectAlternativeNames() Javadoc
399 				if (nameType == 0) {
400 					byte[] otherName = (byte[]) sanField.get(1);
401 					if (logger.isDebugEnabled())
402 						logger.debug(sm.getString(
403 								"activeDirectoryRealm.processingSanOtherName",
404 								Base64.getEncoder().encodeToString(otherName),
405 								dn));
406 					try {
407 						OtherNameParseResult result = OtherNameAsn1Parser.parse(otherName);
408 						if (Arrays.equals(result.getTypeId(), MS_UPN_OID_BYTES)) {
409 							Asn1Parser parser = new Asn1Parser(result.getValue());
410 							String upn = parser.parseUTF8String();
411 							if (logger.isDebugEnabled())
412 								logger.debug(sm.getString("activeDirectoryRealm.msUpnExtracted", upn, dn));
413 
414 							GSSName gssName = new StubGSSName(upn, MS_UPN);
415 
416 							return getPrincipal(gssName, null, true);
417 						}
418 					} catch (IllegalArgumentException | ArrayIndexOutOfBoundsException e) {
419 						logger.warn(sm.getString("activeDirectoryRealm.sanOtherNameParsingFailed"), e);
420 					}
421 				}
422 			}
423 		} catch (CertificateParsingException e) {
424 			logger.warn(sm.getString("activeDirectoryRealm.sanParsingFailed"), e);
425 		}
426 
427 		return null;
428 	}
429 
430 	@Override
431 	protected Principal getPrincipal(GSSName gssName, GSSCredential gssCredential) {
432 		if (gssName.isAnonymous()) return new ActiveDirectoryPrincipal(gssName, Sid.ANONYMOUS_SID, gssCredential);
433 
434 		return getPrincipal(gssName, gssCredential, true);
435 	}
436 
437 	protected Principal getPrincipal(GSSName gssName, GSSCredential gssCredential, boolean retry) {
438 		DirContextConnection connection = null;
439 		try {
440 			connection = acquire();
441 
442 			try {
443 				User user = getUser(connection.context, gssName);
444 
445 				if (user != null) {
446 					List<String> roles = getRoles(connection.context, user);
447 
448 					return new ActiveDirectoryPrincipal(
449 							user.getGssName(), user.getSid(), roles, gssCredential, user.getAdditionalAttributes());
450 				}
451 			} catch (NamingException e) {
452 				// This construct is an ugly hack for
453 				// https://bugs.openjdk.org/browse/JDK-8273402
454 				boolean canRetry = false;
455 				if (e instanceof CommunicationException || e instanceof ServiceUnavailableException) canRetry = true;
456 				else {
457 					String explanation = e.getExplanation();
458 					if (explanation != null
459 							&& (explanation.equals("LDAP connection has been closed")
460 									|| explanation.startsWith("LDAP response read timed out, timeout used:")))
461 						canRetry = true;
462 				}
463 
464 				if (retry && canRetry) {
465 					logger.error(sm.getString("activeDirectoryRealm.principalSearchFailed.retry", gssName), e);
466 
467 					close(connection);
468 
469 					return getPrincipal(gssName, gssCredential, false);
470 				}
471 
472 				logger.error(sm.getString("activeDirectoryRealm.principalSearchFailed", gssName), e);
473 
474 				close(connection);
475 			}
476 		} catch (NamingException e) {
477 			logger.error(sm.getString("activeDirectoryRealm.acquire.namingException"), e);
478 		} finally {
479 			release(connection);
480 		}
481 
482 		return null;
483 	}
484 
485 	protected DirContextConnection acquire() throws NamingException {
486 		if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.acquire"));
487 
488 		DirContextConnection connection = null;
489 
490 		while (connection == null) {
491 			connection = connectionPool.pop();
492 
493 			if (connection != null) {
494 				long idleTime = System.currentTimeMillis() - connection.lastReturnTime;
495 				// TODO support maxIdleTime = -1 (no expiry)
496 				if (idleTime > maxIdleTime) {
497 					if (logger.isDebugEnabled())
498 						logger.debug(sm.getString("activeDirectoryRealm.exceedMaxIdleTime", connection.id));
499 					close(connection);
500 					connection = null;
501 				} else {
502 					boolean valid = validate(connection);
503 					if (valid) {
504 						if (logger.isDebugEnabled())
505 							logger.debug(sm.getString("activeDirectoryRealm.reuse", connection.id));
506 					} else {
507 						close(connection);
508 						connection = null;
509 					}
510 				}
511 			} else {
512 				connection = new DirContextConnection();
513 				open(connection);
514 			}
515 		}
516 
517 		if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.acquired", connection.id));
518 
519 		return connection;
520 	}
521 
522 	protected boolean validate(DirContextConnection connection) {
523 		if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.validate", connection.id));
524 
525 		SearchControls controls = new SearchControls();
526 		controls.setSearchScope(SearchControls.OBJECT_SCOPE);
527 		controls.setCountLimit(1);
528 		controls.setReturningAttributes(new String[] {"objectClass"});
529 		// This applies to a server-side time limit which is actually translated to a second resolution and
530 		// does not apply to a socket read timeout
531 		controls.setTimeLimit(500);
532 
533 		NamingEnumeration<SearchResult> results = null;
534 		try {
535 			results = connection.context.search("", "objectclass=*", controls);
536 
537 			if (results.hasMore()) return true;
538 		} catch (NamingException e) {
539 			logger.error(sm.getString("activeDirectoryRealm.validate.namingException", connection.id), e);
540 		} finally {
541 			close(results);
542 		}
543 
544 		return false;
545 	}
546 
547 	protected void release(DirContextConnection connection) {
548 		if (connection == null) return;
549 
550 		if (connection.context == null) return;
551 
552 		connection.lastReturnTime = System.currentTimeMillis();
553 
554 		if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.release", connection.id));
555 		if (!connectionPool.push(connection)) close(connection);
556 	}
557 
558 	protected void open(DirContextConnection connection) throws NamingException {
559 		javax.naming.Context context = null;
560 
561 		if (localDirContextSource) {
562 			context = ContextBindings.getClassLoader();
563 			context = (javax.naming.Context) context.lookup("comp/env");
564 		} else {
565 			Server server = getServer();
566 			context = server.getGlobalNamingContext();
567 		}
568 
569 		if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.open"));
570 		DirContextSource contextSource = (DirContextSource) context.lookup(dirContextSourceName);
571 		connection.context = contextSource.getDirContext();
572 		connection.id = getNextConnectionId();
573 		if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.opened", connection.id));
574 	}
575 
576 	protected void close(DirContextConnection connection) {
577 		if (connection.context == null) return;
578 
579 		try {
580 			if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.close", connection.id));
581 			connection.context.close();
582 			if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.closed", connection.id));
583 		} catch (NamingException e) {
584 			logger.error(sm.getString("activeDirectoryRealm.close.namingException", connection.id), e);
585 		}
586 
587 		connection.context = null;
588 	}
589 
590 	protected void close(NamingEnumeration<?> results) {
591 		if (results == null) return;
592 
593 		try {
594 			results.close();
595 		} catch (NamingException e) {
596 			; // Ignore
597 		}
598 	}
599 
600 	@Override
601 	protected void initInternal() throws LifecycleException {
602 		super.initInternal();
603 
604 		if (attributes == null) attributes = DEFAULT_USER_ATTRIBUTES;
605 
606 		if (roleFormats == null) setRoleFormats(DEFAULT_ROLE_FORMAT);
607 	}
608 
609 	@Override
610 	protected void startInternal() throws LifecycleException {
611 		connectionPool = new SynchronizedStack<>(connectionPoolSize, connectionPoolSize);
612 
613 		DirContextConnection connection = null;
614 		try {
615 			connection = acquire();
616 
617 			try {
618 				String referral = (String) connection.context.getEnvironment().get(DirContext.REFERRAL);
619 
620 				if ("follow".equals(referral)) logger.warn(sm.getString("activeDirectoryRealm.referralFollow"));
621 			} catch (NamingException e) {
622 				logger.error(sm.getString("activeDirectoryRealm.environmentFailed"), e);
623 
624 				close(connection);
625 			}
626 		} catch (NamingException e) {
627 			logger.error(sm.getString("activeDirectoryRealm.acquire.namingException"), e);
628 		} finally {
629 			release(connection);
630 		}
631 
632 		super.startInternal();
633 	}
634 
635 	@Override
636 	protected void stopInternal() throws LifecycleException {
637 		super.stopInternal();
638 
639 		DirContextConnection connection = null;
640 		while ((connection = connectionPool.pop()) != null) close(connection);
641 
642 		connectionPool = null;
643 	}
644 
645 	private Oid getStringNameType(GSSName gssName) {
646 		try {
647 			return gssName.getStringNameType();
648 		} catch (GSSException e) {
649 			return null;
650 		}
651 	}
652 
653 	private String toRealm(Name distinguishedName) {
654 		LdapName dn = (LdapName) distinguishedName;
655 
656 		StringBuilder realm = new StringBuilder();
657 		for (Rdn rdn : dn.getRdns())
658 			if (rdn.getType().equalsIgnoreCase("DC"))
659 				realm.insert(0, ((String) rdn.getValue()).toUpperCase(Locale.ROOT) + ".");
660 
661 		if (realm.length() > 0) realm.deleteCharAt(realm.length() - 1);
662 
663 		return realm.toString();
664 	}
665 
666 	protected User getUser(DirContext context, GSSName gssName) throws NamingException {
667 		SearchControls searchCtls = new SearchControls();
668 		searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
669 		searchCtls.setReturningAttributes(attributes);
670 
671 		String searchFilter;
672 		Name searchBase = null;
673 		String searchAttributeName;
674 		String searchAttributeValue;
675 
676 		MappedValues mappedValues;
677 		NamingEnumeration<SearchResult> results = null;
678 		for (UsernameSearchMapper mapper : USERNAME_SEARCH_MAPPERS) {
679 			String mapperClassName = mapper.getClass().getSimpleName();
680 
681 			if (!mapper.supportsGssName(gssName)) {
682 				if (logger.isDebugEnabled())
683 					logger.debug(sm.getString(
684 							"activeDirectoryRealm.nameTypeNotSupported",
685 							mapperClassName,
686 							getStringNameType(gssName),
687 							gssName));
688 
689 				continue;
690 			}
691 
692 			mappedValues = mapper.map(context, gssName);
693 
694 			searchBase = getRelativeName(context, mappedValues.getSearchBase());
695 			searchAttributeName = mappedValues.getSearchAttributeName();
696 			searchAttributeValue = mappedValues.getSearchUsername();
697 
698 			searchFilter = String.format("(%s={0})", searchAttributeName);
699 
700 			if (logger.isDebugEnabled())
701 				logger.debug(sm.getString(
702 						"activeDirectoryRealm.usernameSearch",
703 						searchAttributeValue,
704 						searchBase,
705 						searchAttributeName,
706 						mapperClassName));
707 
708 			try {
709 				results = context.search(searchBase, searchFilter, new Object[] {searchAttributeValue}, searchCtls);
710 			} catch (ReferralException e) {
711 				logger.warn(sm.getString(
712 						"activeDirectoryRealm.user.referralException",
713 						mapperClassName,
714 						e.getRemainingName(),
715 						e.getReferralInfo()));
716 
717 				results = null;
718 				continue;
719 			}
720 
721 			try {
722 				if (!results.hasMore()) {
723 					if (logger.isDebugEnabled())
724 						logger.debug(
725 								sm.getString("activeDirectoryRealm.userNotFoundWithMapper", gssName, mapperClassName));
726 
727 					close(results);
728 				} else break;
729 			} catch (PartialResultException e) {
730 				logger.debug(sm.getString(
731 						"activeDirectoryRealm.user.partialResultException", mapperClassName, e.getRemainingName()));
732 
733 				close(results);
734 			}
735 		}
736 
737 		if (results == null || !results.hasMore()) {
738 			logger.debug(sm.getString("activeDirectoryRealm.userNotFound", gssName));
739 
740 			close(results);
741 			return null;
742 		}
743 
744 		SearchResult result = results.next();
745 
746 		try {
747 			if (results.hasMore()) {
748 				logger.error(sm.getString("activeDirectoryRealm.duplicateUser", gssName));
749 
750 				close(results);
751 				return null;
752 			}
753 		} catch (ReferralException e) {
754 			logger.warn(sm.getString(
755 					"activeDirectoryRealm.duplicateUser.referralException",
756 					gssName,
757 					e.getRemainingName(),
758 					e.getReferralInfo()));
759 		} catch (PartialResultException e) {
760 			logger.debug(sm.getString(
761 					"activeDirectoryRealm.duplicateUser.partialResultException", gssName, e.getRemainingName()));
762 		}
763 
764 		close(results);
765 
766 		Attributes userAttributes = result.getAttributes();
767 
768 		long userAccountControl =
769 				Long.parseLong((String) userAttributes.get("userAccountControl").get());
770 
771 		if ((userAccountControl & UF_ACCOUNT_DISABLE) != 0L) {
772 			logger.warn(sm.getString("activeDirectoryRealm.userFoundButDisabled", gssName));
773 			return null;
774 		}
775 
776 		if ((userAccountControl & UF_NORMAL_ACCOUNT) == 0L
777 				&& (userAccountControl & UF_WORKSTATION_TRUST_ACCOUNT) == 0L) {
778 			logger.warn(sm.getString("activeDirectoryRealm.userFoundButNotSupported", gssName));
779 			return null;
780 		}
781 
782 		Name dn = getDistinguishedName(context, searchBase, result);
783 		byte[] sidBytes = (byte[]) userAttributes.get("objectSid;binary").get();
784 		Sid sid = new Sid(sidBytes);
785 
786 		if (logger.isDebugEnabled()) logger.debug(sm.getString("activeDirectoryRealm.userFound", gssName, dn, sid));
787 
788 		if (!KRB5_NT_PRINCIPAL.equals(getStringNameType(gssName))) {
789 			String samAccountName =
790 					(String) userAttributes.get("sAMAccountName").get();
791 			String realm = toRealm(dn);
792 			String krb5Principal = samAccountName + "@" + realm;
793 
794 			if (logger.isTraceEnabled())
795 				logger.trace(sm.getString(
796 						"activeDirectoryRealm.canonicalizingUser", getStringNameType(gssName), KRB5_NT_PRINCIPAL));
797 
798 			GSSName canonGssName = null;
799 			try {
800 				canonGssName = GSSManager.getInstance().createName(krb5Principal, KRB5_NT_PRINCIPAL);
801 			} catch (GSSException e) {
802 				logger.warn(sm.getString("activeDirectoryRealm.canonicalizeUserFailed", gssName));
803 				return null;
804 			}
805 
806 			if (logger.isDebugEnabled())
807 				logger.debug(sm.getString("activeDirectoryRealm.userCanonicalized", canonGssName));
808 
809 			gssName = canonGssName;
810 		}
811 
812 		Attribute memberOfAttr = userAttributes.get("memberOf");
813 
814 		List<String> memberOfs = new LinkedList<String>();
815 
816 		if (memberOfAttr != null && memberOfAttr.size() > 0) {
817 			NamingEnumeration<?> memberOfValues = memberOfAttr.getAll();
818 
819 			while (memberOfValues.hasMore()) memberOfs.add((String) memberOfValues.next());
820 
821 			close(memberOfValues);
822 		}
823 
824 		Map<String, Object> additionalAttributesMap = Collections.emptyMap();
825 
826 		if (additionalAttributes != null && additionalAttributes.length > 0) {
827 			additionalAttributesMap = new HashMap<String, Object>();
828 
829 			for (String addAttr : additionalAttributes) {
830 				Attribute attr = userAttributes.get(addAttr);
831 
832 				if (attr != null && attr.size() > 0) {
833 					if (attr.size() > 1) {
834 						List<Object> attrList = new ArrayList<Object>(attr.size());
835 						NamingEnumeration<?> attrEnum = attr.getAll();
836 
837 						while (attrEnum.hasMore()) attrList.add(attrEnum.next());
838 
839 						close(attrEnum);
840 
841 						additionalAttributesMap.put(addAttr, Collections.unmodifiableList(attrList));
842 					} else additionalAttributesMap.put(addAttr, attr.get());
843 				}
844 			}
845 		}
846 
847 		return new User(gssName, sid, memberOfs, additionalAttributesMap);
848 	}
849 
850 	protected List<String> getRoles(DirContext context, User user) throws NamingException {
851 		List<String> roles = new LinkedList<String>();
852 
853 		if (logger.isDebugEnabled())
854 			logger.debug(sm.getString(
855 					"activeDirectoryRealm.retrievingRoles", user.getRoles().size(), user.getGssName()));
856 
857 		for (String role : user.getRoles()) {
858 			Name roleRdn = getRelativeName(context, role);
859 
860 			Attributes roleAttributes = null;
861 			try {
862 				roleAttributes = context.getAttributes(roleRdn, this.roleAttributes);
863 			} catch (NameNotFoundException e) {
864 				logger.warn(sm.getString("activeDirectoryRealm.role.nameNotFoundException", role));
865 
866 				continue;
867 			} catch (ReferralException e) {
868 				logger.warn(sm.getString(
869 						"activeDirectoryRealm.role.referralException",
870 						role,
871 						e.getRemainingName(),
872 						e.getReferralInfo()));
873 
874 				continue;
875 			} catch (PartialResultException e) {
876 				logger.debug(
877 						sm.getString("activeDirectoryRealm.role.partialResultException", role, e.getRemainingName()));
878 
879 				continue;
880 			}
881 
882 			int groupType =
883 					Integer.parseInt((String) roleAttributes.get("groupType").get());
884 
885 			// Skip distribution groups, i.e., we want security-enabled groups only
886 			// (ADS_GROUP_TYPE_SECURITY_ENABLED)
887 			if ((groupType & Integer.MIN_VALUE) == 0) {
888 				if (logger.isTraceEnabled())
889 					logger.trace(sm.getString("activeDirectoryRealm.skippingDistributionRole", role));
890 
891 				continue;
892 			}
893 
894 			for (String roleFormat : roleFormats) {
895 
896 				String roleFormatPrefix = prependRoleFormat ? roleFormat + ":" : "";
897 
898 				switch (roleFormat) {
899 					case "sid":
900 						byte[] objectSidBytes =
901 								(byte[]) roleAttributes.get("objectSid;binary").get();
902 						String sidString = new Sid(objectSidBytes).toString();
903 
904 						roles.add(roleFormatPrefix + sidString);
905 
906 						Attribute sidHistory = roleAttributes.get("sIDHistory;binary");
907 						List<String> sidHistoryStrings = new LinkedList<String>();
908 						if (sidHistory != null) {
909 							NamingEnumeration<?> sidHistoryEnum = sidHistory.getAll();
910 							while (sidHistoryEnum.hasMore()) {
911 								byte[] sidHistoryBytes = (byte[]) sidHistoryEnum.next();
912 								String sidHistoryString = new Sid(sidHistoryBytes).toString();
913 								sidHistoryStrings.add(sidHistoryString);
914 
915 								roles.add(roleFormatPrefix + sidHistoryString);
916 							}
917 
918 							close(sidHistoryEnum);
919 						}
920 
921 						if (logger.isTraceEnabled()) {
922 							if (sidHistoryStrings.isEmpty())
923 								logger.trace(
924 										sm.getString("activeDirectoryRealm.foundRoleSidConverted", role, sidString));
925 							else
926 								logger.trace(sm.getString(
927 										"activeDirectoryRealm.foundRoleSidConverted.withSidHistory",
928 										role,
929 										sidString,
930 										sidHistoryStrings));
931 						}
932 						break;
933 					case "name":
934 						String msDsPrincipalName = (String)
935 								roleAttributes.get("msDS-PrincipalName").get();
936 
937 						roles.add(roleFormatPrefix + msDsPrincipalName);
938 
939 						if (logger.isTraceEnabled())
940 							logger.trace(sm.getString(
941 									"activeDirectoryRealm.foundRoleNameConverted", role, msDsPrincipalName));
942 						break;
943 					case "nameEx":
944 						String distinguishedName =
945 								(String) roleAttributes.get("distinguishedName").get();
946 						String samAccountName =
947 								(String) roleAttributes.get("sAMAccountName").get();
948 
949 						NameParser parser = context.getNameParser(StringUtils.EMPTY);
950 						LdapName dn = (LdapName) parser.parse(distinguishedName);
951 						String realm = toRealm(dn);
952 						String nameEx = realm + "\\" + samAccountName;
953 
954 						roles.add(roleFormatPrefix + nameEx);
955 
956 						if (logger.isTraceEnabled())
957 							logger.trace(sm.getString("activeDirectoryRealm.foundRoleNameExConverted", role, nameEx));
958 						break;
959 					default:
960 						throw new IllegalArgumentException("The role format '" + roleFormat + "' is invalid");
961 				}
962 			}
963 		}
964 
965 		if (logger.isTraceEnabled())
966 			logger.trace(sm.getString("activeDirectoryRealm.foundRoles", roles.size(), user.getGssName(), roles));
967 		else if (logger.isDebugEnabled())
968 			logger.debug(sm.getString("activeDirectoryRealm.foundRolesCount", roles.size(), user.getGssName()));
969 
970 		return roles;
971 	}
972 
973 	/**
974 	 * Returns the distinguished name of a search result.
975 	 *
976 	 * @param context Our DirContext
977 	 * @param baseName The base DN
978 	 * @param result The search result
979 	 * @return String containing the distinguished name
980 	 * @throws NamingException if DN cannot be build
981 	 */
982 	protected Name getDistinguishedName(DirContext context, Name baseName, SearchResult result) throws NamingException {
983 		// Get the entry's distinguished name. For relative results, this means
984 		// we need to composite a name with the base name, the context name, and
985 		// the result name. For non-relative names, use the returned name.
986 		String resultName = result.getName();
987 		if (result.isRelative()) {
988 			NameParser parser = context.getNameParser(StringUtils.EMPTY);
989 			Name contextName = parser.parse(context.getNameInNamespace());
990 
991 			// Bugzilla 32269
992 			Name entryName = parser.parse(new CompositeName(resultName).get(0));
993 
994 			Name name = contextName.addAll(baseName);
995 			return name.addAll(entryName);
996 		} else {
997 			String absoluteName = result.getName();
998 			try {
999 				// Normalize the name by running it through the name parser.
1000 				NameParser parser = context.getNameParser(StringUtils.EMPTY);
1001 				URI userNameUri = new URI(resultName);
1002 				String pathComponent = userNameUri.getPath();
1003 				// Should not ever have an empty path component, since that is /{DN}
1004 				if (pathComponent.length() < 1) {
1005 					throw new InvalidNameException(sm.getString("activeDirectoryRealm.unparseableName", absoluteName));
1006 				}
1007 				return parser.parse(pathComponent.substring(1));
1008 			} catch (URISyntaxException e) {
1009 				throw new InvalidNameException(sm.getString("activeDirectoryRealm.unparseableName", absoluteName));
1010 			}
1011 		}
1012 	}
1013 
1014 	protected Name getRelativeName(DirContext context, String distinguishedName) throws NamingException {
1015 		NameParser parser = context.getNameParser(StringUtils.EMPTY);
1016 		LdapName nameInNamespace = (LdapName) parser.parse(context.getNameInNamespace());
1017 		LdapName name = (LdapName) parser.parse(distinguishedName);
1018 
1019 		Rdn nameRdn;
1020 		Rdn nameInNamespaceRdn;
1021 
1022 		while (Math.min(name.size(), nameInNamespace.size()) != 0) {
1023 			nameRdn = name.getRdn(0);
1024 			nameInNamespaceRdn = nameInNamespace.getRdn(0);
1025 			if (nameRdn.equals(nameInNamespaceRdn)) {
1026 				name.remove(0);
1027 				nameInNamespace.remove(0);
1028 			} else break;
1029 		}
1030 
1031 		int innerPosn;
1032 		while (Math.min(name.size(), nameInNamespace.size()) != 0) {
1033 			innerPosn = nameInNamespace.size() - 1;
1034 			nameRdn = name.getRdn(0);
1035 			nameInNamespaceRdn = nameInNamespace.getRdn(innerPosn);
1036 			if (nameRdn.equals(nameInNamespaceRdn)) {
1037 				name.remove(0);
1038 				nameInNamespace.remove(innerPosn);
1039 			} else break;
1040 		}
1041 
1042 		return name;
1043 	}
1044 
1045 	protected static class User {
1046 
1047 		private final GSSName gssName;
1048 		private final Sid sid;
1049 		private final List<String> roles;
1050 		private final Map<String, Object> additionalAttributes;
1051 
1052 		public User(GSSName gssName, Sid sid, List<String> roles, Map<String, Object> additionalAttributes) {
1053 			this.gssName = gssName;
1054 			this.sid = sid;
1055 			this.roles = roles;
1056 			this.additionalAttributes = additionalAttributes;
1057 		}
1058 
1059 		public GSSName getGssName() {
1060 			return gssName;
1061 		}
1062 
1063 		public Sid getSid() {
1064 			return sid;
1065 		}
1066 
1067 		public List<String> getRoles() {
1068 			return roles;
1069 		}
1070 
1071 		public Map<String, Object> getAdditionalAttributes() {
1072 			return additionalAttributes;
1073 		}
1074 	}
1075 }