Vor einem Jahr habe ich in meinem Beitrag Skalierbare Authentifizierung – Föderation statt Integration erläutert wie föderative Authentifizierungsverfahren die Skalierbarkeit stark verteilter Systeme fördern. Kürzlich hatte ich wieder die Möglichkeit zum Thema OpenID Connect und Keycloak zu sprechen. Dabei fiel mir auf, dass der tatsächliche Aha-Effekt oftmals erst eintritt, wenn mit konkreten Messungen aufgezeigt wird, wie überlegen föderative Authentifizierungsverfahren gegenüber integrativen Verfahren in einigen Aspekten sind. Daher möchte ich mit diesem Beitrag bei Ihnen einen Aha-Effekt in Bezug auf Performance erzielen. Ich werde anhand eines konkreten Testszenarios zeigen, wie man die Performance einer verteilten Anwendung durch föderative Authentifizierung massiv verbessern kann.

Anmerkung: Die Code-Beispiele wurden der Lesbarkeit halber stark verkürzt. Ein voll lauffähiges Beispiel findet sich auf unserem GitHub-Account.

Der Testaufbau

Für den grundlegenden Testaufbau nutze ich drei Microservices. In Anlehnung an meinen vorherigen Blogbeitrag ist das ein roter, ein grüner und ein blauer Service. Im Testaufbau wird ein Aufruf an den roten Service simuliert, der wiederum jeweils den grünen und anschließend den blauen Service aufruft. Ferner sind alle Services durch entsprechende integrative oder föderative Authentifizierungsverfahren geschützt.

Zur Umsetzung der Services kommt Wildfly Swarm zum Einsatz. Wildfly Swarm unterstützt JAX-RS und somit können die Services sehr einfach wie folgt als JAX-RS Ressourcen definiert werden.

Der blaue ist Service sehr einfach zu implementieren – er gibt einfach den String „blau“ zurück.

@Path("/blau")
public class Blau {

  @GET
  @Produces("text/plain")
  public String get() {
    return "blau";
  }

}

Der grüne Service kann analog gestaltet werden. Der rote Service ist hingegen leicht komplizierter, da dieser den blauen und den grünen Service aufrufen soll. Er nimmt die Ergebnisse der Services und konkateniert diese zu einem String. Den String liefert er wiederum an den Aufrufer zurück.

@Path("/rot")
public class Rot {

  @HeaderParam(HttpHeaders.AUTHORIZATION)
  String authHeader;

  @GET
  @Produces("text/plain")
  public String get() throws IOException {
    String blau = request("blau");
    String gruen = request("gruen");
    return "rot " + gruen + "|" + blau;
  }

  private String request(String farbe) throws IOException {
    try(CloseableHttpClient httpclient = HttpClients.createDefault()) {
      HttpGet request = new HttpGet("http://localhost:8080/" + farbe);
      request.addHeader(HttpHeaders.AUTHORIZATION, authHeader);
      try(CloseableHttpResponse response = httpclient.execute(request)) {
        return EntityUtils.toString(response.getEntity());
      }
    }
  }

}

Außerdem reicht er den HTTP Authorization Header weiter, so dass eine Delegation der Credentials (Benutzernamen und Passwort bzw. Access Token) an den grünen und blauen Service möglich ist.

Der Einfachheit halber werden alle JAX-RS Ressourcen für die Services in einem einzelnen Wildfly Swarm wie folgt deployt:

public class Main {
	public static void main(String
	[] args) throws Exception {

	ClassLoader cl = Main.class.getClassLoader();
	URL stageConfig = cl.getResource("project-defaults.yml");
	Swarm swarm = new Swarm(false)
	.withConfig(stageConfig);

	JAXRSArchive deployment = ShrinkWrap.create(JAXRSArchive.class, "demo.war");
	deployment
	.addResource(Rot.class)
	.addResource(Gruen.class)
	.addResource(Blau.class);
	deployment.addAllDependencies();

	swarm.start();
	swarm.deploy(deployment);
	}
}

Neben der Registrierung der Ressourcen wird außerdem eine Konfigurationsdatei (projects-default.yml) eingelesen, die es erlaubt das Authentifizierungsverfahren zu konfigurieren.

Integrative Authentifizierung – LDAP Integration mit BASIC Authentication

Um eine integrative Authentifizierung zu simulieren, wird das Deployment wie folgt konfiguriert:

