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