/*
* Copyright (C) 2015 The Async HBase Authors. All rights reserved.
* This file is part of Async HBase.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* - Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of the StumbleUpon nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package org.hbase.async.auth;
import java.util.Date;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.security.auth.Subject;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.kerberos.KerberosPrincipal;
import javax.security.auth.kerberos.KerberosTicket;
import javax.security.auth.login.AppConfigurationEntry;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import org.apache.zookeeper.Shell;
import org.hbase.async.Config;
import org.jboss.netty.util.HashedWheelTimer;
import org.jboss.netty.util.Timeout;
import org.jboss.netty.util.TimerTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class is responsible for refreshing credentials for logins. This is a
* singleton class that will only allow authentication for one user against
* HBase per JVM.
*
* This class was culled from zookeeper which was culled from hadoop with some
* slight changes including using the HBaseClient's timer to refresh tokens
* instead of firing up a separate thread and passing settings from the config
* class. (and formatting to match AsyncHBase's code base)
*/
public class Login {
private static final Logger LOG = LoggerFactory.getLogger(Login.class);
/** Renewals won't occur until 80% of time from last refresh to
* ticket's expiration has been reached, at which time it will wake
* and try to renew the ticket. */
private static final float TICKET_RENEW_WINDOW = 0.80f;
/**
* Percentage of random jitter added to the renewal time
*/
private static final float TICKET_RENEW_JITTER = 0.05f;
/** Regardless of TICKET_RENEW_WINDOW setting above and the ticket expiration time,
* thread will not sleep between refresh attempts any less than 1 minute
* (60*1000 milliseconds = 1 minute). Change the '1' to e.g. 5, to change
* this to 5 minutes. */
static final long MIN_TIME_BEFORE_RELOGIN = 1 * 60 * 1000L;
/** Sasl config string for HBase */
public static final String LOGIN_CONTEXT_NAME_KEY =
"hbase.sasl.clientconfig";
/** Random number generator to generate jitter for refreshing tickets to
* avoid slamming the authentication system with requests from various clients
* at the same time. */
private static Random random = new Random(System.currentTimeMillis());
/** A singleton that allows only one user per JVM to authenticate against
* HBase. */
private static Login current_login;
/** The asynchbase config to pull settings from */
private final Config config;
/** A timer to use for renewing the tickets */
private final HashedWheelTimer timer;
/** The callback to trigger on login */
private final CallbackHandler callback_handler;
/** The name of the JAAS config section */
private final String login_context_name;
/** The authenticated subject */
private final Subject subject;
/** Whether or not this login is associated with a kerberos ticket */
private final boolean is_kerberos_ticket;
/** The login context created on login */
private LoginContext login_context;
/** Whether or not we need to use kinit for a ticket cache */
private boolean using_ticket_cache;
/** The authenticated principal */
private String principal;
/**
* Attempts to set the singleton and authenticate using the given context and
* callback. If we're already logged in then this is a no-op and the callback
* is ignored.
* @param config The AsyncHBase config to load settings from
* @param timer A timer to use for renewing tickets
* @param login_context_name Name of section in JAAS file that will be use to login.
* Passed as first param to javax.security.auth.login.LoginContext().
* @param callback_handler The callback to return results to.
* Passed as second param to javax.security.auth.login.LoginContext().
* @throws LoginException If login failed
*/
public static synchronized void initUserIfNeeded(final Config config,
final HashedWheelTimer timer, final String login_context_name,
final CallbackHandler callback_handler)
throws LoginException {
if (current_login == null) {
current_login = new Login(config, timer, login_context_name, callback_handler);
LOG.info("Initialized kerberos login context");
} else {
LOG.debug("Already logged in");
}
}
/** @return the current login. May be null if not set */
static Login getCurrentLogin() {
return current_login;
}
/**
* Package private ctor to block instantiations outside of the singleton
* @param config The AsyncHbase config to pull settings from
* @param timer The client timer to use for renewing tokens
* @param login_context_name The name of the JAAS context to load
* @param callback_handler The callback to respond to
* @throws LoginException if authentication fails
*/
Login(final Config config, final HashedWheelTimer timer,
final String login_context_name, final CallbackHandler callback_handler)
throws LoginException {
this.config = config;
this.timer = timer;
this.login_context_name = login_context_name;
this.callback_handler = callback_handler;
login_context = login(login_context_name);
subject = login_context.getSubject();
is_kerberos_ticket = !subject
.getPrivateCredentials(KerberosTicket.class).isEmpty();
final AppConfigurationEntry entries[] =
Configuration.getConfiguration()
.getAppConfigurationEntry(login_context_name);
for (final AppConfigurationEntry entry : entries) {
// there will only be a single entry, so this for() loop will only
// be iterated through once.
if (entry.getOptions().get("useTicketCache") != null) {
final String val = (String)entry.getOptions().get("useTicketCache");
if (val.toLowerCase().equals("true")) {
using_ticket_cache = true;
}
}
if (entry.getOptions().get("keyTab") != null) {
// TODO (cl) are these useful?
//keytab_file = (String)entry.getOptions().get("keyTab");
//using_keytab = true;
}
if (entry.getOptions().get("principal") != null) {
principal = (String)entry.getOptions().get("principal");
}
break;
}
if (!is_kerberos_ticket) {
return;
}
// Refresh the Ticket Granting Ticket (TGT) periodically. How often to
// refresh is determined by the TGT's existing expiration date and the
// configured MIN_TIME_BEFORE_RELOGIN. For testing and development,
// you can decrease the interval of expiration of tickets
// (for example, to 3 minutes) by running :
// "modprinc -maxlife 3mins <principal>" in kadmin.
final long delay = getRefreshDelay(getTGT());
timer.newTimeout(new TicketRenewalTask(), delay, TimeUnit.MILLISECONDS);
LOG.info("Scheduled ticket renewal in " + delay + " ms");
}
/** @return the current subject */
public Subject getSubject() {
return subject;
}
/**
* Attempts to login using the given context name from the JAAS config
* @param login_context_name The JAAS file section
* @return A login context if successful
* @throws LoginException if authentication failed
*/
private synchronized LoginContext login(final String login_context_name)
throws LoginException {
if (login_context_name == null || login_context_name.isEmpty()) {
throw new LoginException(
"Login context name (JAAS file section header) was null or empty. " +
"Please check your java.security.login.auth.config (=" +
System.getProperty("java.security.auth.login.config") +
") and your " + LOGIN_CONTEXT_NAME_KEY + "(=" +
login_context_name + ")");
}
LOG.debug("Constructing login context with context: " + login_context_name);
final LoginContext login_context = new LoginContext(login_context_name,
callback_handler);
login_context.login();
LOG.info("Successfully logged in.");
return login_context;
}
/**
* Calculates a Unix epoch timestamp in milliseconds when we should attempt
* another login. It will look at the expiration time of the ticket and
* set a time close to but before the expiration when we should try the renewal.
* If a proper time can't be found, we'll try again in
* {@code MIN_TIME_BEFORE_RELOGIN} milliseconds.
* c.f. org.apache.hadoop.security.UserGroupInformation.
* @param tgt The ticket to parse the expiration time from
* @return How long to wait, in milliseconds, before refreshing the ticket
*/
private long getRefreshDelay(final KerberosTicket tgt) {
final long now = System.currentTimeMillis();
long next_refresh = MIN_TIME_BEFORE_RELOGIN;
if (tgt == null) {
LOG.warn("No TGT found: will try again at " + new Date(next_refresh));
return next_refresh;
}
final long start = tgt.getStartTime().getTime();
final long expires = tgt.getEndTime().getTime();
LOG.info("TGT valid starting at: " + tgt.getStartTime().toString());
LOG.info("TGT expires: " + tgt.getEndTime().toString());
next_refresh = (long) ((expires - start) *
(TICKET_RENEW_WINDOW + (TICKET_RENEW_JITTER * random.nextDouble())));
if ((using_ticket_cache) && (tgt.getEndTime().equals(tgt.getRenewTill()))) {
LOG.error("The TGT cannot be renewed beyond the next expiration date: "
+ new Date(expires) + ". This process will not be able to "
+ "authenticate new SASL connections after that time. Ask "
+ "your system administrator to either increase the 'renew "
+ "until' time by doing : 'modprinc -maxrenewlife "
+ principal + "' within kadmin, or instead, to generate a "
+ "keytab for " + principal + ". Because the TGT's expiration "
+ "cannot be further extended by refreshing, exiting refresh "
+ "thread now.");
return MIN_TIME_BEFORE_RELOGIN;
}
// Determine how long to sleep from looking at ticket's expiration.
// We should not allow the ticket to expire, but we should take into
// consideration MIN_TIME_BEFORE_RELOGIN. Will not sleep less than
// MIN_TIME_BEFORE_RELOGIN, unless doing so would cause ticket expiration.
if ((now + next_refresh) > expires ||
(now + MIN_TIME_BEFORE_RELOGIN) > expires) {
// expiration is before next scheduled refresh.
LOG.info("Refreshing now because expiration " + new Date(expires)
+ " is before next scheduled refresh time " + new Date(now + next_refresh)
+ " or we are within " + MIN_TIME_BEFORE_RELOGIN + "ms of expiring.");
next_refresh = 0;
} else {
if ((now + next_refresh) < (now + MIN_TIME_BEFORE_RELOGIN)) {
// next scheduled refresh is sooner than (now + MIN_TIME_BEFORE_LOGIN).
final Date until = new Date(now + next_refresh);
final Date new_until = new Date(now + MIN_TIME_BEFORE_RELOGIN);
LOG.warn("TGT refresh thread time adjusted from : " + until
+ " to : " + new_until + " since "
+ "the former is sooner than the minimum refresh interval ("
+ MIN_TIME_BEFORE_RELOGIN / 1000 + " seconds) from now.");
}
next_refresh = Math.max(next_refresh, MIN_TIME_BEFORE_RELOGIN);
}
if ((now + next_refresh) > expires) {
LOG.error("Next refresh: " + new Date(now + next_refresh)
+ " is later than expiration " + new Date(expires) +
". This may indicate a clock skew problem. "
+ "Check that this host and the KDC's hosts' clocks are in sync.");
next_refresh = MIN_TIME_BEFORE_RELOGIN;
}
return next_refresh;
}
/**
* Fetches the proper Kerberos ticket from the subject if we successfully
* logged in.
* @return The ticket found
*/
private synchronized KerberosTicket getTGT() {
final Set<KerberosTicket> tickets =
subject.getPrivateCredentials(KerberosTicket.class);
for (final KerberosTicket ticket : tickets) {
final KerberosPrincipal server = ticket.getServer();
if (server.getName().equals(
"krbtgt/" + server.getRealm() + "@" + server.getRealm())) {
LOG.debug("Found tgt " + ticket + ".");
return ticket;
}
}
return null;
}
/**
* Attempts to refresh the ticket by shelling out to the kinit utility
*/
private void refreshTicketCache() {
String cmd = "/usr/bin/kinit";
if (config.hasProperty("asynchbase.security.auth.kinit")) {
cmd = config.getString("asynchbase.security.auth.kinit");
}
final String args = "-R";
try {
LOG.info("Executing kinit command: " + cmd + " " + args);
Shell.execCommand(cmd, args);
} catch (Exception e) {
throw new RuntimeException("Could not renew TGT due to problem "
+ "running shell command: '" + cmd + " " + args + "';", e);
}
}
/**
* Re-login a principal. This method assumes that {@link #login(String)}
* has happened already.
* c.f. HADOOP-6559
* @throws javax.security.auth.login.LoginException on a failure
*/
private void reLogin() throws LoginException {
if (!is_kerberos_ticket) {
return;
}
if (login_context == null) {
throw new LoginException("Login must be done first");
}
LOG.info("Initiating logout for " + principal);
synchronized (Login.class) {
//clear up the kerberos state. But the tokens are not cleared! As per
//the Java kerberos login module code, only the kerberos credentials
//are cleared
login_context.logout();
//login and also update the subject field of this instance to
//have the new credentials (pass it to the LoginContext constructor)
login_context = new LoginContext(login_context_name, subject);
LOG.info("Initiating re-login for " + principal);
login_context.login();
LOG.info("Relogin was successful for " + principal);
}
}
/**
* Timer task that refreshes our ticket cache (if applicable) and attempts
* a new login. If the login fails, we'll try again in at least
* {@code MIN_TIME_BEFORE_RELOGIN} milliseconds.
*/
class TicketRenewalTask implements TimerTask {
@Override
public void run(final Timeout timeout) {
// set a default for the next attempt
long next_refresh = MIN_TIME_BEFORE_RELOGIN;
try {
// refresh and/or reattempt to login
if (using_ticket_cache) {
refreshTicketCache();
}
reLogin();
// schedule our next renewal by getting the expiration time
next_refresh = getRefreshDelay(getTGT());
} catch (LoginException e) {
LOG.error("Failed to renew ticket", e);
} catch (Exception e) {
LOG.error("Failed to renew ticket", e);
} finally {
LOG.debug("Scheduling next next login attempt in " + next_refresh + " ms");
timer.newTimeout(this, next_refresh, TimeUnit.MILLISECONDS);
}
}
}
}