MicroProfile unter der Lupe, Teil 3: JWT Security

Blog

Technologie-Blog / Blog 492 Views 0

Das Thema Security stellt in einer stark verteilten, Microservices-basierten Umgebung eine besondere Herausforderung dar. Im Umfeld von RESTful-Services hat sich in den letzten Jahren der auf OpenID Connect (OIDC) basierende Standard JSON Web Tokens (JWT) für rollenbasierte Zugriffskontrolle etabliert. Auf diesen Stack setzt auch die Spezifikation MicroProfile JWT RBAC Security (MP-JWT).

Ein Microservice kommt selten allein. Das ist kein Geheimnis, sondern Teil des Erfolgsrezepts. Ziel einer Microservice-basierten Architektur ist es, durch ein passendes Servicedesign, eine möglichst große Unabhängigkeit der einzelnen Services untereinander zu erreichen. Wie sieht es aber mit dem Thema Sicherheit aus? Gilt auch hier das Prinzip der größtmöglichen Unabhängigkeit? Und wenn ja, muss sich ein Client dann bei jedem Service oder gar bei jedem Service-Aufruf separat mit seinen User Credentials (z.B. Username und Passwort) authentifizieren und autorisieren?

Im Umfeld verteilter Services hat sich in den letzten Jahren der auf OpenID Connect basierende JSON-Web-Tokens-Standard (siehe auch JWT.io Introduction) als der Standard etabliert.

Vereinfacht gesprochen authentifiziert sich bei diesem Verfahren ein Client einmalig bei einem Authentication-Server (Issuer) und erhält im Gegenzug ein zeitlich begrenztes, personalisiertes und in der Regel signiertes Token (JWT mit JWS). Personalisiert bedeutet in diesem Zusammenhang, dass das Security-Token sogenannte Claims enthält, also Key-Value-Paare, welche unter anderem . Informationen zu dem authentifizierten Nutzer (Subject) beinhalten.

Mit Hilfe des Tokens kann sich der Client nun gegenüber externer Services (Resource Server) mittels Bearer Authentication authentifizieren. Dazu schickt er einfach in jedem Request das Token im Authorization-Header mit:

GET /resource/123 HTTP/1.1
Host: openknowledge.com
Authorization: Bearer mF_9.B5f-4.1JqM

Dank Signatur können die aufgerufenen Services das Token auf Echtheit und Unverfälschtheit verifizieren. Da das Token Informationen über den Client enthält, sind die aufgerufenen Service zusätzlich in der Lage, eine rollenbasierte Zugriffskontrolle (RBAC; role based access control) – Autorisierung - durchzuführen.

Der Vorteil dieses Verfahrens liegt klar auf der Hand. Dank Token und den darin enthaltenen Informationen kann ein Service sowohl Authentifizierung als auch Autorisierung durchführen, ohne zusätzlich Informationen von einer dritten Partei wie einem User-Management oder Identity-Service erfragen zu müssen. Dies erspart unnötige Roundtrips. Dies gilt übrigens auch dann, wenn der aufgerufene Service einen weiteren Service aufruft (Service Chaining). In diesem Fall reicht der ursprünglich aufgerufene Service das Token inklusive der enthaltenen Nutzerinformation einfach im Request-Header weiter (Security Context Propagation).

Seit Version 1.2 unterstütz auch das MicroProfile mit der Spezifikation MP-JWT 1.0 JWT-basierte Security. Die Spezifikation setzt dabei gleich an mehreren Punkten an und versucht folgende Aufgaben zu unterstützen:

  1. Token aus dem Request extrahieren,
  2. Token verifizieren und validieren,
  3. Claims des Token zugreifbar machen und
  4. JavaEE-Security-Context erzeugen und setzen.

Zu diesem Zweck definiert die MP-JWT Spezifikationen einen Satz von „non-optional“ Claims, also Pflichtfeldern, die ein spezifikationskonformes Token beinhalten muss:

  • typ: Header-Parameter für das Token-Format („JWT“)
  • alg: Header-Parameter für den Verschlüsselungsalgorithmus („RS256“)
  • kid: Header-Parameter mit Hinweis für die eindeutige Key ID
  • iss: Token-Issuer
  • sub: Subject des Tokens (Principle/User)
  • aud: angedachte Empfängergruppe
  • exp: Gültigkeit
  • iat: Ausstellungszeitpunkt
  • jti: eindeutiter Identifier
  • upn: eindeutiger Principle Name (für java.security.Principle)
  • groups: Liste von Gruppennamen für das Mapping mit Application-Roles

