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 }