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