54738595a4fe3a09c985e30b77110351765d9e29
[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.Properties;
36
37 import javax.naming.CommunicationException;
38 import javax.naming.Context;
39 import javax.naming.NamingEnumeration;
40 import javax.naming.NamingException;
41 import javax.naming.directory.Attributes;
42 import javax.naming.directory.BasicAttribute;
43 import javax.naming.directory.InitialDirContext;
44 import javax.naming.directory.SearchControls;
45 import javax.naming.directory.SearchResult;
46 import javax.naming.ldap.InitialLdapContext;
47 import javax.naming.ldap.LdapContext;
48 import javax.naming.ldap.StartTlsRequest;
49 import javax.naming.ldap.StartTlsResponse;
50 import javax.net.ssl.KeyManager;
51 import javax.net.ssl.SSLContext;
52 import javax.net.ssl.SSLSocketFactory;
53 import javax.net.ssl.X509KeyManager;
54
55 import org.apache.log4j.Logger;
56 import org.w3c.dom.Element;
57 import org.w3c.dom.NodeList;
58
59 import edu.internet2.middleware.shibboleth.aa.attrresolv.AttributeResolver;
60 import edu.internet2.middleware.shibboleth.aa.attrresolv.DataConnectorPlugIn;
61 import edu.internet2.middleware.shibboleth.aa.attrresolv.Dependencies;
62 import edu.internet2.middleware.shibboleth.aa.attrresolv.ResolutionPlugInException;
63 import edu.internet2.middleware.shibboleth.common.Credential;
64 import edu.internet2.middleware.shibboleth.common.Credentials;
65
66 /**
67  * <code>DataConnectorPlugIn</code> implementation that utilizes a user-specified JNDI <code>DirContext</code> to
68  * retrieve attribute data.
69  * 
70  * @author Walter Hoehn (wassa@columbia.edu)
71  */
72 public class JNDIDirectoryDataConnector extends BaseDataConnector implements DataConnectorPlugIn {
73
74         private static Logger log = Logger.getLogger(JNDIDirectoryDataConnector.class.getName());
75         protected String searchFilter;
76         protected Properties properties;
77         protected SearchControls controls;
78         protected String failover = null;
79         protected boolean startTls = false;
80         boolean useExternalAuth = false;
81         private SSLSocketFactory sslsf;
82
83         /**
84          * Constructs a DataConnector based on DOM configuration.
85          * 
86          * @param e
87          *            a &lt;JNDIDirectoryDataConnector /&gt; DOM Element as specified by urn:mace:shibboleth:resolver:1.0
88          * @throws ResolutionPlugInException
89          *             if the PlugIn cannot be initialized
90          */
91         public JNDIDirectoryDataConnector(Element e) throws ResolutionPlugInException {
92
93                 super(e);
94
95                 // Decide if we are using starttls
96                 String tlsAttribute = e.getAttribute("useStartTls");
97                 if (tlsAttribute != null && tlsAttribute.equalsIgnoreCase("TRUE")) {
98                         startTls = true;
99                         log.debug("Start TLS support enabled for connector.");
100                 }
101
102                 // Determine the search filter and controls
103                 NodeList searchNodes = e.getElementsByTagNameNS(AttributeResolver.resolverNamespace, "Search");
104                 if (searchNodes.getLength() != 1) {
105                         log.error("JNDI Directory Data Connector requires a \"Search\" specification.");
106                         throw new ResolutionPlugInException("JNDI Directory Data Connector requires a \"Search\" specification.");
107                 }
108
109                 String searchFilterSpec = ((Element) searchNodes.item(0)).getAttribute("filter");
110                 if (searchFilterSpec != null && !searchFilterSpec.equals("")) {
111                         searchFilter = searchFilterSpec;
112                         log.debug("Search Filter: (" + searchFilter + ").");
113                 } else {
114                         log.error("Search spec requires a filter attribute.");
115                         throw new ResolutionPlugInException("Search spec requires a filter attribute.");
116                 }
117
118                 defineSearchControls(((Element) searchNodes.item(0)));
119
120                 // Load JNDI properties
121                 NodeList propertyNodes = e.getElementsByTagNameNS(AttributeResolver.resolverNamespace, "Property");
122                 properties = new Properties(System.getProperties());
123                 for (int i = 0; propertyNodes.getLength() > i; i++) {
124                         Element property = (Element) propertyNodes.item(i);
125                         String propName = property.getAttribute("name");
126                         String propValue = property.getAttribute("value");
127
128                         log.debug("Property: (" + propName + ").");
129                         log.debug("   Value: (" + propValue + ").");
130
131                         if (propName == null || propName.equals("")) {
132                                 log.error("Property (" + propName + ") is malformed.  Connot accept empty property name.");
133                                 throw new ResolutionPlugInException("Property is malformed.");
134                         } else if (propValue == null || propValue.equals("")) {
135                                 log.error("Property (" + propName + ") is malformed.  Cannot accept empty property value.");
136                                 throw new ResolutionPlugInException("Property is malformed.");
137                         } else {
138                                 properties.setProperty(propName, propValue);
139                         }
140                 }
141
142                 // Fail-fast connection test
143                 InitialDirContext context = null;
144                 try {
145                         if (!startTls) {
146                                 try {
147                                         log.debug("Attempting to connect to JNDI directory source as a sanity check.");
148                                         context = initConnection();
149                                 } catch (IOException ioe) {
150                                         log.error("Failed to startup directory context: " + ioe);
151                                         throw new ResolutionPlugInException("Failed to startup directory context.");
152                                 }
153                         } else {
154                                 // UGLY!
155                                 // We can't do SASL EXTERNAL auth until we have a TLS session
156                                 // So, we need to take this out of the environment and then stick it back in later
157                                 if ("EXTERNAL".equals(properties.getProperty(Context.SECURITY_AUTHENTICATION))) {
158                                         useExternalAuth = true;
159                                         properties.remove(Context.SECURITY_AUTHENTICATION);
160                                 }
161
162                                 // If TLS credentials were supplied, load them and setup a KeyManager
163                                 KeyManager keyManager = null;
164                                 NodeList credNodes = e.getElementsByTagNameNS(Credentials.credentialsNamespace, "Credential");
165                                 if (credNodes.getLength() > 0) {
166                                         log.debug("JNDI Directory Data Connector has a \"Credential\" specification.  "
167                                                         + "Loading credential...");
168                                         Credentials credentials = new Credentials((Element) credNodes.item(0));
169                                         Credential clientCred = credentials.getCredential();
170                                         if (clientCred == null) {
171                                                 log.error("No credentials were loaded.");
172                                                 throw new ResolutionPlugInException("Error loading credential.");
173                                         } else {
174                                                 keyManager = new KeyManagerImpl(clientCred.getPrivateKey(), clientCred
175                                                                 .getX509CertificateChain());
176                                         }
177                                 }
178
179                                 try {
180                                         // Setup a customized SSL socket factory that uses our implementation of KeyManager
181                                         // This factory will be used for all subsequent TLS negotiation
182                                         SSLContext sslc = SSLContext.getInstance("TLS");
183                                         sslc.init(new KeyManager[]{keyManager}, null, new SecureRandom());
184                                         sslsf = sslc.getSocketFactory();
185
186                                         log.debug("Attempting to connect to JNDI directory source as a sanity check.");
187                                         initConnection();
188                                 } catch (GeneralSecurityException gse) {
189                                         log.error("Failed to startup directory context.  Error creating SSL socket: " + gse);
190                                         throw new ResolutionPlugInException("Failed to startup directory context.");
191
192                                 } catch (IOException ioe) {
193                                         log.error("Failed to startup directory context.  Error negotiating Start TLS: " + ioe);
194                                         throw new ResolutionPlugInException("Failed to startup directory context.");
195                                 }
196                         }
197
198                         log.debug("JNDI Directory context activated.");
199
200                 } catch (NamingException ne) {
201                         log.error("Failed to startup directory context: " + ne);
202                         throw new ResolutionPlugInException("Failed to startup directory context.");
203                 } finally {
204                         try {
205                                 if (context != null) {
206                                         context.close();
207                                 }
208                         } catch (NamingException ne) {
209                                 log.error("An error occured while closing the JNDI context: " + e);
210                         }
211
212                 }
213         }
214
215         /**
216          * Create JNDI search controls based on DOM configuration
217          * 
218          * @param searchNode
219          *            a &lt;Controls /&gt; DOM Element as specified by urn:mace:shibboleth:resolver:1.0
220          */
221         protected void defineSearchControls(Element searchNode) {
222
223                 controls = new SearchControls();
224
225                 NodeList controlNodes = searchNode.getElementsByTagNameNS(AttributeResolver.resolverNamespace, "Controls");
226                 if (controlNodes.getLength() < 1) {
227                         log.debug("No Search Control spec found.");
228                 } else {
229                         if (controlNodes.getLength() > 1) {
230                                 log.error("Found multiple Search Control specs for a Connector.  Ignoring all but the first.");
231                         }
232
233                         String searchScopeSpec = ((Element) controlNodes.item(0)).getAttribute("searchScope");
234                         if (searchScopeSpec != null && !searchScopeSpec.equals("")) {
235                                 if (searchScopeSpec.equals("OBJECT_SCOPE")) {
236                                         controls.setSearchScope(SearchControls.OBJECT_SCOPE);
237                                 } else if (searchScopeSpec.equals("ONELEVEL_SCOPE")) {
238                                         controls.setSearchScope(SearchControls.ONELEVEL_SCOPE);
239                                 } else if (searchScopeSpec.equals("SUBTREE_SCOPE")) {
240                                         controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
241                                 } else {
242                                         try {
243                                                 controls.setSearchScope(Integer.parseInt(searchScopeSpec));
244                                         } catch (NumberFormatException nfe) {
245                                                 log.error("Control spec included an invalid (searchScope) attribute value.");
246                                         }
247                                 }
248                         }
249
250                         String timeLimitSpec = ((Element) controlNodes.item(0)).getAttribute("timeLimit");
251                         if (timeLimitSpec != null && !timeLimitSpec.equals("")) {
252                                 try {
253                                         controls.setTimeLimit(Integer.parseInt(timeLimitSpec));
254                                 } catch (NumberFormatException nfe) {
255                                         log.error("Control spec included an invalid (timeLimit) attribute value.");
256                                 }
257                         }
258
259                         String returningObjectsSpec = ((Element) controlNodes.item(0)).getAttribute("returningObjects");
260                         if (returningObjectsSpec != null && !returningObjectsSpec.equals("")) {
261                                 controls.setReturningObjFlag(new Boolean(returningObjectsSpec).booleanValue());
262                         }
263
264                         String linkDereferencingSpec = ((Element) controlNodes.item(0)).getAttribute("linkDereferencing");
265                         if (linkDereferencingSpec != null && !linkDereferencingSpec.equals("")) {
266                                 if (linkDereferencingSpec != null && !linkDereferencingSpec.equals("")) {
267                                         controls.setDerefLinkFlag(new Boolean(linkDereferencingSpec).booleanValue());
268                                 }
269                         }
270
271                         String countLimitSpec = ((Element) controlNodes.item(0)).getAttribute("countLimit");
272                         if (countLimitSpec != null && !countLimitSpec.equals("")) {
273                                 try {
274                                         controls.setCountLimit(Long.parseLong(countLimitSpec));
275                                 } catch (NumberFormatException nfe) {
276                                         log.error("Control spec included an invalid (countLimit) attribute value.");
277                                 }
278                         }
279                 }
280
281                 if (log.isDebugEnabled()) {
282                         log.debug("Search Control (searchScope): " + controls.getSearchScope());
283                         log.debug("Search Control (timeLimit): " + controls.getTimeLimit());
284                         log.debug("Search Control (returningObjects): " + controls.getReturningObjFlag());
285                         log.debug("Search Control (linkDereferencing): " + controls.getDerefLinkFlag());
286                         log.debug("Search Control (countLimit): " + controls.getCountLimit());
287                 }
288         }
289
290         /**
291          * @see edu.internet2.middleware.shibboleth.aa.attrresolv.DataConnectorPlugIn#resolve(java.security.Principal)
292          */
293         public Attributes resolve(Principal principal, String requester, Dependencies depends)
294                         throws ResolutionPlugInException {
295
296                 InitialDirContext context = null;
297                 NamingEnumeration nEnumeration = null;
298                 try {
299                         try {
300                                 context = initConnection();
301                                 nEnumeration = context
302                                                 .search("", searchFilter.replaceAll("%PRINCIPAL%", principal.getName()), controls);
303
304                                 // If we get a failure during the init or query, attempt once to re-establish the connection
305                         } catch (CommunicationException e) {
306                                 log.debug(e);
307                                 log.warn("Encountered a connection problem while querying for attributes.  Re-initializing "
308                                                 + "JNDI context and retrying...");
309                                 context = initConnection();
310                                 nEnumeration = context
311                                                 .search("", searchFilter.replaceAll("%PRINCIPAL%", principal.getName()), controls);
312                         } catch (IOException e) {
313                                 log.debug(e);
314                                 log.warn("Encountered a connection problem while querying for attributes.  Re-initializing "
315                                                 + "JNDI context and retrying...");
316                                 context = initConnection();
317                                 nEnumeration = context
318                                                 .search("", searchFilter.replaceAll("%PRINCIPAL%", principal.getName()), controls);
319                         }
320
321                         if (nEnumeration == null || !nEnumeration.hasMore()) {
322                                 log.error("Could not locate a principal with the name (" + principal.getName() + ").");
323                                 throw new ResolutionPlugInException("No data available for this principal.");
324                         }
325
326                         SearchResult result = (SearchResult) nEnumeration.next();
327                         Attributes attributes = result.getAttributes();
328
329                         if (nEnumeration.hasMore()) {
330                                 log.error("Unable to disambiguate date for principal (" + principal.getName() + ") in search.");
331                                 throw new ResolutionPlugInException("Cannot disambiguate data for this principal.");
332                         }
333
334                         // For Sun's ldap provider only, construct the dn of the returned entry and manually add that as an
335                         // attribute
336                         if (context.getEnvironment().get(Context.INITIAL_CONTEXT_FACTORY)
337                                         .equals("com.sun.jndi.ldap.LdapCtxFactory")) {
338                                 BasicAttribute dn = new BasicAttribute("dn", result.getName() + "," + context.getNameInNamespace());
339                                 attributes.put(dn);
340                         }
341
342                         return attributes;
343
344                 } catch (NamingException e) {
345                         log.error("An error occurred while retieving data for principal (" + principal.getName() + ") :"
346                                         + e.getMessage());
347                         throw new ResolutionPlugInException("Error retrieving data for principal.");
348                 } catch (IOException e) {
349                         log.error("An error occurred while retieving data for principal (" + principal.getName() + ") :"
350                                         + e.getMessage());
351                         throw new ResolutionPlugInException("Error retrieving data for principal.");
352
353                 } finally {
354                         try {
355                                 if (context != null) {
356                                         context.close();
357                                 }
358                         } catch (NamingException e) {
359                                 log.error("An error occured while closing the JNDI context: " + e);
360                         }
361                 }
362         }
363
364         private InitialDirContext initConnection() throws NamingException, IOException, ResolutionPlugInException {
365
366                 InitialDirContext context;
367                 if (!startTls) {
368                         context = new InitialDirContext(properties);
369
370                 } else {
371                         context = new InitialLdapContext(properties, null);
372                         if (!(context instanceof LdapContext)) {
373                                 log.error("Directory context does not appear to be an implementation of LdapContext.  "
374                                                 + "This is required for startTls.");
375                                 throw new ResolutionPlugInException("Start TLS is only supported for implementations of LdapContext.");
376                         }
377                         StartTlsResponse tls = (StartTlsResponse) ((LdapContext) context).extendedOperation(new StartTlsRequest());
378                         tls.negotiate(sslsf);
379                         if (useExternalAuth) {
380                                 context.addToEnvironment(Context.SECURITY_AUTHENTICATION, "EXTERNAL");
381                         }
382                 }
383                 return context;
384         }
385 }
386
387 /**
388  * Implementation of <code>X509KeyManager</code> that always uses a hard-coded client certificate.
389  */
390
391 class KeyManagerImpl implements X509KeyManager {
392
393         private PrivateKey key;
394         private X509Certificate[] chain;
395
396         KeyManagerImpl(PrivateKey key, X509Certificate[] chain) {
397
398                 this.key = key;
399                 this.chain = chain;
400         }
401
402         public String[] getClientAliases(String arg0, Principal[] arg1) {
403
404                 return new String[]{"default"};
405         }
406
407         public String chooseClientAlias(String[] arg0, Principal[] arg1, Socket arg2) {
408
409                 return "default";
410         }
411
412         public String[] getServerAliases(String arg0, Principal[] arg1) {
413
414                 return null;
415         }
416
417         public String chooseServerAlias(String arg0, Principal[] arg1, Socket arg2) {
418
419                 return null;
420         }
421
422         public X509Certificate[] getCertificateChain(String arg0) {
423
424                 return chain;
425         }
426
427         public PrivateKey getPrivateKey(String arg0) {
428
429                 return key;
430         }
431
432 }