001/**
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *      http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018
019package org.apache.oozie.client;
020
021import java.io.BufferedReader;
022import java.io.File;
023import java.io.FileReader;
024import java.io.FileWriter;
025import java.io.IOException;
026import java.io.Writer;
027import java.lang.management.ManagementFactory;
028import java.net.HttpURLConnection;
029import java.net.URL;
030import java.nio.file.Files;
031import java.nio.file.StandardCopyOption;
032import java.util.HashMap;
033import java.util.Map;
034
035import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
036import org.apache.hadoop.security.authentication.client.AuthenticationException;
037import org.apache.hadoop.security.authentication.client.Authenticator;
038import org.apache.hadoop.security.authentication.client.KerberosAuthenticator;
039import org.apache.hadoop.security.authentication.client.PseudoAuthenticator;
040
041/**
042 * This subclass of {@link XOozieClient} supports Kerberos HTTP SPNEGO and simple authentication.
043 */
044public class AuthOozieClient extends XOozieClient {
045
046    /**
047     * Java system property to specify a custom Authenticator implementation.
048     */
049    public static final String AUTHENTICATOR_CLASS_SYS_PROP = "authenticator.class";
050
051    /**
052     * Java system property that, if set the authentication token will be cached in the user home directory in a hidden
053     * file <code>.oozie-auth-token</code> with user read/write permissions only.
054     */
055    public static final String USE_AUTH_TOKEN_CACHE_SYS_PROP = "oozie.auth.token.cache";
056
057    /**
058     * File constant that defines the location of the authentication token cache file.
059     * <p>
060     * It resolves to <code>${user.home}/.oozie-auth-token</code>.
061     */
062    public static final File AUTH_TOKEN_CACHE_FILE = new File(System.getProperty("user.home"), ".oozie-auth-token");
063
064    public static enum AuthType {
065        KERBEROS, SIMPLE
066    }
067
068    private String authOption = null;
069
070    /**
071     * Create an instance of the AuthOozieClient.
072     *
073     * @param oozieUrl the Oozie URL
074     */
075    public AuthOozieClient(String oozieUrl) {
076        this(oozieUrl, null);
077    }
078
079    /**
080     * Create an instance of the AuthOozieClient.
081     *
082     * @param oozieUrl the Oozie URL
083     * @param authOption the auth option
084     */
085    public AuthOozieClient(String oozieUrl, String authOption) {
086        super(oozieUrl);
087        this.authOption = authOption;
088    }
089
090    /**
091     * Create an authenticated connection to the Oozie server.
092     * <p>
093     * It uses Hadoop-auth client authentication which by default supports
094     * Kerberos HTTP SPNEGO, Pseudo/Simple and anonymous.
095     * <p>
096     * if the Java system property {@link #USE_AUTH_TOKEN_CACHE_SYS_PROP} is set to true Hadoop-auth
097     * authentication token will be cached/used in/from the '.oozie-auth-token' file in the user
098     * home directory.
099     *
100     * @param url the URL to open a HTTP connection to.
101     * @param method the HTTP method for the HTTP connection.
102     * @return an authenticated connection to the Oozie server.
103     * @throws IOException if an IO error occurred.
104     * @throws OozieClientException if an oozie client error occurred.
105     */
106    @Override
107    protected HttpURLConnection createConnection(URL url, String method) throws IOException, OozieClientException {
108        boolean useAuthFile = System.getProperty(USE_AUTH_TOKEN_CACHE_SYS_PROP, "false").equalsIgnoreCase("true");
109        AuthenticatedURL.Token readToken = null;
110        AuthenticatedURL.Token currentToken = null;
111
112        // Read the token in from the file
113        if (useAuthFile) {
114            readToken = readAuthToken();
115        }
116        if (readToken == null) {
117            currentToken = new AuthenticatedURL.Token();
118        } else {
119            currentToken = new AuthenticatedURL.Token(readToken.toString());
120        }
121
122        // To prevent rare race conditions and to save a call to the Server, lets check the token's expiration time locally, and
123        // consider it expired if its expiration time has passed or will pass in the next 5 minutes (or if there's a problem parsing
124        // it)
125        if (currentToken.isSet()) {
126            long expires = getExpirationTime(currentToken);
127            if (expires < System.currentTimeMillis() + 300000) {
128                if (useAuthFile) {
129                    AUTH_TOKEN_CACHE_FILE.delete();
130                }
131                currentToken = new AuthenticatedURL.Token();
132            }
133        }
134
135        // If we have a token, double check with the Server to make sure it hasn't expired yet
136        if (currentToken.isSet()) {
137            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
138            conn.setRequestMethod("OPTIONS");
139            AuthenticatedURL.injectToken(conn, currentToken);
140            if (conn.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED
141                    || conn.getResponseCode() == HttpURLConnection.HTTP_FORBIDDEN) {
142                if (useAuthFile) {
143                    AUTH_TOKEN_CACHE_FILE.delete();
144                }
145                currentToken = new AuthenticatedURL.Token();
146            } else {
147                // After HADOOP-10301, with Kerberos the above token expiration check will now send 200 even with an expired token
148                // if you still have valid Kerberos credentials.  Previously, it would send 401 so the client knows that it needs to
149                // use the KerberosAuthenticator to get a new token.  Now, it may even provide a token back from this call, so we
150                // 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
151                // no-op.
152                // With Pseudo, the above token expiration check will now send 403 instead of the 401; we're now checking for either
153                // response code above.  However, unlike with Kerberos, Pseudo doesn't give us a new token here; we'll have to get
154                // one later.
155                try {
156                    AuthenticatedURL.extractToken(conn, currentToken);
157                } catch (AuthenticationException ex) {
158                    if (useAuthFile) {
159                        AUTH_TOKEN_CACHE_FILE.delete();
160                    }
161                    currentToken = new AuthenticatedURL.Token();
162                }
163            }
164        }
165
166        // If we didn't have a token, or it had expired, let's get a new one from the Server using the configured Authenticator
167        if (!currentToken.isSet()) {
168            Authenticator authenticator = getAuthenticator();
169            try {
170                authenticator.authenticate(url, currentToken);
171            }
172            catch (AuthenticationException ex) {
173                if (useAuthFile) {
174                    AUTH_TOKEN_CACHE_FILE.delete();
175                }
176                throw new OozieClientException(OozieClientException.AUTHENTICATION,
177                                               "Could not authenticate, " + ex.getMessage(), ex);
178            }
179        }
180
181        // If we got a new token, save it to the cache file
182        if (useAuthFile && currentToken.isSet() && !currentToken.equals(readToken)) {
183            writeAuthToken(currentToken);
184        }
185
186        // Now create a connection using the token and return it to the caller
187        HttpURLConnection conn = super.createConnection(url, method);
188        AuthenticatedURL.injectToken(conn, currentToken);
189        return conn;
190    }
191
192    private static long getExpirationTime(AuthenticatedURL.Token token) {
193        long expires = 0L;
194        String[] splits = token.toString().split("&");
195        for (String split : splits) {
196            if (split.startsWith("e=")) {
197                try {
198                    expires = Long.parseLong(split.substring(2));
199                } catch (Exception e) {
200                    // token is somehow invalid, assume it expired already
201                    break;
202                }
203            }
204        }
205        return expires;
206    }
207
208    /**
209     * Read a authentication token cached in the user home directory.
210     * <p>
211     *
212     * @return the authentication token cached in the user home directory, NULL if none.
213     */
214    protected AuthenticatedURL.Token readAuthToken() {
215        AuthenticatedURL.Token authToken = null;
216        if (AUTH_TOKEN_CACHE_FILE.exists()) {
217            try {
218                BufferedReader reader = new BufferedReader(new FileReader(AUTH_TOKEN_CACHE_FILE));
219                String line = reader.readLine();
220                reader.close();
221                if (line != null) {
222                    authToken = new AuthenticatedURL.Token(line);
223                }
224            }
225            catch (IOException ex) {
226                //NOP
227            }
228        }
229        return authToken;
230    }
231
232    /**
233     * Write the current authentication token to the user home directory.authOption
234     * <p>
235     * The file is written with user only read/write permissions.
236     * <p>
237     * If the file cannot be updated or the user only ready/write permissions cannot be set the file is deleted.
238     *
239     * @param authToken the authentication token to cache.
240     */
241    protected void writeAuthToken(AuthenticatedURL.Token authToken) {
242        try {
243            String jvmName = ManagementFactory.getRuntimeMXBean().getName();
244            File tmpTokenFile = File.createTempFile(".oozie-auth-token", jvmName + "tmp",
245                    new File(System.getProperty("user.home")));
246            // just to be safe, if something goes wrong delete tmp file eventually
247            tmpTokenFile.deleteOnExit();
248            Writer writer = new FileWriter(tmpTokenFile);
249            writer.write(authToken.toString());
250            writer.close();
251            Files.move(tmpTokenFile.toPath(), AUTH_TOKEN_CACHE_FILE.toPath(), StandardCopyOption.ATOMIC_MOVE);
252            // sets read-write permissions to owner only
253            AUTH_TOKEN_CACHE_FILE.setReadable(false, false);
254            AUTH_TOKEN_CACHE_FILE.setReadable(true, true);
255            AUTH_TOKEN_CACHE_FILE.setWritable(true, true);
256        }
257        catch (IOException ioe) {
258            // if case of any error we just delete the cache, if user-only
259            // write permissions are not properly set a security exception
260            // is thrown and the file will be deleted.
261            AUTH_TOKEN_CACHE_FILE.delete();
262
263        }
264    }
265
266    /**
267     * Return the Hadoop-auth Authenticator to use.
268     * <p>
269     * It first looks for value of command line option 'auth', if not set it continues to check
270     * {@link #AUTHENTICATOR_CLASS_SYS_PROP} Java system property for Authenticator.
271     * <p>
272     * It the value of the {@link #AUTHENTICATOR_CLASS_SYS_PROP} is not set it uses
273     * Hadoop-auth <code>KerberosAuthenticator</code> which supports both Kerberos HTTP SPNEGO and Pseudo/simple
274     * authentication.
275     *
276     * @return the Authenticator to use, <code>NULL</code> if none.
277     *
278     * @throws OozieClientException thrown if the authenticator could not be instantiated.
279     */
280    protected Authenticator getAuthenticator() throws OozieClientException {
281        if (authOption != null) {
282            try {
283                Class<? extends Authenticator> authClass = getAuthenticators().get(authOption.toUpperCase());
284                if (authClass == null) {
285                    throw new OozieClientException(OozieClientException.AUTHENTICATION,
286                            "Authenticator class not found [" + authClass + "]");
287                }
288                return authClass.newInstance();
289            }
290            catch (IllegalArgumentException iae) {
291                throw new OozieClientException(OozieClientException.AUTHENTICATION, "Invalid options provided for auth: " + authOption
292                        + ", (" + AuthType.KERBEROS + " or " + AuthType.SIMPLE + " expected.)");
293            }
294            catch (InstantiationException ex) {
295                throw new OozieClientException(OozieClientException.AUTHENTICATION,
296                        "Could not instantiate Authenticator for option [" + authOption + "], " +
297                        ex.getMessage(), ex);
298            }
299            catch (IllegalAccessException ex) {
300                throw new OozieClientException(OozieClientException.AUTHENTICATION,
301                        "Could not instantiate Authenticator for option [" + authOption + "], " +
302                        ex.getMessage(), ex);
303            }
304
305        }
306
307        String className = System.getProperty(AUTHENTICATOR_CLASS_SYS_PROP, KerberosAuthenticator.class.getName());
308        if (className != null) {
309            try {
310                ClassLoader cl = Thread.currentThread().getContextClassLoader();
311                Class<? extends Object> klass = (cl != null) ? cl.loadClass(className) :
312                    getClass().getClassLoader().loadClass(className);
313                if (klass == null) {
314                    throw new OozieClientException(OozieClientException.AUTHENTICATION,
315                            "Authenticator class not found [" + className + "]");
316                }
317                return (Authenticator) klass.newInstance();
318            }
319            catch (Exception ex) {
320                throw new OozieClientException(OozieClientException.AUTHENTICATION,
321                                               "Could not instantiate Authenticator [" + className + "], " +
322                                               ex.getMessage(), ex);
323            }
324        }
325        else {
326            throw new OozieClientException(OozieClientException.AUTHENTICATION,
327                                           "Authenticator class not found [" + className + "]");
328        }
329    }
330
331    /**
332     * Get the map for classes of Authenticator.
333     * Default values are:
334     * null : KerberosAuthenticator
335     * SIMPLE : PseudoAuthenticator
336     * KERBEROS : KerberosAuthenticator
337     *
338     * @return the map for classes of Authenticator
339     */
340    protected Map<String, Class<? extends Authenticator>> getAuthenticators() {
341        Map<String, Class<? extends Authenticator>> authClasses = new HashMap<String, Class<? extends Authenticator>>();
342        authClasses.put(AuthType.KERBEROS.toString(), KerberosAuthenticator.class);
343        authClasses.put(AuthType.SIMPLE.toString(), PseudoAuthenticator.class);
344        authClasses.put(null, KerberosAuthenticator.class);
345        return authClasses;
346    }
347
348    /**
349     * Get authOption
350     *
351     * @return the authOption
352     */
353    public String getAuthOption() {
354        return authOption;
355    }
356
357}