1 /* Copyright (c) 2001-2016, The HSQL Development Group
2  * All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions are met:
6  *
7  * Redistributions of source code must retain the above copyright notice, this
8  * list of conditions and the following disclaimer.
9  *
10  * Redistributions in binary form must reproduce the above copyright notice,
11  * this list of conditions and the following disclaimer in the documentation
12  * and/or other materials provided with the distribution.
13  *
14  * Neither the name of the HSQL Development Group nor the names of its
15  * contributors may be used to endorse or promote products derived from this
16  * software without specific prior written permission.
17  *
18  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21  * ARE DISCLAIMED. IN NO EVENT SHALL HSQL DEVELOPMENT GROUP, HSQLDB.ORG,
22  * OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
23  * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
24  * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25  * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
26  * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29  */
30 
31 
32 package org.hsqldb.auth;
33 
34 import java.io.IOException;
35 import java.util.ArrayList;
36 import java.util.Hashtable;
37 import java.util.List;
38 import java.util.regex.Matcher;
39 import java.util.regex.Pattern;
40 import javax.naming.AuthenticationException;
41 import javax.naming.Context;
42 import javax.naming.NamingEnumeration;
43 import javax.naming.NamingException;
44 import javax.naming.directory.Attribute;
45 import javax.naming.directory.Attributes;
46 import javax.naming.directory.BasicAttributes;
47 import javax.naming.directory.SearchResult;
48 import javax.naming.ldap.InitialLdapContext;
49 import javax.naming.ldap.LdapContext;
50 import javax.naming.ldap.StartTlsRequest;
51 import javax.naming.ldap.StartTlsResponse;
52 
53 import org.hsqldb.lib.FrameworkLogger;
54 
55 /**
56  * Authenticates to a HyperSQL catalog according to entries in a LDAP
57  * database.
58  * If using LDAP StartTLS and your server has a certificate not trusted by
59  * default by your JRE, then set system property 'javax.net.ssl.trustStore' to
60  * the path to a trust store containing the cert (as well as any other certs
61  * that your app needs for other purposes).
62  * <P>
63  * This class with authenticate login attempts against LDAP entries with RDN of
64  * the HyperSQL account name (the precise attribute name defaults to 'uid', but
65  * you may change that).
66  * </P> <P>
67  * This class purposefully does not support LDAPS, because LDAPS is deprecated
68  * in favor of StartTLS, which we do support.
69  * If you need to support LDAPS and are using SE 1.6, use our JaasAuthBean with
70  * Sun's LdapLoginModule.
71  * </P> <P>
72  * This class does not support SASL/External authentication, because the work
73  * involved with securely obtaining user-specific certs would be more complex
74  * than everything else here combined.
75  * Another AuthFunctionBean would have to be written if SASL/External is needed.
76  * </P> <P>
77  * To use instances of this class, you must use at least the methods
78  * setLdapHost, setParentDn, initialize, plus
79  * rolesSchemaAttribute and/or accessAttribute.
80  * </P> <P>
81  * For a user to be given HyperSQL catalog access, that user must either have
82  * a value for accessAttribute if that property is set (optionally requiring
83  * a match with accessValuePattern); or, if the accessAttribute is not set then
84  * must have some (any) value for rolesSchemaAttribute (optionally requiring a
85  * match with roleSchemaValuePattern).
86  * Consequently, if you have set both accessAttribute and rolesSchemaAttribute,
87  * the latter attribute will only be consulted if the check of the former
88  * attribute succeeds.
89  * </P> <P>
90  * If you want roles assigned according to the local HyperSQL database instead
91  * of according to LDAP, then set accessAttribute but not rolesSchemaAttribute.
92  * </P> <P>
93  * If what is wanted is to grant access but with no roles (overriding local
94  * roles if there are any), then set both accessAttribute and
95  * rolesSchemaAttribute, but do not set any rolesSchemaAttribute attribute
96  * values for these no-role users.
97  * (I hesitate to mention it, but you could accomplish the same thing with only
98  * a rolesSchemaAttribute attribute, by setting only a dummy role/schema value
99  * for non-role users, because HyperSQL will ignore unknown roles or schemas
100  * but still give access since a list was still supplied).
101  * </P>
102  *
103  * @see AuthFunctionBean
104  * @see #setLdapHost(String)
105  * @see #setParentDn(String)
106  * @see #init()
107  * @author Blaine Simpson (blaine dot simpson at admc dot com)
108  * @since 2.0.1
109  */
110 public class LdapAuthBean implements AuthFunctionBean {
111     private static FrameworkLogger logger =
112             FrameworkLogger.getLog(LdapAuthBean.class);
113 
114     private Integer ldapPort;
115     private String ldapHost, principalTemplate, saslRealm, parentDn;
116     private Pattern roleSchemaValuePattern, accessValuePattern;
117     private String initialContextFactory = "com.sun.jndi.ldap.LdapCtxFactory";
118     private boolean tls;  // This is for StartTLS, not tunneled TLS/LDAPS.
119                   // Variable named just "tls" only for brevity.
120     private String mechanism = "SIMPLE";
121     private String rdnAttribute = "uid";
122     private boolean initialized;
123     private String rolesSchemaAttribute, accessAttribute;
124     protected String[] attributeUnion;
125 
LdapAuthBean()126     public LdapAuthBean() {
127         // Intentionally empty
128     }
129 
130     /**
131      * If this is set, then the entire (brief) transaction with the LDAP server
132      * will be encrypted.
133      */
setStartTls(boolean isTls)134     public void setStartTls(boolean isTls) {
135         this.tls = isTls;
136     }
137 
setLdapPort(int ldapPort)138     public void setLdapPort(int ldapPort) {
139         this.ldapPort = Integer.valueOf(ldapPort);
140     }
141 
142     /**
143      * @throws IllegalStateException if any required setting has not been set.
144      */
init()145     public void init() {
146         if (ldapHost == null) {
147             throw new IllegalStateException(
148                     "Required property 'ldapHost' not set");
149         }
150         if (parentDn == null) {
151             throw new IllegalStateException(
152                     "Required property 'parentDn' not set");
153         }
154         if (initialContextFactory == null) {
155             throw new IllegalStateException(
156                     "Required property 'initialContextFactory' not set");
157         }
158         if (mechanism == null) {
159             throw new IllegalStateException(
160                     "Required property 'mechanism' not set");
161         }
162         if (rdnAttribute == null) {
163             throw new IllegalStateException(
164                     "Required property 'rdnAttribute' not set");
165         }
166         if (rolesSchemaAttribute == null && accessAttribute == null) {
167             throw new IllegalStateException(
168                     "You must set property 'rolesSchemaAttribute' "
169                     + "and/or property 'accessAttribute'");
170         }
171         if (roleSchemaValuePattern != null && rolesSchemaAttribute == null) {
172             throw new IllegalStateException(
173                     "If property 'roleSchemaValuePattern' is set, then you "
174                     + "must also set property 'rolesSchemaAttribute' to "
175                     + "indicate which attribute to evaluate");
176         }
177         if (accessValuePattern != null && accessAttribute == null) {
178             throw new IllegalStateException(
179                     "If property 'accessValuePattern' is set, then you "
180                     + "must also set property 'accessAttribute' to "
181                     + "indicate which attribute to evaluate");
182         }
183         if (rolesSchemaAttribute != null && accessAttribute != null) {
184             attributeUnion = new String[]
185                     { rolesSchemaAttribute, accessAttribute };
186         } else if (rolesSchemaAttribute != null) {
187             attributeUnion = new String[] { rolesSchemaAttribute };
188         } else {
189             attributeUnion = new String[] { accessAttribute };
190         }
191         initialized = true;
192     }
193 
194     /**
195      * Assign a pattern to detect honored accessAttribute values.
196      * If you set accessAttribute but not accessValuePattern, then all that will
197      * be checked for access is if the RDN + parentDN entry has the
198      * accessAttribute attribute.  (I.e. the specific value will not matter
199      * whatsoever).
200      * </P><P>
201      * You may only use this property if you have set property accessAttribute.
202      * If you have set accessAttribute but not this property, then access will
203      * be decided based solely upon existence of this attribute.
204      * </P><P>
205      * Capture groups in the pattern will be ignored and serve no purpose.
206      * </P><P>
207      * N.b. this Pattern will be used for the matches() operation, therefore it
208      * must match the entire candidate value strings (this is different than
209      * the find operation which does not need to satisfy the entire candidate
210      * value).
211      * </P><P>Example1 :<CODE><PRE>
212      *     TRUE
213      * </PRE></CODE>
214      * This will match true values per OpenLDAP's boolean OID.
215      * </P>
216      *
217      * @see Matcher#matches()
218      */
setAccessValuePattern(Pattern accessValuePattern)219     public void setAccessValuePattern(Pattern accessValuePattern) {
220         this.accessValuePattern = accessValuePattern;
221     }
222 
223     /**
224      * String wrapper for method setAccessValuePattern(Pattern)
225      *
226      * Use the (x?) Pattern constructs to set options.
227      *
228      * @throws java.util.regex.PatternSyntaxException
229      * @see #setAccessValuePattern(Pattern)
230      */
setAccessValuePatternString(String patternString)231     public void setAccessValuePatternString(String patternString) {
232         setAccessValuePattern(Pattern.compile(patternString));
233     }
234 
235     /**
236      * Assign a pattern to both detect honored values, and to map from a single
237      * value of "rolesSchemaAttribute"s to a HyperSQL role or schema string.
238      * If your rolesSchemaAttribute holds only the String values precisely as
239      * HyperSQL needs them, then don't use this method at all and all matching
240      * attribute values will be passed directly.
241      * </P><P>
242      * You may only use this property if you have set property
243      * rolesSchemaAttribute.
244      * If rolesSchemaAttribute is set but this property is not set, then
245      * the value will directly determine the user's roles and schema.
246      * </P><P>
247      * <B>Unlike the rolesSchemaAttribute, the property at-hand uses the
248      * singular for "role", because whereas rolesSchemaAttribute is the
249      * attribute for listing multiple roles, roleSchemaValuePattern is used
250      * to evaluate single role values.</B>
251      * </P><P>
252      * These are two distinct and important purposes for the specified Pattern.
253      * <OL>
254      *   <LI>
255      *      Values that do not successfully match the pattern will be ignored.
256      *   <LI>
257      *      Optionally uses parentheses to specify a single capture group
258      *      (if you use parentheses to specify more than one matching group, we
259      *      will only capture for the first).
260      *      What is captured by this group is exactly the role or schema that
261      *      HyperSQL will attempt to assign.
262      *      If no capture parens are given then the Pattern is only used for the
263      *      acceptance decision, and the LDAP-provided value will be returned
264      *      verbatim.
265      * </OL>
266      * </P><P>
267      * Together, these two features work great to extract just the needed role
268      * and schema names from 'memberof' DNs, and will have no problem if you
269      * also use 'memberof' for unrelated purposes.
270      * </P><P>
271      * N.b. this Pattern will be used for the matches() operation, therefore it
272      * must match the entire candidate value strings (this is different than
273      * the find operation which does not need to satisfy the entire candidate
274      * value).
275      * </P><P>Example1 :<CODE><PRE>
276      *     cn=([^,]+),ou=dbRole,dc=admc,dc=com
277      * </PRE></CODE>
278      *     will extract the CN value from matching attribute values.
279      * </P><P>Example1 :<CODE><PRE>
280      *     cn=[^,]+,ou=dbRole,dc=admc,dc=com
281      * </PRE></CODE>
282      *     will return the entire <CODE>cn...com</CODE> string for matching
283      *     attribute values.
284      * </P>
285      *
286      * @see Matcher#matches()
287      */
setRoleSchemaValuePattern(Pattern roleSchemaValuePattern)288     public void setRoleSchemaValuePattern(Pattern roleSchemaValuePattern) {
289         this.roleSchemaValuePattern = roleSchemaValuePattern;
290     }
291 
292     /**
293      * String wrapper for method setRoleSchemaValuePattern(Pattern)
294      *
295      * Use the (x?) Pattern constructs to set options.
296      *
297      * @throws java.util.regex.PatternSyntaxException
298      * @see #setRoleSchemaValuePattern(Pattern)
299      */
setRoleSchemaValuePatternString(String patternString)300     public void setRoleSchemaValuePatternString(String patternString) {
301         setRoleSchemaValuePattern(Pattern.compile(patternString));
302     }
303 
304     /**
305      * Defaults to "SIMPLE".
306      *
307      * @param mechanism  Either 'SIMPLE' (the default) for LDAP Simple, or
308      *                    one of the LDAP SASL mechanisms, such as 'DIGEST-MD5'.
309      */
setSecurityMechanism(String mechanism)310     public void setSecurityMechanism(String mechanism) {
311         this.mechanism = mechanism;
312     }
313 
314     /**
315      * Do not specify URL scheme ("ldap:") because that is implied.
316      * (Since we purposefully don't support LDAPS, there would be no reason to
317      * change that).
318      * <P>
319      * If using StartTLS, then this host name must match the cn of the LDAP
320      * server's certificate.
321      * </P> <P>
322      * If you need to support LDAPS and are using SE 1.6, use our JaasAuthBean
323      * with Sun's LdapLoginModule instead of this class.
324      * </P>
325      *
326      * @see JaasAuthBean
327      */
setLdapHost(String ldapHost)328     public void setLdapHost(String ldapHost) {
329         this.ldapHost = ldapHost;
330     }
331 
332     /**
333      * A template String containing place-holder token '${username}'.
334      * All occurrences of '${username}' (without the quotes) will be translated
335      * to the username that authentication is being attempted with.
336      * <P>
337      * If you supply a principalTemplate that does not contain '${username}',
338      * then authentication will be user-independent.
339      * </P> <P>
340      * It is common to authenticate to LDAP servers with the DN of the user's
341      * LDAP entry.  In this situation, set principalTemplate to
342      * <CODE>&lt;RDN_ATTR=&gt;${username},&lt;PARENT_DN&gt;</CODE>.
343      * For example if you use parentDn of
344      * <CODE>"ou=people,dc=admc,dc=com"</CODE> and rdnAttribute of
345      * <CODE>uid</CODE>, then you would set <CODE><PRE>
346      *     "uid=${username},ou=people,dc=admc,dc=com"
347      * </PRE></CODE>
348      * </P> <P>
349      * By default the user name will be passed exactly as it is, so don't use
350      * this setter if that is what you want.  (This works great for OpenLDAP
351      * with DIGEST-MD5 SASL, for example).
352      * </P>
353      */
setPrincipalTemplate(String principalTemplate)354     public void setPrincipalTemplate(String principalTemplate) {
355         this.principalTemplate = principalTemplate;
356     }
357 
358     /**
359      * Most users should not call this, and will get the default of
360      * "com.sun.jndi.ldap.LdapCtxFactory".
361      * Use this method if you prefer to use a context factory provided by your
362      * framework or container, for example, or if you are using a non-Sun JRE.
363      */
setInitialContextFactory(String initialContextFactory)364     public void setInitialContextFactory(String initialContextFactory) {
365         this.initialContextFactory = initialContextFactory;
366     }
367 
368     /**
369      * Some LDAP servers using a SASL mechanism require a realm to be specified,
370      * and some mechanisms allow a realm to be specified if you wish to use that
371      * feature.
372      * By default no realm will be sent to the LDAP server.
373      * <P>
374      * Don't use this setter if you are not setting a SASL mechanism.
375      * </P>
376      */
setSaslRealm(String saslRealm)377     public void setSaslRealm(String saslRealm) {
378         this.saslRealm = saslRealm;
379     }
380 
381     /**
382      * Set DN which is parent of the user DNs.
383      * E.g.  "ou=people,dc=admc,dc=com"
384      */
setParentDn(String parentDn)385     public void setParentDn(String parentDn) {
386         this.parentDn = parentDn;
387     }
388 
389     /**
390      * rdnAttribute must hold the user name exactly as the HyperSQL login will
391      * be made with.
392      * <P>
393      * This is the RDN relative to the Parent DN specified with setParentDN.
394      * Defaults to 'uid'.
395      * </P>
396      *
397      * @see #setParentDn(String)
398      */
setRdnAttribute(String rdnAttribute)399     public void setRdnAttribute(String rdnAttribute) {
400         this.rdnAttribute = rdnAttribute;
401     }
402 
403     /**
404      * Set the attribute name of the RDN + parentDn entries in which is stored
405      * the list of roles and optional schema for the authenticating user.
406      * <P>
407      * There is no default.  <b>You must set this attribute if you want LDAP
408      * instead of the local HyperSQL database to determine the user's roles!</b>
409      * You must set the rolesSchemaAttribute property and/or the
410      * accessAttribute property.
411      * Consequently, if you do no tset this property, then you must set the
412      * accessAttribute property, and this LdapAuthBean will only determine
413      * access not roles.
414      * </P> <P>
415      * To use the nice <i>reverse group membership</i> feature of LDAP, set
416      * this value to "memberof".
417      * </P> <P>
418      * If you have set both rolesSchemaAttribute and this value, then the
419      * attribute set here will only be consulted if the accessAttribute check
420      * succeeds.
421      * </P>
422      */
setRolesSchemaAttribute(String attribute)423     public void setRolesSchemaAttribute(String attribute) {
424         rolesSchemaAttribute = attribute;
425     }
426 
427     /**
428      * Set the attribute name of the RDN + parentDn entries which will be
429      * consulted to decide whether the user can access the HyperSQL database.
430      * <P>
431      * There is no default.  If you set this attribute, then the attribute will
432      * determine whether the user can access the HyperSQL database, regardless
433      * of whether the rolesSchemaAttribute attribute is set.
434      * </P> <P>
435      * If you set just this property, then the local HyperSQL database will
436      * decide all roles for the user.  If you set this property and property
437      * rolesSchemaAttribute then this attribute will determine access, and if
438      * this attribute grants access then the rolesSchemaAttribute value will
439      * determine the user's roles.
440      * </P>
441      */
setAccessAttribute(String attribute)442     public void setAccessAttribute(String attribute) {
443         accessAttribute = attribute;
444     }
445 
446     /**
447      * @see AuthFunctionBean#authenticate(String, String)
448      */
authenticate(String userName, String password)449     public String[] authenticate(String userName, String password)
450             throws DenyException {
451         if (!initialized) {
452             throw new IllegalStateException(
453                 "You must invoke the 'init' method to initialize the "
454                 + LdapAuthBean.class.getName() + " instance.");
455         }
456         Hashtable env = new Hashtable(5, 0.75f);
457         env.put(Context.INITIAL_CONTEXT_FACTORY, initialContextFactory);
458         env.put(Context.PROVIDER_URL, "ldap://" + ldapHost
459                 + ((ldapPort == null) ? "" : (":" + ldapPort)));
460         StartTlsResponse tlsResponse = null;
461         LdapContext ctx = null;
462 
463         try {
464             ctx = new InitialLdapContext(env, null);
465 
466             if (tls) {
467                 // Requesting to start TLS on an LDAP association
468                 tlsResponse = (StartTlsResponse) ctx.extendedOperation(
469                         new StartTlsRequest());
470 
471                 // Starting TLS
472                 tlsResponse.negotiate();
473             }
474 
475             // A TLS/SSL secure channel has been established if you reach here.
476 
477             // Assertion of client's authorization Identity -- Explicit way
478             ctx.addToEnvironment(Context.SECURITY_AUTHENTICATION, mechanism);
479             ctx.addToEnvironment(Context.SECURITY_PRINCIPAL,
480                   ((principalTemplate == null)
481                   ? userName
482                   : principalTemplate.replace("${username}", userName)));
483             ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, password);
484             if (saslRealm != null) {
485                 env.put("java.naming.security.sasl.realm", saslRealm);
486             }
487 
488             // The Context.SECURITY_* authorizations are only applied when the
489             // following statement executes.  (Or any other remote operations done
490             // while the TLS connection is still open).
491             NamingEnumeration<SearchResult> sRess = null;
492             try {
493                 sRess = ctx.search(parentDn,
494                         new BasicAttributes(rdnAttribute, userName),
495                         attributeUnion);
496             } catch (AuthenticationException ae) {
497                 throw new DenyException();
498             } catch (Exception e) {
499                 throw new RuntimeException(e);
500             }
501             if (!sRess.hasMore()) {
502                 throw new DenyException();
503             }
504             SearchResult sRes = sRess.next();
505             if (sRess.hasMore()) {
506                 throw new RuntimeException("> 1 result");
507             }
508             Attributes attrs = sRes.getAttributes();
509             if (accessAttribute != null) {
510                 Attribute attribute =  attrs.get(accessAttribute);
511                 if (attribute == null) {
512                     throw new DenyException();
513                 }
514                 if (attribute.size() != 1) {
515                     throw new RuntimeException("Access attribute '"
516                             + accessAttribute + "' has unexpected value count: "
517                             + attribute.size());
518                 }
519                 if (accessValuePattern != null) {
520                     Object accessValue = attribute.get(0);
521                     if (accessValue == null) {
522                         throw new RuntimeException(
523                                 "Access Attr. value is null");
524                     }
525                     if (!(accessValue instanceof String)) {
526                         throw new RuntimeException("Access Attr. value "
527                                 + "not a String: "
528                                 + accessValue.getClass().getName());
529                     }
530                     if (!accessValuePattern.matcher(
531                             (String) accessValue).matches()) {
532                         throw new DenyException();
533                     }
534                 }
535             }
536             if (rolesSchemaAttribute == null) {
537                 return null;
538             }
539 
540             // If we reach here, then we definitely need to try to return a
541             // list of roles + schema.
542             List<String> returns = new ArrayList<String>();
543             Attribute attribute =  attrs.get(rolesSchemaAttribute);
544             if (attribute != null) {
545                 int valCount = attribute.size();
546                 Matcher matcher;
547                 Object oneVal;
548                 for (int i = 0; i < valCount; i++) {
549                     oneVal = attribute.get(i);
550                     if (oneVal == null) {
551                         throw new RuntimeException(
552                                 "R/S Attr value #" + i + " is null");
553                     }
554                     if (!(oneVal instanceof String)) {
555                         throw new RuntimeException(
556                                 "R/S Attr value #" + i + " not a String: "
557                                 + oneVal.getClass().getName());
558                     }
559                     if (roleSchemaValuePattern == null) {
560                         returns.add((String) oneVal);
561                     } else {
562                         matcher = roleSchemaValuePattern.matcher(
563                                 (String) oneVal);
564                         if (matcher.matches()) {
565                             returns.add((matcher.groupCount() > 0)
566                                     ? matcher.group(1)
567                                     : (String) oneVal);
568                         }
569                     }
570                 }
571             }
572             if (returns.size() < 1) {
573                 if (accessAttribute == null) {
574                     throw new DenyException();
575                 }
576                 return new String[0];
577             }
578             return returns.toArray(new String[0]);
579         } catch (DenyException de) {
580             // This throws a non-runtime Exception, which is handled as an
581             // access denial instead of a system problem.
582             throw de;
583         } catch (RuntimeException re) {
584             throw re;
585         } catch (IOException ioe) {
586             throw new RuntimeException(ioe);
587         } catch (NamingException ne) {
588             throw new RuntimeException(ne);
589         } finally {
590             if (tlsResponse != null) try {
591                 tlsResponse.close();
592             } catch (IOException ioe) {
593                 logger.error("Failed to close TLS Response", ioe);
594             }
595             if (ctx != null) try {
596                 ctx.close();
597             } catch (NamingException ne) {
598                 logger.error("Failed to close LDAP Context", ne);
599             }
600         }
601     }
602 }
603