blob: 1093d8a2539ebf795487e69a7a765991f8397af5 [file] [log] [blame]
/**
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License. See accompanying LICENSE file.
*/
package org.apache.hadoop.security.authentication.client;
import org.apache.hadoop.security.authentication.server.AuthenticationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.CookieHandler;
import java.net.HttpCookie;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* The {@link AuthenticatedURL} class enables the use of the JDK {@link URL} class
* against HTTP endpoints protected with the {@link AuthenticationFilter}.
* <p>
* The authentication mechanisms supported by default are Hadoop Simple authentication
* (also known as pseudo authentication) and Kerberos SPNEGO authentication.
* <p>
* Additional authentication mechanisms can be supported via {@link Authenticator} implementations.
* <p>
* The default {@link Authenticator} is the {@link KerberosAuthenticator} class which supports
* automatic fallback from Kerberos SPNEGO to Hadoop Simple authentication.
* <p>
* <code>AuthenticatedURL</code> instances are not thread-safe.
* <p>
* The usage pattern of the {@link AuthenticatedURL} is:
* <pre>
*
* // establishing an initial connection
*
* URL url = new URL("http://foo:8080/bar");
* AuthenticatedURL.Token token = new AuthenticatedURL.Token();
* AuthenticatedURL aUrl = new AuthenticatedURL();
* HttpURLConnection conn = new AuthenticatedURL().openConnection(url, token);
* ....
* // use the 'conn' instance
* ....
*
* // establishing a follow up connection using a token from the previous connection
*
* HttpURLConnection conn = new AuthenticatedURL().openConnection(url, token);
* ....
* // use the 'conn' instance
* ....
*
* </pre>
*/
public class AuthenticatedURL {
private static final Logger LOG =
LoggerFactory.getLogger(AuthenticatedURL.class);
/**
* Name of the HTTP cookie used for the authentication token between the client and the server.
*/
public static final String AUTH_COOKIE = "hadoop.auth";
// a lightweight cookie handler that will be attached to url connections.
// client code is not required to extract or inject auth cookies.
private static class AuthCookieHandler extends CookieHandler {
private HttpCookie authCookie;
private Map<String, List<String>> cookieHeaders = Collections.emptyMap();
@Override
public synchronized Map<String, List<String>> get(URI uri,
Map<String, List<String>> requestHeaders) throws IOException {
// call getter so it will reset headers if token is expiring.
getAuthCookie();
return cookieHeaders;
}
@Override
public void put(URI uri, Map<String, List<String>> responseHeaders) {
List<String> headers = responseHeaders.get("Set-Cookie");
if (headers != null) {
for (String header : headers) {
List<HttpCookie> cookies;
try {
cookies = HttpCookie.parse(header);
} catch (IllegalArgumentException iae) {
// don't care. just skip malformed cookie headers.
LOG.debug("Cannot parse cookie header: " + header, iae);
continue;
}
for (HttpCookie cookie : cookies) {
if (AUTH_COOKIE.equals(cookie.getName())) {
setAuthCookie(cookie);
}
}
}
}
}
// return the auth cookie if still valid.
private synchronized HttpCookie getAuthCookie() {
if (authCookie != null && authCookie.hasExpired()) {
setAuthCookie(null);
}
return authCookie;
}
private synchronized void setAuthCookie(HttpCookie cookie) {
final HttpCookie oldCookie = authCookie;
// will redefine if new cookie is valid.
authCookie = null;
cookieHeaders = Collections.emptyMap();
boolean valid = cookie != null && !cookie.getValue().isEmpty() &&
!cookie.hasExpired();
if (valid) {
// decrease lifetime to avoid using a cookie soon to expire.
// allows authenticators to pre-emptively reauthenticate to
// prevent clients unnecessarily receiving a 401.
long maxAge = cookie.getMaxAge();
if (maxAge != -1) {
cookie.setMaxAge(maxAge * 9/10);
valid = !cookie.hasExpired();
}
}
if (valid) {
// v0 cookies value aren't quoted by default but tomcat demands
// quoting.
if (cookie.getVersion() == 0) {
String value = cookie.getValue();
if (!value.startsWith("\"")) {
value = "\"" + value + "\"";
cookie.setValue(value);
}
}
authCookie = cookie;
cookieHeaders = new HashMap<>();
cookieHeaders.put("Cookie", Arrays.asList(cookie.toString()));
}
LOG.trace("Setting token value to {} ({})", authCookie, oldCookie);
}
private void setAuthCookieValue(String value) {
HttpCookie c = null;
if (value != null) {
c = new HttpCookie(AUTH_COOKIE, value);
}
setAuthCookie(c);
}
}
/**
* Client side authentication token.
*/
public static class Token {
private final AuthCookieHandler cookieHandler = new AuthCookieHandler();
/**
* Creates a token.
*/
public Token() {
}
/**
* Creates a token using an existing string representation of the token.
*
* @param tokenStr string representation of the tokenStr.
*/
public Token(String tokenStr) {
if (tokenStr == null) {
throw new IllegalArgumentException("tokenStr cannot be null");
}
set(tokenStr);
}
/**
* Returns if a token from the server has been set.
*
* @return if a token from the server has been set.
*/
public boolean isSet() {
return cookieHandler.getAuthCookie() != null;
}
/**
* Sets a token.
*
* @param tokenStr string representation of the tokenStr.
*/
void set(String tokenStr) {
cookieHandler.setAuthCookieValue(tokenStr);
}
/**
* Installs a cookie handler for the http request to manage session
* cookies.
* @param url
* @return HttpUrlConnection
* @throws IOException
*/
HttpURLConnection openConnection(URL url,
ConnectionConfigurator connConfigurator) throws IOException {
// the cookie handler is unfortunately a global static. it's a
// synchronized class method so we can safely swap the handler while
// instantiating the connection object to prevent it leaking into
// other connections.
final HttpURLConnection conn;
synchronized(CookieHandler.class) {
CookieHandler current = CookieHandler.getDefault();
CookieHandler.setDefault(cookieHandler);
try {
conn = (HttpURLConnection)url.openConnection();
} finally {
CookieHandler.setDefault(current);
}
}
if (connConfigurator != null) {
connConfigurator.configure(conn);
}
return conn;
}
/**
* Returns the string representation of the token.
*
* @return the string representation of the token.
*/
@Override
public String toString() {
String value = "";
HttpCookie authCookie = cookieHandler.getAuthCookie();
if (authCookie != null) {
value = authCookie.getValue();
if (value.startsWith("\"")) { // tests don't want the quotes.
value = value.substring(1, value.length()-1);
}
}
return value;
}
}
private static Class<? extends Authenticator> DEFAULT_AUTHENTICATOR = KerberosAuthenticator.class;
/**
* Sets the default {@link Authenticator} class to use when an {@link AuthenticatedURL} instance
* is created without specifying an authenticator.
*
* @param authenticator the authenticator class to use as default.
*/
public static void setDefaultAuthenticator(Class<? extends Authenticator> authenticator) {
DEFAULT_AUTHENTICATOR = authenticator;
}
/**
* Returns the default {@link Authenticator} class to use when an {@link AuthenticatedURL} instance
* is created without specifying an authenticator.
*
* @return the authenticator class to use as default.
*/
public static Class<? extends Authenticator> getDefaultAuthenticator() {
return DEFAULT_AUTHENTICATOR;
}
private Authenticator authenticator;
private ConnectionConfigurator connConfigurator;
/**
* Creates an {@link AuthenticatedURL}.
*/
public AuthenticatedURL() {
this(null);
}
/**
* Creates an <code>AuthenticatedURL</code>.
*
* @param authenticator the {@link Authenticator} instance to use, if <code>null</code> a {@link
* KerberosAuthenticator} is used.
*/
public AuthenticatedURL(Authenticator authenticator) {
this(authenticator, null);
}
/**
* Creates an <code>AuthenticatedURL</code>.
*
* @param authenticator the {@link Authenticator} instance to use, if <code>null</code> a {@link
* KerberosAuthenticator} is used.
* @param connConfigurator a connection configurator.
*/
public AuthenticatedURL(Authenticator authenticator,
ConnectionConfigurator connConfigurator) {
try {
this.authenticator = (authenticator != null) ? authenticator : DEFAULT_AUTHENTICATOR.newInstance();
} catch (Exception ex) {
throw new RuntimeException(ex);
}
this.connConfigurator = connConfigurator;
this.authenticator.setConnectionConfigurator(connConfigurator);
}
/**
* Returns the {@link Authenticator} instance used by the
* <code>AuthenticatedURL</code>.
*
* @return the {@link Authenticator} instance
*/
protected Authenticator getAuthenticator() {
return authenticator;
}
/**
* Returns an authenticated {@link HttpURLConnection}.
*
* @param url the URL to connect to. Only HTTP/S URLs are supported.
* @param token the authentication token being used for the user.
*
* @return an authenticated {@link HttpURLConnection}.
*
* @throws IOException if an IO error occurred.
* @throws AuthenticationException if an authentication exception occurred.
*/
public HttpURLConnection openConnection(URL url, Token token) throws IOException, AuthenticationException {
if (url == null) {
throw new IllegalArgumentException("url cannot be NULL");
}
if (!url.getProtocol().equalsIgnoreCase("http") && !url.getProtocol().equalsIgnoreCase("https")) {
throw new IllegalArgumentException("url must be for a HTTP or HTTPS resource");
}
if (token == null) {
throw new IllegalArgumentException("token cannot be NULL");
}
authenticator.authenticate(url, token);
// allow the token to create the connection with a cookie handler for
// managing session cookies.
return token.openConnection(url, connConfigurator);
}
/**
* Helper method that injects an authentication token to send with a
* connection. Callers should prefer using
* {@link Token#openConnection(URL, ConnectionConfigurator)} which
* automatically manages authentication tokens.
*
* @param conn connection to inject the authentication token into.
* @param token authentication token to inject.
*/
public static void injectToken(HttpURLConnection conn, Token token) {
HttpCookie authCookie = token.cookieHandler.getAuthCookie();
if (authCookie != null) {
conn.addRequestProperty("Cookie", authCookie.toString());
}
}
/**
* Helper method that extracts an authentication token received from a connection.
* <p>
* This method is used by {@link Authenticator} implementations.
*
* @param conn connection to extract the authentication token from.
* @param token the authentication token.
*
* @throws IOException if an IO error occurred.
* @throws AuthenticationException if an authentication exception occurred.
*/
public static void extractToken(HttpURLConnection conn, Token token) throws IOException, AuthenticationException {
int respCode = conn.getResponseCode();
if (respCode == HttpURLConnection.HTTP_OK
|| respCode == HttpURLConnection.HTTP_CREATED
|| respCode == HttpURLConnection.HTTP_ACCEPTED) {
// cookie handler should have already extracted the token. try again
// for backwards compatibility if this method is called on a connection
// not opened via this instance.
token.cookieHandler.put(null, conn.getHeaderFields());
} else if (respCode == HttpURLConnection.HTTP_NOT_FOUND) {
LOG.trace("Setting token value to null ({}), resp={}", token, respCode);
token.set(null);
throw new FileNotFoundException(conn.getURL().toString());
} else {
LOG.trace("Setting token value to null ({}), resp={}", token, respCode);
token.set(null);
throw new AuthenticationException("Authentication failed" +
", URL: " + conn.getURL() +
", status: " + conn.getResponseCode() +
", message: " + conn.getResponseMessage());
}
}
}