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.ByteArrayOutputStream;
022    import java.io.IOException;
023    import java.io.InputStream;
024    import java.io.OutputStream;
025    import java.net.HttpURLConnection;
026    import java.net.URL;
027    import java.security.PrivilegedActionException;
028    import java.util.Arrays;
029    import java.util.LinkedHashMap;
030    import java.util.List;
031    import java.util.Map;
032    import java.util.Set;
033    import java.util.concurrent.locks.Lock;
034    import java.util.concurrent.locks.ReentrantLock;
035    import java.util.logging.Level;
036    import java.util.logging.Logger;
037    
038    import javax.security.auth.callback.CallbackHandler;
039    import javax.security.auth.login.LoginContext;
040    import javax.security.auth.login.LoginException;
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    
048    /**
049     * This Class may be used by custom clients as a convenience when connecting 
050     * to a protected HTTP server.
051     * 
052     * <p>
053     * This mechanism is an alternative to HTTP Basic Authentication where the 
054     * HTTP server does not support Basic Auth but instead has SPNEGO support 
055     * (take a look at {@link SpnegoHttpFilter}).
056     * </p>
057     * 
058     * <p>
059     * A krb5.conf and a login.conf is required when using this class. Take a 
060     * look at the <a href="http://spnego.sourceforge.net" target="_blank">spnego.sourceforge.net</a> 
061     * documentation for an example krb5.conf and login.conf file. 
062     * Also, you must provide a keytab file, or a username and password, or allowtgtsessionkey.
063     * </p>
064     * 
065     * <p>
066     * Example usage (username/password):
067     * <pre>
068     *     public static void main(final String[] args) throws Exception {
069     *         System.setProperty("java.security.krb5.conf", "krb5.conf");
070     *         System.setProperty("sun.security.krb5.debug", "true");
071     *         System.setProperty("java.security.auth.login.config", "login.conf");
072     *         
073     *         SpnegoHttpURLConnection spnego = null;
074     *         
075     *         try {
076     *             spnego = new SpnegoHttpURLConnection("spnego-client", "dfelix", "myp@s5");
077     *             spnego.connect(new URL("http://medusa:8080/index.jsp"));
078     *             
079     *             System.out.println(spnego.getResponseCode());
080     *         
081     *         } finally {
082     *             if (null != spnego) {
083     *                 spnego.disconnect();
084     *             }
085     *         }
086     *     }
087     * </pre>
088     * </p>
089     * 
090     * <p>
091     * Alternatively, if the server supports HTTP Basic Authentication, this Class 
092     * is NOT needed and instead you can do something like the following:
093     * <pre>
094     *     public static void main(final String[] args) throws Exception {
095     *         final String creds = "dfelix:myp@s5";
096     *         
097     *         final String token = Base64.encode(creds.getBytes());
098     *         
099     *         URL url = new URL("http://medusa:8080/index.jsp");
100     *         
101     *         HttpURLConnection conn = (HttpURLConnection) url.openConnection();
102     *         
103     *         conn.setRequestProperty(Constants.AUTHZ_HEADER
104     *                 , Constants.BASIC_HEADER + " " + token);
105     *                 
106     *         conn.connect();
107     *         
108     *         System.out.println("Response Code:" + conn.getResponseCode());
109     *     }
110     * </pre>
111     * </p>
112     * 
113     * <p>
114     * To see a working example and instructions on how to use a keytab, take 
115     * a look at the <a href="http://spnego.sourceforge.net/client_keytab.html"
116     * target="_blank">creating a client keytab</a> example.
117     * </p>
118     * 
119     * <p>
120     * Finally, the {@link SpnegoSOAPConnection} class is another example of a class 
121     * that uses this class.
122     * <p>
123     * 
124     * @author Darwin V. Felix
125     * 
126     */
127    public final class SpnegoHttpURLConnection {
128    
129        private static final Logger LOGGER = Logger.getLogger(Constants.LOGGER_NAME);
130        
131        /** GSSContext is not thread-safe. */
132        private static final Lock LOCK = new ReentrantLock();
133        
134        private static final byte[] EMPTY_BYTE = new byte[0];
135    
136        /**
137         * If false, this connection object has not created a communications link to 
138         * the specified URL. If true, the communications link has been established.
139         */
140        private transient boolean connected = false;
141    
142        /**
143         * Default is GET.
144         * 
145         * @see java.net.HttpURLConnection#getRequestMethod()
146         */
147        private transient String requestMethod = "GET";
148        
149        /**
150         * @see java.net.URLConnection#getRequestProperties()
151         */
152        private final transient Map<String, List<String>> requestProperties = 
153            new LinkedHashMap<String, List<String>>();
154    
155        /** 
156         * Login Context for authenticating client. If username/password 
157         * or GSSCredential is provided (in constructor) then this 
158         * field will always be null.
159         */
160        private final transient LoginContext loginContext;
161    
162        /**
163         * Client's credentials. If username/password or LoginContext is provided 
164         * (in constructor) then this field will always be null.
165         */
166        private transient GSSCredential credential;
167    
168        /** 
169         * Flag to determine if GSSContext has been established. Users of this 
170         * class should always check that this field is true before using/trusting 
171         * the contents of the response.
172         */
173        private transient boolean cntxtEstablished = false;
174    
175        /** 
176         * Ref to HTTP URL Connection object after calling connect method. 
177         * Always call spnego.disconnect() when done using this class.
178         */
179        private transient HttpURLConnection conn = null;
180    
181        /** 
182         * Request credential to be delegated. 
183         * Default is false. 
184         */
185        private transient boolean reqCredDeleg = false;
186        
187        /**
188         * Determines if the GSSCredentials (if any) used during the 
189         * connection request should be automatically disposed by 
190         * this class when finished.
191         * Default is true.
192         */
193        private transient boolean autoDisposeCreds = true;
194    
195        /**
196         * Creates an instance where the LoginContext relies on a keytab 
197         * file being specified by "java.security.auth.login.config" or 
198         * where LoginContext relies on tgtsessionkey.
199         * 
200         * @param loginModuleName 
201         * @throws LoginException 
202         */
203        public SpnegoHttpURLConnection(final String loginModuleName)
204            throws LoginException {
205    
206            this.loginContext = new LoginContext(loginModuleName);
207            this.loginContext.login();
208            this.credential = null;
209        }
210        
211        /**
212         * Create an instance where the GSSCredential is specified by the parameter 
213         * and where the GSSCredential is automatically disposed after use.
214         *  
215         * @param creds credentials to use
216         */
217        public SpnegoHttpURLConnection(final GSSCredential creds) {
218            this(creds, true);
219        }
220        
221        /**
222         * Create an instance where the GSSCredential is specified by the parameter 
223         * and whether the GSSCredential should be disposed after use.
224         * 
225         * @param creds credentials to use
226         * @param dispose true if GSSCredential should be diposed after use
227         */
228        public SpnegoHttpURLConnection(final GSSCredential creds, final boolean dispose) {
229            this.loginContext = null;
230            this.credential = creds;
231            this.autoDisposeCreds = dispose;
232        }
233    
234        /**
235         * Creates an instance where the LoginContext does not require a keytab
236         * file. However, the "java.security.auth.login.config" property must still
237         * be set prior to instantiating this object.
238         * 
239         * @param loginModuleName 
240         * @param username 
241         * @param password 
242         * @throws LoginException 
243         */
244        public SpnegoHttpURLConnection(final String loginModuleName,
245            final String username, final String password) throws LoginException {
246    
247            final CallbackHandler handler = SpnegoProvider.getUsernamePasswordHandler(
248                    username, password);
249    
250            this.loginContext = new LoginContext(loginModuleName, handler);
251            this.loginContext.login();
252            this.credential = null;
253        }
254    
255        /**
256         * Throws IllegalStateException if this connection object has not yet created 
257         * a communications link to the specified URL.
258         */
259        private void assertConnected() {
260            if (!this.connected) {
261                throw new IllegalStateException("Not connected.");
262            }
263        }
264    
265        /**
266         * Throws IllegalStateException if this connection object has already created 
267         * a communications link to the specified URL.
268         */
269        private void assertNotConnected() {
270            if (this.connected) {
271                throw new IllegalStateException("Already connected.");
272            }
273        }
274    
275        /**
276         * Opens a communications link to the resource referenced by 
277         * this URL, if such a connection has not already been established.
278         * 
279         * <p>
280         * This implementation simply calls this objects 
281         * connect(URL, ByteArrayOutputStream) method but passing in a null 
282         * for the second argument.
283         * </p>
284         * 
285         * @param url 
286         * @return an HttpURLConnection object
287         * @throws GSSException 
288         * @throws PrivilegedActionException 
289         * @throws IOException 
290         * @throws LoginException 
291         * 
292         * @see java.net.URLConnection#connect()
293         */
294        public HttpURLConnection connect(final URL url)
295            throws GSSException, PrivilegedActionException, IOException {
296            
297            return this.connect(url, null);
298        }
299    
300        /**
301         * Opens a communications link to the resource referenced by 
302         * this URL, if such a connection has not already been established.
303         * 
304         * @param url 
305         * @param dooutput optional message/payload to send to server
306         * @return an HttpURLConnection object
307         * @throws GSSException 
308         * @throws PrivilegedActionException 
309         * @throws IOException 
310         * @throws LoginException 
311         * 
312         * @see java.net.URLConnection#connect()
313         */
314        public HttpURLConnection connect(final URL url, final ByteArrayOutputStream dooutput)
315            throws GSSException, PrivilegedActionException, IOException {
316    
317            assertNotConnected();
318    
319            GSSContext context = null;
320            
321            try {
322                byte[] data = null;
323                
324                SpnegoHttpURLConnection.LOCK.lock();
325                try {
326                    // work-around to GSSContext/AD timestamp vs sequence field replay bug
327                    try { Thread.sleep(31); } catch (InterruptedException e) { assert true; }
328                    
329                    context = this.getGSSContext(url);
330                    context.requestMutualAuth(true);
331                    context.requestConf(true);
332                    context.requestInteg(true);
333                    context.requestReplayDet(true);
334                    context.requestSequenceDet(true);
335                    context.requestCredDeleg(this.reqCredDeleg);
336                    
337                    data = context.initSecContext(EMPTY_BYTE, 0, 0);
338                } finally {
339                    SpnegoHttpURLConnection.LOCK.unlock();
340                }
341                
342                this.conn = (HttpURLConnection) url.openConnection();
343                this.connected = true;
344    
345                final Set<String> keys = this.requestProperties.keySet();
346                for (final String key : keys) {
347                    for (String value : this.requestProperties.get(key)) {
348                        this.conn.addRequestProperty(key, value);
349                    }
350                }
351    
352                // TODO : re-factor to support (302) redirects
353                this.conn.setInstanceFollowRedirects(false);
354                this.conn.setRequestMethod(this.requestMethod);
355    
356                this.conn.setRequestProperty(Constants.AUTHZ_HEADER
357                    , Constants.NEGOTIATE_HEADER + ' ' + Base64.encode(data));
358    
359                if (null != dooutput && dooutput.size() > 0) {
360                    this.conn.setDoOutput(true);
361                    dooutput.writeTo(this.conn.getOutputStream());
362                }
363    
364                this.conn.connect();
365    
366                final SpnegoAuthScheme scheme = SpnegoProvider.getAuthScheme(
367                        this.conn.getHeaderField(Constants.AUTHN_HEADER));
368                
369                // app servers will not return a WWW-Authenticate on 302, (and 30x...?)
370                if (null == scheme) {
371                    LOGGER.fine("SpnegoProvider.getAuthScheme(...) returned null.");
372                    
373                } else {
374                    data = scheme.getToken();
375        
376                    if (Constants.NEGOTIATE_HEADER.equalsIgnoreCase(scheme.getScheme())) {
377                        SpnegoHttpURLConnection.LOCK.lock();
378                        try {
379                            data = context.initSecContext(data, 0, data.length);
380                        } finally {
381                            SpnegoHttpURLConnection.LOCK.unlock();
382                        }
383    
384                        // TODO : support context loops where i>1
385                        if (null != data) {
386                            LOGGER.warning("Server requested context loop: " + data.length);
387                        }
388                        
389                    } else {
390                        throw new UnsupportedOperationException("Scheme NOT Supported: " 
391                                + scheme.getScheme());
392                    }
393    
394                    this.cntxtEstablished = context.isEstablished();
395                }
396            } finally {
397                this.dispose(context);
398            }
399    
400            return this.conn;
401        }
402    
403        /**
404         * Logout the LoginContext instance, and call dispose() on GSSCredential 
405         * if autoDisposeCreds is set to true, and call dispose on the passed-in 
406         * GSSContext instance.
407         */
408        private void dispose(final GSSContext context) {
409            if (null != context) {
410                try {
411                    SpnegoHttpURLConnection.LOCK.lock();
412                    try {
413                        context.dispose();
414                    } finally {
415                        SpnegoHttpURLConnection.LOCK.unlock();
416                    }
417                } catch (GSSException gsse) {
418                    LOGGER.log(Level.WARNING, "call to dispose context failed.", gsse);
419                }
420            }
421            
422            if (null != this.credential && this.autoDisposeCreds) {
423                try {
424                    this.credential.dispose();
425                } catch (final GSSException gsse) {
426                    LOGGER.log(Level.WARNING, "call to dispose credential failed.", gsse);
427                }
428            }
429            
430            if (null != this.loginContext) {
431                try {
432                    this.loginContext.logout();
433                } catch (final LoginException le) {
434                    LOGGER.log(Level.WARNING, "call to logout context failed.", le);
435                }
436            }
437        }
438    
439        /**
440         * Logout and clear request properties.
441         * 
442         * @see java.net.HttpURLConnection#disconnect()
443         */
444        public void disconnect() {
445            this.dispose(null);
446            this.requestProperties.clear();
447            this.connected = false;
448            if (null != this.conn) {
449                this.conn.disconnect();
450            }
451        }
452    
453        /**
454         * Returns true if GSSContext has been established.
455         * 
456         * @return true if GSSContext has been established, false otherwise.
457         */
458        public boolean isContextEstablished() {
459            return this.cntxtEstablished;
460        }
461    
462        /**
463         * Internal sanity check to validate not null key/value pairs.
464         */
465        private void assertKeyValue(final String key, final String value) {
466            if (null == key || key.isEmpty()) {
467                throw new IllegalArgumentException("key parameter is null or empty");
468            }
469            if (null == value) {
470                throw new IllegalArgumentException("value parameter is null");
471            }
472        }
473    
474        /**
475         * Adds an HTTP Request property.
476         * 
477         * @param key request property name
478         * @param value request propery value
479         * @see java.net.URLConnection#addRequestProperty(String, String)
480         */
481        public void addRequestProperty(final String key, final String value) {
482            assertNotConnected();
483            assertKeyValue(key, value);
484    
485            if (this.requestProperties.containsKey(key)) {
486                final List<String> val = this.requestProperties.get(key);
487                val.add(value);
488                this.requestProperties.put(key, val);            
489            } else {
490                setRequestProperty(key, value);
491            }
492        }
493    
494        /**
495         * Sets an HTTP Request property.
496         * 
497         * @param key request property name
498         * @param value request property value
499         * @see java.net.URLConnection#setRequestProperty(String, String)
500         */
501        public void setRequestProperty(final String key, final String value) {
502            assertNotConnected();
503            assertKeyValue(key, value);
504    
505            this.requestProperties.put(key, Arrays.asList(value));
506        }
507        
508        /**
509         * Returns a GSSContextt for the given url with a default lifetime.
510         *  
511         * @param url http address
512         * @return GSSContext for the given url
513         * @throws GSSException
514         * @throws PrivilegedActionException
515         */
516        private GSSContext getGSSContext(final URL url) throws GSSException
517            , PrivilegedActionException {
518    
519            if (null == this.credential) {
520                if (null == this.loginContext) {
521                    throw new IllegalStateException(
522                            "GSSCredential AND LoginContext NOT initialized");
523                    
524                } else {
525                    this.credential = SpnegoProvider.getClientCredential(
526                            this.loginContext.getSubject());
527                }
528            }
529            
530            return SpnegoProvider.getGSSContext(this.credential, url);
531        }
532        
533        /**
534         * Returns an error stream that reads from this open connection.
535         * 
536         * @return error stream that reads from this open connection
537         * @throws IOException 
538         * 
539         * @see java.net.HttpURLConnection#getErrorStream()
540         */
541        public InputStream getErrorStream() throws IOException {
542            assertConnected();
543    
544            return this.conn.getInputStream();
545        }
546    
547        /**
548         * Get header value at specified index.
549         * 
550         * @param index
551         * @return header value at specified index
552         */
553        public String getHeaderField(final int index) {
554            assertConnected();
555            
556            return this.conn.getHeaderField(index);
557        }
558        
559        /**
560         * Get header value by header name.
561         * 
562         * @param name name header
563         * @return header value
564         * @see java.net.HttpURLConnection#getHeaderField(String)
565         */
566        public String getHeaderField(final String name) {
567            assertConnected();
568    
569            return this.conn.getHeaderField(name);
570        }
571        
572        /**
573         * Get header field key at specified index.
574         * 
575         * @param index
576         * @return header field key at specified index
577         */
578        public String getHeaderFieldKey(final int index) {
579            assertConnected();
580            
581            return this.conn.getHeaderFieldKey(index);
582        }
583    
584        /**
585         * Returns an input stream that reads from this open connection.
586         * 
587         * @return input stream that reads from this open connection
588         * @throws IOException 
589         * 
590         * @see java.net.HttpURLConnection#getInputStream()
591         */
592        public InputStream getInputStream() throws IOException {
593            assertConnected();
594    
595            return this.conn.getInputStream();
596        }
597        
598        /**
599         * Returns an output stream that writes to this open connection.
600         * 
601         * @return output stream that writes to this connections
602         * @throws IOException
603         * 
604         * @see java.net.HttpURLConnection#getOutputStream()
605         */
606        public OutputStream getOutputStream() throws IOException {
607            assertConnected();
608            
609            return this.conn.getOutputStream();
610        }
611    
612        /**
613         * Returns HTTP Status code.
614         * 
615         * @return HTTP Status Code
616         * @throws IOException 
617         * 
618         * @see java.net.HttpURLConnection#getResponseCode()
619         */
620        public int getResponseCode() throws IOException {
621            assertConnected();
622    
623            return this.conn.getResponseCode();
624        }
625    
626        /**
627         * Returns HTTP Status message.
628         * 
629         * @return HTTP Status Message
630         * @throws IOException 
631         * 
632         * @see java.net.HttpURLConnection#getResponseMessage()
633         */
634        public String getResponseMessage() throws IOException {
635            assertConnected();
636    
637            return this.conn.getResponseMessage();
638        }
639        
640        /**
641         * Request that this GSSCredential be allowed for delegation.
642         * 
643         * @param requestDelegation true to allow/request delegation
644         */
645        public void requestCredDeleg(final boolean requestDelegation) {
646            this.assertNotConnected();
647            
648            this.reqCredDeleg = requestDelegation;
649        }
650    
651        /**
652         * May override the default GET method.
653         * 
654         * @param method 
655         * 
656         * @see java.net.HttpURLConnection#setRequestMethod(String)
657         */
658        public void setRequestMethod(final String method) {
659            assertNotConnected();
660    
661            this.requestMethod = method;
662        }
663    }