REST-Webservices basieren wie SOAP-Webservices auf dem HTTP-Protokoll, über das sämtliche Nachrichten zwischen Client und Server ausgetauscht werden. Damit ist ein REST-Webservice auch grundlegend auf synchrone Kommunikation ausgelegt. Die Nachrichten werden üblicherweise ebenso wie bei SOAP in einem standardisierten, textbasierten und damit technologieunabhängigen Dateiformat wie XML oder JSON ausgetauscht. Demzufolge sind auch REST-Webservices unabhängig von einer konkreten Laufzeitumgebung oder Programmiersprache, so dass Client und Server in ganz unterschiedlichen Sprachen implementiert werden können und als Komponenten klar voneinander abgegrenzt sind. Dies führt wie gewünscht zu einer losen Kopplung der Komponenten in der Systemarchitektur.
Der Begriff REST steht für Representational State Transfer und geht auf die Dissertation von Roy Fielding im Jahr 2000 zurück. Die wesentliche Idee besteht darin, alle Funktionalität über eindeutig adressierbare Ressourcen bereitzustellen – und nicht wie bei RPC-Ansätzen (SOAP, RMI) über Methoden. Der prinzipielle Ablauf der Kommunikation zwischen Client und Server ist in der folgenden Abbildung skizziert.
http://<host>:<port>/<context-path>/<resource-path>
aufgebaut ist.
http://example.com/banking/accounts/1
http://example.com/banking/accounts
REST ist kein Protokoll sondern ein Architekturstil, der auf Konventionen basiert. Es gibt keine Schnittstellenbeschreibungssprache (Interface Definition Language, kurz IDL) wie z.B. WSDL bei SOAP-Webservices. Eine wichtige Konvention ist die Einhaltung eines Uniform Interface, demzufolge die HTTP-Methoden je Ressource wie folgt abzubilden sind:
Es ergeben sich folgende typische Operationen auf einer Ressource, die bei einer Anfrage an der URI entsprechend der gewählten HTTP-Methode ausgeführt werden. Bezüglich der leeren Zellen der Tabelle werden i.d.R. keine Operationen von einem REST-Webservice unterstützt, da z.B. das Löschen einer Menge von Ressourcen oder deren vollständiges Ersetzen durch eine andere Menge nicht benötigt werden.
Ressource | GET | POST | PUT | PATCH | DELETE |
---|---|---|---|---|---|
Menge, z.B. /accounts |
Ausgabe einer Liste aller Elemente der Menge. Es werden i.d.R. nicht alle Attribute der Elemente zurückgegeben, aber mind. deren URIs. Die Liste ist ggf. mittels weiterer Parameter des HTTP-Request sortiert, gefiltert und/oder in paginierte Abschnitte unterteilt. | Erzeugen eines neuen Elements in der Menge, dessen URI im Header oder Body der HTTP-Response zurückgegeben wird. | |||
Element, z.B. /accounts/1 |
Lesen aller Attribute des Elements. | Ersetzen des Elements an der angegeben URI. Erzeugen des Elements, falls es nicht existiert. | Aktualisieren von ausgewählten Attributen des Elements. | Löschen des Elements. |
Die Ressourcen können vom Server in unterschiedlichen Repräsentationen an einen Client ausgeliefert werden. Die geläufigsten Repräsentationen sind die beiden strukturierten Datenformate XML und JSON, aber eine Ressource könnte auch in HTML, in einem spezifischen Bildformat (JPG, PNG, ...), o.ä. repräsentiert werden. Ein Client kann über den HTTP-Header Accept angeben, welches Format er in der HTTP-Response erhalten möchte. Falls möglich, liefert der Server genau dieses Format aus, andernfalls ein möglichst ähnliches. Es folgt eine Auswahl häufig verwendeter Formate und ihrer MIME-Typen nach RFC 6838.
Format | MIME-Type |
---|---|
JSON | application/json |
XML | application/xml |
HTML | text/html |
Unstrukturierter Text | text/plain |
Spezifische Bildformate | image/jpeg, image/png, image/gif, ... |
Spezifische Audioformate | audio/mpeg, audio/wav, audio/webm, ... |
Spezifische Videoformate | video/mp4, video/ogg, video/webm, ... |
Unspezifisches Binärformat | application/octet-stream |
Bei einer GET-Anfrage an die Ressource /accounts/1
könnte der Body der HTTP-Response, der die Attribute des Kontos mit der Id 1 darstellt, je nach angeforderter Repräsentation exemplarisch wie folgt aussehen:
{
"id": 1,
"owner": "Hendricks",
"entries": [
{
"subject": "Credit 1",
"value": 10,
"date": 1612015200
},
{
"subject": "Credit 2",
"value": 20,
"date": 1612101600
}
]
}
Bei POST-, PUT- und PATCH-Anfragen wird der Client seinerseits Daten in einem bestimmten Format im Body des HTTP-Request an den Server übermitteln wollen. Dazu kann er über den HTTP-Header Content-Type den Server informieren, in welchem Format er diese Daten sendet.
Weitere Konventionen eines REST-Webservice sind:Um REST-Webservices in Java zu implementierten, ist zunächst die in JSR 370 spezifizierte API namens JAX-RS maßgeblich. Die Referenzimplementierung für JAX-RS ist das Framework Jersey, das wir für das folgende Anwendungsbeispiel verwenden werden. Alternative Implementierungen für JAX-RS sind RESTEasy, Apache CXF und Restlet. Auch mit dem Spring Framework lässt sich einfach ein REST-Webservice in Java implementieren, der aber nicht unbedingt konform zu JAX-RS sein muss, wenn die Implementierung den Empfehlungen von Spring MVC folgt.
Wie die meisten aktuellen Java-Frameworks arbeitet auch JAX-RS intensiv mit Annotationen. Diese Annotationen aus dem Package javax.ws.rs
werden anhand der folgenden Code-Beispiele sukzessive erklärt. Der Code bezieht sich auf ein Anwendungsbeispiel, in dem erneut – wie in den vorherigen Kapiteln – Bankkonten über eine API verwaltet werden sollen. Um den REST-Webservice zu realisieren, wird passend zur Datenmodellklasse Account
eine zugehörige Ressourcenklasse AccountResource
entworfen, die über die oben vorgestellten HTTP-Methoden eine Menge von Konten verwaltet. Diese Klasse verwaltet die Konten in einer Map, deren Schlüssel die Konto-Ids sind (Zeile 12). Diese Map ist noch flüchtig und wird erst im nächsten Kapitel in einer relationalen Datenbank gespeichert (s. Kapitel Objekt-Relationales Mapping). Da die Klasse AccountResource
insgesamt relativ umfangreich ist, werden zunächst nur die Signaturen ihrer Methoden dargestellt und deren Implementierungen anschließend schrittweise ergänzt und erläutert.
import com.sun.net.httpserver.HttpServer;
// ...
public class WebServiceStarter {
public static void main(String[] args) throws Exception {
ResourceConfig rc = new ResourceConfig().packages("resources");
HttpServer server = JdkHttpServerFactory.createHttpServer(URI.create("http://localhost:8080/"), rc);
System.out.println("Hit enter to stop HTTP server.");
System.in.read();
server.stop(0);
}
}
package resources;
import javax.ws.rs.core.Response;
import javax.ws.rs.Path;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
// ...
@Path("/accounts")
public class AccountResource {
final static Map<Integer, Account> accounts = new ConcurrentHashMap<>();
@GET
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Collection<Account> getAccounts() { /* ... */ }
@POST
@Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Response postAccount(Account account) { /* ... */ }
@GET @Path("{id}")
public Response getAccount(@PathParam("id") int id) { /* ... */ }
@PUT @Path("{id}")
public Response putAccount(@PathParam("id") int id, Account account) { /* ... */ }
@PATCH @Path("{id}")
public Response patchAccount(@PathParam("id") int id, Account patchedAccount) { /* ... */ }
@DELETE @Path("{id}")
public Response deleteAccount(@PathParam("id") int id) { /* ... */ }
}
package model;
import javax.xml.bind.annotation.XmlRootElement;
// ...
@XmlRootElement // required for XML binding
public class Account {
public static AtomicInteger nextId = new AtomicInteger(1);
public int id;
public String owner;
public List<AccountEntry> entries;
public int balance() {
if (entries == null) return 0;
return entries.stream().mapToInt(entry -> entry.value).sum();
}
}
package model;
public class AccountEntry {
public String subject;
public int value;
public Date date;
public AccountEntry() {
this.date = new Date();
}
}
javax.ws.rs
in dem obigen Code-Beispiel:
WebServiceStarter
wird ein HTTP-Server gestartet, dem eine Ressourcen-Konfiguration als Argument übergeben wird (s. WebServiceStarter
, Zeilen 7-8). Mittels der Ressourcen-Konfiguration wird Jersey angewiesen alle Klassen im Package resources
dahingehend zu analysieren, ob sie mit einer Annotation @Path
versehen sind (z.B. AccountResource
, Zeile 9). Falls diese Annotation vorhanden ist, handelt es sich um eine REST-Ressourcenklasse, die unter dem entsprechenden Pfad erreichbar gemacht wird.@GET
(Zeile 14) oder @POST
(Zeile 18).@Path("{id}")
(Zeile 23). Die angehängte Id wird von Jersey aus der URI extrahiert und mittels der Annotation @PathParam("id")
als Methodenargument injiziert (Zeile 24). Hier wird implizit ein Cast von String
auf int
durchgeführt, der auch fehlschlagen kann.@Consumes
(Zeile 19) und @Produces
(Zeile 15) explizit angegeben werden, in welchen Formaten die annotierten Methoden Eingaben verarbeiten bzw. Ausgaben erzeugen können. Diese Annotationen sind insbesondere dann notwendig, wenn je nach Eingabeformat (HTTP-Header Content-Type
) oder angefordertem Ausgabeformat (HTTP-Header Accept
) eine unterschiedliche Methode der Ressourcenklasse ausgeführt werden soll.Die gezeigten Code-Beispiele zu REST-Webservices finden sich im Verzeichnis /remote/rest des Modul-Repository.
Ein REST-Webservice sollte einem Client grundsätzlich mit HTTP-Status-Codes antworten, die dem vereinbarten Standard entsprechen. Die folgende Tabelle zeigt eine Auswahl von gebräuchlichen HTTP-Status-Codes, wobei der Bereich 2xx stets für erfolgreiche HTTP-Requests steht, 3xx für Weiterleitungen, 4xx für Fehler des aufrufenden Client und 5xx für Fehler im Server.
HTTP-Status-Code | Beschreibung |
---|---|
200 | OK, generischer Code für eine erfolgreiche Anfrage |
201 | Created, neue Ressource erfolgreich angelegt |
204 | No Content, Anfrage erfolgreich, aber es wird kein Inhalt zurückgegeben |
301 | Moved Permanently, Ressource ist dauerhaft unter anderer URI erreichbar |
304 | Not Modified, unverändert, d.h. Cache muss nicht aktualisiert werden |
400 | Bad Request, generischer Code für einen Fehler des Client in der Parametrisierung der Anfrage |
403 | Forbidden, Zugriff auf die Ressource ist verboten |
404 | Not Found, für die URI konnte keine Ressource gefunden werden |
500 | Internal Server Error, generischer Code für einen nicht weiter behandelten Fehler im Server |
503 | Service Unavailable, Service ist vorübergehend nicht zu erreichen |
Die beiden Methoden im folgenden Code-Beispiel geben eine Liste aller Konten bzw. ein einzelnes Konto zurück. Der Rückgabetyp der Methode getAccounts
(Pfad /accounts
) ist Collection<Account>
(Zeile 2). Für diese Methode wird die HTTP-Response implizit durch das JAX-RS-Framework zusammengestellt. Die Liste der Konten wird ohne Zutun des Entwicklers in eine JSON- oder XML-Repräsentation transformiert und in ein Objekt vom Typ javax.ws.rs.core.Response
eingebettet, dessen Status-Code 200 ist. Bei der zweiten Methode getAccount
(Pfad z.B. /accounts/1
) wird das Response
-Objekt explizit zusammengestellt (Zeilen 10 und 12). Die Klasse Response
bietet dazu statische Methoden um den Status-Code (Methode status
), den Body (Methode entity
) und spezifische Header-Attribute (Methode header
) zu setzen.
@GET
public Collection<Account> getAccounts() {
return accounts.values(); // return code is 200
}
@GET @Path("{id}")
public Response getAccount(@PathParam("id") int id) {
Account account = accounts.get(id);
if (account == null) {
return Response.status(Response.Status.NOT_FOUND).build(); // return code is 404
}
return Response.status(Response.Status.OK).entity(account).build(); // return code is 200
}
Im Body eines HTTP-Request und einer HTTP-Response wird i.d.R. JSON oder XML von einem REST-Webservice empfangen bzw. von diesem versendet. Das JAX-RS-Framework übernimmt implizit die Transformation von Java-Objekten in JSON/XML und andersherum. Dazu wird jeweils eine entsprechende Bibliothek benötigt. Diese Bibliotheken sind üblicherweise Jackson für das JSON-Binding und JAXB für das XML-Binding.
Die folgenden drei Methoden zeigen Implementierungen für die HTTP-Methoden POST, PUT und PATCH, die eine Ressource anlegen bzw. verändern. Dabei sind die im Folgenden erläuterten Unterschiede relevant.
/accounts
. Die neu vergebene Id muss dem Client im Response-Body und/oder Response-Header mitgeteilt werden (Zeile 5).@POST
public Response postAccount(@NotNull Account account, @Context UriInfo uriInfo) {
boolean validId = account.id > 0 && accounts.get(account.id) == null;
if (!validId) {
account.id = Account.nextId.getAndIncrement();
}
accounts.put(account.id, account);
URI uri = uriInfo.getAbsolutePathBuilder().path(Integer.toString(account.id)).build(); // append new id to URI
return Response.created(uri).entity(account).build(); // return code is 201
}
postAccount
in Zeile 6). Die bestehende Ressource wird vollständig ersetzt (Zeile 8).@PUT @Path("{id}")
public Response putAccount(@PathParam("id") int id, @NotNull Account account, @Context UriInfo uriInfo) {
boolean exists = accounts.get(id) != null;
account.id = id;
if (!exists) {
return postAccount(account, uriInfo);
} else {
accounts.put(id, account);
return Response.ok(account).build(); // return code is 200
}
}
@PATCH @Path("{id}")
public Response patchAccount(@PathParam("id") int id, @NotNull Account patchedAccount) {
Account account = accounts.get(id);
boolean exists = account != null;
if (!exists) {
return Response.status(404).build(); // return code is 404
} else {
if (patchedAccount.owner != null) {
account.owner = patchedAccount.owner;
}
if (patchedAccount.entries != null) {
account.entries = patchedAccount.entries;
}
return Response.ok(account).build(); // return code is 200
}
}
Das Anlegen oder Verändern einer Ressource kann grundsätzlich fehlschlagen, falls die Validierung einzelner Attribute fehlschlägt. Exemplarisch werden einige typische Bedingungen für Validierungsschritte aufgezählt.
Zur Validierung bieten sich die Bean Validation-Annotationen an. Im obigen Code-Beispiel wird exemplarisch bei jeder Methode die selbsterklärende Annotation @NotNull
verwendet.
Der Unterschied zwischen PUT und PATCH soll noch anhand eines Beispiels verdeutlicht werden. Zur Vorbereitung wird über folgende POST-Anfrage ein neues Konto angelegt.
Anschließend wird der Eigentümer des Kontos zunächst mittels einer PATCH-Anfrage verändert.
Zum Vergleich wird der Eigentümer des Kontos danach mittels einer PUT-Anfrage verändert. Wie in der jeweiligen HTTP-Response zu erkennen ist, verändert PATCH die Kontoeinträge nicht, während PUT dafür sorgt, dass keine Kontoeinträge mehr existieren, da im Body des HTTP-Request auch keine übergeben worden sind.
Das folgende Code-Beispiel zeigt zwei Ressourcen-Methoden, die beide auf GET-Anfragen bezüglich des Pfads /users/<id>
reagieren, aber jeweils eine Ausgabe in unterschiedlichem Format erzeugen. Während die erste Methode getUser
JSON oder XML als Ausgabe produziert, erzeugt die zweite Methode getUserImage
eine Binärdatei, die das User-Profilbild enthält. Das JAX-RS-Framework wertet den HTTP-Header Accept
aus, um zu entscheiden, welche der beiden Methoden ausgeführt werden soll. Dabei soll die Rückgabe in einem Format erfolgen, das möglichst der Anforderung des Client entspricht. Es kann allerdings sein, dass der Client z.B. Accept: image/jpeg
sendet, aber eine Antwort mit Content-Type: image/png
erhält, da das User-Profilbild serverseitig im PNG-Format vorliegt.
@Path("/users")
public class UserResource {
@GET @Path("{id}")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public Response getUser(@PathParam("id") int id) {
User user = getUserById(id);
return Response.ok(user).build();
}
@GET @Path("{id}")
@Produces({MediaType.APPLICATION_OCTET_STREAM, "image/png", "image/jpeg"})
public Response getUserImage(@PathParam("id") int id) throws IOException {
User user = getUserById(id);
InputStream is = new ByteArrayInputStream(user.image);
String type = URLConnection.guessContentTypeFromStream(is); // get MIME type
String ext = type.substring(type.lastIndexOf("/") + 1); // get file extension
return Response.ok(is).type(type)
.header("Content-Disposition", "inline; filename=\"" + user.name + "." + ext + "\"").build();
}
private User getUserById(int id) {
Optional<User> user = UserService.queryById(id); // query user object from database
if (user.isPresent()) { return user.get(); }
throw new NotFoundException("User not found.");
}
}
RuntimeException
(z.B. eine NullPointerException
) auftritt, wird diese durch das JAX-RS-Framework gefangen und "verschluckt", so dass keine Ausgabe auf der Konsole oder im Log erscheint. JAX-RS sieht vor, dass der Entwickler gezielt sogenannte ExceptionMapper registriert, die auf spezifische Exceptions reagieren und diese auswerten, um eine aussagekräftige HTTP-Response an den aufrufenden Client zu senden. Das folgende Code-Beispiel zeigt exemplarisch drei verschiedene Implementierungen des ExceptionMapper
-Interface.
ValidationExceptionMapper
behandelt Exceptions, die während der Validierung der Bean Validation-Annotationen auftreten, d.h. jede ConstraintValidationException
.JsonProcessingExceptionMapper
behandelt Exceptions, die während der Transformation von JSON-Input aus dem HTTP-Request-Body in Java-Objekte auftreten (z.B. unbekannte Attribute), d.h. jede JsonProcessingException
.GenericExceptionMapper
reagiert auf jede beliebige RuntimeException
. Falls es sich um eine konkrete ClientException
(Status-Code 4xx) handelt, soll die Fehlermeldung lediglich an den Client weitergereicht werden. Falls nicht, wird der Exception-Stacktrace protokolliert und der generische Status-Code 500 ausgegeben.import javax.ws.rs.ext.Provider;
import javax.ws.rs.ext.ExceptionMapper;
import javax.validation.ConstraintViolationException;
// ...
@Provider
public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException e) {
ConstraintViolation violation = e.getConstraintViolations().stream().findFirst().get();
String message = violation.getPropertyPath() + " " + violation.getMessage();
return Response.status(Response.Status.BAD_REQUEST).entity(message).type("text/plain").build();
}
}
import javax.ws.rs.ext.Provider;
import javax.ws.rs.ext.ExceptionMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
// ...
@Provider
public class JsonProcessingExceptionMapper implements ExceptionMapper<JsonProcessingException> {
@Override
public Response toResponse(JsonProcessingException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).type("text/plain").build();
}
}
import javax.ws.rs.ext.Provider;
import javax.ws.rs.ext.ExceptionMapper;
// ...
@Provider
public class GenericExceptionMapper implements ExceptionMapper<RuntimeException> {
@Override
public Response toResponse(RuntimeException e) {
if (e instanceof ClientErrorException) {
int status = ((ClientErrorException) e).getResponse().getStatus();
return Response.status(status).entity(e.getMessage()).type("text/plain").build();
}
else {
e.printStackTrace();
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
}
}
Für automatisierte Unit-Tests bietet Jersey die Klasse JerseyTest
(enthalten in der Bibliothek Jersey Test Framework Core), die durch eine eigene Testklasse erweitert werden kann (Zeile 7). Das Jersey-Test-Framework startet automatisch einen HTTP-Server unter Port 9998, auf dem die zu testende Ressource bereitgestellt wird (Zeile 11) und anschließend die Testfälle ausgeführt werden. Mittels der Methode target
der JerseyTest
-Klasse können Anfragen an den REST-Webservice aus den Testfällen heraus erzeugt werden. Ansonsten gelten die üblichen Konvention für JUnit-Tests. Dazu gehört u.a., dass der gemeinsame initiale Zustand für alle Testfälle durch eine Methode mit der Annotation @BeforeEach
hergestellt wird (Zeilen 14-20). Das folgende Code-Beispiel zeigt zwei exemplarische Testfälle für PUT (Zeilen 22-30) und DELETE (Zeilen 32-39).
import org.glassfish.jersey.test.JerseyTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
// ...
@TestInstance(Lifecycle.PER_CLASS)
public class AccountResourceTest extends JerseyTest {
@Override
protected Application configure() {
return new ResourceConfig(AccountResource.class);
}
@BeforeEach
public void prepareTest() {
target().path("accounts").request().delete(); // delete all accounts
String a = "{\"owner\": \"A\", \"entries\": [{\"value\": 20}, {\"value\": -10}]}";
target("accounts/1").request().put(Entity.json(a)); // put a test account with id = 1
// ...
}
@Test
public void testPutAccount() {
String a = "{\"owner\": \"Changed\"}";
Response res = target("accounts/1").request().put(Entity.json(a));
assertEquals(200, res.getStatus());
Account account = res.readEntity(Account.class);
assertEquals("Changed", account.owner);
assertEquals(0, account.balance());
}
@Test
public void testDeleteAccount() {
Response res = target("accounts/1").request().delete();
assertEquals(204, res.getStatus());
res = target("accounts/1").request().get(Response.class);
assertEquals(404, res.getStatus());
}
// ...
}
Zur Entwicklungszeit ist es wichtig, die REST-API einfach stichprobenartig testen zu können. Die erstellten Testfälle sollen zum Austausch mit anderen Entwicklern und zur Versionierung gespeichert werden können, und sie sollen wiederholt und automatisch ausführbar sein. Dazu gibt es mehrere, relativ ähnliche Werkzeuge, die entweder als Desktop-Anwendung oder als Browser-Plugin installiert werden können – z.B. Postman, Insomnia und Advanced REST Client. Alternativ ist es auch möglich die REST-API direkt aus der Entwicklungsumgebung (IDE) zu testen. Die beiden folgenden Videos zeigen kurz, wie die entwickelte REST-API zur Kontenverwaltung exemplarisch mittels Postman und IntelliJ IDEA getestet werden können.
REST-Webservices sind per Konvention zustandslos, d.h. auf dem Server werden keine Sessions für die aktuell verbundenen Clients verwalten. Demzufolge muss jeder Client selbst seine Session verwaltet und mit jedem HTTP-Request ein Token o.ä. an den Server senden, über das er authentifiziert und für den Zugriff autorisiert werden kann. Eine standardisierte Möglichkeit zur Erzeugung derartiger Tokens findet sich in RFC 7519, in dem sogenannte JSON Web Token (JWT) eingeführt werden.
Der Kommunikationsablauf zwischen Client und Server bei Verwendung von JWT wird in der folgenden Abbildung dargestellt.
{"alg": "HS256"}
.{"sub": 123, "name": "John Doe", "iat": 1612083600}
. Es gibt einige verbreitete Claim-Attribute, die im RFC 7519 genannt werden, wie z.B. Subject (sub), Issued At (iat) und Expiration Time (exp).{"alg": "HS256"}
, Payload {"sub": 123, "name": "John Doe", "iat": 1612083600}
und Secret my-secret wäre z.B. eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEyMywibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNjEyMDgzNjAwfQ.FQVhKVWAzvW-Pje0FlB30ITulrRwcI9Mm8HpOinMYYc.Authorization
an den Server.Es existieren diverse JWT-Implementierungen für alle gängigen Programmiersprachen. Eine gute Übersicht über die zur Verfügung stehenden JWT-Bibliotheken sowie eine interaktive Einführung findet sich auf jwt.io. Das folgende Code-Beispiel verwendet die JWT-Bibliothek Nimbus JOSE+JWT für Java.
@Path("/login")
public class AuthenticationResource {
@POST
@Produces(MediaType.TEXT_PLAIN)
public String login(User userClaim) {
Optional<User> user = UserService.queryByCredentials(userClaim.name, userClaim.passwordHash); // lookup user in database
if (user.isPresent()) {
try {
// map claim object to JSON
JWTClaim claim = new JWTClaim(String.valueOf(user.get().id), user.get().name);
String claimJson = new ObjectMapper().writeValueAsString(claim);
// create JWS object with claim as payload
JWSObject jwsObject = new JWSObject(new JWSHeader(JWSAlgorithm.HS256), new Payload(claimJson));
// use a secret key for HS256 and sign the JWS object using HMAC
byte[] secret = AuthenticationFilter.SECRET.getBytes();
jwsObject.sign(new MACSigner(secret));
// provide token to the client
return Response.ok( jwsObject.serialize() ).build();
} catch (JsonProcessingException | JOSEException e) {
throw new RuntimeException(e.getMessage());
}
}
return Response.status(401).entity("Login failed").build();
}
}
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.ext.Provider;
// ...
@Provider
@Priority(Priorities.AUTHENTICATION)
public class AuthenticationFilter implements ContainerRequestFilter {
public static final String SECRET = "my-very-private-secret:32-symbols=256-bit";
static final String TOKEN_TYPE = "Bearer";
@NameBinding @Retention(RetentionPolicy.RUNTIME)
public @interface Secured { }
@Override
public void filter(ContainerRequestContext request) {
// validate HTTP authorization header
String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith(TOKEN_TYPE + " ")) {
request.abortWith(Response.status(401).entity("JWT not found in HTTP header").build());
}
// extract the token from the HTTP authorization header
String jwt = authHeader.substring(TOKEN_TYPE.length()).trim();
// validate JWT signature
try {
JWSObject jwsObject = JWSObject.parse(jwt);
JWSVerifier verifier = new MACVerifier(SECRET);
if (jwsObject.verify(verifier)) {
String payload = jwsObject.getPayload().toString();
JWTClaim claim = new ObjectMapper().readValue(payload, JWTClaim.class);
System.out.println("User verified: " + claim.name);
return;
}
else {
request.abortWith(Response.status(401).entity("Invalid JWT").build());
}
} catch (ParseException | JOSEException | IOException e) {
request.abortWith(Response.status(400).entity(e.getMessage()).build());
}
}
}
@Path("/users")
public class UserResource {
@Secured
@GET
public Collection<User> getUsers() {
return UserService.queryAllUsers();
}
// ...
}
public class JWTClaim {
// registered JWT claim names, see https://tools.ietf.org/html/rfc7519#section-4.1
String sub; // subject
String name;
Date iat; // issued at
Date exp; // expiration time
public JWTClaim(String sub, String name) {
this.sub = sub;
this.name = name;
Calendar cal = Calendar.getInstance();
this.iat = cal.getTime();
cal.add(Calendar.HOUR, 1); // tokens are valid for 1 hour
this.exp = cal.getTime();
}
}
AuthenticationResource
: Diese Ressourcen-Klasse implementiert eine Methode zur Authentifizierung, die über den Pfad /login
erreichbar ist. Die Ausgabe ist bei erfolgreichem Login ein JSON Web Token (Zeile 22), bei nicht erfolgreichem Login ein entsprechender Fehler (Zeilen 25+28). Falls die übergebenen Credentials stimmen (Zeilen 7-8), werden folgende Schritte ausgeführt:
AuthenticationFilter
: Das JWT, das der Client bei weiteren Anfragen im HTTP-Header mitsendet, soll bei gesicherten Ressourcen-Methoden geprüft werden. Die Sicherung einer Ressourcen-Methode erfolgt über einen eigene Annotation, die hier @Secured
heißen soll und in den Zeilen 12-13 der Klasse AuthenticationFilter
deklariert wird. Diese Klasse implementiert das Interface ContainerRequestFilter
und funktioniert ihrem Namen entsprechend als Filter vor jedem Aufruf einer Methode mit der Annotation @Secured
. Die Verifikation des JWT erfolgt in drei Schritten:
UserResource
: Die Klasse enthält eine Ressourcen-Methode, die exemplarisch mit @Secured
annotiert ist, um den Zugriff auf diese Methode mittels der AuthenticationFilter
-Klasse abzusichern.