KerberosTicketRenewalTest.java
/*
* 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;
import static org.apache.zookeeper.server.quorum.auth.MiniKdc.MAX_TICKET_LIFETIME;
import static org.apache.zookeeper.server.quorum.auth.MiniKdc.MIN_TICKET_LIFETIME;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.security.Principal;
import java.time.Duration;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import javax.security.auth.login.Configuration;
import javax.security.auth.login.LoginException;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.zookeeper.common.ZKConfig;
import org.apache.zookeeper.server.quorum.auth.KerberosTestUtils;
import org.apache.zookeeper.server.quorum.auth.MiniKdc;
import org.apache.zookeeper.test.ClientBase;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This test class is mainly testing the TGT renewal logic implemented
* in the org.apache.zookeeper.Login class.
*/
public class KerberosTicketRenewalTest {
private static final Logger LOG = LoggerFactory.getLogger(KerberosTicketRenewalTest.class);
private static final String JAAS_CONFIG_SECTION = "ClientUsingKerberos";
private static final String TICKET_LIFETIME = "5000";
private static File testTempDir;
private static MiniKdc kdc;
private static File kdcWorkDir;
private static String PRINCIPAL = KerberosTestUtils.getClientPrincipal();
TestableKerberosLogin login;
@BeforeAll
public static void setupClass() throws Exception {
// by default, we should wait at least 1 minute between subsequent TGT renewals.
// changing it to 500ms.
System.setProperty(Login.MIN_TIME_BEFORE_RELOGIN_CONFIG_KEY, "500");
testTempDir = ClientBase.createTmpDir();
startMiniKdcAndAddPrincipal();
String keytabFilePath = FilenameUtils.normalize(KerberosTestUtils.getKeytabFile(), true);
// note: we use "refreshKrb5Config=true" to refresh the kerberos config in the JVM,
// making sure that we use the latest config even if other tests already have been executed
// and initialized the kerberos client configs before)
String jaasEntries = ""
+ "ClientUsingKerberos {\n"
+ " com.sun.security.auth.module.Krb5LoginModule required\n"
+ " storeKey=\"false\"\n"
+ " useTicketCache=\"false\"\n"
+ " useKeyTab=\"true\"\n"
+ " doNotPrompt=\"true\"\n"
+ " debug=\"true\"\n"
+ " refreshKrb5Config=\"true\"\n"
+ " keyTab=\"" + keytabFilePath + "\"\n"
+ " principal=\"" + PRINCIPAL + "\";\n"
+ "};\n";
setupJaasConfig(jaasEntries);
}
@AfterAll
public static void tearDownClass() {
System.clearProperty(Login.MIN_TIME_BEFORE_RELOGIN_CONFIG_KEY);
System.clearProperty("java.security.auth.login.config");
stopMiniKdc();
if (testTempDir != null) {
// the testTempDir contains the jaas config file and also the
// working folder of the currently running KDC server
FileUtils.deleteQuietly(testTempDir);
}
}
@AfterEach
public void tearDownTest() throws Exception {
if (login != null) {
login.shutdown();
login.logout();
}
}
/**
* We extend the regular Login class to be able to properly control the
* "sleeping" between the retry attempts of ticket refresh actions.
*/
private static class TestableKerberosLogin extends Login {
private AtomicBoolean refreshFailed = new AtomicBoolean(false);
private CountDownLatch continueRefreshThread = new CountDownLatch(1);
public TestableKerberosLogin() throws LoginException {
super(JAAS_CONFIG_SECTION, () -> {
return (callbacks) -> {};
}, new ZKConfig());
}
@Override
protected void sleepBeforeRetryFailedRefresh() throws InterruptedException {
LOG.info("sleep started due to failed refresh");
refreshFailed.set(true);
continueRefreshThread.await(20, TimeUnit.SECONDS);
LOG.info("sleep due to failed refresh finished");
}
public void assertRefreshFailsEventually(Duration timeout) {
assertEventually(timeout, () -> refreshFailed.get());
}
public void continueWithRetryAfterFailedRefresh() {
LOG.info("continue refresh thread");
continueRefreshThread.countDown();
}
}
@Test
public void shouldLoginUsingKerberos() throws Exception {
login = new TestableKerberosLogin();
login.startThreadIfNeeded();
assertPrincipalLoggedIn();
}
@Test
public void shouldRenewTicketUsingKerberos() throws Exception {
login = new TestableKerberosLogin();
login.startThreadIfNeeded();
long initialLoginTime = login.getLastLogin();
// ticket lifetime is 5sec, so we will trigger ticket renewal in each ~2-3 sec
assertTicketRefreshHappenedUntil(Duration.ofSeconds(15));
assertPrincipalLoggedIn();
assertTrue(initialLoginTime < login.getLastLogin());
}
@Test
public void shouldRecoverIfKerberosNotAvailableForSomeTime() throws Exception {
login = new TestableKerberosLogin();
login.startThreadIfNeeded();
assertTicketRefreshHappenedUntil(Duration.ofSeconds(15));
stopMiniKdc();
// ticket lifetime is 5sec, so we will trigger ticket renewal in each ~2-3 sec
// the very next ticket renewal should fail (as KDC is offline)
login.assertRefreshFailsEventually(Duration.ofSeconds(15));
// now the ticket thread is "sleeping", it will retry the refresh later
// we restart KDC, then terminate the "sleeping" and expecting
// that the next retry should succeed
startMiniKdcAndAddPrincipal();
login.continueWithRetryAfterFailedRefresh();
assertTicketRefreshHappenedUntil(Duration.ofSeconds(15));
assertPrincipalLoggedIn();
}
private void assertPrincipalLoggedIn() {
assertEquals(PRINCIPAL, login.getUserName());
assertNotNull(login.getSubject());
assertEquals(1, login.getSubject().getPrincipals().size());
Principal actualPrincipal = login.getSubject().getPrincipals().iterator().next();
assertEquals(PRINCIPAL, actualPrincipal.getName());
}
private void assertTicketRefreshHappenedUntil(Duration timeout) {
long lastLoginTime = login.getLastLogin();
assertEventually(timeout, () -> login.getLastLogin() != lastLoginTime
&& login.getSubject() != null && !login.getSubject().getPrincipals().isEmpty());
}
private static void assertEventually(Duration timeout, Supplier<Boolean> test) {
assertTimeout(timeout, () -> {
while (true) {
if (test.get()) {
return;
}
Thread.sleep(100);
}
});
}
public static void startMiniKdcAndAddPrincipal() throws Exception {
kdcWorkDir = createTmpDirInside(testTempDir);
Properties conf = MiniKdc.createConf();
conf.setProperty(MAX_TICKET_LIFETIME, TICKET_LIFETIME);
conf.setProperty(MIN_TICKET_LIFETIME, TICKET_LIFETIME);
kdc = new MiniKdc(conf, kdcWorkDir);
kdc.start();
String principalName = PRINCIPAL.substring(0, PRINCIPAL.lastIndexOf("@"));
kdc.createPrincipal(new File(KerberosTestUtils.getKeytabFile()), principalName);
}
private static void stopMiniKdc() {
if (kdc != null) {
kdc.stop();
kdc = null;
}
if (kdcWorkDir != null) {
FileUtils.deleteQuietly(kdcWorkDir);
kdcWorkDir = null;
}
}
private static File createTmpDirInside(File parentDir) throws IOException {
File tmpFile = File.createTempFile("test", ".junit", parentDir);
// don't delete tmpFile - this ensures we don't attempt to create
// a tmpDir with a duplicate name
File tmpDir = new File(tmpFile + ".dir");
// never true if tmpfile does it's job
assertFalse(tmpDir.exists());
assertTrue(tmpDir.mkdirs());
return tmpDir;
}
private static void setupJaasConfig(String jaasEntries) {
try {
File saslConfFile = new File(testTempDir, "jaas.conf");
FileWriter fwriter = new FileWriter(saslConfFile);
fwriter.write(jaasEntries);
fwriter.close();
System.setProperty("java.security.auth.login.config", saslConfFile.getAbsolutePath());
} catch (IOException ioe) {
LOG.error("Failed to initialize JAAS conf file", ioe);
}
// refresh the SASL configuration in this JVM (making sure that we use the latest config
// even if other tests already have been executed and initialized the SASL configs before)
Configuration.getConfiguration().refresh();
}
}