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.FileNotFoundException;
022import java.io.IOException;
023import java.net.URISyntaxException;
024import java.security.PrivilegedActionException;
025import java.util.Collections;
026import java.util.Enumeration;
027import java.util.Map;
028import java.util.concurrent.locks.Lock;
029import java.util.concurrent.locks.ReentrantLock;
030import java.util.logging.Level;
031import java.util.logging.Logger;
032
033import javax.security.auth.callback.CallbackHandler;
034import javax.security.auth.kerberos.KerberosPrincipal;
035import javax.security.auth.login.LoginContext;
036import javax.security.auth.login.LoginException;
037import javax.servlet.FilterConfig;
038import javax.servlet.ServletContext;
039import javax.servlet.http.HttpServletRequest;
040import javax.servlet.http.HttpServletResponse;
041
042import net.sourceforge.spnego.SpnegoHttpFilter.Constants;
043
044import org.ietf.jgss.GSSContext;
045import org.ietf.jgss.GSSCredential;
046import org.ietf.jgss.GSSException;
047import org.ietf.jgss.GSSManager;
048
049/**
050 * Handles <a href="http://en.wikipedia.org/wiki/SPNEGO">SPNEGO</a> or <a
051 * href="http://en.wikipedia.org/wiki/Basic_access_authentication">Basic</a>
052 * authentication.
053 * 
054 * <p>
055 * Be cautious about who you give a reference to.</b>
056 * </p>
057 * 
058 * <p>
059 * Basic Authentication must be enabled through the filter configuration. See
060 * an example web.xml configuration in the <a href="http://spnego.sourceforge.net/spnego_tomcat.html" 
061 * target="_blank">installing on tomcat</a> documentation or the 
062 * {@link SpnegoHttpFilter} javadoc. 
063 * </p>
064 * 
065 * <p>
066 * Localhost is supported but must be enabled through the filter configuration. Allowing 
067 * requests to come from the DNS http://localhost will obviate the requirement that a 
068 * service must have an SPN. <b>Note that Kerberos authentication (if localhost) does 
069 * not occur but instead simply returns the <code>System.getProperty("user.name")</code> 
070 * or the Server's pre-authentication username.</b>
071 * </p>
072 * 
073 * <p>
074 * NTLM tokens are NOT supported. However it is still possible to avoid an error 
075 * being returned by downgrading the authentication from Negotiate NTLM to Basic Auth.
076 * </p>
077 * 
078 * <p>
079 * See the <a href="http://spnego.sourceforge.net/reference_docs.html" 
080 * target="_blank">reference docs</a> on how to configure the web.xml to prompt 
081 * when if a request is being made using NTLM.
082 * </p>
083 * 
084 * <p>
085 * Finally, to see a working example and instructions on how to use a keytab, take 
086 * a look at the <a href="http://spnego.sourceforge.net/server_keytab.html"
087 * target="_blank">creating a server keytab</a> example.
088 * </p>
089 * 
090 * @author Darwin V. Felix
091 * 
092 */
093public final class SpnegoAuthenticator {
094
095    private static final Logger LOGGER = Logger.getLogger(Constants.LOGGER_NAME);
096    
097    /** GSSContext is not thread-safe. */
098    private static final Lock LOCK = new ReentrantLock();
099    
100    /** Default GSSManager. */
101    private static final GSSManager MANAGER = GSSManager.getInstance();
102    
103    /** Flag to indicate if BASIC Auth is allowed. */
104    private final transient boolean allowBasic;
105    
106    /** Flag to indicate if credential delegation is allowed. */
107    private final transient boolean allowDelegation;
108
109    /** Flag to skip auth if localhost. */
110    private final transient boolean allowLocalhost;
111
112    /** Flag to indicate if non-SSL BASIC Auth allowed. */
113    private final transient boolean allowUnsecure;
114    
115    /** Flag to indicate if NTLM is accepted. */
116    private final transient boolean promptIfNtlm;
117
118    /** Login Context module name for client auth. */
119    private final transient String clientModuleName;
120
121    /** Login Context server uses for pre-authentication. */
122    private final transient LoginContext loginContext;
123
124    /** Credentials server uses for authenticating requests. */
125    private final transient GSSCredential serverCredentials;
126    
127    /** Server Principal used for pre-authentication. */
128    private final transient KerberosPrincipal serverPrincipal;
129
130    /**
131     * Create an authenticator for SPNEGO and/or BASIC authentication.
132     * 
133     * @param config servlet filter initialization parameters
134     * @throws LoginException 
135     * @throws GSSException 
136     * @throws PrivilegedActionException 
137     */
138    public SpnegoAuthenticator(final SpnegoFilterConfig config) 
139        throws LoginException, GSSException, PrivilegedActionException {
140
141        LOGGER.fine("config=" + config);
142
143        this.allowBasic = config.isBasicAllowed();
144        this.allowUnsecure = config.isUnsecureAllowed();  
145        this.clientModuleName = config.getClientLoginModule();
146        this.allowLocalhost = config.isLocalhostAllowed();
147        this.promptIfNtlm = config.downgradeNtlm();
148        this.allowDelegation = config.isDelegationAllowed();
149
150        if (config.useKeyTab()) {
151            this.loginContext = new LoginContext(config.getServerLoginModule());
152        } else {
153            final CallbackHandler handler = SpnegoProvider.getUsernamePasswordHandler(
154                    config.getPreauthUsername()
155                    , config.getPreauthPassword());
156
157            this.loginContext = new LoginContext(config.getServerLoginModule(), handler);            
158        }
159
160        this.loginContext.login();
161
162        this.serverCredentials = SpnegoProvider.getServerCredential(
163                this.loginContext.getSubject());
164
165        this.serverPrincipal = new KerberosPrincipal(
166                this.serverCredentials.getName().toString());
167    }
168    
169    /**
170     * Create an authenticator for SPNEGO and/or BASIC authentication. For third-party 
171     * code/frameworks that want to authenticate via their own filter/valve/code/etc.
172     * 
173     * <p>
174     * The ExampleSpnegoAuthenticatorValve.java demonstrates a working example of 
175     * how to use this constructor.
176     * </p>
177     * 
178     * <p>
179     * Example of some Map keys and values: <br />
180     * <code>
181     * 
182     * Map map = new HashMap();
183     * map.put("spnego.krb5.conf", "krb5.conf");
184     * map.put("spnego.allow.basic", "true");
185     * map.put("spnego.preauth.username", "dfelix");
186     * map.put("spnego.preauth.password", "myp@s5");
187     * ...
188     * 
189     * SpnegoAuthenticator authenticator = new SpnegoAuthenticator(map);
190     * ...
191     * </code>
192     * </p>
193     * 
194     * @param config
195     * @throws LoginException
196     * @throws GSSException
197     * @throws PrivilegedActionException
198     * @throws FileNotFoundException
199     * @throws URISyntaxException
200     */
201    public SpnegoAuthenticator(final Map<String, String> config) 
202        throws LoginException, GSSException, PrivilegedActionException
203        , FileNotFoundException, URISyntaxException {
204
205        this(SpnegoFilterConfig.getInstance(new FilterConfig() {
206
207            private final Map<String, String> map = Collections.unmodifiableMap(config);
208            
209            @Override
210            public String getFilterName() {
211                throw new UnsupportedOperationException();
212            }
213
214            @Override
215            public String getInitParameter(final String param) {
216                if (null == map.get(param)) {
217                    throw new NullPointerException("Config missing param value for: " + param);
218                }
219                return map.get(param);
220            }
221
222            @SuppressWarnings("rawtypes")
223            @Override
224            public Enumeration getInitParameterNames() {
225                throw new UnsupportedOperationException();
226            }
227
228            @Override
229            public ServletContext getServletContext() {
230                throw new UnsupportedOperationException();
231            }
232        }));
233    }
234    
235    /**
236     * Create an authenticator for SPNEGO and/or BASIC authentication.
237     * 
238     * @param loginModuleName module named defined in login.conf
239     * @param config servlet filter initialization parameters
240     * @throws LoginException 
241     * @throws GSSException 
242     * @throws PrivilegedActionException 
243     */
244    public SpnegoAuthenticator(final String loginModuleName
245        , final SpnegoFilterConfig config) throws LoginException
246        , GSSException, PrivilegedActionException {
247
248        LOGGER.fine("loginModuleName=" + loginModuleName);
249
250        this.allowBasic = config.isBasicAllowed();
251        this.allowUnsecure = config.isUnsecureAllowed();  
252        this.clientModuleName = config.getClientLoginModule();
253        this.allowLocalhost = config.isLocalhostAllowed();
254        this.promptIfNtlm = config.downgradeNtlm();
255        this.allowDelegation = config.isDelegationAllowed();
256
257        final String username = config.getPreauthUsername();
258        final boolean hasUsername = null != username && !username.trim().isEmpty();
259        
260        if (hasUsername) {
261            this.loginContext = new LoginContext(loginModuleName
262                , SpnegoProvider.getUsernamePasswordHandler(username
263                    , config.getPreauthPassword()));
264        } else if (config.useKeyTab()) {
265            this.loginContext = new LoginContext(loginModuleName);
266        } else {
267            throw new IllegalArgumentException(
268                "Must provide a username/password or specify a keytab file");
269        }
270
271        this.loginContext.login();
272
273        this.serverCredentials = SpnegoProvider.getServerCredential(
274                this.loginContext.getSubject());
275
276        this.serverPrincipal = new KerberosPrincipal(
277                this.serverCredentials.getName().toString());
278    }
279    
280    /**
281     * Returns the KerberosPrincipal of the user/client making the HTTP request.
282     * 
283     * <p>
284     * Null may be returned if client did not provide auth info.
285     * </p>
286     * 
287     * <p>
288     * Method will throw UnsupportedOperationException if client authz 
289     * request is NOT "Negotiate" or "Basic". 
290     * </p>
291     * @param req servlet request
292     * @param resp servlet response
293     * 
294     * @return null if auth not complete else SpnegoPrincipal of client
295     * @throws GSSException 
296     * @throws IOException 
297     */
298    public SpnegoPrincipal authenticate(final HttpServletRequest req
299        , final SpnegoHttpServletResponse resp) throws GSSException
300        , IOException {
301                
302        // Skip auth if localhost
303        if (this.allowLocalhost && this.isLocalhost(req)) {
304            return doLocalhost();
305        }
306
307        // domain/realm of server
308        final String serverRealm = this.serverPrincipal.getRealm();
309        
310        // determine if we allow basic
311        final boolean basicSupported = 
312            this.allowBasic && (this.allowUnsecure || req.isSecure());
313        
314        final SpnegoPrincipal principal;
315        final SpnegoAuthScheme scheme = SpnegoProvider.negotiate(
316                req, resp, basicSupported, this.promptIfNtlm, serverRealm);
317        
318        // NOTE: this may also occur if we do not allow Basic Auth and
319        // the client only supports Basic Auth
320        if (null == scheme) {
321            LOGGER.finer("scheme null.");
322            return null;
323        }
324
325        // NEGOTIATE scheme
326        if (scheme.isNegotiateScheme()) {
327            principal = doSpnegoAuth(scheme, resp);
328            
329        // BASIC scheme
330        } else if (scheme.isBasicScheme()) {
331            // check if we allow Basic Auth AND if can be un-secure
332            if (basicSupported) {
333                principal = doBasicAuth(scheme, resp);
334            } else {
335                LOGGER.severe("allowBasic=" + this.allowBasic 
336                        + "; allowUnsecure=" + this.allowUnsecure
337                        + "; req.isSecure()=" + req.isSecure());
338                throw new UnsupportedOperationException("Basic Auth not allowed"
339                        + " or SSL required.");
340            }
341
342        // Unsupported scheme
343        } else {
344            throw new UnsupportedOperationException("scheme=" + scheme);
345        }
346
347        return principal;
348    }
349    
350    /**
351     * Logout. Since server uses LoginContext to login/pre-authenticate, we must
352     * also logout when we are done using this object.
353     * 
354     * <p>
355     * Generally, instantiators of this class should be the only to call 
356     * dispose() as it indicates that this class will no longer be used.
357     * </p>
358     */
359    public void dispose() {
360        if (null != this.serverCredentials) {
361            try {
362                this.serverCredentials.dispose();
363            } catch (GSSException e) {
364                LOGGER.log(Level.WARNING, "Dispose failed.", e);
365            }
366        }
367        if (null != this.loginContext) {
368            try {
369                this.loginContext.logout();
370            } catch (LoginException lex) {
371                LOGGER.log(Level.WARNING, "Logout failed.", lex);
372            }
373        }
374    }
375    
376    /**
377     * Performs authentication using the BASIC Auth mechanism.
378     *
379     * <p>
380     * Returns null if authentication failed or if the provided 
381     * the auth scheme did not contain BASIC Auth data/token.
382     * </p>
383     * 
384     * @return SpnegoPrincipal for the given auth scheme.
385     */
386    private SpnegoPrincipal doBasicAuth(final SpnegoAuthScheme scheme
387        , final SpnegoHttpServletResponse resp) throws IOException {
388
389        final byte[] data = scheme.getToken();
390
391        if (0 == data.length) {
392            LOGGER.finer("Basic Auth data was NULL.");
393            return null;
394        }
395
396        final String[] basicData = new String(data).split(":", 2);
397
398        // assert
399        if (basicData.length != 2) {
400            throw new IllegalArgumentException("Username/Password may"
401                    + " have contained an invalid character. basicData.length=" 
402                    + basicData.length);
403        }
404
405        // substring to remove domain (if provided)
406        final String username = basicData[0].substring(basicData[0].indexOf('\\') + 1);
407        final String password = basicData[1];
408        final CallbackHandler handler = SpnegoProvider.getUsernamePasswordHandler(
409                username, password);
410        
411        SpnegoPrincipal principal = null;
412        
413        try {
414            // assert
415            if (null == username || username.isEmpty()) {
416                throw new LoginException("Username is required.");
417            }
418
419            final LoginContext cntxt = new LoginContext(this.clientModuleName, handler);
420
421            // validate username/password by login/logout  
422            cntxt.login();
423            cntxt.logout();
424
425            principal = new SpnegoPrincipal(username + '@' 
426                    + this.serverPrincipal.getRealm()
427                    , KerberosPrincipal.KRB_NT_PRINCIPAL);
428
429        } catch (LoginException lex) {
430            LOGGER.fine(lex.getMessage() + ": Login failed. username=" + username);
431
432            resp.setHeader(Constants.AUTHN_HEADER, Constants.NEGOTIATE_HEADER);
433            resp.addHeader(Constants.AUTHN_HEADER, Constants.BASIC_HEADER 
434                    + " realm=\"" + this.serverPrincipal.getRealm() + '\"');
435
436            resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED, true);
437        }
438
439        return principal;
440    }
441
442    private SpnegoPrincipal doLocalhost() {
443        final String username = System.getProperty("user.name");
444        
445        if (null == username || username.isEmpty()) {
446            return new SpnegoPrincipal(this.serverPrincipal.getName() + '@' 
447                    + this.serverPrincipal.getRealm()
448                    , this.serverPrincipal.getNameType());            
449        } else {
450            return new SpnegoPrincipal(username + '@' 
451                    + this.serverPrincipal.getRealm()
452                    , KerberosPrincipal.KRB_NT_PRINCIPAL);            
453        }
454    }
455
456    /**
457     * Performs authentication using the SPNEGO mechanism.
458     *
459     * <p>
460     * Returns null if authentication failed or if the provided 
461     * the auth scheme did not contain the SPNEGO/GSS token.
462     * </p>
463     * 
464     * @return SpnegoPrincipal for the given auth scheme.
465     */
466    private SpnegoPrincipal doSpnegoAuth(
467        final SpnegoAuthScheme scheme, final SpnegoHttpServletResponse resp) 
468        throws GSSException, IOException {
469
470        final String principal;
471        final byte[] gss = scheme.getToken();
472
473        if (0 == gss.length) {
474            LOGGER.finer("GSS data was NULL.");
475            return null;
476        }
477
478        GSSContext context = null;
479        GSSCredential delegCred = null;
480        
481        try {
482            final byte[] token;
483            
484            SpnegoAuthenticator.LOCK.lock();
485            try {
486                context = SpnegoAuthenticator.MANAGER.createContext(this.serverCredentials);
487                token = context.acceptSecContext(gss, 0, gss.length);
488            } finally {
489                SpnegoAuthenticator.LOCK.unlock();
490            }
491
492            if (null == token) {
493                LOGGER.finer("Token was NULL.");
494                return null;
495            }
496
497            resp.setHeader(Constants.AUTHN_HEADER, Constants.NEGOTIATE_HEADER 
498                    + ' ' + Base64.encode(token));
499
500            if (!context.isEstablished()) {
501                LOGGER.fine("context not established");
502                resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED, true);
503                return null;
504            }
505
506            principal = context.getSrcName().toString();
507            
508            if (this.allowDelegation && context.getCredDelegState()) {
509                delegCred = context.getDelegCred();
510            }
511
512        } finally {
513            if (null != context) {
514                SpnegoAuthenticator.LOCK.lock();
515                try {
516                    context.dispose();
517                } finally {
518                    SpnegoAuthenticator.LOCK.unlock();
519                }
520            }
521        }
522
523        return new SpnegoPrincipal(principal, KerberosPrincipal.KRB_NT_PRINCIPAL, delegCred);
524    }
525    
526    public String getServerRealm() {
527        return this.serverPrincipal.getRealm();
528    }
529
530    /**
531     * Returns true if HTTP request is from the same host (localhost).
532     * 
533     * @param req servlet request
534     * @return true if HTTP request is from the same host (localhost)
535     */
536    private boolean isLocalhost(final HttpServletRequest req) {
537        boolean isLocal = req.getLocalAddr().equals(req.getRemoteAddr());
538        
539        if (!isLocal && "0.0.0.0".equals(req.getLocalAddr()) // NOPMD
540                && "0:0:0:0:0:0:0:1".equals(req.getRemoteAddr())) { // NOPMD
541            isLocal = true;
542        }
543        
544        return isLocal;
545    }
546}