View Javadoc

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