JWT kezelése Java/Jakarta EE környezetben JAX-RS-sel

Egy korábbi posztban (JWT és Spring Security) írtam arról, hogy mi az a JWT token, és hogyan lehet használni Spring Security-vel. Ez a poszt azt írja le, hogy lehet JAX-RS-sel használni (ami a Java/Jakarta EE szabvány része is).

A példámban a Jetty servlet containert, Jersey JAX-RS implementációt fogom használni, és a Java JWT könyvtárat. A szabványos megoldásnak köszönhetően bármelyik komponens cserélhető.

A folyamat nagyon egyszerű:

  • A felhasználó bejelentkezik, és ezáltal kap egy HttpOnly Cookie-t, benne a tokennel, melyet minden kéréssel visszaküld a szervernek
  • Egy JAX-RS ContainerRequestFilter példányon megy keresztül minden kérés. Ez ellenőrzi, kicsomagolja a tokent, és létrehoz egy új SecurityContext példányt, mely tárolja a bejelentkezett felhasználó adatait, pl. a felhasználónevet és szerepkörét
  • A JAX-RS végpontban, azaz a Resource-ban már paraméter injectionnel elérhető a SecurityContext, de működik pl. a @RolesAllowed annotáció is

A poszthoz egy példa projekt is tartozik, mely elérhető a GitHubon. Indítható az mvn package jetty:run paranccsal, példa http kérések a src/test/http/hello.http fájlban találhatóak. Automata integrációs tesztek is vannak.

Bejelentkezéshez el kell küldeni a következő kérést:

POST http://localhost:8080/api/auth
Content-Type: application/json

{
  "username": "user",
  "password": "user"
}

Ezt az AuthResource kapja meg, ez először a JJWT használatával előállít egy tokent:

return Jwts.builder()
    .setSubject(username)
    .claim(ROLE_CLAIM, role)
    .setIssuedAt(new Date(now))
    .setExpiration(new Date(now + EXPIRATION))
    .signWith(SignatureAlgorithm.HS512, SECRET.getBytes())
    .compact();

Látható, hogy a szabványos mezőkön kívül a claim() metódussal saját mezőket is fel lehet venni, a kód a felhasználó szerepkörét is eltárolja a tokenben.

Majd visszaad egy cookie-t, mely tartalmazza a tokent:

NewCookie newCookie = 
    new NewCookie(COOKIE_NAME, token, "/", "http://localhost:8080", "JWT", 30 * 60, true, true);

return Response.ok(new MessageResponse("Successful"))
    .cookie(newCookie)
    .build();

Ez a http válaszban valahogy így néz ki:

Set-Cookie: token=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIiLCJpYXQiOjE2MjI2MzkwMDgsImV4cCI6MTYyMjY0MDgwOH0.VxwmtuuAUPXlhYZVfX7uHc4RQWt8laR65Kb4YmKNm2azSPtvAkWO8fluVp_H4a2v9j_uvtK4fFE_qBraxmBqzg;Version=1;Comment=JWT;Domain=http://localhost:8080;Path=/;Max-Age=1800;Secure;HttpOnly

Ha ezt bemásoljuk a https://jwt.io/ címen található dekóderbe, akkor a következőt kapjuk:

{
  "sub": "user",
  "role": "user",
  "iat": 1622639008,
  "exp": 1622640808
}

Ezt a tokent kell minden alkalommal elküldeni fejlécben:

GET http://localhost:8080/api/hello/user
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIiLCJpYXQiOjE2MjI2MzkwMDgsImV4cCI6MTYyMjY0MDgwOH0.VxwmtuuAUPXlhYZVfX7uHc4RQWt8laR65Kb4YmKNm2azSPtvAkWO8fluVp_H4a2v9j_uvtK4fFE_qBraxmBqzg

Ezt a címet a JwtFilter fogja fogadni, mely egy ContainerRequestFilter. Fontos, hogy rajta legyen a @Provider és @Priority(Priorities.AUTHENTICATION) annotáció. Az implementálásnál arra is kell figyelni, hogy a /api/auth cím esetén ne aktiválódjon, különben a bejelentkezéshez is bejelentkezést fog kérni.

A filter kiveszi a tokent a headerből, ellenőrzi és kicsomagolja a JJWT segítségével.

Jwts.parser()
    .setSigningKey(SECRET.getBytes())
    .parseClaimsJws(token)
    .getBody();

A tokenből olvassa ki a felhasználónevet és a szerepkört is. Ahhoz, hogy bejelentkeztessen egy felhasználót, egy SecurityContext interfészt implementáló osztályt kell megvalósítanunk, aminek getUserPrincipal() metódusa adja vissza a felhasználó nevét, az isUserInRole(String role) metódusa pedig ellenőrzi, hogy a felhasználó rendelkezik-e a paraméterként átadott szerepkörrel.

A vezérlés ezután a HelloResource-ra ugrik.

@GET
@RolesAllowed("user")
public MessageResponse sayHello(@Context SecurityContext context) {
    System.out.println("Username: " + context.getUserPrincipal().getName());
    System.out.println("Has user role? " + context.isUserInRole("user"));
    return new MessageResponse("Hello JAX-WS!");
}

Itt a @RolesAllowed("user") annotáció hatására ellenőrzésre kerül, hogy a felhasználó rendelkezik-e az adott szerepkörrel. Ekkor automatikusan meghívja a isUserInRole(String role) metódust. Ha ez false értéket ad vissza, akkor 403 (Forbidden) státuszkódot kapunk vissza. Ez a deklaratív ellenőrzés.

Azonban a bejelentkezett felhasználót, és szerepköreit programozottan is le lehet kérdezni, ekkor paraméter injectiont alkalmazva egy SecurityContext példányt kapunk, ha rajta van egy @Context annotáció.

Amire még figyelni kell, hogy ahhoz, hogy a @RolesAllowed annotációk működjenek, a Jersey-nek be kell kapcsolni a RolesAllowedDynamicFeature-t. Ez a következő osztállyal lehetséges:

@ApplicationPath("api")
public class JerseyConfig extends ResourceConfig {

    public JerseyConfig() {
        packages("hello");
        register(RolesAllowedDynamicFeature.class);
    }
}

Az automata tesztesetek elindítanak egy beépített Grizzly Http Servert, és itt is kell a RolesAllowedDynamicFeature regisztrálása. Majd egyszerű, szabványos JAX-RS kliens hívásokkal ellenőrzik a működést.