/** * 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.zookeeper; /** * This class is responsible for refreshing Kerberos credentials for * logins for both Zookeeper client and server. * See ZooKeeperSaslServer for server-side usage. * See ZooKeeperSaslClient for client-side usage. */ import javax.security.auth.kerberos.KerberosPrincipal; 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 javax.security.auth.callback.CallbackHandler; import org.apache.log4j.Logger; import javax.security.auth.kerberos.KerberosTicket; import javax.security.auth.Subject; import java.io.IOException; import java.util.Date; import java.util.Set; public class Login { Logger LOG = Logger.getLogger(Login.class); public CallbackHandler callbackHandler; // LoginThread will sleep until 80% of time from last refresh to // ticket's expiry has been reached, at which time it will wake // and try to renew the ticket. private static final float TICKET_RENEW_WINDOW = 0.80f; // Regardless of TICKET_RENEW_WINDOW setting above and the ticket expiry 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. private static final long MIN_TIME_BEFORE_RELOGIN = 1 * 60 * 1000L; private Subject subject = null; private Thread t = null; private boolean isKrbTicket = false; private boolean isUsingTicketCache = false; /** * LoginThread constructor. The constructor starts the thread used * to periodically re-login to the Kerberos Ticket Granting Server. * @param loginContextName * name of section in JAAS file that will be use to login. * Passed as first param to javax.security.auth.login.LoginContext(). * * @param callbackHandler * Passed as second param to javax.security.auth.login.LoginContext(). * @throws javax.security.auth.login.LoginException * Thrown if authentication fails. */ public Login(final String loginContextName, CallbackHandler callbackHandler) throws LoginException { this.callbackHandler = callbackHandler; final LoginContext loginContext = login(loginContextName); subject = loginContext.getSubject(); isKrbTicket = !subject.getPrivateCredentials(KerberosTicket.class).isEmpty(); AppConfigurationEntry entries[] = Configuration.getConfiguration().getAppConfigurationEntry(loginContextName); for (AppConfigurationEntry entry: entries) { if (entry.getOptions().get("useTicketCache") != null) { String val = (String)entry.getOptions().get("useTicketCache"); if (val.equals("true")) { isUsingTicketCache = true; } break; } } if (isKrbTicket && isUsingTicketCache) { // Refresh the Ticket Granting Ticket (TGT) cache periodically. How often to refresh is determined by the // TGT's existing expiry 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. t = new Thread(new Runnable() { public void run() { LOG.info("TGT refresh thread started."); while (true) { // renewal thread's main loop. if it exits from here, thread will exit. KerberosTicket tgt = getTGT(); long now = System.currentTimeMillis(); long nextRefresh; Date nextRefreshDate; if (tgt == null) { nextRefresh = now + MIN_TIME_BEFORE_RELOGIN; nextRefreshDate = new Date(nextRefresh); LOG.warn("No TGT found: will try again at " + nextRefreshDate); } else { // determine how long to sleep from looking at ticket's expiry. // We must 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, except when // unless it would cause ticket expiration. nextRefresh = getRefreshTime(tgt); long expiry = tgt.getEndTime().getTime(); if ((nextRefresh > expiry) || ((now + MIN_TIME_BEFORE_RELOGIN) > expiry)) { // expiry is before next scheduled refresh). LOG.info("refreshing now because expiry is before next scheduled refresh time."); nextRefresh = now; } else { if (nextRefresh < (now + MIN_TIME_BEFORE_RELOGIN)) { // next scheduled refresh is sooner than (now + MIN_TIME_BEFORE_LOGIN). Date until = new Date(nextRefresh); Date newuntil = new Date(now + MIN_TIME_BEFORE_RELOGIN); LOG.warn("TGT refresh thread time adjusted from : " + until + " to : " + newuntil + " since " + "the former is sooner than the minimum refresh interval (" + MIN_TIME_BEFORE_RELOGIN / 1000 + " seconds) from now."); } nextRefresh = Math.max(nextRefresh, now + MIN_TIME_BEFORE_RELOGIN); } nextRefreshDate = new Date(nextRefresh); if (nextRefresh > expiry) { Date expiryDate = new Date(expiry); LOG.error("next refresh: " + nextRefreshDate + " is later than expiry " + expiryDate + ". This may indicated a clock skew problem. Check that this host and the KDC's " + "hosts' clocks are in sync."); return; } } if (now < nextRefresh) { Date until = new Date(nextRefresh); LOG.info("TGT refresh thread sleeping until: " + until.toString()); try { Thread.sleep(nextRefresh - now); } catch (InterruptedException ie) { LOG.warn("TGT renewal thread has been interrupted and will exit."); break; } } else { LOG.error("nextRefresh:" + nextRefreshDate + " is in the past: exiting refresh thread. Check" + " clock sync between this host and KDC - (KDC's clock is likely ahead of this host)." + " Manual intervention will be required for this client to successfully authenticate."); // TODO: if we have a keytab, we can use that to re-initialize and avoid the need for // manual intervention. return; } String cmd = "/usr/bin/kinit"; if (System.getProperty("zookeeper.kinit") != null) { cmd = System.getProperty("zookeeper.kinit"); } String kinitArgs = "-R"; try { Shell.execCommand(cmd,kinitArgs); } catch (Shell.ExitCodeException e) { LOG.error("Could not renew TGT due to problem running shell command: '" + cmd + " " + kinitArgs + "'" + "; exception was:" + e + ". Will try shell command again at: " + nextRefreshDate); } catch (IOException e) { LOG.error("Could not renew TGT due to problem running shell command: '" + cmd + " " + kinitArgs + "'; exception was:" + e + ". Will try shell command again at: " + nextRefreshDate); } try { reloginFromTicketCache(loginContextName, loginContext); LOG.debug("renewed TGT successfully."); } catch (LoginException e) { LOG.error("Could not renew TGT due to LoginException: " + e + "." + " Will try again at: " + nextRefreshDate); } } } }); t.setDaemon(true); } else { LOG.error("Not using Ticket Granting Ticket cache: will not start a TGT renewal thread."); } } public void startThreadIfNeeded() { // thread object 't' will be null if a refresh thread is not needed. if (t != null) { t.start(); } } private synchronized LoginContext login(final String loginContextName) throws LoginException { if (loginContextName == null) { throw new LoginException("loginContext name (JAAS file section header) was null. " + "Please check your java.security.login.auth.config setting."); } LoginContext loginContext = new LoginContext(loginContextName,callbackHandler); loginContext.login(); LOG.info("successfully logged in."); return loginContext; } public Subject getSubject() { return subject; } // c.f. org.apache.hadoop.security.UserGroupInformation. private long getRefreshTime(KerberosTicket tgt) { long start = tgt.getStartTime().getTime(); long expires = tgt.getEndTime().getTime(); LOG.info("TGT valid starting at: " + tgt.getStartTime().toString()); LOG.info("TGT expires: " + tgt.getEndTime().toString()); long proposedRefresh = start + (long) ((expires - start) * TICKET_RENEW_WINDOW); if (proposedRefresh > expires) { // proposedRefresh is too far in the future: it's after ticket expires: simply return now. return System.currentTimeMillis(); } else { return proposedRefresh; } } private synchronized KerberosTicket getTGT() { Set<KerberosTicket> tickets = subject.getPrivateCredentials(KerberosTicket.class); for(KerberosTicket ticket: tickets) { KerberosPrincipal server = ticket.getServer(); if (server.getName().equals("krbtgt/" + server.getRealm() + "@" + server.getRealm())) { LOG.debug("Found tgt " + ticket + "."); return ticket; } } return null; } // TODO : refactor this with login() to maximize code-sharing. public synchronized void reloginFromTicketCache(final String loginContextName, LoginContext loginContext) throws LoginException { if (!(isKrbTicket && isUsingTicketCache)) { return; } if (loginContext == null) { throw new LoginException("login must be done first"); } String principalName = getPrincipalName(); try { LOG.info("Logging out " + principalName); //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. loginContext.logout(); //login and also update the subject field of this instance to //have the new credentials (pass it to the LoginContext constructor) if (loginContextName == null) { throw new LoginException("loginContext name (JAAS file section header) was null. " + "Please check your java.security.login.auth.config setting."); } if (subject == null) { throw new LoginException("login subject was null."); } LOG.info("Logging in " + principalName); loginContext.login(); if (principalName.equals("(no principal name)")) { // try again to get the principal name, in case the ticket cache was manually refreshed. principalName = getPrincipalName(); } LOG.info("Login successful for " + principalName); } catch (LoginException le) { throw new LoginException("Login failure for " + principalName); } } private String getPrincipalName() { try { return getSubject().getPrincipals(KerberosPrincipal.class).toArray()[0].toString(); } catch (NullPointerException e) { LOG.warn("could not display principal name because login was null or login's subject was null: returning '(no principal found)'."); } catch (ArrayIndexOutOfBoundsException e) { LOG.warn("could not display principal name because login's subject had no principals: returning '(no principal found)'."); } return "(no principal found)"; } public void shutdown() { if ((t != null) && (t.isAlive())) { t.interrupt(); try { t.join(); } catch (InterruptedException e) { LOG.error("error while waiting for Login thread to shutdown: " + e); } } } }