Kerberos Single-Sign-On auf Tomcat und Websphere mit Active Directory

2 Kommentare

Wir hatten bei einem Kunden die Notwendigkeit, eine Webanwendung per Single-Sign-On, im weiteren Verlauf als SSO bezeichnet, abzusichern. Dabei werden die Daten für die Authentifizierung und Autorisierung benutzt, die der Benutzer schon bei der Anmeldung in der Windows-Domäne eingegeben hat. Hierbei kommuniziert der Webbrowser über einen speziellen Mechanismus (Kerberos) mit der Webanwendung und dem Active Directory, welches hier als Authentication-Service und Ticket Granting Service fungiert. Eine einfache Erläuterung des Mechanismus kann man hier erhalten.

Gibt’s da nicht was von Spring?

Nun hatten wir beim Kunden zudem die Anforderung, dass das SSO der Webanwendung sowohl im altbewährten Websphere Application Server als auch in einem Tomcat im Docker-Container funktionieren soll. Insbesondere hierin bestand die Herausforderung. Da es sich bei der Webanwendung im weitesten Sinne um eine Spring-Anwendung handelt, haben wir uns für die Spring-Lösung mit Spring-Security und Spring-Security-Kerberos entschieden. Dieses ließ sich leicht integrieren und durch die Nutzung von Profilen für Websphere und Tomcat auch leicht konfigurieren.

Konfiguration

Zunächst muss ein WebApplicationInitializer im Klassenpfad platziert werden, der mittels Servlet-3.0-Api automatisch geladen wird und die Spring-Security springSecurityFilterChain mit einer Reihe von HTTP-Servlet-Filtern registriert. Der dafür vorgesehene WebApplicationInitializer muss die Spring-Klasse AbstractSecurityWebApplicationInitializer erweitern, kann ansonsten aber leer sein. Dieser WebApplicationInitializer registriert allerdings nur die Referenzen auf die Servlet-Filter-Chain, daher müssen des Weiteren noch weitere Spring-Beans erzeugt werden. Mithilfe von Java-Config kann das leicht erfolgen. Hierzu wird im ApplicationContext der Anwendung eine Konfiguration abgelegt, die die Spring-Klasse WebSecurityConfigurerAdapter erweitert und mit @EnableWebSecurity annotiert wird. In dieser Konfigurationsklasse werden nun auch die Spring-Beans erzeugt, die für den Kerberos-Mechanismus nötig sind. Hierzu zählen insbesondere KerberosTicketValidator, KerberosServiceAuthenticationProvider, SpnegoAuthenticationProcessingFilter und SpnegoEntryPoint.

Ein Beispiel, wie die Konfiguration aussehen könnte:

