blob: 7f6585404b4a75c5e61356c879bad205c4044e71 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package org.apache.oozie.client;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.lang.management.ManagementFactory;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import com.google.common.annotations.VisibleForTesting;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.FilenameUtils;
import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
import org.apache.hadoop.security.authentication.client.AuthenticationException;
import org.apache.hadoop.security.authentication.client.Authenticator;
import org.apache.hadoop.security.authentication.client.KerberosAuthenticator;
import org.apache.hadoop.security.authentication.client.PseudoAuthenticator;
/**
* This subclass of {@link XOozieClient} supports Kerberos HTTP SPNEGO and simple authentication.
*/
public class AuthOozieClient extends XOozieClient {
/**
* Java system property to specify a custom Authenticator implementation.
*/
public static final String AUTHENTICATOR_CLASS_SYS_PROP = "authenticator.class";
/**
* Java system property that, if set the authentication token will be cached in the user home directory in a hidden
* file <code>.oozie-auth-token</code> with user read/write permissions only.
*/
public static final String USE_AUTH_TOKEN_CACHE_SYS_PROP = "oozie.auth.token.cache";
public static final int AUTH_TOKEN_CACHE_FILENAME_MAXLENGTH = 255;
public enum AuthType {
KERBEROS, SIMPLE, BASIC
}
private String authOption = null;
/**
* authTokenCacheFile defines the location of the authentication token cache file.
* <p>
* It resolves to <code>${user.home}/.oozie-auth-token-Base64(${oozieUrl})</code>.
*/
private final File authTokenCacheFile;
/**
* Create an instance of the AuthOozieClient.
*
* @param oozieUrl the Oozie URL
*/
public AuthOozieClient(String oozieUrl) {
this(oozieUrl, null);
}
/**
* Create an instance of the AuthOozieClient.
*
* @param oozieUrl the Oozie URL
* @param authOption the auth option
*/
@SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "FilenameUtils is used to filter user input. JDK8+ is used.")
public AuthOozieClient(String oozieUrl, String authOption) {
super(oozieUrl);
this.authOption = authOption;
String filename = getAuthCacheFileName(oozieUrl);
// just to filter user input
authTokenCacheFile = new File(System.getProperty("user.home"), FilenameUtils.getName(filename));
if (filename.length() >= AUTH_TOKEN_CACHE_FILENAME_MAXLENGTH && authTokenCacheFile.exists()) {
System.out.println("Warn: the same Oozie auth cache filename exists, filename=" + filename);
}
}
@VisibleForTesting
public String getAuthCacheFileName(String oozieUrl) {
String encodeBase64OozieUrl = Base64.encodeBase64URLSafeString(oozieUrl.getBytes(StandardCharsets.UTF_8));
String filename = ".oozie-auth-token-" + encodeBase64OozieUrl;
if (filename.length() >= AUTH_TOKEN_CACHE_FILENAME_MAXLENGTH) {
filename = filename.substring(0, AUTH_TOKEN_CACHE_FILENAME_MAXLENGTH);
}
return filename;
}
/**
* Create an authenticated connection to the Oozie server.
* <p>
* It uses Hadoop-auth client authentication which by default supports
* Kerberos HTTP SPNEGO, Pseudo/Simple and anonymous.
* <p>
* if the Java system property {@link #USE_AUTH_TOKEN_CACHE_SYS_PROP} is set to true Hadoop-auth
* authentication token will be cached/used in/from the '.oozie-auth-token' file in the user
* home directory.
*
* @param url the URL to open a HTTP connection to.
* @param method the HTTP method for the HTTP connection.
* @return an authenticated connection to the Oozie server.
* @throws IOException if an IO error occurred.
* @throws OozieClientException if an oozie client error occurred.
*/
@Override
protected HttpURLConnection createConnection(URL url, String method) throws IOException, OozieClientException {
boolean useAuthFile = System.getProperty(USE_AUTH_TOKEN_CACHE_SYS_PROP, "false").equalsIgnoreCase("true");
AuthenticatedURL.Token readToken = null;
AuthenticatedURL.Token currentToken;
// Read the token in from the file
if (useAuthFile) {
readToken = readAuthToken();
}
if (readToken == null) {
currentToken = new AuthenticatedURL.Token();
} else {
currentToken = new AuthenticatedURL.Token(readToken.toString());
}
// To prevent rare race conditions and to save a call to the Server, lets check the token's expiration time locally, and
// consider it expired if its expiration time has passed or will pass in the next 5 minutes (or if there's a problem parsing
// it)
if (currentToken.isSet()) {
long expires = getExpirationTime(currentToken);
if (expires < System.currentTimeMillis() + 300000) {
if (useAuthFile) {
authTokenCacheFile.delete();
}
currentToken = new AuthenticatedURL.Token();
}
}
// If we have a token, double check with the Server to make sure it hasn't expired yet
if (currentToken.isSet()) {
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("OPTIONS");
AuthenticatedURL.injectToken(conn, currentToken);
if (conn.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED
|| conn.getResponseCode() == HttpURLConnection.HTTP_FORBIDDEN) {
if (useAuthFile) {
authTokenCacheFile.delete();
}
currentToken = new AuthenticatedURL.Token();
} else {
// After HADOOP-10301, with Kerberos the above token expiration check will now send 200 even with an expired token
// if you still have valid Kerberos credentials. Previously, it would send 401 so the client knows that it needs to
// use the KerberosAuthenticator to get a new token. Now, it may even provide a token back from this call, so we
// need to check for a new token and update ours. If no new token was given and we got a 20X code, this will do a
// no-op.
// With Pseudo, the above token expiration check will now send 403 instead of the 401; we're now checking for either
// response code above. However, unlike with Kerberos, Pseudo doesn't give us a new token here; we'll have to get
// one later.
try {
AuthenticatedURL.extractToken(conn, currentToken);
} catch (AuthenticationException ex) {
if (useAuthFile) {
authTokenCacheFile.delete();
}
currentToken = new AuthenticatedURL.Token();
}
}
}
// If we didn't have a token, or it had expired, let's get a new one from the Server using the configured Authenticator
if (!currentToken.isSet()) {
Authenticator authenticator = getAuthenticator();
try {
authenticator.authenticate(url, currentToken);
}
catch (AuthenticationException ex) {
if (useAuthFile) {
authTokenCacheFile.delete();
}
throw new OozieClientException(OozieClientException.AUTHENTICATION,
"Could not authenticate, " + ex.getMessage(), ex);
}
}
// If we got a new token, save it to the cache file
// For comparison of currentToken and readToken, please see the details of OOZIE-3396
// Here, because of Hadoop AuthenticatedURL.Token don't override the equals() method,
// we have to compare the token.toString()
if (useAuthFile && currentToken.isSet() &&
(readToken == null || !currentToken.toString().equals(readToken.toString()))) {
writeAuthToken(currentToken);
}
// Now create a connection using the token and return it to the caller
HttpURLConnection conn = super.createConnection(url, method);
AuthenticatedURL.injectToken(conn, currentToken);
return conn;
}
private static long getExpirationTime(AuthenticatedURL.Token token) {
long expires = 0L;
String[] splits = token.toString().split("&");
for (String split : splits) {
if (split.startsWith("e=")) {
try {
expires = Long.parseLong(split.substring(2));
} catch (Exception e) {
// token is somehow invalid, assume it expired already
break;
}
}
}
return expires;
}
/**
* Read a authentication token cached in the user home directory.
* <p>
*
* @return the authentication token cached in the user home directory, NULL if none.
*/
protected AuthenticatedURL.Token readAuthToken() {
AuthenticatedURL.Token authToken = null;
if (authTokenCacheFile.exists()) {
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(authTokenCacheFile),
StandardCharsets.UTF_8));
String line = reader.readLine();
reader.close();
if (line != null) {
authToken = new AuthenticatedURL.Token(line);
}
}
catch (IOException ex) {
//NOP
}
}
return authToken;
}
/**
* Write the current authentication token to the user home directory.authOption
* <p>
* The file is written with user only read/write permissions.
* <p>
* If the file cannot be updated or the user only ready/write permissions cannot be set the file is deleted.
*
* @param authToken the authentication token to cache.
*/
protected void writeAuthToken(AuthenticatedURL.Token authToken) {
try {
String jvmName = ManagementFactory.getRuntimeMXBean().getName();
File tmpTokenFile = File.createTempFile(".oozie-auth-token", jvmName + "tmp",
new File(System.getProperty("user.home")));
// just to be safe, if something goes wrong delete tmp file eventually
tmpTokenFile.deleteOnExit();
Writer writer = new OutputStreamWriter(new FileOutputStream(tmpTokenFile), StandardCharsets.UTF_8);
writer.write(authToken.toString());
writer.close();
Files.move(tmpTokenFile.toPath(), authTokenCacheFile.toPath(), StandardCopyOption.ATOMIC_MOVE);
// sets read-write permissions to owner only
authTokenCacheFile.setReadable(false, false);
authTokenCacheFile.setReadable(true, true);
authTokenCacheFile.setWritable(true, true);
}
catch (IOException ioe) {
// if case of any error we just delete the cache, if user-only
// write permissions are not properly set a security exception
// is thrown and the file will be deleted.
authTokenCacheFile.delete();
}
}
/**
* Return the Hadoop-auth Authenticator to use.
* <p>
* It first looks for value of command line option 'auth', if not set it continues to check
* {@link #AUTHENTICATOR_CLASS_SYS_PROP} Java system property for Authenticator.
* <p>
* It the value of the {@link #AUTHENTICATOR_CLASS_SYS_PROP} is not set it uses
* Hadoop-auth <code>KerberosAuthenticator</code> which supports both Kerberos HTTP SPNEGO and Pseudo/simple
* authentication.
*
* @return the Authenticator to use, <code>NULL</code> if none.
*
* @throws OozieClientException thrown if the authenticator could not be instantiated.
*/
protected Authenticator getAuthenticator() throws OozieClientException {
if (authOption != null) {
try {
Class<? extends Authenticator> authClass = getAuthenticators().get(authOption.toUpperCase());
if (authClass == null) {
throw new OozieClientException(OozieClientException.AUTHENTICATION,
"Authenticator class not found [" + authClass + "]");
}
return authClass.newInstance();
}
catch (IllegalArgumentException iae) {
throw new OozieClientException(OozieClientException.AUTHENTICATION,
"Invalid options provided for auth: " + authOption
+ ", (" + AuthType.KERBEROS + " or " + AuthType.SIMPLE + " or " + AuthType.BASIC + " expected.)");
}
catch (InstantiationException | IllegalAccessException ex) {
throw new OozieClientException(OozieClientException.AUTHENTICATION,
"Could not instantiate Authenticator for option [" + authOption + "], " +
ex.getMessage(), ex);
}
}
String className = System.getProperty(AUTHENTICATOR_CLASS_SYS_PROP, KerberosAuthenticator.class.getName());
if (className != null) {
try {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
Class<?> klass = (cl != null) ? cl.loadClass(className) :
getClass().getClassLoader().loadClass(className);
if (klass == null) {
throw new OozieClientException(OozieClientException.AUTHENTICATION,
"Authenticator class not found [" + className + "]");
}
return (Authenticator) klass.newInstance();
}
catch (Exception ex) {
throw new OozieClientException(OozieClientException.AUTHENTICATION,
"Could not instantiate Authenticator [" + className + "], " +
ex.getMessage(), ex);
}
}
else {
throw new OozieClientException(OozieClientException.AUTHENTICATION,
"Authenticator class not found [" + className + "]");
}
}
/**
* Get the map for classes of Authenticator.
* Default values are:
* null : KerberosAuthenticator
* SIMPLE : PseudoAuthenticator
* KERBEROS : KerberosAuthenticator
*
* @return the map for classes of Authenticator
*/
protected Map<String, Class<? extends Authenticator>> getAuthenticators() {
Map<String, Class<? extends Authenticator>> authClasses = new HashMap<>();
authClasses.put(AuthType.KERBEROS.toString(), KerberosAuthenticator.class);
authClasses.put(AuthType.SIMPLE.toString(), PseudoAuthenticator.class);
authClasses.put(AuthType.BASIC.toString(), BasicAuthenticator.class);
authClasses.put(null, KerberosAuthenticator.class);
return authClasses;
}
/**
* Get authOption
*
* @return the authOption
*/
public String getAuthOption() {
return authOption;
}
}