A scenario that popped up recently was to login a user via Java code to Kerberos and retrieve a GSSCredential object containing the Kerberos ticket. I used Java 8, but this works since Java 7 onwards.
Java offers a Krb5LoginModule class which can be used in conjuction with a LoginContext to achieve this.
The flow is quite simple (once you have read all the Kerberos documentation):
- on the machine where the code runs, place a correct krb5.conf file in the default location for your OS (Windows uses krb5.ini) OR set the java.security.krb5.conf system property pointing to the file
- define a PasswordCallback handler class
- create a LoginContext with a configuration using Krb5LoginModule and provide the password callback handler. The configuration must force the login to request a user input, which will then be routed to the callback handler. It is possible to use a keytab or cache credentials, but it's not shown here
- login the user and get its KerberosTicket
- create a GSSCredentials object using the ticket
This procedure allows handling multiple login mechanisms in the application and even multiple Kerberos realms.
The code:
- Sample class handling the login and credentials generation:
import com.sun.security.auth.module.Krb5LoginModule;
import java.security.PrivilegedExceptionAction;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
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.Configuration;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import org.ietf.jgss.GSSCredential;
import org.ietf.jgss.GSSException;
import org.ietf.jgss.GSSManager;
import org.ietf.jgss.GSSName;
import org.ietf.jgss.Oid;
public class Sample {
private static final String KRB5_OID_STR = "1.2.840.113554.1.2.2";
public static final Oid KRB5_OID;
private static final String KRB5_NAME_OID_STR = "1.2.840.113554.1.2.2.1";
public static final Oid KRB5_NAME_OID;
static {
try {
KRB5_OID = new Oid(KRB5_OID_STR);
KRB5_NAME_OID = new Oid(KRB5_NAME_OID_STR);
} catch (GSSException e) {
throw new RuntimeException(e);
}
}
/**
* Attempts a login for the given user via Krb5LoginModule using the given password. If successful, returns
* the GSSCredentials for the user. It uses the krb5.conf file which must be present on the host
* or set using the java.security.krb5.conf property.
* @param techUser the username to login.
* @param techUserPwd the password for the given username, in plaintext.
* @param debug set to true if we should activate the Kerberos debug config.
* @return the GSSCredentials for the user if login was successful, null otherwise.
* @throws LoginException if something goes wrong during the login process.
*/
public GSSCredential getTechUserGSSCredentials(String techUser, String techUserPwd, boolean debug) throws LoginException {
Subject loginSubject;
LoginContext lc;
try {
final Configuration config = createLoginContextConfig(debug);
lc = new LoginContext("", null, new PasswordAuthCallbackHandler(techUser, techUserPwd), config);
lc.login(); //logs in but does NOT get the subject
loginSubject = lc.getSubject();
} catch (LoginException e) {
//logger.error("Failed to login user " + techUser, e);
throw e;
}
KerberosTicket ticket = null;
//should only be one ticket per user, get the one with latest expiration date in case of multiple ones
for (KerberosTicket t : loginSubject.getPrivateCredentials(KerberosTicket.class)) {
if (ticket == null) {
ticket = t;
} else {
if (t.getEndTime().compareTo(ticket.getEndTime()) > 0) {
ticket = t;
}
}
}
//this will return null if something goes wrong
//depending on your needs, could just let it raise the exception instead
GSSCredential gssCredential = kerberosTicketToGSSCredential(ticket, GSSCredential.DEFAULT_LIFETIME, GSSCredential.INITIATE_ONLY);
//logger.debug("Tech user gssCredential: {}", gssCredential);
return gssCredential;
}
/**
* Create a config for Krb5LoginModule (https://docs.oracle.com/javase/8/docs/jre/api/security/jaas/spec/com/sun/security/auth/module/Krb5LoginModule.html)
* that expects a PasswordCallback to handle the login attempt:
* doNotPrompt = false
* tryFirstPass = true
* @param debug if true, activates the Kerberos debug
* @return a configuration for Krb5LoginModule that expects a PasswordCallback to handle the login attempt.
*/
private Configuration createLoginContextConfig(boolean debug) {
return new Configuration() {
@Override
public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
final Map<String, String> options = new HashMap<>();
options.put("doNotPrompt", "false");
options.put("tryFirstPass", "true");
if(debug) {
options.put("debug", "true");
}
final AppConfigurationEntry entry = new AppConfigurationEntry(Krb5LoginModule.class.getName(), AppConfigurationEntry.LoginModuleControlFlag.OPTIONAL, options);
return new AppConfigurationEntry[]{entry};
}
};
}
/**
* From https://github.com/keycloak/keycloak/blob/master/common/src/main/java/org/keycloak/common/util/KerberosJdkProvider.java
* create the GSSCredentials from the given Kerberos ticket.
* @param kerberosTicket the user's Kerberos ticket.
* @param lifetime the lifetime for these credentials.
* @param usage the usage for these credentials.
* @return the GSSCredentials built from the given ticket, or null if an error occurred.
*/
private GSSCredential kerberosTicketToGSSCredential(KerberosTicket kerberosTicket, final int lifetime, final int usage) {
try {
final GSSManager gssManager = GSSManager.getInstance();
KerberosPrincipal kerberosPrincipal = kerberosTicket.getClient();
String krbPrincipalName = kerberosTicket.getClient().getName();
final GSSName gssName = gssManager.createName(krbPrincipalName, KRB5_NAME_OID);
Set<KerberosPrincipal> principals = Collections.singleton(kerberosPrincipal);
Set<GSSName> publicCreds = Collections.singleton(gssName);
Set<KerberosTicket> privateCreds = Collections.singleton(kerberosTicket);
Subject subject = new Subject(false, principals, publicCreds, privateCreds);
return Subject.doAs(subject, (PrivilegedExceptionAction<GSSCredential>) () -> gssManager.createCredential(gssName, lifetime, KRB5_OID, usage));
} catch (Exception e) {
//logger.error("Failed to retrieve GSSCredential from KerberosTicket", e);
}
return null;
}
}
- PasswordCallbackHandler class:
import java.util.Arrays;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
public class PasswordAuthCallbackHandler implements CallbackHandler {
private String user;
private String pwd;
public PasswordAuthCallbackHandler(String user, String pwd){
this.user = user;
this.pwd = pwd;
}
public void handle(Callback[] callbacks) throws UnsupportedCallbackException {
for (int i = 0; i < callbacks.length; i++) {
if (callbacks[i] instanceof NameCallback) {
NameCallback nc = (NameCallback)callbacks[i];
nc.setName(this.user);
} else if (callbacks[i] instanceof PasswordCallback) {
PasswordCallback pc = (PasswordCallback)callbacks[i];
char[] passwordChars = this.pwd.toCharArray();
pc.setPassword(passwordChars);
//overwrite pwd in memory
Arrays.fill(passwordChars, '*');
} else{
throw new UnsupportedCallbackException(callbacks[i], "Unrecognised callback");
}
}
}
}
- Testing this can be done via JUnit using testcontainers and a docker image with a KDC:
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import javax.security.auth.login.LoginException;
import org.apache.commons.io.IOUtils;
import org.ietf.jgss.GSSCredential;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.testcontainers.containers.Container;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
/**
* Tests ONLY the Kerberos login logic and retrieval of GSSCredentials from a KerberosTicket.
*/
public class KerberosLoginTest {
private static final String validUser = "simple";
private static final String validUserPwd = "12345";
private static final String domain = "EXAMPLE.COM";
private static final String kerberosAdminPwd = "mypass";
private static final String invalidUser = "asd";
private static final String invalidPwd = "lol";
//expected exception messages
private static final String KERBEROS_CLIENT_NOT_FOUND = "Client not found in Kerberos database (6) - CLIENT_NOT_FOUND";
private static final String KERBEROS_WRONG_PWD = "Pre-authentication information was invalid (24) - PREAUTH_FAILED";
//we use the krb5.conf file for our connection, but must set the correct KDC port number, so we clone it and set the port
private static final String krb5confFileOrig = "krb5.conf.orig";
private static final String krb5confFile = "krb5.conf";
//https://hub.docker.com/r/gcavalcante8808/krb5-server/
private static final DockerImageName KERBEROS_IMAGE = DockerImageName.parse("gcavalcante8808/krb5-server");
private static GenericContainer<?> kerberosContainer;
private static Sample sample;
@ClassRule
public static TemporaryFolder testFolder = new TemporaryFolder();
@Rule
public ExpectedException exception = ExpectedException.none();
@BeforeClass
public static void setup() throws InterruptedException, IOException {
kerberosContainer = new GenericContainer<>(KERBEROS_IMAGE)
.withExposedPorts(88, 464, 749)
.withEnv("KRB5_REALM", domain)
.withEnv("KRB5_KDC", "localhost")
.withEnv("KRB5_PASS", kerberosAdminPwd);
kerberosContainer.start();
int kdcPort = kerberosContainer.getMappedPort(88);
//add "simple" user with "12345" password
Container.ExecResult result = kerberosContainer.execInContainer("sh", "-c", "kadmin -p admin/admin@" + domain + " -w " + kerberosAdminPwd + " -q \"ank -pw " + validUserPwd + " " + validUser + "\"");
assertEquals("user was created", 0, result.getExitCode());
//clone the original config file and set the KDC port, then set it as java property
//assumes you placed under your test/resources folder the template file
String content;
Charset charset = StandardCharsets.UTF_8;
try (InputStream stream = KerberosLoginTest.class.getClassLoader().getResourceAsStream(krb5confFileOrig)) {
content = IOUtils.toString(stream, charset);
content = content.replaceAll("#PORT_PLACEHOLDER#", String.valueOf(kdcPort));
}
testFolder.newFile(krb5confFile);
String confFilePath = testFolder.getRoot() + File.separator + krb5confFile;
Files.write(Paths.get(confFilePath), content.getBytes(charset));
//udp_preference_limit=1 set to force TCP, otherwise we get ICMP error..
System.setProperty("java.security.krb5.conf", confFilePath);
sample = new Sample();
}
@AfterClass
public static void cleanup() {
//testcontainers + ryuk container should stop and kill all containers at end of testing anyway, but safety first
if (kerberosContainer != null) {
kerberosContainer.stop();
}
}
@Test
public void testSuccessLogin() {
try {
GSSCredential gssCredential = sample.getTechUserGSSCredentials(validUser, validUserPwd, true);
assertEquals("principal is correct", validUser + "@" + domain, gssCredential.getName().toString());
} catch (Exception e) {
//gssCredential.getName() declares a thrown exception, which we would never get in this test
//however we must write code to handle that, so if we ever get here just fail the test
fail("Error getting principal name from gssCredential: " + e);
}
}
@Test
public void testLoginWrongPwd() throws LoginException {
exception.expect(LoginException.class);
exception.expectMessage(KERBEROS_WRONG_PWD);
sample.getTechUserGSSCredentials(validUser, invalidPwd, true);
}
@Test
public void testLoginUnknownUser() throws LoginException {
exception.expect(LoginException.class);
exception.expectMessage(KERBEROS_CLIENT_NOT_FOUND);
sample.getTechUserGSSCredentials(invalidUser, invalidPwd, true);
}
//note: if user is null, the current user is attempted to login instead, it should still fail in our case
@Test
public void testLoginUserNull() throws LoginException {
exception.expect(LoginException.class);
exception.expectMessage(KERBEROS_CLIENT_NOT_FOUND);
sample.getTechUserGSSCredentials(null, invalidPwd, true);
}
@Test
public void testLoginPwdNull() throws LoginException {
exception.expect(LoginException.class);
//the NPE thrown in the callback class is caught by the login code and results in a LoginException
exception.expectMessage("java.lang.NullPointerException");
sample.getTechUserGSSCredentials(validUser, null, true);
}
}
- And finally a simple test for the PasswordCallbackHandler class:
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.LanguageCallback;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class PasswordAuthCallbackHandlerTests {
private static final String username = "simple";
private static final String pwd = "12345";
@Test
public void testNameCallback() throws UnsupportedCallbackException {
PasswordAuthCallbackHandler handler = new PasswordAuthCallbackHandler(username, pwd);
NameCallback nc = new NameCallback("username");
Callback[] callbacks = new Callback[]{nc};
handler.handle(callbacks);
assertEquals("name is correct", username, nc.getName());
}
@Test
public void testPwdCallback() throws UnsupportedCallbackException {
PasswordAuthCallbackHandler handler = new PasswordAuthCallbackHandler(username, pwd);
PasswordCallback pc = new PasswordCallback("password", false);
Callback[] callbacks = new Callback[]{pc};
handler.handle(callbacks);
assertEquals("password is correct", pwd, String.valueOf(pc.getPassword()));
}
@Test(expected = UnsupportedCallbackException.class)
public void testUnsupportedCallback() throws UnsupportedCallbackException {
PasswordAuthCallbackHandler handler = new PasswordAuthCallbackHandler(username, pwd);
LanguageCallback lc = new LanguageCallback();
Callback[] callbacks = new Callback[]{lc};
handler.handle(callbacks);
}
}
No comments:
Post a Comment
With great power comes great responsibility