@EnableWebSecurity
public abstract class AbstractSecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    @Value("${security.ldap.server-url:ldap://ads.codecentric.de:389}")
    private String serverUrl;
 
    @Value("${security.ldap.domain:CODECENTRIC.DE}")
    private String domain;
 
    @Value("${security.ldap.search-base:dc=codecentric,dc=de}")
    private String searchBase;
 
    @Value("${security.ldap.search-filter:(sAMAccountName={0})}")
    private String searchFilter;
 
    @Value("${security.ldap.connection-name:an_admin_user}")
    private String connectionName;
 
    @Value("${security.ldap.connection-password:secret_password}")
    private String connectionPassword;
 
    @Value("${security.ldap.referral:follow}")
    private String referral;
 
    @Value("${security.debug:false}")
    private boolean debug;
 
    public boolean isDebug() {
        return debug;
    }
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.exceptionHandling()//
            .authenticationEntryPoint(spnegoEntryPoint())//
            .and()//
            .authorizeRequests().antMatchers("/js/**", "/css/**").permitAll()//
            .antMatchers("/secured/*").authenticated()//
            .and()//
            .formLogin().loginPage("/login").failureUrl("/login?error=true").loginProcessingUrl("/j_security_check").permitAll()//
            .usernameParameter("j_username").passwordParameter("j_password")//
            .and()//
            .logout().permitAll()//
            .and()//
            .csrf().disable()//
            .addFilterBefore(spnegoAuthenticationProcessingFilter(authenticationManagerBean()),
                BasicAuthenticationFilter.class);
    }
 
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth //
            .authenticationProvider(activeDirectoryLdapAuthenticationProvider()) //
            .authenticationProvider(kerberosServiceAuthenticationProvider());
    }
 
    @Bean
    public ActiveDirectoryLdapAuthenticationProvider activeDirectoryLdapAuthenticationProvider() {
        return new ActiveDirectoryLdapAuthenticationProvider(domain, serverUrl);
    }
 
    @Bean
    public SpnegoEntryPoint spnegoEntryPoint() {
        return new SSOUrlSpnegoEntryPoint("/login");
    }
 
    @Bean
    public SpnegoAuthenticationProcessingFilter spnegoAuthenticationProcessingFilter(
            AuthenticationManager authenticationManager) {
        SpnegoAuthenticationProcessingFilter filter = new SpnegoAuthenticationProcessingFilter();
        filter.setAuthenticationManager(authenticationManager);
        return filter;
    }
 
    @Bean
    public KerberosServiceAuthenticationProvider kerberosServiceAuthenticationProvider() {
        KerberosServiceAuthenticationProvider provider = new KerberosServiceAuthenticationProvider();
        provider.setTicketValidator(kerberosTicketValidator());
        provider.setUserDetailsService(ldapUserDetailsService());
        return provider;
    }
 
    @Bean
    public LdapUserDetailsService ldapUserDetailsService() {
        FilterBasedLdapUserSearch userSearch = new FilterBasedLdapUserSearch("", searchFilter, contextSource());
        DefaultLdapAuthoritiesPopulator authoritiesPopulator = new DefaultLdapAuthoritiesPopulator(contextSource(), "");
        authoritiesPopulator.setSearchSubtree(true);
        LdapUserDetailsService service = new UsernameStrippingLdapUserDetailsService(userSearch, authoritiesPopulator);
        service.setUserDetailsMapper(new LdapUserDetailsMapper());
        return service;
    }
 
    @Bean
    public DefaultSpringSecurityContextSource contextSource() {
        DefaultSpringSecurityContextSource contextSource = new DefaultSpringSecurityContextSource(serverUrl);
        contextSource.setBase(searchBase);
        contextSource.setUserDn(connectionName);
        contextSource.setPassword(connectionPassword);
        contextSource.setReferral(referral);
        contextSource.afterPropertiesSet();
        return contextSource;
    }
 
    @Bean
    public GlobalSunJaasKerberosConfig globalSunJaasKerberosConfig() {
        GlobalSunJaasKerberosConfig config = new GlobalSunJaasKerberosConfig();
        config.setKrbConfLocation(getKrbConfLocation());
        config.setDebug(debug);
        return config;
    }
 
    protected abstract String getKrbConfLocation();
 
    protected abstract String getKeytabLocation();
 
    protected abstract String getServicePrincipal();
 
    protected abstract KerberosTicketValidator kerberosTicketValidator();
 
}

Wie man sehen kann, ist diese Konfiguration abstrakt und muss noch implementiert werden. Dies wurde nun profilabhängig für den Websphere und den Tomcat implementiert, um die unterschiedlichen Systeme abzubilden. Dazu nun also zwei Konfigurationen.

Websphere-Konfiguration

@Configuration
@Profile("websphere")
public class SecurityWebsphereConfiguration extends AbstractSecurityConfiguration {
 
    @Value("${security.kerberos.websphere.service-principal:HTTP/websphere.codecentric.de}")
    private String servicePrincipal;
 
    @Value("${security.kerberos.websphere.keytab-location:/etc/kerberos/kerberos.keytab}")
    private String keytabLocation;
 
    @Value("${security.kerberos.websphere.krb-conf-location:/etc/kerberos/krb5.ini}")
    private String krbConfLocation;
 
    @Override
    public String getServicePrincipal() {
        return servicePrincipal;
    }
 
    @Override
    public String getKeytabLocation() {
        return keytabLocation;
    }
 
    @Override
    public String getKrbConfLocation() {
        return krbConfLocation;
    }
 
    @Override
    @Bean
    public KerberosTicketValidator kerberosTicketValidator() {
        IbmJaasKerberosTicketValidator ticketValidator = new IbmJaasKerberosTicketValidator();
        ticketValidator.setServicePrincipal(getServicePrincipal());
        ticketValidator.setKeyTabLocation(new FileSystemResource(getKeytabLocation()));
        ticketValidator.setDebug(isDebug());
        return ticketValidator;
    }
 
}

Tomcat-Konfiguration

@Configuration
@Profile("tomcat")
public class SecurityDistributedConfiguration extends AbstractSecurityConfiguration {
 
    @Value("${security.kerberos.tomcat.service-principal:HTTP/tomcat.codecentric.de}")
    private String servicePrincipal;
 
    @Value("${security.kerberos.tomcat.keytab-location:/usr/local/tomcat/conf/kerberos.keytab}")
    private String keytabLocation;
 
    @Value("${security.kerberos.tomcat.krb-conf-location:/usr/local/tomcat/conf/krb5.ini}")
    private String krbConfLocation;
 
    @Override
    public String getServicePrincipal() {
        return servicePrincipal;
    }
 
    @Override
    public String getKeytabLocation() {
        return keytabLocation;
    }
 
    @Override
    public String getKrbConfLocation() {
        return krbConfLocation;
    }
 
