/** * Copyright 2015 StreamSets Inc. * * Licensed under 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 com.streamsets.datacollector.security; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.streamsets.datacollector.main.RuntimeInfo; import com.streamsets.datacollector.util.Configuration; import com.streamsets.pipeline.api.impl.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.security.auth.Subject; import javax.security.auth.kerberos.KerberosPrincipal; import javax.security.auth.kerberos.KerberosTicket; import javax.security.auth.login.AppConfigurationEntry; import javax.security.auth.login.LoginContext; import javax.security.auth.login.LoginException; import java.io.File; import java.security.Principal; import java.util.ArrayList; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.TimeUnit; /** * The <code>SecurityContext</code> allows to run a JVM within a client Kerberos session that is propagated to * all threads created within a <code>Subject.doAs()</code> invocation using the Subject from the * <code></code> */ public class SecurityContext { private static final Logger LOG = LoggerFactory.getLogger(SecurityContext.class); private static final long THIRTY_SECONDS_MS = TimeUnit.SECONDS.toMillis(30); private final SecurityConfiguration securityConfiguration; private LoginContext loginContext; private volatile Subject subject; private Thread renewalThread; private double renewalWindow; public SecurityContext(RuntimeInfo runtimeInfo, Configuration serviceConf) { this.securityConfiguration = new SecurityConfiguration(runtimeInfo, serviceConf); renewalWindow = computeRenewalWindow(); } @VisibleForTesting double computeRenewalWindow() { return (50D + new Random().nextInt(20)) / 100; } @VisibleForTesting double getRenewalWindow() { return renewalWindow; } @VisibleForTesting long getRenewalTime(long start, long end) { return start + (long) (getRenewalWindow() * (end - start)); } public SecurityConfiguration getSecurityConfiguration() { return securityConfiguration; } @VisibleForTesting long getTimeNow() { return System.currentTimeMillis(); } /** * Get the Kerberos TGT, it purges old expired tickets from Subject * @return the user's TGT or null if none was found */ @VisibleForTesting synchronized KerberosTicket getKerberosTicket() { KerberosTicket found = null; Set<KerberosTicket> expiredTickets = new HashSet<>(); SortedSet<KerberosTicket> tickets = new TreeSet<>(new Comparator<KerberosTicket>() { @Override public int compare(KerberosTicket ticket1, KerberosTicket ticket2) { return Long.compare(ticket1.getEndTime().getTime(), ticket2.getEndTime().getTime()); } }); for (KerberosTicket ticket : getSubject().getPrivateCredentials(KerberosTicket.class)) { KerberosPrincipal principal = ticket.getServer(); String principalName = Utils.format("krbtgt/{}@{}", principal.getRealm(), principal.getRealm()); if (principalName.equals(principal.getName())) { if (ticket.getEndTime().getTime() < getTimeNow()) { // the ticket in question expired, we should remove it from the subject as it is useless expiredTickets.add(ticket); LOG.debug("Found expired Kerberos ticket '{}', will remove it", ticket.getServer().getName()); } tickets.add(ticket); } } if (!tickets.isEmpty()) { // lets get the most recent ticket found = tickets.last(); // take out the last ticket from expired tickets as we don' want to purge it as we want to renew that one // this should not really happen as we renew before the expire if (expiredTickets.contains(found)) { LOG.warn("Last Kerberos ticket '{}' already expired", found.getServer().getName()); found = null; } } if (!expiredTickets.isEmpty()) { // removing expired tickets from subject getSubject().getPrivateCredentials().removeAll(expiredTickets); LOG.debug("Removed '{}' expired Kerberos tickets from SDC subject", expiredTickets.size()); } return found; } private synchronized long calculateRenewalTime(KerberosTicket kerberosTicket) { long start = kerberosTicket.getStartTime().getTime(); long end = kerberosTicket.getEndTime().getTime(); long renewTime = getRenewalTime(start, end); if (LOG.isDebugEnabled()) { LOG.trace( "Ticket: {}, numPrivateCredentials: {}, ticketStartTime: {}, ticketEndTime: {}, now: {}, renewalTime: {}", System.identityHashCode(kerberosTicket), getSubject().getPrivateCredentials(KerberosTicket.class).size(), new Date(start), new Date(end), new Date(), new Date(renewTime) ); } return Math.max(1, renewTime - System.currentTimeMillis()); } private synchronized void relogin() { LOG.info("Attempting re-login"); // do not logout old context, it may be in use try { loginContext = createLoginContext(); } catch (Exception ex) { throw new RuntimeException(Utils.format("Could not get Kerberos credentials: {}", ex.toString()), ex); } } @VisibleForTesting boolean sleep(long millis) { try { Thread.sleep(millis); return true; } catch (InterruptedException ex) { return false; } } /** * Logs in. If Kerberos is enabled it logs in against the KDC, otherwise is a NOP. */ public synchronized void login() { if (subject != null) { throw new IllegalStateException(Utils.format("Service already login, Principal '{}'", subject.getPrincipals())); } if (securityConfiguration.isKerberosEnabled()) { try { loginContext = createLoginContext(); subject = loginContext.getSubject(); } catch (Exception ex) { throw new RuntimeException(Utils.format("Could not get Kerberos credentials: {}", ex.toString()), ex); } if (renewalThread == null) { renewalThread = new Thread() { @Override public void run() { LOG.debug("Starting renewal thread"); if (!SecurityContext.this.sleep(THIRTY_SECONDS_MS)) { LOG.info("Interrupted, exiting renewal thread"); return; } while (true) { LOG.trace("Renewal check starts"); try { KerberosTicket kerberosTicket = getKerberosTicket(); if (kerberosTicket == null) { LOG.warn( "Could not obtain kerberos ticket, it may have expired already or it was logged out, will wait" + "30 secs to attempt a relogin" ); LOG.trace("Ticket not found, sleeping 30 secs and trying to login"); if (!SecurityContext.this.sleep(THIRTY_SECONDS_MS)) { LOG.info("Interrupted, exiting renewal thread"); return; } } else { long renewalTimeMs = calculateRenewalTime(kerberosTicket) - THIRTY_SECONDS_MS; LOG.trace("Ticket found time to renewal '{}ms', sleeping that time", renewalTimeMs); if (renewalTimeMs > 0) { if (!SecurityContext.this.sleep(renewalTimeMs)) { LOG.info("Interrupted, exiting renewal thread"); return; } } } LOG.debug("Triggering relogin"); relogin(); } catch (Exception exception) { LOG.error("Stopping renewal thread because of exception: " + exception, exception); return; } catch (Throwable throwable) { LOG.error("Error in renewal thread: " + throwable, throwable); return; } } } }; List<String> principals = new ArrayList<>(); for (Principal p : subject.getPrincipals()) { principals.add(p.getName()); } renewalThread.setName("Kerberos-Renewal-Thread-" + Joiner.on(",").join(principals)); renewalThread.setContextClassLoader(Thread.currentThread().getContextClassLoader()); renewalThread.setDaemon(true); renewalThread.start(); } } else { subject = new Subject(); } LOG.debug("Login. Kerberos enabled '{}', Principal '{}'", securityConfiguration.isKerberosEnabled(), subject.getPrincipals()); } /** * Logs out. If Keberos is enabled it logs out from the KDC, otherwise is a NOP. */ public synchronized void logout() { if (subject != null) { LOG.debug("Logout. Kerberos enabled '{}', Principal '{}'", securityConfiguration.isKerberosEnabled(), subject.getPrincipals()); if (loginContext != null) { try { loginContext.logout(); } catch (LoginException ex) { LOG.warn("Error while doing logout from Kerberos: {}", ex.toString(), ex); } finally { loginContext = null; } } subject = null; } } /** * Returns the current <code>Subject</code> after a login. * <p/> * If Kerberos is enabled it returns a <code>Subject</code> with Kerberos credentials. * <p/> * If Kerberos is not enabled it returns a default <code>Subject</code>. * * @return the login <code>Subject</code>, or <code>null</code> if not logged in. */ public synchronized Subject getSubject() { return subject; } private LoginContext createLoginContext() throws Exception { String principalName = securityConfiguration.getKerberosPrincipal(); if (subject == null) { Set<Principal> principals = new HashSet<>(); principals.add(new KerberosPrincipal(principalName)); subject = new Subject(false, principals, new HashSet<>(), new HashSet<>()); } LoginContext context = new LoginContext( "", subject, null, new KeytabKerberosConfiguration( principalName, new File(securityConfiguration.getKerberosKeytab()), true ) ); context.login(); LOG.info("Login, principal '{}'", principalName); return context; } private static String getJvmKrb5LoginModuleName() { return System.getProperty("java.vendor").contains("IBM") ? "com.ibm.security.auth.module.Krb5LoginModule" : "com.sun.security.auth.module.Krb5LoginModule"; } private static class KeytabKerberosConfiguration extends javax.security.auth.login.Configuration { private String principal; private String keytab; private boolean isInitiator; public KeytabKerberosConfiguration(String principal, File keytab, boolean client) { this.principal = principal; this.keytab = keytab.getAbsolutePath(); this.isInitiator = client; } @Override public AppConfigurationEntry[] getAppConfigurationEntry(String name) { Map<String, String> options = new HashMap<>(); options.put("keyTab", keytab); options.put("principal", principal); options.put("useKeyTab", "true"); options.put("storeKey", "true"); options.put("doNotPrompt", "true"); options.put("refreshKrb5Config", "true"); options.put("isInitiator", Boolean.toString(isInitiator)); options.put("debug", System.getProperty("sun.security.krb5.debug", "true")); // Do not store/use ticket in/from cache. SecurityContext does not renew it by doing something like "kinit -R" // Instead a new ticket is requested during the renewal. // Credentials are explicitly saved into credential cache ${SDC_DATA}/sdc-krb5.ticketCache for services like // Kafka to pick up. return new AppConfigurationEntry[]{ new AppConfigurationEntry(getJvmKrb5LoginModuleName(), AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options)}; } } }