swarm:
  deployment:
    demo.war:
      web:
        login-config:
          auth-method: BASIC
          security-domain: integrated-domain
        security-constraints:
          - url-pattern: /*
            methods: [GET]
            roles: [admin]
  security:
    security-domains:
      integrated-domain:
        classic-authentication:
          login-modules:
            Ldap:
              code: Ldap
              flag: required
              module-options:
                java.naming.factory.initial: com.sun.jndi.ldap.LdapCtxFactory
                java.naming.provider.url: ldap://iam-system:389/
                java.naming.security.authentication: simple
                java.naming.security.principal: cn=reader,dc=example,dc=org
                java.naming.security.credentials: top-secret
                principalDNPrefix: uid=
                principalDNSuffix: ",dc=example,dc=org"
                rolesCtxDN: ou=Roles,dc=example,dc=org
                uidAttributeID: member
                matchOnUserDN: true
                roleAttributeID: cn
                roleAttributeIsDN: false

Die Authentifizierungsmethode wird auf BASIC festgelegt. Das entspricht der klassischen Authentifizierung mit Benutzername und Passwort. Gleichzeitig wird der Zugriff auf alle Ressourcen mittels HTTP GET-Aufrufen lediglich Benutzern mit der Rolle „admin“ gestattet.

Damit die Authentifizierung erfolgen kann, wird ein Login-Modul konfiguriert, das einen LDAP-Server als IAM-System integriert. Das Login-Modul nutz das IAM-System zum einen, um Benutzernamen und Passwort zu validieren, und zum anderen, um eine Autorisierung des Benutzers anhand seiner Rollen durchzuführen. Dazu liest es die Rollen aus dem LDAP-System.

Föderative Authentifizierung – Keycloak und OpenID Connect

Bei der föderativen Authentifizierung mit Keycloak sieht diese Konfiguration deutlich einfacher aus.

swarm:
  deployment:
    demo.war:
      web:
        login-config:
          auth-method: KEYCLOAK
        security-constraints:
          - url-pattern: /*
            methods: [GET]
            roles: [admin]

Wildfly Swarm unterstützt durch Einsatz einer Fraction die Authentifizierungsmethode KEYCLOAK. Hierdurch wird automatisch das föderative Authentifizierungsverfahren OpenID Connect aktiviert. Statt einer aufwendigen LDAP-Konfiguration muss stattdessen nun die Keycloak-Anbindung konfiguriert werden. Dies geschieht in einer separaten Datei keycloak.json.

{
  "realm": "my-realm",
  "auth-server-url": "http://localhost:9080/auth",
  "resource": "my-client"
}

Hier wird im Wesentlichen die URL des Keycloak-Servers (und damit der OpenID Connect Issuer) mittels auth-server-url und realm sowie der Client (resource) definiert.

Aufsetzen der Umgebung mit Docker Compose

Nachdem die Services definiert sind und die Authentifizierung ausgeprägt wurde, braucht man eine Test-Infrastruktur. Ich empfehle für einen ersten Wurf auf Docker Compose zu setzen. Es erlaubt die Infrastruktur vollständig in einer einzelnen Konfigurationsdatei (docker-compose.yml) zu definieren und zu starten. Das ist für einen ersten Test vollkommen ausreichend.

Für die Services ist die Nutzung zweier Wildfly Swarm Instanzen mit den deployten Ressourcen notwendig.

integrated:
  image: integrated
  container_name: integrated
  ports:
  - "8081:8080"
  links:
  - iam-system:toxiproxy

federated:
  image: federated
  container_name: federated
  ports:
  - "8082:8080"

Dabei ist die Instanz mit integrierter Authentifizierung (integrated) auf Port 8081 konfiguriert und die Instanz mit föderativer Authentifizierung (federated) auf Port 8082.

Für die integrative Authentifizierung ist außerdem die Anbindung an das IAM-System notwendig. Hierzu legen Sie einen Link auf iam-system an, der auf eine weitere Docker Instanz mit dem Namen toxiproxy verweist.

Bei Toxiproxy handelt es sich um einen Proxyserver, der es unter anderem erlaubt Netzwerklatenzen zu simulieren. Dieser Proxy-Server wird zwischen die Services und das IAM-System geschaltet, um zu simulieren, wie sich langsame Anfragen an das IAM-System auf die Performance des Gesamtsystems auswirken können.

toxiproxy:
  image: shopify/toxiproxy:2.1.0
  container_name: toxiproxy
  links:
  - openldap:openldap
  ports:
  - "8474:8474"
  volumes:
  - ./:/config:ro
  command: -config /config/proxies.json -host toxiproxy

Dazu bekommt Toxiproxy einen Link auf das LDAP-System. Zusätzlich wird über die Datei proxies.json der Proxy zunächst einmal ohne Latenzen wie folgt definiert:

[{
  "name": "openldap",
  "listen": "toxiproxy:389",
  "upstream": "openldap:389"
}]

Als LDAP-System kommt OpenLDAP zum Einsatz. Auch dazu wird einfach ein entsprechender Container definiert.

openldap:
  image: osixia/openldap:1.1.8
  container_name: openldap

Letztendlich fehlt noch eine Keycloak-Instanz, die über den Port 9080 verfügbar gemacht werden kann. Analog zum Service mit integrativer Authentifizierung ist auch hier der Toxiproxy zwischen Keycloak und das IAM-System zu schalten.

keycloak:
  image: jboss/keycloak:3.0.0.Final
  container_name: keycloak
  ports:
  - "9080:8080"
  links:
   - iam-system:toxiproxy

Die Umgebung, die ich aufgebaut habe, sieht nun folgendermaßen aus:

Darstellung der Abhängigkeiten zwischen Docker Container für den Testfall

Übersicht der Infrastruktur

Der Test

Soweit ist die Infrastruktur für den Test vorbereitet. Nachfolgend werde ich aufzeigen, wie mit Gatling sehr effizient und einfach entsprechende Performance-Tests gegen das Gesamtsystem beschrieben werden können.

Nachfolgendes Beispiel beschreibt den Test für die integrative Authentifizierung.

class PerformanceTest extends Simulation {

  val USER = "testuser"
  val PASS = "testpassword"
  val userPassBase64 = Base64.getEncoder.encodeToString((USER + ":" + PASS).getBytes(StandardCharsets.UTF_8))

  val BASE_URL = "http://localhost:8081"
  val ROT_URL = BASE_URL + "/rot"

  val httpProtocol = http.acceptHeader("*/*")

  val request =
      exec(http("Request")
        .get(ROT_URL)
        .header("Authorization", "Basic " + userPassBase64))

  val scn = scenario("Integrative Authentifizierung").exec(request)

  setUp(scn.inject(rampUsers(10) over 1)).protocols(httpProtocol)
}