    @Override
    @Bean
    public KerberosTicketValidator kerberosTicketValidator() {
        SunJaasKerberosTicketValidator ticketValidator = new SunJaasKerberosTicketValidator();
        ticketValidator.setServicePrincipal(getServicePrincipal());
        ticketValidator.setKeyTabLocation(new FileSystemResource(getKeytabLocation()));
        ticketValidator.setDebug(isDebug());
        return ticketValidator;
    }
 
}

Vorbereitung des Key Distribution Center (KDC)

In dem Beispiel wird die Rolle des KDC vom Active Directory ausgefüllt. Wie man sieht, wurden in der Konfiguration sogenannt „Service-Principals“ und weitere Dateien referenziert, auf die ich im folgenden eingehe.

1. Service-Principal

Der Service-Principal, kurz SPN, bezeichnet den Namen eines Dienstes im der Netzwerk-Domäne mit Kerberos-Authentifizierung. Dieser besteht aus der Dienstklasse, einem Hostnamen und ggf. einem Port. In dem Beispiel gibt es für jeden Server, der die Webanwendung bereitstellt, einen SPN der Dienstklasse HTTP.

  • HTTP/tomcat.codecentric.de
  • HTTP/websphere.codecentric.de

Der Hostnamen muss dem entsprechen, wie die Anwendung vom Webbrowser aufgerufen wird. In dem Beispiel könnte es also die URL https://tomcat.provinzial.com:8080/beispiel sein. Diese beiden SPNs müssen im KDC nun registriert und einem Benutzer zugeordnet werden. Hierzu werden in einer Windows-Konsole (der PC muss in der Domäne angemeldet sein) die folgenden Befehle ausgeführt:

setspn -A HTTP/tomcat.codecentric.de A_KERBEROS_USER

Mit diesem Befehl wird der SPN im erzeugt und dem Benutzer A_KERBEROS_USER zugeordnet.

2. kerberos.keytab

Diese Datei ist der Schlüssel, der zwischen der Webanwendung und dem KDC zur Authentifizierung benutzt wird.

ktpass /out c:\kerberos.keytab /mapuser A_KERBEROS_USER@CODECENTRIC.DE 
  /princ HTTP/tomcat.codecentric.de@CODECENTRIC.DE /pass A_SECRET_PASSWD 
  /kvno 0 /crypto RC4-HMAC-NT

Der Befehlt legt für den Benutzer A_KERBEROS_USER und sein Passwort A_SECRET_PASSWD die Keytab-Datei an, die jedoch nur für den SPN HTTP/tomcat.codecentric.de verwendet werden kann. Für den SPN HTTP/websphere.codecentric.de muss analog dazu eine weitere Keytab-Datei erzeugt werden.

3. krb5.ini

In dieser Datei wird Kerberos selbst konfiguriert. Hier wird zum Beispiel der KDC konfiguriert:

[libdefaults]
default_realm = CODECENTRIC.DE
default_keytab_name = FILE:/usr/local/tomcat/conf/kerberos.keytab
default_tkt_enctypes = rc4-hmac
default_tgs_enctypes = rc4-hmac
dns_lookup_realm = false
dns_lookup_kdc = false
forwardable=true
 
[realms]
CODECENTRIC.DE = {
  kdc = 192.168.0.1
  admin_server = 192.168.0.1
}
 
[domain_realm]
codecentric.de = CODECENTRIC.DE
.codecentric.de = CODECENTRIC.DE

Diese Konfiguration ist stark von der Domänen-Konfiguration im Active Directory abhängig. Dies hier ist nur ein Beispiel.

Aufruf der Webanwendung

Nach dem Erstellen der notwendigen Dateien und dem Deployment der Webanwendungen mit der obigen Spring-Konfiguration wird beim Aufruf des URL https://tomcat.codecentric.de/beispiel der Kerberos-Mechanismus in Gang gesetzt. Zunächst prüft die Anwendung, ob der Aufrufer schon eingeloggt ist, wenn nicht, schreibt der SpnegoEntryPoint einen HTTP-Header (WWW-Authenticate=Negotiate) und gibt Status 401 zurück. Damit weiß der Webbrowser, dass er die Daten des Windows-Benutzers in einem neuen Request das Kerberos-Ticket im HTTP-Header mitsenden muss. Dieses Ticket liegt entweder schon im Ticket-Cache oder es muss noch beim KDC geholt werden.

Webspheres IBM Runtime Java

Die Spring-Bibliothek für Kerberos beinhaltet leider nur einen KerberosTicketValidator, der mit dem Oracle-JRE funktioniert und explizit nicht mit dem IBM-JRE. Speziell die Referenz auf die Implementierung des LoginModule ist bei der IBM-JRE eine andere. Einen weiteren Unterschied bildet die Implementierung der Kommunikation mit dem KDC.

