13/10/2021

[Java] Kerberos login and retrieve GSSCredentials from KerberosTicket

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");  
       }  
     }  
   }  
 }  

 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