Hier wird zunächst einmal Benutzername und Passwort festgelegt. Anschließend wird definiert, dass der rote Service mit Basic-Authentifizierung angefragt werden soll. Letztlich simuliert der Test zehn parallele Benutzer, so dass die Anfrage 10-mal parallel abgesetzt wird.

Während der Ausführung misst Gatling die Antwortzeiten und erstellt folgende Statistik.

================================================================================
---- Global Information --------------------------------------------------------
> request count                                         10 (OK=10     KO=0     )
> min response time                                     40 (OK=40     KO=-     )
> max response time                                    121 (OK=121    KO=-     )
> mean response time                                    77 (OK=77     KO=-     )

Wie Sie sehen, sind 10 Anfragen ausgeführt worden, die im Mittel 77 Millisekunden benötigen.

Mittels Toxiproxy können nun Latenzen simuliert werden. Das ist mit folgendem Befehl schnell umgesetzt:

curl -s -XPOST -d '{"type" : "latency", "attributes" : {"latency" : 1000}}' http://localhost:8474/proxies/openldap/toxics

Der Befehl simuliert Netzwerklatenzen von einer Sekunde (1000ms) für alle Anfragen an das LDAP-System. Führen Sie den Performance-Test unverändert erneut aus, erhalten Sie folgendes Ergebnis:

================================================================================
---- Global Information --------------------------------------------------------
> request count                                         10 (OK=10     KO=0     )
> min response time                                   9039 (OK=9039   KO=-     )
> max response time                                   9075 (OK=9075   KO=-     )
> mean response time                                  9052 (OK=9052   KO=-     )

Wie Sie feststellen können, ist die Gesamtperformance des Systems massiv eingebrochen. Jede einzelne Anfrage liegt nun nicht mehr im Bereich von Millisekunden, sondern bedarf mindestens 9 Sekunden.

Wie kommt ein so massiver Einbruch zu Stande?

Ganz einfach. Bei jedem Aufruf eines der Services (rot, grün, blau), werden 3 Anfragen an den LDAP-Server erzeugt. Zunächst einmal wird geprüft, ob der Benutzer existiert. In der zweiten Anfrage wird geprüft, ob sein Passwort korrekt ist. Letztlich wird in einer dritten Anfrage noch geprüft, ob der Benutzer in der korrekten Rolle ist. Da jede dieser Anfragen aufgrund der Latenz nun mindestens 1 Sekunde benötigt, benötigt nun die Anfrage an den roten Service in Summe mindestens 9 Sekunden.

Performance bei Einsatz föderativer Authentifizierungsverfahren

Es stellt sich nun im Wesentlichen die Frage, wie sich der Einsatz eines föderativen Authentifizierungsverfahrens in einem solchen Szenario auswirkt.

Um das zu prüfen, würde ich den Performance-Test ein wenig erweitern.

