1   package org.mortbay.jetty.plus.jaas.spi;
2   
3   // ========================================================================
4   // Copyright 2007 Mort Bay Consulting Pty. Ltd.
5   // ------------------------------------------------------------------------
6   // Licensed under the Apache License, Version 2.0 (the "License");
7   // you may not use this file except in compliance with the License.
8   // You may obtain a copy of the License at
9   // http://www.apache.org/licenses/LICENSE-2.0
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  
17  import java.io.IOException;
18  import java.util.ArrayList;
19  import java.util.Hashtable;
20  import java.util.List;
21  import java.util.Map;
22  import java.util.Properties;
23  
24  import javax.naming.Context;
25  import javax.naming.NamingEnumeration;
26  import javax.naming.NamingException;
27  import javax.naming.directory.Attribute;
28  import javax.naming.directory.Attributes;
29  import javax.naming.directory.DirContext;
30  import javax.naming.directory.InitialDirContext;
31  import javax.naming.directory.SearchControls;
32  import javax.naming.directory.SearchResult;
33  import javax.security.auth.Subject;
34  import javax.security.auth.callback.Callback;
35  import javax.security.auth.callback.CallbackHandler;
36  import javax.security.auth.callback.NameCallback;
37  import javax.security.auth.callback.UnsupportedCallbackException;
38  import javax.security.auth.login.LoginException;
39  
40  import org.mortbay.jetty.plus.jaas.callback.ObjectCallback;
41  import org.mortbay.jetty.security.Credential;
42  import org.mortbay.log.Log;
43  
44  /**
45   * A LdapLoginModule for use with JAAS setups
46   * <p/>
47   * The jvm should be started with the following parameter:
48   * <br><br>
49   * <code>
50   * -Djava.security.auth.login.config=etc/ldap-loginModule.conf
51   * </code>
52   * <br><br>
53   * and an example of the ldap-loginModule.conf would be:
54   * <br><br>
55   * <pre>
56   * ldaploginmodule {
57   *    org.mortbay.jetty.plus.jaas.spi.LdapLoginModule required
58   *    debug="true"
59   *    contextFactory="com.sun.jndi.ldap.LdapCtxFactory"
60   *    hostname="ldap.example.com"
61   *    port="389"
62   *    bindDn="cn=Directory Manager"
63   *    bindPassword="directory"
64   *    authenticationMethod="simple"
65   *    forceBindingLogin="false"
66   *    userBaseDn="ou=people,dc=alcatel"
67   *    userRdnAttribute="uid"
68   *    userIdAttribute="uid"
69   *    userPasswordAttribute="userPassword"
70   *    userObjectClass="inetOrgPerson"
71   *    roleBaseDn="ou=groups,dc=example,dc=com"
72   *    roleNameAttribute="cn"
73   *    roleMemberAttribute="uniqueMember"
74   *    roleObjectClass="groupOfUniqueNames";
75   *    };
76   *  </pre>
77   *
78   * @author Jesse McConnell <jesse@codehaus.org>
79   * @author Frederic Nizery <frederic.nizery@alcatel-lucent.fr>
80   * @author Trygve Laugstol <trygvis@codehaus.org>
81   */
82  public class LdapLoginModule extends AbstractLoginModule
83  {
84      /**
85       * hostname of the ldap server
86       */
87      private String _hostname;
88  
89      /**
90       * port of the ldap server
91       */
92      private int _port;
93  
94      /**
95       * Context.SECURITY_AUTHENTICATION
96       */
97      private String _authenticationMethod;
98  
99      /**
100      * Context.INITIAL_CONTEXT_FACTORY
101      */
102     private String _contextFactory;
103 
104     /**
105      * root DN used to connect to
106      */
107     private String _bindDn;
108 
109     /**
110      * password used to connect to the root ldap context
111      */
112     private String _bindPassword;
113 
114     /**
115      * object class of a user
116      */
117     private String _userObjectClass = "inetOrgPerson";
118 
119     /**
120      * attribute that the principal is located
121      */
122     private String _userRdnAttribute = "uid";
123 
124     /**
125      * attribute that the principal is located
126      */
127     private String _userIdAttribute = "cn";
128 
129     /**
130      * name of the attribute that a users password is stored under
131      * <p/>
132      * NOTE: not always accessible, see force binding login
133      */
134     private String _userPasswordAttribute = "userPassword";
135 
136     /**
137      * base DN where users are to be searched from
138      */
139     private String _userBaseDn;
140 
141     /**
142      * base DN where role membership is to be searched from
143      */
144     private String _roleBaseDn;
145 
146     /**
147      * object class of roles
148      */
149     private String _roleObjectClass = "groupOfUniqueNames";
150 
151     /**
152      * name of the attribute that a username would be under a role class
153      */
154     private String _roleMemberAttribute = "uniqueMember";
155 
156     /**
157      * the name of the attribute that a role would be stored under
158      */
159     private String _roleNameAttribute = "roleName";
160 
161     private boolean _debug;
162 
163     /**
164      * if the getUserInfo can pull a password off of the user then
165      * password comparison is an option for authn, to force binding
166      * login checks, set this to true
167      */
168     private boolean _forceBindingLogin = false;
169 
170     private DirContext _rootContext;
171 
172     /**
173      * get the available information about the user
174      * <p/>
175      * for this LoginModule, the credential can be null which will result in a
176      * binding ldap authentication scenario
177      * <p/>
178      * roles are also an optional concept if required
179      *
180      * @param username
181      * @return
182      * @throws Exception
183      */
184     public UserInfo getUserInfo(String username) throws Exception
185     {
186         String pwdCredential = getUserCredentials(username);
187 
188         if (pwdCredential == null)
189         {
190             return null;
191         }
192 
193         pwdCredential = convertCredentialLdapToJetty(pwdCredential);
194 
195         //String md5Credential = Credential.MD5.digest("foo");
196         //byte[] ba = digestMD5("foo");
197         //System.out.println(md5Credential + "  " + ba );
198         Credential credential = Credential.getCredential(pwdCredential);
199         List roles = getUserRoles(_rootContext, username);
200 
201         return new UserInfo(username, credential, roles);
202     }
203 
204     protected String doRFC2254Encoding(String inputString)
205     {
206         StringBuffer buf = new StringBuffer(inputString.length());
207         for (int i = 0; i < inputString.length(); i++)
208         {
209             char c = inputString.charAt(i);
210             switch (c)
211             {
212                 case '\\':
213                     buf.append("\\5c");
214                     break;
215                 case '*':
216                     buf.append("\\2a");
217                     break;
218                 case '(':
219                     buf.append("\\28");
220                     break;
221                 case ')':
222                     buf.append("\\29");
223                     break;
224                 case '\0':
225                     buf.append("\\00");
226                     break;
227                 default:
228                     buf.append(c);
229                     break;
230             }
231         }
232         return buf.toString();
233     }
234 
235     /**
236      * attempts to get the users credentials from the users context
237      * <p/>
238      * NOTE: this is not an user authenticated operation
239      *
240      * @param username
241      * @return
242      * @throws LoginException
243      */
244     private String getUserCredentials(String username) throws LoginException
245     {
246         String ldapCredential = null;
247 
248         SearchControls ctls = new SearchControls();
249         ctls.setCountLimit(1);
250         ctls.setDerefLinkFlag(true);
251         ctls.setSearchScope(SearchControls.SUBTREE_SCOPE);
252 
253         String filter = "(&(objectClass={0})({1}={2}))";
254 
255         Log.debug("Searching for users with filter: \'" + filter + "\'" + " from base dn: " + _userBaseDn);
256 
257         try
258         {
259             Object[] filterArguments = {_userObjectClass, _userIdAttribute, username};
260             NamingEnumeration results = _rootContext.search(_userBaseDn, filter, filterArguments, ctls);
261 
262             Log.debug("Found user?: " + results.hasMoreElements());
263 
264             if (!results.hasMoreElements())
265             {
266                 throw new LoginException("User not found.");
267             }
268 
269             SearchResult result = findUser(username);
270 
271             Attributes attributes = result.getAttributes();
272 
273             Attribute attribute = attributes.get(_userPasswordAttribute);
274             if (attribute != null)
275             {
276                 try
277                 {
278                     byte[] value = (byte[]) attribute.get();
279 
280                     ldapCredential = new String(value);
281                 }
282                 catch (NamingException e)
283                 {
284                     Log.debug("no password available under attribute: " + _userPasswordAttribute);
285                 }
286             }
287         }
288         catch (NamingException e)
289         {
290             throw new LoginException("Root context binding failure.");
291         }
292 
293         Log.debug("user cred is: " + ldapCredential);
294 
295         return ldapCredential;
296     }
297 
298     /**
299      * attempts to get the users roles from the root context
300      * <p/>
301      * NOTE: this is not an user authenticated operation
302      *
303      * @param dirContext
304      * @param username
305      * @return
306      * @throws LoginException
307      */
308     private List getUserRoles(DirContext dirContext, String username) throws LoginException, NamingException
309     {
310         String userDn = _userRdnAttribute + "=" + username + "," + _userBaseDn;
311 
312         return getUserRolesByDn(dirContext, userDn);
313     }
314 
315     private List getUserRolesByDn(DirContext dirContext, String userDn) throws LoginException, NamingException
316     {
317         ArrayList roleList = new ArrayList();
318 
319         if (dirContext == null || _roleBaseDn == null || _roleMemberAttribute == null || _roleObjectClass == null)
320         {
321             return roleList;
322         }
323 
324         SearchControls ctls = new SearchControls();
325         ctls.setDerefLinkFlag(true);
326         ctls.setSearchScope(SearchControls.SUBTREE_SCOPE);
327 
328         String filter = "(&(objectClass={0})({1}={2}))";
329         Object[] filterArguments = {_roleObjectClass, _roleMemberAttribute, userDn};
330         NamingEnumeration results = dirContext.search(_roleBaseDn, filter, filterArguments, ctls);
331 
332         Log.debug("Found user roles?: " + results.hasMoreElements());
333 
334         while (results.hasMoreElements())
335         {
336             SearchResult result = (SearchResult) results.nextElement();
337 
338             Attributes attributes = result.getAttributes();
339 
340             if (attributes == null)
341             {
342                 continue;
343             }
344 
345             Attribute roleAttribute = attributes.get(_roleNameAttribute);
346 
347             if (roleAttribute == null)
348             {
349                 continue;
350             }
351 
352             NamingEnumeration roles = roleAttribute.getAll();
353             while (roles.hasMore())
354             {
355                 roleList.add(roles.next());
356             }
357         }
358 
359         return roleList;
360     }
361 
362 
363     /**
364      * since ldap uses a context bind for valid authentication checking, we override login()
365      * <p/>
366      * if credentials are not available from the users context or if we are forcing the binding check
367      * then we try a binding authentication check, otherwise if we have the users encoded password then
368      * we can try authentication via that mechanic
369      *
370      * @return
371      * @throws LoginException
372      */
373     public boolean login() throws LoginException
374     {
375         try
376         {
377             if (getCallbackHandler() == null)
378             {
379                 throw new LoginException("No callback handler");
380             }
381 
382             Callback[] callbacks = configureCallbacks();
383             getCallbackHandler().handle(callbacks);
384 
385             String webUserName = ((NameCallback) callbacks[0]).getName();
386             Object webCredential = ((ObjectCallback) callbacks[1]).getObject();
387 
388             if (webUserName == null || webCredential == null)
389             {
390                 setAuthenticated(false);
391                 return isAuthenticated();
392             }
393 
394             if (_forceBindingLogin)
395             {
396                 return bindingLogin(webUserName, webCredential);
397             }
398 
399             // This sets read and the credential
400             UserInfo userInfo = getUserInfo(webUserName);
401 
402             if (userInfo == null)
403             {
404                 setAuthenticated(false);
405                 return false;
406             }
407 
408             setCurrentUser(new JAASUserInfo(userInfo));
409 
410             if (webCredential instanceof String)
411             {
412                 return credentialLogin(Credential.getCredential((String) webCredential));
413             }
414 
415             return credentialLogin(webCredential);
416         }
417         catch (UnsupportedCallbackException e)
418         {
419             throw new LoginException("Error obtaining callback information.");
420         }
421         catch (IOException e)
422         {
423             if (_debug)
424             {
425                 e.printStackTrace();
426             }
427             throw new LoginException("IO Error performing login.");
428         }
429         catch (Exception e)
430         {
431             if (_debug)
432             {
433                 e.printStackTrace();
434             }
435             throw new LoginException("Error obtaining user info.");
436         }
437     }
438 
439     /**
440      * password supplied authentication check
441      *
442      * @param webCredential
443      * @return
444      * @throws LoginException
445      */
446     protected boolean credentialLogin(Object webCredential) throws LoginException
447     {
448         setAuthenticated(getCurrentUser().checkCredential(webCredential));
449         return isAuthenticated();
450     }
451 
452     /**
453      * binding authentication check
454      * This methode of authentication works only if the user branch of the DIT (ldap tree)
455      * has an ACI (acces control instruction) that allow the access to any user or at least
456      * for the user that logs in.
457      *
458      * @param username
459      * @param password
460      * @return
461      * @throws LoginException
462      */
463     protected boolean bindingLogin(String username, Object password) throws LoginException, NamingException
464     {
465         SearchResult searchResult = findUser(username);
466 
467         String userDn = searchResult.getNameInNamespace();
468 
469         Log.info("Attempting authentication: " + userDn);
470 
471         Hashtable environment = getEnvironment();
472         environment.put(Context.SECURITY_PRINCIPAL, userDn);
473         environment.put(Context.SECURITY_CREDENTIALS, password);
474 
475         DirContext dirContext = new InitialDirContext(environment);
476         List roles = getUserRolesByDn(dirContext, userDn);
477 
478         UserInfo userInfo = new UserInfo(username, null, roles);
479 
480         setCurrentUser(new JAASUserInfo(userInfo));
481 
482         setAuthenticated(true);
483 
484         return true;
485     }
486 
487     private SearchResult findUser(String username) throws NamingException, LoginException
488     {
489         SearchControls ctls = new SearchControls();
490         ctls.setCountLimit(1);
491         ctls.setDerefLinkFlag(true);
492         ctls.setSearchScope(SearchControls.SUBTREE_SCOPE);
493 
494         String filter = "(&(objectClass={0})({1}={2}))";
495 
496         Log.info("Searching for users with filter: \'" + filter + "\'" + " from base dn: " + _userBaseDn);
497 
498         Object[] filterArguments = new Object[]{
499             _userObjectClass,
500             _userIdAttribute,
501             username
502         };
503         NamingEnumeration results = _rootContext.search(_userBaseDn, filter, filterArguments, ctls);
504 
505         Log.info("Found user?: " + results.hasMoreElements());
506 
507         if (!results.hasMoreElements())
508         {
509             throw new LoginException("User not found.");
510         }
511 
512         return (SearchResult) results.nextElement();
513     }
514 
515 
516     /**
517      * Init LoginModule.
518      * Called once by JAAS after new instance is created.
519      *
520      * @param subject
521      * @param callbackHandler
522      * @param sharedState
523      * @param options
524      */
525     public void initialize(Subject subject,
526                            CallbackHandler callbackHandler,
527                            Map sharedState,
528                            Map options)
529     {
530 
531         super.initialize(subject, callbackHandler, sharedState, options);
532 
533         _hostname = (String) options.get("hostname");
534         _port = Integer.parseInt((String) options.get("port"));
535         _contextFactory = (String) options.get("contextFactory");
536         _bindDn = (String) options.get("bindDn");
537         _bindPassword = (String) options.get("bindPassword");
538         _authenticationMethod = (String) options.get("authenticationMethod");
539 
540         _userBaseDn = (String) options.get("userBaseDn");
541 
542         _roleBaseDn = (String) options.get("roleBaseDn");
543 
544         if (options.containsKey("forceBindingLogin"))
545         {
546             _forceBindingLogin = Boolean.parseBoolean((String) options.get("forceBindingLogin"));
547         }
548 
549         _userObjectClass = getOption(options, "userObjectClass", _userObjectClass);
550         _userRdnAttribute = getOption(options, "userRdnAttribute", _userRdnAttribute);
551         _userIdAttribute = getOption(options, "userIdAttribute", _userIdAttribute);
552         _userPasswordAttribute = getOption(options, "userPasswordAttribute", _userPasswordAttribute);
553         _roleObjectClass = getOption(options, "roleObjectClass", _roleObjectClass);
554         _roleMemberAttribute = getOption(options, "roleMemberAttribute", _roleMemberAttribute);
555         _roleNameAttribute = getOption(options, "roleNameAttribute", _roleNameAttribute);
556         _debug = Boolean.parseBoolean(String.valueOf(getOption(options, "debug", Boolean.toString(_debug))));
557 
558         try
559         {
560             _rootContext = new InitialDirContext(getEnvironment());
561         }
562         catch (NamingException ex)
563         {
564             throw new IllegalStateException("Unable to establish root context", ex);
565         }
566     }
567       
568 	public boolean commit() throws LoginException {
569 		
570 		try 
571 		{
572 			_rootContext.close();
573 		} 
574 		catch (NamingException e) 
575 		{
576 			throw new LoginException( "error closing root context: " + e.getMessage() );
577 		}
578 		
579 		return super.commit();
580 	}
581 	
582 	public boolean abort() throws LoginException {
583 		try 
584 		{
585 			_rootContext.close();
586 		} 
587 		catch (NamingException e) 
588 		{
589 			throw new LoginException( "error closing root context: " + e.getMessage() );
590 		}
591 		
592 		return super.abort();
593 	}
594 
595 	private String getOption(Map options, String key, String defaultValue)
596     {
597         Object value = options.get(key);
598 
599         if (value == null)
600         {
601             return defaultValue;
602         }
603 
604         return (String) value;
605     }
606 
607     /**
608      * get the context for connection
609      *
610      * @return
611      */
612     public Hashtable<Object, Object> getEnvironment()
613     {
614         Properties env = new Properties();
615 
616         env.put(Context.INITIAL_CONTEXT_FACTORY, _contextFactory);
617 
618         if (_hostname != null)
619         {
620             if (_port != 0)
621             {
622                 env.put(Context.PROVIDER_URL, "ldap://" + _hostname + ":" + _port + "/");
623             }
624             else
625             {
626                 env.put(Context.PROVIDER_URL, "ldap://" + _hostname + "/");
627             }
628         }
629 
630         if (_authenticationMethod != null)
631         {
632             env.put(Context.SECURITY_AUTHENTICATION, _authenticationMethod);
633         }
634 
635         if (_bindDn != null)
636         {
637             env.put(Context.SECURITY_PRINCIPAL, _bindDn);
638         }
639 
640         if (_bindPassword != null)
641         {
642             env.put(Context.SECURITY_CREDENTIALS, _bindPassword);
643         }
644 
645         return env;
646     }
647 
648     public static String convertCredentialJettyToLdap(String encryptedPassword)
649     {
650         if ("MD5:".startsWith(encryptedPassword.toUpperCase()))
651         {
652             return "{MD5}" + encryptedPassword.substring("MD5:".length(), encryptedPassword.length());
653         }
654 
655         if ("CRYPT:".startsWith(encryptedPassword.toUpperCase()))
656         {
657             return "{CRYPT}" + encryptedPassword.substring("CRYPT:".length(), encryptedPassword.length());
658         }
659 
660         return encryptedPassword;
661     }
662 
663     public static String convertCredentialLdapToJetty(String encryptedPassword)
664     {
665         if (encryptedPassword == null)
666         {
667             return encryptedPassword;
668         }
669 
670         if ("{MD5}".startsWith(encryptedPassword.toUpperCase()))
671         {
672             return "MD5:" + encryptedPassword.substring("{MD5}".length(), encryptedPassword.length());
673         }
674 
675         if ("{CRYPT}".startsWith(encryptedPassword.toUpperCase()))
676         {
677             return "CRYPT:" + encryptedPassword.substring("{CRYPT}".length(), encryptedPassword.length());
678         }
679 
680         return encryptedPassword;
681     }
682 }