Ein minimales Beispiel eines MP-JWT in JSON-Format könnte wie folgt aussehen:

{ 
"typ": "JWT",
"alg": "RS256",
"kid": "abc-1234567890"
}
{
"iss": "https://auth.openknowledge.com",
"aud": "kNowLedGe",
"jti": "ok-12345",
"exp": 1311281970,
"iat": 1311280970,
"sub": "13579",
"upn": "lars.roewekamp@auth.openknowledge.com",
"groups": ["first-group", "second-group", "admin"],
}

Damit eine automatische Extraktion des Tokens aus dem Authorization-Header des Request erfolgen kann, muss der JAX-RS-Anwendung zunächst signalisiert werden, dass zur Authentifizierung und Autorisierung der MP-JWT JASPI (Java Authentication SPI for Containers) verwendet werden soll. Das erfolgt mit der Annotation LoginConfig aus dem Package org.eclipse.microprofile.jwt.


import org.eclipse.microprofile.auth.LoginConfig;

// Sets the authentication method to the MP-JWT for the MicroProfile JWT.
// The optional parameter realName is only needed, if authMethod
// is set to "BASIC" or for vendor specific configurations.
@LoginConfig(authMethod = "MP-JWT", realName="...")
@ApplicationScoped
@ApplicationPath("/myapi")
public class MyJaxrsApp extends Application { }

Anmerkung: Die Annotation LoginConfig wurde eingeführt, da das MicroProfile keinerlei Aussagen zu möglichen Deployment-Formaten trifft und somit auch nicht von dem Vorhandensein eines Servlet Containers bzw. einer web.xml ausgegangen werden kann. Ist eine web.xml vorhanden, kann alternativ zur Verwendung der Annotation auch der gleichnamige Eintrag login-config innerhalb der web.xml-Datei genutzt werden.

Die anschließende Verifizierung des Tokens erfolgt automatisch im Hintergrund. Zu diesem Zweck muss lediglich der Public Key des Token-Issuers in der Konfiguration des MicroProfile-Runtime-Container hinterlegt sein. Damit ein Token als gültiges MP-JWT angesehen wird, muss es zusätzlich mindestens die folgenden Bedingungen erfüllen:

  • JOSE (JavaScript Object Signing and Encryption) Header, der angibt, dass das Token via RS256 signiert wurde
  • iss-Claim, der denselben Token-Issuer beinhaltet, wie im MicroProfile-Runtime-Container konfiguriert

Einmal extrahiert und verifiziert kann innerhalb des Microservice via CDI auf das Token beziehungsweise auf einzelne Claims des Tokens zugegriffen werden. Der Zugriff kann dabei via Raw-Typen, ClaimValue-Wrapper, javax.enterprise.inject.Instance oder JSON-P-Typen erfolgen:

@Path(/endpoint) 
@DenyAll
@RequestScoped
public class SomeEndpoint {

// 1) JWT object with @RequestScoped scoping
@Inject
private JsonWebToken callerPrincipal;

// 2) JWT as raw String
@Inject
@Claim(standard = Claim.raw_token)

private String rawToken;

// 3) IAT claim as Long value
@Inject
@Claim(standard=Claims.iat)

private Long issuedAt;

// 4) ISS claim as ClaimValue of type String
@Inject
@Claim(standard=Claims.iss)

private ClaimValue<String> issuer;

// 5) JTI claim as optional ClaimValue of type String
@Inject
@Claim(standard=Claims.jti)

private ClaimValue<Optional<String>> optJti;

// 6) JTI claim as JsonString
@Inject
@Claim(standard=Claims.jti)

private JsonString jsonJti;

// 7) custom claim "roles" as JsonArray
@Inject
@Claim("roles")

private JsonArray jsonRoles;

// 8) custom claim "customObject" as JsonArray
@Inject
@Claim("customObject")

private JsonObject jsonCustomObject;
...
}