class PerformanceTest extends Simulation {

  val REALM = "keycloak-example"
  val TOKEN_URL = "http://localhost:9080/auth/realms/" + REALM + "/protocol/openid-connect/token"

  val USER = "testuser"
  val PASS = "testpassword"

  val BASE_URL = "http://localhost:8082"
  val ROT_URL = BASE_URL + "/rot"

  val httpProtocol = http.acceptHeader("*/*")

  val login =
    exec(http("Login")
      .post(TOKEN_URL)
      .silent
      .formParamMap(Map(
        "username" -> USER,
        "password" -> PASS,
        "client_id" -> "keycloak-example",
        "grant_type" -> "password"
      ))
      .check(jsonPath("$.access_token").saveAs("accessToken")))

  val request =
      exec(http("Request")
        .get(ROT_URL)
        .header("Authorization", "Bearer ${accessToken}"))


  val scn = scenario("Föderative Authentifizierung")
        .exec(login)
        .exec(request)

  setUp(scn.inject(rampUsers(10) over 1)).protocols(httpProtocol)
}

Zunächst einmal ist in der BASE_URL der Port auf 8082 zu ändern. Dadurch wird der Service mit föderativer Authentifizierung angesprochen. Außerdem muss der Test so erweitert werden, dass vor der ersten Anfrage an einen Service, sich jeder Benutzer zunächst am Keycloak-System einloggt, um ein Access Token zu holen. Diese Anfragen sollten als silent markiert werden. Hierdurch gehen sie nicht in die Ergebnisstatistik ein, so dass weiterhin nur die Antwortzeiten der Services gemessen werden.

Führen Sie diesen Test zunächst einmal ohne simulierte Latenzen aus, erhalten Sie ein Ergebnis der folgenden Art.

================================================================================
---- Global Information --------------------------------------------------------
> request count                                         10 (OK=10     KO=0     )
> min response time                                     50 (OK=50     KO=-     )
> max response time                                    360 (OK=360    KO=-     )
> mean response time                                   204 (OK=204    KO=-     )

Hier sind gegenüber der integrativen Authentifizierung zunächst einmal keine Überraschungen festzustellen. Die Antwortzeiten sind in beiden Szenarien durchaus vergleichbar. Die Abweichungen können einem gewissen Grundrauschen zugeschrieben werden.

Führen Sie den Test allerdings ebenfalls mit der zuvor simulierten Latenz von einer Sekunde aus, erhalten Sie hingegen folgendes Ergebnis.

================================================================================
---- Global Information --------------------------------------------------------
> request count                                         10 (OK=10     KO=0     )
> min response time                                     89 (OK=89     KO=-     )
> max response time                                    223 (OK=223    KO=-     )
> mean response time                                   155 (OK=155    KO=-     )

Gegenüber dem Szenario ohne Latenz sehen Sie (mit Ausnahme des bereits erwähnten Grundrauschens) keinerlei Abweichungen. Ein Performance-Einbruch des Gesamtsystems wie bei der integrativen Authentifizierung existiert schlichtweg nicht.

Das liegt daran, dass nur bei Erzeugung eines Tokens die Prüfung von Benutzername und Passwort durchgeführt und die Rollen geladen werden müssen. Dies muss nicht mehr von jedem Service selbst durchgeführt werden. Warum das so ist, hatte ich bereits in meinem letzten Beitrag Skalierbare Authentifizierung – Föderation statt Integration erläutert. Selbst wenn an dieser Stelle der initiale Login bzw. die Anfrage an Keycloak mit betrachtet werden würde, würde diese nur einmalig die 3 Sekunden Latenz für Prüfung der Credentials addieren. Jedoch wäre sogar in diesem Falle das Gesamtsystem mit föderativer Authentifizierung noch 3 mal so schnell. Grundsätzlich gilt jedoch, dass vor allem bei vielen Anfragen eines Benutzers der initiale Login nicht ins Gewicht fällt, da das Access Token für weitere Anfragen wiederverwendet werden kann.

Zusammenfassung

Ich habe gezeigt, dass föderative Authentifizierungsverfahren nicht nur hinsichtlich der Skalierbarkeit eines stark verteilten Systems enorme Vorteile bringen. Auch hinsichtlich von Performance-Aspekten ist Auswahl und korrekte Implementierung eines solchen Verfahrens einer klassischen integrativen Authentifizierung überlegen.

Bei der Konzeption und Implementierung unterstützen meine erfahrenen Kollegen und ich Sie gerne. Auch geben wir gerne Impulse oder befähigen Ihre Teams die Implementierung selbst durchzuführen. Nehmen Sie für ein erstes Gespräch einfach unverbindlich Kontakt auf.