001/** 
002 * Copyright (C) 2009 "Darwin V. Felix" <darwinfelix@users.sourceforge.net>
003 * 
004 * This library is free software; you can redistribute it and/or
005 * modify it under the terms of the GNU Lesser General Public
006 * License as published by the Free Software Foundation; either
007 * version 2.1 of the License, or (at your option) any later version.
008 * 
009 * This library is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
012 * Lesser General Public License for more details.
013 * 
014 * You should have received a copy of the GNU Lesser General Public
015 * License along with this library; if not, write to the Free Software
016 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
017 */
018
019package net.sourceforge.spnego;
020
021import java.io.File;
022import java.io.FileNotFoundException;
023import java.net.URI;
024import java.net.URISyntaxException;
025import java.util.ArrayList;
026import java.util.Collections;
027import java.util.List;
028import java.util.Map;
029import java.util.logging.Level;
030import java.util.logging.Logger;
031
032import javax.security.auth.login.AppConfigurationEntry;
033import javax.security.auth.login.Configuration;
034import javax.servlet.FilterConfig;
035
036import net.sourceforge.spnego.SpnegoHttpFilter.Constants;
037
038/**
039 * Class that applies/enforces web.xml init params.
040 * 
041 * <p>These properties are set in the servlet's init params 
042 * in the web.xml file.</>
043 * 
044 * <p>This class also validates if a keyTab should be used 
045 * and if all of the LoginModule options have been set.</p>
046 * 
047 * <p>
048 * To see a working example and instructions on how to use a keytab, take 
049 * a look at the <a href="http://spnego.sourceforge.net/server_keytab.html"
050 * target="_blank">creating a server keytab</a> example.
051 * </p>
052 * 
053 * <p>The class should be used as a Singleton:<br />
054 * <code>
055 * SpnegoFilterConfig config = SpnegoFilterConfig.getInstance(filter);
056 * </code>
057 * </p>
058 * 
059 * <p>See an example web.xml configuration in the 
060 * <a href="http://spnego.sourceforge.net/spnego_tomcat.html" 
061 * target="_blank">installing on tomcat</a> documentation. 
062 * </p>
063 * 
064 * @author Darwin V. Felix
065 *
066 */
067public final class SpnegoFilterConfig { // NOPMD
068    
069    private static final Logger LOGGER = Logger.getLogger(Constants.LOGGER_NAME);
070    
071    private static final String MISSING_PROPERTY = 
072        "Servlet Filter init param(s) in web.xml missing: ";
073    
074    private static transient SpnegoFilterConfig instance = null;
075
076    /** true if Basic auth should be offered. */
077    private transient boolean allowBasic = false;
078    
079    /** true if server should support credential delegation requests. */
080    private transient boolean allowDelegation = false;
081    
082    /** true if request from localhost should not be authenticated. */
083    private transient boolean allowLocalhost = true;
084    
085    /** true if non-ssl for basic auth is allowed. */
086    private transient boolean allowUnsecure = true;
087    
088    /** true if all req. login module options set. */
089    private transient boolean canUseKeyTab = false;
090    
091    /** name of the client login module. */
092    private transient String clientLoginModule = null;
093    
094    /** url directory path(s) that should NOT undergo authentication. */
095    private transient String excludeDirs = null;
096    
097    /** password to domain account. */
098    private transient String password = null;
099    
100    /** true if instead of err on ntlm token, prompt for username/pass. */
101    private transient boolean promptNtlm = false;
102
103    /** name of the server login module. */
104    private transient String serverLoginModule = null;
105    
106    /** domain account to use for pre-authentication. */
107    private transient String username = null;
108    
109    private SpnegoFilterConfig() {
110        // default private
111    }
112    
113    /**
114     * Class is a Singleton. Use the static getInstance() method.
115     */
116    private SpnegoFilterConfig(final FilterConfig config) throws FileNotFoundException
117        , URISyntaxException {
118
119        // specify logging level
120        setLogLevel(config.getInitParameter(Constants.LOGGER_LEVEL));
121        
122        // check if exists
123        assert loginConfExists(config.getInitParameter(Constants.LOGIN_CONF));
124        
125        // specify krb5 conf as a System property
126        if (null == config.getInitParameter(Constants.KRB5_CONF)) {
127            throw new IllegalArgumentException(
128                    SpnegoFilterConfig.MISSING_PROPERTY + Constants.KRB5_CONF);
129        } else {
130            System.setProperty("java.security.krb5.conf"
131                    , config.getInitParameter(Constants.KRB5_CONF));            
132        }
133
134        // specify login conf as a System property
135        if (null == config.getInitParameter(Constants.LOGIN_CONF)) {
136            throw new IllegalArgumentException(
137                    SpnegoFilterConfig.MISSING_PROPERTY + Constants.LOGIN_CONF);
138        } else {
139            System.setProperty("java.security.auth.login.config"
140                    , config.getInitParameter(Constants.LOGIN_CONF));            
141        }
142        
143        // check if exists and no options specified
144        doClientModule(config.getInitParameter(Constants.CLIENT_MODULE));
145        
146        // determine if all req. met to use keyTab
147        doServerModule(config.getInitParameter(Constants.SERVER_MODULE));
148        
149        // if username/password provided, don't use key tab 
150        setUsernamePassword(config.getInitParameter(Constants.PREAUTH_USERNAME)
151                , config.getInitParameter(Constants.PREAUTH_PASSWORD));
152        
153        // determine if we should support Basic Authentication
154        setBasicSupport(config.getInitParameter(Constants.ALLOW_BASIC)
155                , config.getInitParameter(Constants.ALLOW_UNSEC_BASIC));
156        
157        // determine if we should Basic Auth prompt if rec. NTLM token
158        setNtlmSupport(config.getInitParameter(Constants.PROMPT_NTLM));
159        
160        // requests from localhost will not be authenticated against the KDC 
161        if (null != config.getInitParameter(Constants.ALLOW_LOCALHOST)) {
162            this.allowLocalhost = 
163                Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_LOCALHOST));
164        }
165        
166        // determine if the server supports credential delegation 
167        if (null != config.getInitParameter(Constants.ALLOW_DELEGATION)) {
168            this.allowDelegation = 
169                Boolean.parseBoolean(config.getInitParameter(Constants.ALLOW_DELEGATION));
170        }
171        
172        // determine if a url path(s) should NOT undergo authentication
173        this.excludeDirs = config.getInitParameter(Constants.EXCLUDE_DIRS);
174    }
175    
176    private void doClientModule(final String moduleName) {
177        
178        assert moduleExists("client", moduleName);
179        
180        this.clientLoginModule = moduleName;
181        
182        // client must not have any options
183        
184        // confirm that runtime loaded the login file
185        final Configuration config = Configuration.getConfiguration();
186 
187        // we only expect one entry
188        final AppConfigurationEntry entry = config.getAppConfigurationEntry(moduleName)[0];
189        
190        // get login module options
191        final Map<String, ?> opt = entry.getOptions();
192        
193        // assert
194        if (!opt.isEmpty()) {
195            for (Map.Entry<String, ?> option : opt.entrySet()) {
196                // do not allow client modules to have any options
197                // unless they are jboss options
198                if (!option.getKey().startsWith("jboss")) {
199                    throw new UnsupportedOperationException("Login Module for client must not "
200                            + "specify any options: " + opt.size()
201                            + "; moduleName=" + moduleName
202                            + "; options=" + opt.toString());                    
203                }
204            }
205        }
206    }
207    
208    /**
209     * Set the canUseKeyTab flag by determining if all LoginModule options 
210     * have been set.
211     * 
212     * <pre>
213     * my-spnego-login-module {
214     *      com.sun.security.auth.module.Krb5LoginModule
215     *      required
216     *      storeKey=true
217     *      useKeyTab=true
218     *      keyTab="file:///C:/my_path/my_file.keytab"
219     *      principal="my_preauth_account";
220     * };
221     * </pre>
222     * 
223     * @param moduleName
224     */
225    private void doServerModule(final String moduleName) {
226        
227        assert moduleExists("server", moduleName);
228        
229        this.serverLoginModule = moduleName;
230        
231        // confirm that runtime loaded the login file
232        final Configuration config = Configuration.getConfiguration();
233 
234        // we only expect one entry
235        final AppConfigurationEntry entry = config.getAppConfigurationEntry(moduleName)[0];
236        
237        // get login module options
238        final Map<String, ?> opt = entry.getOptions();
239        
240        // storeKey must be set to true
241        if (opt.containsKey("storeKey")) {
242            final Object store = opt.get("storeKey");
243            if (null == store || !Boolean.parseBoolean((String) store)) {
244                throw new UnsupportedOperationException("Login Module for server "
245                        + "must have storeKey option in login file set to true.");
246            }
247        } else {
248            throw new UnsupportedOperationException("Login Module for server does "
249                    + "not have the storeKey option defined in login file.");
250        }
251        
252        if (opt.containsKey("useKeyTab") 
253                && opt.containsKey("principal") 
254                && opt.containsKey("keyTab")) {
255            
256            this.canUseKeyTab = true;
257        } else {
258            this.canUseKeyTab = false;
259        }
260    }
261
262    /**
263     * Returns true if a client sends an NTLM token and the 
264     * filter should ask client for a Basic Auth token instead.
265     * 
266     * @return true if client should be prompted for Basic Auth
267     */
268    boolean downgradeNtlm() {
269        return this.promptNtlm;
270    }
271    
272    /**
273     * Return the value defined in the servlet's init params 
274     * in the web.xml file.
275     * 
276     * @return the name of the login module for the client
277     */
278    String getClientLoginModule() {
279        return this.clientLoginModule;
280    }
281    
282    /**
283     * Return the value defined in the servlet's init params 
284     * in the web.xml file as a List object.
285     * 
286     * @return a List of directories to exclude
287     */
288    List<String> getExcludeDirs() {
289        if (null == this.excludeDirs || this.excludeDirs.isEmpty()) {
290            return Collections.emptyList();
291        } else {
292            return SpnegoFilterConfig.split(this.excludeDirs);
293        }
294    }
295    
296    /**
297     * Return the password to the pre-authentication domain account.
298     * 
299     * @return password of pre-auth domain account
300     */
301    String getPreauthPassword() {
302        return this.password;
303    }
304    
305    /**
306     * Return the name of the pre-authentication domain account.
307     * 
308     * @return name of pre-auth domain account
309     */
310    String getPreauthUsername() {
311        return this.username;
312    }
313    
314    /**
315     * Return the value defined in the servlet's init params 
316     * in the web.xml file.
317     * 
318     * @return the name of the login module for the server
319     */
320    String getServerLoginModule() {
321        return this.serverLoginModule;
322    }
323    
324    /**
325     * Returns the instance of the servlet's config parameters.
326     * 
327     * @param config FilterConfi from servlet's init method
328     * @return the instance of that represent the init params
329     * @throws FileNotFoundException if login conf file not found
330     * @throws URISyntaxException if path to login conf is bad
331     */
332    public static SpnegoFilterConfig getInstance(final FilterConfig config) 
333        throws FileNotFoundException, URISyntaxException {
334        
335        synchronized (SpnegoFilterConfig.class) {
336            if (null == SpnegoFilterConfig.instance) {
337                SpnegoFilterConfig.instance = new SpnegoFilterConfig(config);
338            }
339        }
340
341        return SpnegoFilterConfig.instance;
342    }
343
344    /**
345     * Returns true if Basic Authentication is allowed.
346     * 
347     * @return true if Basic Auth is allowed
348     */
349    boolean isBasicAllowed() {
350        return this.allowBasic;
351    }
352    
353    /**
354     * Returns true if the server should support credential delegation requests.
355     * 
356     * @return true if server supports credential delegation
357     */
358    boolean isDelegationAllowed() {
359        return this.allowDelegation;
360    }
361    
362    /**
363     * Returns true if requests from localhost are allowed.
364     * 
365     * @return true if requests from localhost are allowed
366     */
367    boolean isLocalhostAllowed() {
368        return this.allowLocalhost;
369    }
370
371    /**
372     * Returns true if SSL/TLS is required.
373     * 
374     * @return true if SSL/TLS is required
375     */
376    boolean isUnsecureAllowed() {
377        return this.allowUnsecure;
378    }
379    
380    private boolean loginConfExists(final String loginconf) 
381        throws FileNotFoundException, URISyntaxException {
382
383        // confirm login.conf file exists
384        if (null == loginconf || loginconf.isEmpty()) {
385            throw new FileNotFoundException("Must provide a login.conf file.");
386        } else {
387            final File file = new File(new URI(loginconf));
388            if (!file.exists()) {
389                throw new FileNotFoundException(loginconf);
390            }
391        }
392
393        return true;
394    }
395    
396    private boolean moduleExists(final String side, final String moduleName) {
397        
398        // confirm that runtime loaded the login file
399        final Configuration config = Configuration.getConfiguration();
400 
401        // we only expect one entry
402        final AppConfigurationEntry[] entry = config.getAppConfigurationEntry(moduleName); 
403        
404        // confirm that the module name exists in the file
405        if (null == entry) {
406            throw new IllegalArgumentException("The " + side + " module name " 
407                    + "was not found in the login file: " + moduleName);
408        }
409        
410        // confirm that the login module class was defined
411        if (0 == entry.length) {
412            throw new IllegalArgumentException("The " + side + " module name " 
413                    + "exists but login module class not defined: " + moduleName);
414        }
415        
416        // confirm that only one login module class specified
417        if (entry.length > 1) {
418            throw new IllegalArgumentException("Only one login module class " 
419                    + "is supported for the " + side + " module: " + entry.length);
420        }
421        
422        // confirm class name is "com.sun.security.auth.module.Krb5LoginModule"
423        if (!entry[0].getLoginModuleName().equals(
424                "com.sun.security.auth.module.Krb5LoginModule")) {
425            throw new UnsupportedOperationException("Login module class not "
426                    + "supported: " + entry[0].getLoginModuleName());
427        }
428        
429        // confirm Control Flag is specified as REQUIRED
430        if (!entry[0].getControlFlag().equals(
431                AppConfigurationEntry.LoginModuleControlFlag.REQUIRED)) {
432            throw new UnsupportedOperationException("Control Flag must "
433                    + "have a value of REQUIRED: " + entry[0].getControlFlag());
434        }
435        
436        return true;
437    }
438
439    /**
440     * Specify if Basic authentication is allowed and if un-secure/non-ssl 
441     * Basic should be allowed.
442     * 
443     * @param basic true if basic is allowed
444     * @param unsecure true if un-secure basic is allowed
445     */
446    private void setBasicSupport(final String basic, final String unsecure) {
447        if (null == basic) {
448            throw new IllegalArgumentException(
449                    SpnegoFilterConfig.MISSING_PROPERTY + Constants.ALLOW_BASIC);
450        }
451        
452        if (null == unsecure) {
453            throw new IllegalArgumentException(
454                    SpnegoFilterConfig.MISSING_PROPERTY + Constants.ALLOW_UNSEC_BASIC);
455        }
456        
457        this.allowBasic = Boolean.parseBoolean(basic);
458        this.allowUnsecure = Boolean.parseBoolean(unsecure);
459    }
460
461    /**
462     * Specify the logging level.
463     * 
464     * @param level logging level
465     */
466    private void setLogLevel(final String level) {
467        if (null != level) {
468            switch (Integer.parseInt(level)) {
469                case 1:
470                    LOGGER.setLevel(Level.FINEST);
471                    break;
472                case 2:
473                    LOGGER.setLevel(Level.FINER);
474                    break;
475                case 3:
476                    LOGGER.setLevel(Level.FINE);
477                    break;
478                case 4:
479                    LOGGER.setLevel(Level.CONFIG);
480                    break;
481                case 6:
482                    LOGGER.setLevel(Level.WARNING);
483                    break;
484                case 7:
485                    LOGGER.setLevel(Level.SEVERE);
486                    break;
487                default :
488                    LOGGER.setLevel(Level.INFO);
489                    break;
490            }
491        }
492    }
493    
494    /**
495     * If request contains NTLM token, specify if a 401 should 
496     * be sent back to client with Basic Auth as the only 
497     * available authentication scheme.
498     * 
499     * @param ntlm true/false
500     */
501    private void setNtlmSupport(final String ntlm) {
502        if (null == ntlm) {
503            throw new IllegalArgumentException(
504                    SpnegoFilterConfig.MISSING_PROPERTY + Constants.PROMPT_NTLM);
505        }
506        
507        final boolean downgradeNtlm = Boolean.parseBoolean(ntlm);
508        
509        if (!this.allowBasic && downgradeNtlm) {
510            throw new IllegalArgumentException("If prompt ntlm is true, then "
511                    + "allow basic auth must also be true.");
512        }
513        
514        this.promptNtlm = downgradeNtlm;
515    }
516
517    /**
518     * Set the username and password if specified in web.xml's init params.
519     * 
520     * @param usr domain account
521     * @param psswrd the password to the domain account
522     * @throws IllegalArgumentException if user/pass AND keyTab set
523     */
524    private void setUsernamePassword(final String usr, final String psswrd) {
525        boolean mustUseKtab = false;
526
527        if (null == usr) {
528            this.username = "";
529        } else {
530            this.username = usr;
531        }
532        
533        if (null == psswrd) {
534            this.password = "";
535        } else {
536            this.password = psswrd;
537        }
538        
539        if (this.username.isEmpty() || this.password.isEmpty()) {
540            mustUseKtab = true;
541        }
542
543        if (mustUseKtab && !this.canUseKeyTab) {
544            throw new IllegalArgumentException("Must specify a username "
545                    + "and password or a keyTab.");            
546        }
547    }
548    
549    /**
550     * Returns true if LoginContext should use keyTab.
551     * 
552     * @return true if LoginContext should use keyTab.
553     */
554    boolean useKeyTab() {
555        return this.canUseKeyTab && this.username.isEmpty() && this.password.isEmpty();
556    }
557
558    private static String clean(final String path) {
559        
560        // assert - more than one char (we do not support ROOT) and no wild card
561        if (path.length() < 2 || path.contains("*")) {
562            throw new IllegalArgumentException(
563                "Invalid exclude.dirs pattern or char(s): " + path);
564        }
565        
566        // ensure that it ends with the slash character
567        final String tmp;
568        if (path.endsWith("/")) {
569            tmp = path;
570        } else {
571            tmp = path + "/";
572        }
573        
574        // we want to include the slash character
575        return tmp.substring(0, tmp.lastIndexOf('/') + 1);
576    }
577    
578    private static List<String> split(final String dirs) {
579        final List<String> list = new ArrayList<String>();
580        
581        for (String dir : dirs.split(",")) {
582            list.add(SpnegoFilterConfig.clean(dir.trim()));
583        }
584        
585        return list;
586    }
587    
588    @Override
589    public String toString() {
590        final StringBuilder buff = new StringBuilder();
591        
592        buff.append("allowBasic=" + this.allowBasic
593                + "; allowUnsecure=" + this.allowUnsecure
594                + "; canUseKeyTab=" + this.canUseKeyTab
595                + "; clientLoginModule=" + this.clientLoginModule
596                + "; serverLoginModule=" + this.serverLoginModule);
597        
598        return buff.toString();
599    }
600}