Patch from Chad so that JNDI data connector can optionally aggregate attributes from...
[java-idp.git] / src / edu / internet2 / middleware / shibboleth / aa / attrresolv / provider / JNDIDirectoryDataConnector.java
1 /*
2  * The Shibboleth License, Version 1. Copyright (c) 2002 University Corporation for Advanced Internet Development, Inc.
3  * All rights reserved Redistribution and use in source and binary forms, with or without modification, are permitted
4  * provided that the following conditions are met: Redistributions of source code must retain the above copyright
5  * notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above
6  * copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials
7  * provided with the distribution, if any, must include the following acknowledgment: "This product includes software
8  * developed by the University Corporation for Advanced Internet Development <http://www.ucaid.edu>Internet2 Project.
9  * Alternately, this acknowledegement may appear in the software itself, if and wherever such third-party
10  * acknowledgments normally appear. Neither the name of Shibboleth nor the names of its contributors, nor Internet2, nor
11  * the University Corporation for Advanced Internet Development, Inc., nor UCAID may be used to endorse or promote
12  * products derived from this software without specific prior written permission. For written permission, please contact
13  * shibboleth@shibboleth.org Products derived from this software may not be called Shibboleth, Internet2, UCAID, or the
14  * University Corporation for Advanced Internet Development, nor may Shibboleth appear in their name, without prior
15  * written permission of the University Corporation for Advanced Internet Development. THIS SOFTWARE IS PROVIDED BY THE
16  * COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND WITH ALL FAULTS. ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17  * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT ARE
18  * DISCLAIMED AND THE ENTIRE RISK OF SATISFACTORY QUALITY, PERFORMANCE, ACCURACY, AND EFFORT IS WITH LICENSEE. IN NO
19  * EVENT SHALL THE COPYRIGHT OWNER, CONTRIBUTORS OR THE UNIVERSITY CORPORATION FOR ADVANCED INTERNET DEVELOPMENT, INC.
20  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
21  * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
22  * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
23  * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 package edu.internet2.middleware.shibboleth.aa.attrresolv.provider;
27
28 import java.io.IOException;
29 import java.net.Socket;
30 import java.security.GeneralSecurityException;
31 import java.security.Principal;
32 import java.security.PrivateKey;
33 import java.security.SecureRandom;
34 import java.security.cert.X509Certificate;
35 import java.util.HashMap;
36 import java.util.HashSet;
37 import java.util.Iterator;
38 import java.util.Properties;
39 import java.util.Set;
40
41 import javax.naming.CommunicationException;
42 import javax.naming.Context;
43 import javax.naming.NamingEnumeration;
44 import javax.naming.NamingException;
45 import javax.naming.directory.Attribute;
46 import javax.naming.directory.Attributes;
47 import javax.naming.directory.BasicAttribute;
48 import javax.naming.directory.BasicAttributes;
49 import javax.naming.directory.InitialDirContext;
50 import javax.naming.directory.SearchControls;
51 import javax.naming.directory.SearchResult;
52 import javax.naming.ldap.InitialLdapContext;
53 import javax.naming.ldap.LdapContext;
54 import javax.naming.ldap.StartTlsRequest;
55 import javax.naming.ldap.StartTlsResponse;
56 import javax.net.ssl.KeyManager;
57 import javax.net.ssl.SSLContext;
58 import javax.net.ssl.SSLSocketFactory;
59 import javax.net.ssl.X509KeyManager;
60
61 import org.apache.log4j.Logger;
62 import org.w3c.dom.Element;
63 import org.w3c.dom.NodeList;
64
65 import edu.internet2.middleware.shibboleth.aa.attrresolv.AttributeResolver;
66 import edu.internet2.middleware.shibboleth.aa.attrresolv.DataConnectorPlugIn;
67 import edu.internet2.middleware.shibboleth.aa.attrresolv.Dependencies;
68 import edu.internet2.middleware.shibboleth.aa.attrresolv.ResolutionPlugInException;
69 import edu.internet2.middleware.shibboleth.common.Credential;
70 import edu.internet2.middleware.shibboleth.common.Credentials;
71
72 /**
73  * <code>DataConnectorPlugIn</code> implementation that utilizes a user-specified JNDI <code>DirContext</code> to
74  * retrieve attribute data.
75  * 
76  * @author Walter Hoehn (wassa@columbia.edu)
77  */
78 public class JNDIDirectoryDataConnector extends BaseDataConnector implements DataConnectorPlugIn {
79
80         private static Logger log = Logger.getLogger(JNDIDirectoryDataConnector.class.getName());
81         protected String searchFilter;
82         protected Properties properties;
83         protected SearchControls controls;
84         protected boolean mergeMultiResults = false;
85         protected boolean startTls = false;
86         boolean useExternalAuth = false;
87         private SSLSocketFactory sslsf;
88
89         /**
90          * Constructs a DataConnector based on DOM configuration.
91          * 
92          * @param e
93          *            a &lt;JNDIDirectoryDataConnector /&gt; DOM Element as specified by urn:mace:shibboleth:resolver:1.0
94          * @throws ResolutionPlugInException
95          *             if the PlugIn cannot be initialized
96          */
97         public JNDIDirectoryDataConnector(Element e) throws ResolutionPlugInException {
98
99                 super(e);
100
101                 // Decide if we are using starttls
102                 String tlsAttribute = e.getAttribute("useStartTls");
103                 if (tlsAttribute != null && tlsAttribute.equalsIgnoreCase("TRUE")) {
104                         startTls = true;
105                         log.debug("Start TLS support enabled for connector.");
106                 }
107
108                 // Do we merge the attributes in the event of multiple results from a search?
109                 String mergeMultiResultsAttrib = e.getAttribute("mergeMultipleResults");
110                 if (mergeMultiResultsAttrib != null && mergeMultiResultsAttrib.equalsIgnoreCase("TRUE")) {
111                         mergeMultiResults = true;
112                         log.debug("Multiple searcg result merging enabled for connector.");
113                 }
114
115                 // Determine the search filter and controls
116                 NodeList searchNodes = e.getElementsByTagNameNS(AttributeResolver.resolverNamespace, "Search");
117                 if (searchNodes.getLength() != 1) {
118                         log.error("JNDI Directory Data Connector requires a \"Search\" specification.");
119                         throw new ResolutionPlugInException("JNDI Directory Data Connector requires a \"Search\" specification.");
120                 }
121
122                 String searchFilterSpec = ((Element) searchNodes.item(0)).getAttribute("filter");
123                 if (searchFilterSpec != null && !searchFilterSpec.equals("")) {
124                         searchFilter = searchFilterSpec;
125                         log.debug("Search Filter: (" + searchFilter + ").");
126                 } else {
127                         log.error("Search spec requires a filter attribute.");
128                         throw new ResolutionPlugInException("Search spec requires a filter attribute.");
129                 }
130
131                 defineSearchControls(((Element) searchNodes.item(0)));
132
133                 // Load JNDI properties
134                 NodeList propertyNodes = e.getElementsByTagNameNS(AttributeResolver.resolverNamespace, "Property");
135                 properties = new Properties(System.getProperties());
136                 for (int i = 0; propertyNodes.getLength() > i; i++) {
137                         Element property = (Element) propertyNodes.item(i);
138                         String propName = property.getAttribute("name");
139                         String propValue = property.getAttribute("value");
140
141                         log.debug("Property: (" + propName + ").");
142                         log.debug("   Value: (" + propValue + ").");
143
144                         if (propName == null || propName.equals("")) {
145                                 log.error("Property (" + propName + ") is malformed.  Connot accept empty property name.");
146                                 throw new ResolutionPlugInException("Property is malformed.");
147                         } else if (propValue == null || propValue.equals("")) {
148                                 log.error("Property (" + propName + ") is malformed.  Cannot accept empty property value.");
149                                 throw new ResolutionPlugInException("Property is malformed.");
150                         } else {
151                                 properties.setProperty(propName, propValue);
152                         }
153                 }
154
155                 // Fail-fast connection test
156                 InitialDirContext context = null;
157                 try {
158                         if (!startTls) {
159                                 try {
160                                         log.debug("Attempting to connect to JNDI directory source as a sanity check.");
161                                         context = initConnection();
162                                 } catch (IOException ioe) {
163                                         log.error("Failed to startup directory context: " + ioe);
164                                         throw new ResolutionPlugInException("Failed to startup directory context.");
165                                 }
166                         } else {
167                                 // UGLY!
168                                 // We can't do SASL EXTERNAL auth until we have a TLS session
169                                 // So, we need to take this out of the environment and then stick it back in later
170                                 if ("EXTERNAL".equals(properties.getProperty(Context.SECURITY_AUTHENTICATION))) {
171                                         useExternalAuth = true;
172                                         properties.remove(Context.SECURITY_AUTHENTICATION);
173                                 }
174
175                                 // If TLS credentials were supplied, load them and setup a KeyManager
176                                 KeyManager keyManager = null;
177                                 NodeList credNodes = e.getElementsByTagNameNS(Credentials.credentialsNamespace, "Credential");
178                                 if (credNodes.getLength() > 0) {
179                                         log.debug("JNDI Directory Data Connector has a \"Credential\" specification.  "
180                                                         + "Loading credential...");
181                                         Credentials credentials = new Credentials((Element) credNodes.item(0));
182                                         Credential clientCred = credentials.getCredential();
183                                         if (clientCred == null) {
184                                                 log.error("No credentials were loaded.");
185                                                 throw new ResolutionPlugInException("Error loading credential.");
186                                         }
187                                         keyManager = new KeyManagerImpl(clientCred.getPrivateKey(), clientCred.getX509CertificateChain());
188                                 }
189
190                                 try {
191                                         // Setup a customized SSL socket factory that uses our implementation of KeyManager
192                                         // This factory will be used for all subsequent TLS negotiation
193                                         SSLContext sslc = SSLContext.getInstance("TLS");
194                                         sslc.init(new KeyManager[]{keyManager}, null, new SecureRandom());
195                                         sslsf = sslc.getSocketFactory();
196
197                                         log.debug("Attempting to connect to JNDI directory source as a sanity check.");
198                                         initConnection();
199                                 } catch (GeneralSecurityException gse) {
200                                         log.error("Failed to startup directory context.  Error creating SSL socket: " + gse);
201                                         throw new ResolutionPlugInException("Failed to startup directory context.");
202
203                                 } catch (IOException ioe) {
204                                         log.error("Failed to startup directory context.  Error negotiating Start TLS: " + ioe);
205                                         throw new ResolutionPlugInException("Failed to startup directory context.");
206                                 }
207                         }
208
209                         log.debug("JNDI Directory context activated.");
210
211                 } catch (NamingException ne) {
212                         log.error("Failed to startup directory context: " + ne);
213                         throw new ResolutionPlugInException("Failed to startup directory context.");
214                 } finally {
215                         try {
216                                 if (context != null) {
217                                         context.close();
218                                 }
219                         } catch (NamingException ne) {
220                                 log.error("An error occured while closing the JNDI context: " + e);
221                         }
222
223                 }
224         }
225
226         /**
227          * Create JNDI search controls based on DOM configuration
228          * 
229          * @param searchNode
230          *            a &lt;Controls /&gt; DOM Element as specified by urn:mace:shibboleth:resolver:1.0
231          */
232         protected void defineSearchControls(Element searchNode) {
233
234                 controls = new SearchControls();
235
236                 NodeList controlNodes = searchNode.getElementsByTagNameNS(AttributeResolver.resolverNamespace, "Controls");
237                 if (controlNodes.getLength() < 1) {
238                         log.debug("No Search Control spec found.");
239                 } else {
240                         if (controlNodes.getLength() > 1) {
241                                 log.error("Found multiple Search Control specs for a Connector.  Ignoring all but the first.");
242                         }
243
244                         String searchScopeSpec = ((Element) controlNodes.item(0)).getAttribute("searchScope");
245                         if (searchScopeSpec != null && !searchScopeSpec.equals("")) {
246                                 if (searchScopeSpec.equals("OBJECT_SCOPE")) {
247                                         controls.setSearchScope(SearchControls.OBJECT_SCOPE);
248                                 } else if (searchScopeSpec.equals("ONELEVEL_SCOPE")) {
249                                         controls.setSearchScope(SearchControls.ONELEVEL_SCOPE);
250                                 } else if (searchScopeSpec.equals("SUBTREE_SCOPE")) {
251                                         controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
252                                 } else {
253                                         try {
254                                                 controls.setSearchScope(Integer.parseInt(searchScopeSpec));
255                                         } catch (NumberFormatException nfe) {
256                                                 log.error("Control spec included an invalid (searchScope) attribute value.");
257                                         }
258                                 }
259                         }
260
261                         String timeLimitSpec = ((Element) controlNodes.item(0)).getAttribute("timeLimit");
262                         if (timeLimitSpec != null && !timeLimitSpec.equals("")) {
263                                 try {
264                                         controls.setTimeLimit(Integer.parseInt(timeLimitSpec));
265                                 } catch (NumberFormatException nfe) {
266                                         log.error("Control spec included an invalid (timeLimit) attribute value.");
267                                 }
268                         }
269
270                         String returningObjectsSpec = ((Element) controlNodes.item(0)).getAttribute("returningObjects");
271                         if (returningObjectsSpec != null && !returningObjectsSpec.equals("")) {
272                                 controls.setReturningObjFlag(new Boolean(returningObjectsSpec).booleanValue());
273                         }
274
275                         String linkDereferencingSpec = ((Element) controlNodes.item(0)).getAttribute("linkDereferencing");
276                         if (linkDereferencingSpec != null && !linkDereferencingSpec.equals("")) {
277                                 if (linkDereferencingSpec != null && !linkDereferencingSpec.equals("")) {
278                                         controls.setDerefLinkFlag(new Boolean(linkDereferencingSpec).booleanValue());
279                                 }
280                         }
281
282                         String countLimitSpec = ((Element) controlNodes.item(0)).getAttribute("countLimit");
283                         if (countLimitSpec != null && !countLimitSpec.equals("")) {
284                                 try {
285                                         controls.setCountLimit(Long.parseLong(countLimitSpec));
286                                 } catch (NumberFormatException nfe) {
287                                         log.error("Control spec included an invalid (countLimit) attribute value.");
288                                 }
289                         }
290                 }
291
292                 if (log.isDebugEnabled()) {
293                         log.debug("Search Control (searchScope): " + controls.getSearchScope());
294                         log.debug("Search Control (timeLimit): " + controls.getTimeLimit());
295                         log.debug("Search Control (returningObjects): " + controls.getReturningObjFlag());
296                         log.debug("Search Control (linkDereferencing): " + controls.getDerefLinkFlag());
297                         log.debug("Search Control (countLimit): " + controls.getCountLimit());
298                 }
299         }
300
301         /**
302          * See {@link DataConnectorPlugIn#resolve(Principal, String, Dependencies)}
303          */
304         public Attributes resolve(Principal principal, String requester, Dependencies depends)
305                         throws ResolutionPlugInException {
306
307                 InitialDirContext context = null;
308                 NamingEnumeration nEnumeration = null;
309                 String populatedSearch = searchFilter.replaceAll("%PRINCIPAL%", principal.getName());
310                 try {
311                         try {
312                                 context = initConnection();
313                                 nEnumeration = context.search("", populatedSearch, controls);
314
315                                 // If we get a failure during the init or query, attempt once to re-establish the connection
316                         } catch (CommunicationException e) {
317                                 log.debug(e);
318                                 log.warn("Encountered a connection problem while querying for attributes.  Re-initializing "
319                                                 + "JNDI context and retrying...");
320                                 context = initConnection();
321                                 nEnumeration = context.search("", populatedSearch, controls);
322                         } catch (IOException e) {
323                                 log.debug(e);
324                                 log.warn("Encountered a connection problem while querying for attributes.  Re-initializing "
325                                                 + "JNDI context and retrying...");
326                                 context = initConnection();
327                                 nEnumeration = context.search("", populatedSearch, controls);
328                         }
329
330                         if (nEnumeration == null || !nEnumeration.hasMore()) {
331                                 log.error("Could not locate a principal with the name (" + principal.getName() + ").");
332                                 throw new ResolutionPlugInException("No data available for this principal.");
333                         }
334
335                         SearchResult result = (SearchResult) nEnumeration.next();
336                         Attributes attributes = result.getAttributes();
337
338                         if (!mergeMultiResults) {
339                                 if (nEnumeration.hasMore()) {
340                                         log.error("Multiple results returned from filter " + searchFilter + " for principal " + principal
341                                                         + ", only one expected.");
342                                         throw new ResolutionPlugInException("Multiple results returned when only one expected.");
343                                 }
344                         } else {
345                                 log.debug("Multiple results returned by filter " + populatedSearch
346                                                 + " merging attributes from each result");
347                                 attributes = mergeResults(nEnumeration, attributes);
348                         }
349
350                         // For Sun's ldap provider only, construct the dn of the returned entry and manually add that as an
351                         // attribute
352                         if (context.getEnvironment().get(Context.INITIAL_CONTEXT_FACTORY)
353                                         .equals("com.sun.jndi.ldap.LdapCtxFactory")) {
354                                 BasicAttribute dn = new BasicAttribute("dn", result.getName() + "," + context.getNameInNamespace());
355                                 attributes.put(dn);
356                         }
357
358                         return attributes;
359
360                 } catch (NamingException e) {
361                         log.error("An error occurred while retieving data for principal (" + principal.getName() + ") :"
362                                         + e.getMessage());
363                         throw new ResolutionPlugInException("Error retrieving data for principal.");
364                 } catch (IOException e) {
365                         log.error("An error occurred while retieving data for principal (" + principal.getName() + ") :"
366                                         + e.getMessage());
367                         throw new ResolutionPlugInException("Error retrieving data for principal.");
368
369                 } finally {
370                         try {
371                                 if (context != null) {
372                                         context.close();
373                                 }
374                                 if (nEnumeration != null) {
375                                         nEnumeration.close();
376                                 }
377                         } catch (NamingException e) {
378                                 log.error("An error occured while closing the JNDI context: " + e);
379                         }
380                 }
381         }
382
383         /**
384          * Merges the attributes found in each result and a base Attributes object. If a named attribute appears in more
385          * than one result it's values are added to any existing values already in the given Attributes object. Duplicate
386          * attribute values are eliminated.
387          * 
388          * @param searchResults
389          *            the search result
390          * @param attributes
391          *            the container to add the attributes from the search result to (may already contain attributes)
392          * @return all the attributes and values merged from search results and initial attributes set
393          * @throws NamingException
394          *             thrown if there is a problem reading result data
395          */
396         private Attributes mergeResults(NamingEnumeration searchResults, Attributes attributes) throws NamingException {
397
398                 HashMap attributeMap = new HashMap();
399
400                 mergeAttributes(attributeMap, attributes);
401
402                 SearchResult result;
403                 while (searchResults.hasMore()) {
404                         result = (SearchResult) searchResults.next();
405                         mergeAttributes(attributeMap, result.getAttributes());
406                 }
407
408                 Attributes mergedAttribs = new BasicAttributes(false);
409                 Attribute mergedAttrib;
410                 Iterator attribNames = attributeMap.keySet().iterator();
411                 Iterator attribValues;
412                 String attribName;
413                 while (attribNames.hasNext()) {
414                         attribName = (String) attribNames.next();
415                         mergedAttrib = new BasicAttribute(attribName, false);
416                         Set valueSet = (Set) attributeMap.get(attribName);
417                         attribValues = valueSet.iterator();
418                         while (attribValues.hasNext()) {
419                                 mergedAttrib.add(attribValues.next());
420                         }
421                         mergedAttribs.put(mergedAttrib);
422                 }
423
424                 return mergedAttribs;
425         }
426
427         /**
428          * Merges a given collection of Attributes into an existing collection.
429          * 
430          * @param attributeMap
431          *            existing collection of attribute data
432          * @param attributes
433          *            collection of attribute data to be merged in
434          * @throws NamingException
435          *             thrown if there is a problem getting attribute information
436          */
437         private void mergeAttributes(HashMap attributeMap, Attributes attributes) throws NamingException {
438
439                 if (attributes == null || attributes.size() <= 0) {
440                         // In case the search result this came from was empty
441                         return;
442                 }
443
444                 HashSet valueSet;
445                 NamingEnumeration baseAttribs = attributes.getAll();
446                 Attribute baseAttrib;
447                 while (baseAttribs.hasMore()) {
448                         baseAttrib = (Attribute) baseAttribs.next();
449                         if (attributeMap.containsKey(baseAttrib.getID())) {
450                                 valueSet = (HashSet) attributeMap.get(baseAttrib.getID());
451                         } else {
452                                 valueSet = new HashSet();
453                         }
454                         for (int i = 0; i < baseAttrib.size(); i++) {
455                                 valueSet.add(baseAttrib.get(i));
456                         }
457                         attributeMap.put(baseAttrib.getID(), valueSet);
458                 }
459         }
460
461         private InitialDirContext initConnection() throws NamingException, IOException, ResolutionPlugInException {
462
463                 InitialDirContext context;
464                 if (!startTls) {
465                         context = new InitialDirContext(properties);
466
467                 } else {
468                         context = new InitialLdapContext(properties, null);
469                         if (!(context instanceof LdapContext)) {
470                                 log.error("Directory context does not appear to be an implementation of LdapContext.  "
471                                                 + "This is required for startTls.");
472                                 throw new ResolutionPlugInException("Start TLS is only supported for implementations of LdapContext.");
473                         }
474                         StartTlsResponse tls = (StartTlsResponse) ((LdapContext) context).extendedOperation(new StartTlsRequest());
475                         tls.negotiate(sslsf);
476                         if (useExternalAuth) {
477                                 context.addToEnvironment(Context.SECURITY_AUTHENTICATION, "EXTERNAL");
478                         }
479                 }
480                 return context;
481         }
482 }
483
484 /**
485  * Implementation of <code>X509KeyManager</code> that always uses a hard-coded client certificate.
486  */
487
488 class KeyManagerImpl implements X509KeyManager {
489
490         private PrivateKey key;
491         private X509Certificate[] chain;
492
493         KeyManagerImpl(PrivateKey key, X509Certificate[] chain) {
494
495                 this.key = key;
496                 this.chain = chain;
497         }
498
499         public String[] getClientAliases(String arg0, Principal[] arg1) {
500
501                 return new String[]{"default"};
502         }
503
504         public String chooseClientAlias(String[] arg0, Principal[] arg1, Socket arg2) {
505
506                 return "default";
507         }
508
509         public String[] getServerAliases(String arg0, Principal[] arg1) {
510
511                 return null;
512         }
513
514         public String chooseServerAlias(String arg0, Principal[] arg1, Socket arg2) {
515
516                 return null;
517         }
518
519         public X509Certificate[] getCertificateChain(String arg0) {
520
521                 return chain;
522         }
523
524         public PrivateKey getPrivateKey(String arg0) {
525
526                 return key;
527         }
528
529 }