Die IBM-Implementierung ist im Wesentlichen eine Kopie des SunJaasKerberosTicketValidator bis auf  folgende Ausschnitte:

@Override
public KerberosTicketValidation run() throws Exception {
    Principal p = (Principal) Subject.getSubject(AccessController.getContext()).getPrincipals().toArray()[0];
    GSSManager manager = GSSManager.getInstance();
    Oid kerberos = new Oid("1.3.6.1.5.5.2");
    GSSName serverGSSName = manager.createName(p.getName(), null);
    GSSCredential serverGSSCreds =
        manager.createCredential(serverGSSName, GSSCredential.INDEFINITE_LIFETIME, kerberos, GSSCredential.ACCEPT_ONLY);
    GSSContext context = manager.createContext(serverGSSCreds);
    byte[] responseToken = context.acceptSecContext(kerberosTicket, 0, kerberosTicket.length);
    GSSName gssName = context.getSrcName();
    if (gssName == null) {
        throw new BadCredentialsException("GSSContext name of the context initiator is null");
    }
    if (!holdOnToGSSContext) {
        context.dispose();
    }
    return new KerberosTicketValidation(gssName.toString(), servicePrincipal, responseToken, context);
}
 
@Override
public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
    HashMap<String, String> options = new HashMap<String, String>();
    options.put("useKeytab", this.keyTabLocation);
    options.put("principal", this.servicePrincipalName);
    if (this.debug) {
        options.put("debug", "true");
    }
    options.put("credsType", "acceptor");
 
    return new AppConfigurationEntry[] {new AppConfigurationEntry("com.ibm.security.auth.module.Krb5LoginModule",
        AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options),};
}

Die Implementierung der run()-Methode geht auf die Dokumentation von IBM zurück.

Möglichkeit des Aufrufs ohne SSO

Eine letzte Anforderung war die Schaffung der Möglichkeit, einen Login in die Webanwendung zu ermöglichen, ohne dass eine automatische Anmeldung per SSO erfolgt. Dies kann nützlich sein, wenn der Anwender sich nicht mit seinem eigenen Benutzer, sondern mit einem fremden anmelden möchte.

Die Idee hierzu war simpel. Es wurde ein Hostname im DNS eingetragen, der die Subdomäne nosso enthält und auf die gleiche IP zeigt wie der Hostname ohne nosso-Subdomäne. In dem Beispiel wäre die Webanwendung also auch über die URL https://tomcat.nosso.codecentric.de/beispiel erreichbar.

In der Webanwendung wurde dann in dem eigenen SpnegoEntryPoint eine Weiche implementiert. Der Ausschnitt der überschrieben Methode zeigt die Weiche.

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex)
        throws IOException, ServletException {
    if (!request.getRequestURL().contains(".nosso.")) {
        response.addHeader("WWW-Authenticate", "Negotiate");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    } else {
        // Es wird kein HTTP-Header ergänzt, so dass der Request ganz normal an die springSecurityFilterChain 
        // übergeben wird und somit kein SSO gestartet wird
    }
    ...
}

Somit wird der Aufruf von https://tomcat.nosso.codecentric.de/beispiel keinen Kerberos-Mechanismus auslösen, sondern zu einem Redirect zu https://tomcat.nosso.codecentric.de/beispiel/login führen, wo der Anwender dann die gewünschten Benutzerdaten eingeben kann.

Fazit

Viel Experimentieren war notwendig, um diese Lösung zu erarbeiten. Ich hoffe, mit meiner Ausführung dazu beitragen zu können, dass künftig weniger experimentiert werden muss.

Nach seinem Studium der Informatik stieß er direkt im Anschluss, im Jahr 2007, auf codecentric und ist seitdem begeisterter Arbeitnehmer.
In seinen Projekten bei der codecentric war er für diverse Versicherungen tätig und hat darin Systeme und Lösungen für Versicherungsprodukte geschaffen.

Kommentare

  • Name

    Wo passiert die Konfiguration der Security-Constraints, welche üblicherweise in der server.xml gesetzt wird?
    bspw.: (größer, kleiner durch [,] ersetzt)
    [security-constraint]
    [web-resource-collection]
    [web-resource-name]all[/web-resource-name]
    [url-pattern]/*[/url-pattern]
    [/web-resource-collection]
    [user-data-constraint]
    [transport-guarantee]CONFIDENTIAL[/transport-guarantee]
    [/user-data-constraint]
    [/security-constraint]

    • Thomas Bosch

      Die Absicherung der URL geschieht in meinem Fall nicht mehr über die server.xml, wird also nicht mehr vom ApplicationServer verwaltet. Die Absicherung der URL (in meinem Fall /secured/*) ist komplett über Spring Security implementiert, also hier im Beispiel in der AbstractSecurityConfiguration.java (siehe oben)

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.