Anmerkung: Die im obigen Listing gezeigte Klasse SomeEndpoint muss zwingendermaßen vom Scope @ReqiestScoped sein, das in ihr non-proxyable Typen (String und Long) aus dem Token injected werden.

Der aktuell angemeldete Caller bzw. das zugehörige Token kann mittels @lnject JsonWebToken mit dem Scope @RequestScoped injected werden (1). Das von Principal abgeleitete Interface JsonWebToken bietet eine Reihe von Convenient-Methoden, um gezielt und typenssicher auf die wichtigsten Claims des Tokens (Name, Issuer, Audience, Subject, TokenID, ExperiationTime, IssuedAtTime, Groups) zugreifen zu können.

Alternativ können die Claims aber auch direkt injected werden. Geschieht dies als non-proxyable Raw Typ, wie zum Beispiel String (2) oder Long (3), muss die umliegende Bean vom Scope @RequestScoped sein. Statt den Claim als Raw-Typen zu injecten, können alternativ auch ClaimValue- und/oder Optional-Wrapper verwendet werden (4 und 5). Und auch ein Zugriff via in JSON-P definierter Typen ist möglich (6, 7 und 8).

Um Tippfehler zu verhindern, sollte bei dem Zugriff auf Standard-Claims immer die Claims-Enumeration verwendet werden. Für den Zugriff auf Custom-Claims dagegen, können Strings oder eigene Enumerations genutzt werden.

Nachdem wir nun in der Lage sind, das JSON Web Token automatisch aus dem Request zu extrahieren und im Anschluss zu verifizieren, wäre es natürlich schön, wenn wir innerhalb der JAX-RS Ressourcen auf gewohnte JavaEE-Security-Mechanismen zurückgreifen könnten. Und auch das sieht die MP-JWT Spezifikation vor.

Ein Aufruf der Methode getUserPrinciple() der Klasse javax.ws.rs.core.SecurityContext liefert uns eine Instanz des aktuellen JsonWebToken zurück. Entsprechend können wir mit einem Aufruf der Methode isUserInRole(someRole) testen, ob die angegebene Rolle in dem Claim groups des Tokens enthalten ist oder nicht. Zusätzlich lässt sich mit Hilfe der Annotation @RollesAllowed(…) der Zugriff auf einzelne Methoden entsprechend der angegebenen Rolle(n) sperren bzw. freigeben.

Wer schon einmal das „Glück“ hatte, die notwendigen Schritte zum Extrahieren, Validierung und Verifizieren eines JSON Web Tokens - inkl. der Überführung in einen JavaEE Security Context - implementieren zu dürfen, der weiß die Vorteile einer integrierten und standardisierten Lösung zu schätzen. Dies gilt insbesondere auch für die innerhalb des Tokens zu erwartenden Claims.

Das ganze Konzept steht und fällt allerdings damit, dass es am Ende hinreichend Identity Provider und Service Provider geben wird, die sich auf das MP-JWT Format einlassen. Um die Wahrscheinlichkeit zu erhöhen, hat man bei der Auswahl der „non-optional“ Claims bewusst darauf verzichtet, das Rad neu zu erfinden und stattdessen ein Subset von fünf der im IANA JWT Assignement standardisierten Claims gewählt. Lediglich die zwei der "non-optional" Claims - upn (user principle name) und groups (Token Subject‘s Gruppen, welche auf JavaEE Security-Rollen gemappt werden) - sind proprietär.

Für zukünftige Versionen sind noch einige weitere Claims angedacht. So sollen mit Hilfe des Claims resouce_access servicespezifische Rollen angegeben werden können. Weiterhin wird darüber nachgedacht, neben dem bereits vorhanden Claim groups auch einen Claim roles einzuführen. Während groups dafür gedacht ist, dass die enthaltenen Werte auf das Rollenmodell der Ziel-Ressource gemappt werden, sollen die in roles enthaltenen Werte eins zu eins weitergereicht und in @RolesAllowed verwendet werden können.

Was ein wenig verwundert ist die Tatsache, dass die Art und Weise der Konfiguration, also zum Beispiel der Zurgiff auf den Public Key, den jeweiligen Runtime-Container-Herstellern überlassen wird, anstatt auf die MP Config API zurückzugreifen. Entsprechende Überlegungen zur Vereinheitlichung gibt es aber bereits. Man darf gespannt sein.

Comments