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