Vaadin Security, Teil 3.3 – Vaadin als REST-Client, drei Anbindungsstile und Bilanz

Sven Ruppert

Der bisher in Teil 3.1 als Übergang und in Teil 3.2 als Implementierung beschriebene REST-Adapter findet im Modul demo-vaadin-rest-client seinen ersten Konsumenten. Dort tritt die Vaadin-UI in einer Doppelrolle auf, die in Teil 1 und Teil 2 noch nicht im Bild war: Sie ist weiterhin View-Anbieter mit serverseitiger Navigationsprüfung und zugleich REST-Client gegenüber einem separaten Backend. Beide Rollen werden durch denselben Kern und denselben SPI-Mechanismus getragen; was sich ändert, sind ausschließlich drei META-INF/services-Dateien und drei Implementierungsklassen.

Sämtliche im Folgenden vorgestellten Quelltexte sind auf GitHub veröffentlicht und

unter https://3g3.eu/vaadin-security abrufbar.

Vier Kapitel führen durch diese zweite Demo. Erst die Architektur der sechs Klassen, die das Modul zusammenhalten. Dann die drei Anbindungsstile an einer einzigen Vaadin-Anwendung. Schließlich die didaktische Klammer am BackendOperationCard, an der sich drei Schichten — View-Schutz, Endpunktprüfung und serverseitige Operation-Visibility — in einem einzigen UI-Bauteil treffen. Die Bilanz im letzten Kapitel schließt mit der gemeinsamen Tagesordnung, die für die Teile 4 und 5 offen bleibt.

Das Modul demo-vaadin-rest-client als zweite Vaadin-Demo

Im Repository leben zwei Vaadin-Demos. Das in Teil 1 und Teil 2 entwickelte Modul demo-vaadin ist eine Single-JVM-Anwendung mit lokal gespeicherten Benutzerdaten und lokal entscheidender Autorisierung. Das Modul demo-vaadin-rest-client ist seine Antithese: dieselbe Vaadin-Welt, aber alle Entscheidungen über Authentifizierung, Autorisierung, Bootstrap-Status und Sichtbarkeit von Operationen werden an ein separates REST-Backend delegiert. Das Backend ist die autoritative Quelle; die UI ist sein Konsument. Das Demo trägt diese Entscheidung vom Login bis zum Logout und macht damit die in Teil 2 etablierte SPI-Mechanik erstmals sichtbar produktiv: Drei kleine Klassen genügen, um aus der in Teil 1 entwickelten Single-JVM-Demo eine REST-konsumierende Anwendung zu machen.

Bevor die drei Adapter betrachtet werden, lohnt sich ein Blick auf die Klasse, die alle anderen umgeht — den DemoBackendClient. Es handelt sich um eine Schnittstelle mit einer überschaubaren Methodenliste: bootstrapStatus, createInitialAdmin, login, logout, currentUser, visibleOperations, listDocuments, createDocument, deleteDocument, adminStatus. Jede Methode entspricht direkt einem Endpunkt aus Kapitel 4 und liefert einen domänenorientierten Rückgabetyp — etwa LoginResult, RemoteUser, BootstrapResult —, der die HTTP-Welt vor dem Aufrufer verbirgt. Die einzige Implementierung dieser Schnittstelle ist HttpDemoBackendClient, und das JavaDoc dieser Klasse drückt ihre Rolle prägnant aus: „The single class in this module that knows about HTTP, JSON and endpoint paths.” HttpClient aus java.net.http, DemoJson als minimaler JSON-Parser, kein RestTemplate, keine externe JSON-Bibliothek, kein zusätzlicher HTTP-Client. Wer sehen möchte, wie ein Login zu einem LoginResult.Authenticated mit Token und RemoteUser wird oder wie ein 401 in eine BackendException.Kind.Unauthenticated übersetzt: Die gesamte Mechanik befindet sich in dieser einen Klasse.

Die SPI-geladenen Adapter verfügen nicht über einen Dependency-Injection-Container, der ihnen den DemoBackendClient zur Konstruktionszeit übergeben würde. Aus dieser Beschränkung ergibt sich eine kleine Konstruktion namens BackendClientProvider — ein statischer Singleton-Accessor, der eine voreingestellte Implementierung hält und Test-Doubles über setClient(…) und reset() zulässt. Die Klasse gibt ihren Existenzgrund explizit im JavaDoc an: „Required because the SPI-loaded authorization adapters have no DI surface to receive the client.” Statische Accessoren sind in größeren Anwendungen meist unerwünscht; in einem SPI-getriebenen Modul ohne weitere Container-Infrastruktur sind sie die kleinstmögliche Möglichkeit, einen geteilten Dienst sichtbar zu machen.

Mit diesen beiden Bausteinen lassen sich die drei Adapter in nur wenigen Zeilen formulieren. Der RestBackedAuthenticationService ist die Login-seitige Delegation:

public class RestBackedAuthenticationService
     implements AuthenticationService<Credentials, RemoteUser> {
 
   @Override
   public boolean checkCredentials(Credentials credentials) {
     if (credentials == null) return false;
     LoginResult result = BackendClientProvider.client().login(credentials);
     if (result instanceof LoginResult.Authenticated ok) {
       ClientSecurityContext.setActiveLogin(ok.token(), ok.user());
       return true;
     }
     return false;
   }
 
   @Override
   public RemoteUser loadSubject(Credentials credentials) {
     return ClientSecurityContext.user().orElse(null);
   }
 
   @Override
   public Class<RemoteUser> subjectType() {
     return RemoteUser.class;
   }
 }

Die Klasse implementiert den AuthenticationService<Credentials, RemoteUser>-Vertrag aus Teil 2 und ist unter META-INF/services als SPI-Implementierung registriert. Die checkCredentials-Methode ruft den Backend-Client auf, prüft das Ergebnis als versiegelten LoginResult-Typ und legt im Erfolgsfall Token und RemoteUser über den ClientSecurityContext ab. Die loadSubject-Methode greift anschließend auf denselben Speicher zu und gibt den bereits gecacheten RemoteUser zurück — ohne weiteren Round-Trip zum Backend. Wer den Code aus Teil 2 vor Augen hat, sieht in dieser Klasse nichts Neues; sie ist eine ServiceLoader-konforme Implementierung des bekannten Vertrags, deren konkrete Logik schlicht eine Methodendelegation ist.

Die zweite Adapterklasse, der RestBackedAuthorizationService, ist noch knapper. Sie liefert für einen gegebenen RemoteUser dessen Rollen und Permissions zurück — und tut nichts Weiteres, weil der RemoteUser selbst beide Listen als Felder enthält und zugleich HasRoles und HasPermissions implementiert. Der Vertrag fragt nach Listen, der Subject-Typ liefert sie, der Adapter ist ein Pass-Through. Diese Trivialität ist die Pointe der Klasse: Weil das Backend Rollen und Permissions bereits beim Login mitliefert und der Client sie im RemoteUser-Snapshot ablegt, fallen für nachfolgende UI-Entscheidungen keinerlei weitere REST-Aufrufe an. Das JavaDoc sagt es klar: „This service simply returns the snapshot — no further round-trips per UI decision.”

Der dritte Adapter ist der BackedLoginListener. Er erbt vom LoginListener<RemoteUser> aus Teil 1 und legt fest, welche die Login-View und welche die Default-Navigation der Anwendung sind — MyLoginView.class und MainView.class, beides Klassen des demo-vaadin-rest-client. Mehr leistet er nicht; die eigentliche Mechanik des Login-Routings liegt im AuthorizationListener aus Teil 2, und der LoginListener liefert nur die anwendungsspezifischen Klassennamen.

Der ClientSecurityContext, an den die beiden zuvor genannten Adapter ihre Daten übergeben, ist die didaktisch tragende Klasse dieses Kapitels. Er löst eine konkrete Spannung auf: Der Token ist eine HTTP-Gegebenheit ohne natürliche Form in der Vaadin-Welt; der RemoteUser ist ein Subject im in Teil 2 etablierten Sinn. Die Klasse hält beide und nutzt für jeden den passenden Speichermechanismus:

public final class ClientSecurityContext {
 
   private static final String TOKEN_KEY = ClientSecurityContext.class.getName() + ".token";
 
   public static void setActiveLogin(String token, RemoteUser user) {
     VaadinSession session = VaadinSession.getCurrent();
     if (session != null) session.setAttribute(TOKEN_KEY, token);
     SubjectStores.subjectStore().setCurrentSubject(user, RemoteUser.class);
   }
 
   public static Optional<String> token() {
     VaadinSession session = VaadinSession.getCurrent();
     if (session == null) return Optional.empty();
     Object value = session.getAttribute(TOKEN_KEY);
     return value instanceof String s && !s.isBlank() ? Optional.of(s) : Optional.empty();
   }
 
   public static Optional<RemoteUser> user() {
     return SubjectStores.subjectStore().currentSubject(RemoteUser.class);
   }
 
   public static void clear() {
     VaadinSession session = VaadinSession.getCurrent();
     if (session != null) session.setAttribute(TOKEN_KEY, null);
     SubjectStores.subjectStore().deleteCurrentSubject(RemoteUser.class);
   }
 }

Der Token lebt in einem privaten Session-Attribut der VaadinSession — einer schmalen, applikationsspezifischen Ablage, die der Adapter aus Teil 2 nicht kennt und nicht kennen muss. Der RemoteUser lebt im SubjectStore aus Teil 2, also genau dort, wo der AuthorizationListener ihn bei der Auflösung des aktuellen Subjects sucht. Diese Doppelung ist die Pointe der Klasse: Was Vaadin braucht, lebt im Vaadin-Speicher; was die HTTP-Brücke braucht, lebt daneben unter einem privaten Schlüssel. Beide Speicherformen werden in setActiveLogin gemeinsam gesetzt und in clear gemeinsam geleert; dazwischen fragt der Vaadin-Adapter aus Teil 2 den SubjectStore ab und der BackendOperationCard aus Kapitel 10 fragt den Token-Accessor ab. Keine der beiden Seiten weiß von der jeweils anderen Speicherform.

Die MyLoginView der Demo zeigt eine letzte, kleinere Eigenschaft, die das autoritative Backend-Verhältnis sichtbar macht. Beim Betreten der Login-Views prüft ein BeforeEnterObserver, ob das Backend ein Bootstrap-Setup verlangt, indem er BackendClientProvider.client().bootstrapStatus() aufruft und die Eigenschaft bootstrapRequired() abfragt. Liefert sie true, leitet die View zur SetupView weiter, die den ersten Administrator über backend.createInitialAdmin(…) anlegt. Damit ist die Anwesenheit oder Abwesenheit eines bereits angelegten Administrators keine UI-Konfiguration, sondern eine Frage an das Backend; eine zweite parallel gestartete Vaadin-Instanz desselben Backends zeigt denselben Status, und ein neu gestartetes Backend erzwingt das Setup auch dann, wenn die UI bereits läuft.

Abbildung 3

Abbildung 3: Architektur des Modulsdemo-vaadin-rest-client. Sechs Klassen in vier Bereichen, der externedemo-rest-Backend als eigenständiger Prozess. Die SPI-Adapter sind die einzige Stelle, an der sich die Architektur gegenüber dem Vaadin-Adapter aus Teil 2 ändert.

Wer diese sechs Klassen — DemoBackendClient, HttpDemoBackendClient, BackendClientProvider, die drei SPI-Adapter und ClientSecurityContext — vor Augen hat, erkennt eine bemerkenswerte Eigenschaft der Architektur. Der Vaadin-Adapter selbst muss für die Architektur des demo-vaadin-rest-clients an keiner Stelle angepasst werden. Was sich ändert, sind ausschließlich die ServiceLoader-Implementierungen der drei in Teil 1 und Teil 2 etablierten Verträge — AuthenticationService, AuthorizationService, LoginListener — sowie eine kleine, applikationsspezifische Subject-Speicherklasse für den Token. Der Wechsel von der Single-JVM-Demo zur REST-konsumierenden Demo erfolgt über drei META-INF/services-Dateien und die genannten Klassen, ohne dass in security-core oder security-vaadin eine einzige Zeile geändert wird. Diese Eigenschaft ist die eigentliche Bestätigung der in Teil 2 vorgenommenen Trennung: Was getragen werden sollte, trägt — und was sich ändern darf, ist klar abgegrenzt.

Damit ist die zweite Vaadin-Demo in ihrer Grundstruktur beschrieben. Was bleibt, sind die konkreten Views, die zeigen, wie eine Vaadin-Anwendung die Annotationen aus Kapitel 4 nutzt — und in welchen Stilen die Demo dies bewusst nebeneinander zeigt. Diese Frage greift in Kapitel 9 auf die drei Anbindungsstile @RequiresPermission, @RequiresRole und @VisibleForRoles zurück.

Drei Anbindungsstile in der Vaadin-UI

Wer Kapitel 4 vor Augen hat, weiß, dass der Kern drei Restriktionsannotationen anbietet — @RequiresRole, @RequiresPermission und @ProtectedBy — und dass die Bibliothek zusätzlich erlaubt, eigene Restriktionsannotationen über die Meta-Annotation @SecurityAnnotation zu definieren. Das demo-vaadin-rest-client-Modul nimmt dieses Angebot vollständig an. Es führt drei Anbindungsstile vor: zwei generische, die direkt mit den Annotationen aus dem Kern arbeiten, und einen projekt-eigenen, der das Annotation-Scanner-Muster mit eigener Annotation und eigenem Evaluator wiederholt. Diese Stilbreite ist keine Inkonsequenz, sondern eine bewusste Demonstration: Wer im eigenen Projekt eine konkrete Wahl treffen möchte, sieht im selben Modul, wie sich die drei Wege im Code verhalten.

Die ersten beiden Stile sind generisch. In views/standalone/DocumentsView trägt die Route die Annotation @RequiresPermission(“document:read”) direkt an der Klasse — das ist Stil A1. Damit ist die View für jeden geschützt, der diese Permission im Subject führt, also für ROLE_VIEWER, ROLE_EDITOR und ROLE_ADMIN in der Demo. Die Annotation stammt unverändert aus security-core, der Evaluator ist der RequiresPermissionEvaluator, und die View enthält selbst keinerlei Code für die Schutzentscheidung. In views/standalone/AdminStatusView trägt die Route die Annotation @RequiresRole(“ROLE_ADMIN”) — das ist Stil A2. Hier ist die View für die anderen Rollen unzugänglich; der Editor und der Viewer werden vom AuthorizationListener zur Login-View bzw. zur Fehlerseite umgeleitet. Beide Stile sind unmittelbar verständlich, beide kommen ohne projekt-eigenen Code aus und produzieren denselben Pfad durch den SecurityAnnotationScanner sowie denselben Evaluator-Aufruf im Kern.

Der dritte Stil — Stil B — geht einen anderen Weg. In views/standalone/NerdView steht weder @RequiresRole noch @RequiresPermission, sondern eine projekt-eigene Annotation:

@Route(NerdView.NAV)
 @VisibleForRoles({ProjectRole.ADMIN, ProjectRole.EDITOR})
 public class NerdView extends Composite<Div> { /* ... */ }

Die Annotation @VisibleForRoles lebt nicht im Kern, sondern im Modul demo-vaadin-rest-client selbst — im Paket security. Ihre Definition ist denkbar schmal:

@Retention(RetentionPolicy.RUNTIME)
 @Target(TYPE)
 @SecurityAnnotation(ProjectRoleAccessEvaluator.class)
 public @interface VisibleForRoles {
   ProjectRole[] value();
 }

Drei Eigenschaften sind hervorzuheben. Erstens trägt die Annotation selbst die Meta-Annotation @SecurityAnnotation aus Kapitel 4 — sie verbindet sich damit deklarativ mit ihrem Evaluator, ohne dass irgendwo eine Konfiguration oder ein Registry-Eintrag nötig wäre. Zweitens nimmt sie nicht ein String-Array entgegen wie @RequiresRole, sondern ein typensicheres Enum-Array ProjectRole[], das die Verwendung leserlicher macht und vor Tippfehlern in Rollennamen schützt. Drittens ist die Annotation auf TYPE beschränkt — sie steht ausschließlich an View-Klassen und nicht an Methoden, was zu ihrer fachlichen Bedeutung „sichtbar für diese Rollen” passt.

Der zugehörige Evaluator vollendet das Bild:

public final class ProjectRoleAccessEvaluator implements AuthorizationEvaluator<VisibleForRoles> {
 
   @Override
   public AuthorizationDecision evaluate(AccessContext context, VisibleForRoles annotation) {
     if (context.subject().isEmpty()) {
       return AuthorizationDecision.unauthenticated("No authenticated subject");
     }
     Set<RoleName> required = Arrays.stream(annotation.value())
         .map(ProjectRole::roleName)
         .collect(Collectors.toUnmodifiableSet());
     boolean granted = RoleMatcher.containsAny(
         context.subject().orElseThrow().roleNames(),
         required);
     return granted
         ? AuthorizationDecision.granted()
         : AuthorizationDecision.forbidden("Missing required role");
   }
 }

Wer diesen Evaluator neben dem RequiresRoleEvaluator aus dem Kern liest, sieht eine fast identische Struktur. Erst die subject().isEmpty()-Prüfung mit unauthenticated-Antwort, dann die Übersetzung der Annotation-Werte in eine Menge von RoleName, dann der RoleMatcher.containsAny-Test gegen die Rollen des Subjects, schließlich granted oder forbidden. Der einzige Unterschied liegt im Annotation-Wert-Typ — ProjectRole-Enum statt String-Array — und in der Übersetzung in RoleName über die roleName()-Methode des Enums, die jedem Eintrag das Präfix ROLE_ voranstellt. Diese Nähe zum Kern-Evaluator ist beabsichtigt: Wer einen eigenen Evaluator schreibt, soll das Muster im Kern erkennen und nicht neu erfinden.

Die didaktische Pointe der drei Stile liegt darin, was sie gemeinsam haben, nicht darin, was sie voneinander unterscheidet. Alle drei Routen werden vom selben AuthorizationListener aus Teil 2 inspiziert. Alle drei Annotationen werden vom selben SecurityAnnotationScanner aus Kapitel 4 gefunden, weil alle drei die Meta-Annotation @SecurityAnnotation tragen — die generischen aus dem Kern haben sie auf der Annotation-Definition, die projekt-eigene @VisibleForRoles ebenfalls. Alle drei Evaluatoren liefern eine AuthorizationDecision, die der Listener über die in Kapitel 2 beschriebene map-Methode in eine AccessDecision für die Vaadin-Welt übersetzt. Der Mechanismus ist identisch; nur das Annotation-Vokabular unterscheidet sich.

Daraus ergibt sich eine Aussage, die für die Wahl zwischen den drei Stilen entscheidend ist. Ein Projekt, das ausschließlich generische Annotationen verwendet, schreibt keinen einzigen Evaluator selbst und bleibt vollständig im Vokabular der Bibliothek. Ein Projekt, das eine eigene Annotation einführt, schreibt eine Annotation-Definition mit Meta-Annotation und einen Evaluator von etwa fünfzehn Zeilen — dafür gewinnt es ein typsicheres, projekt-spezifisches Vokabular, das näher an der Domäne liegt. Beide Wege sind im Repository ohne Verschiebung der Mechanik nebeneinander gangbar; der Wechsel zwischen ihnen erfordert keine Anpassung an security-core, security-vaadin oder security-rest. Genau deshalb zeigt die Demo die drei Stile gleichzeitig: Wer sich für einen entscheidet, soll im selben Repository den Code für die Alternative finden und sehen, wie wenig sich in Wahrheit ändert.

Damit ist die Frage nach den View-Stilen geklärt. Was bleibt, ist die Stelle, an der die Vaadin-UI nicht eine Route schützt, sondern eine konkrete Operation auslöst — der Klick auf eine Schaltfläche, der anschließend einen REST-Aufruf an einen permission-geschützten Endpunkt auslöst. An dieser Stelle treffen die drei Schichten aufeinander, die der gesamte Artikel bisher einzeln behandelt hat: die View-Sichtbarkeit, die Endpunktprüfung und die serverseitige Operation-Visibility. Diese Klammer ist Gegenstand von Kapitel 10 mit der BackendOperationCard als didaktischem Ankerpunkt.

Operation-Visibility und doppelte Schutzlinie am BackendOperationCard

Wer in Kapitel 9 die drei Anbindungsstile gelesen hat, hat eine vollständige Antwort darauf, wie eine View ihre Sichtbarkeit auf eine Rolle oder eine Permission stützt. Was aber geschieht, wenn die Sichtbarkeit nicht für die gesamte View gefordert ist, sondern für einzelne Schaltflächen innerhalb einer View? Die naheliegende Antwort wäre, jeden Button mit einer eigenen Annotation zu schützen — was zwar funktioniert, aber zwei Probleme aufwirft. Erstens müsste die UI dann eine Liste aller Buttons gegen den aktuellen RemoteUser-Snapshot einzeln durchgehen, wie es die PermissionDemoCard im Repository zur Demonstration zeigt. Zweitens — und schwerwiegend — wäre die UI auf eine in der Vaadin-Welt gespeicherte Permission-Liste angewiesen, deren Aktualität sie nicht garantieren kann. Was geschieht, wenn der Benutzer im Backend gerade eine Berechtigung verloren hat?

Die saubere Antwort liegt in der Kombination aus zwei Mechanismen. Der Erste lebt im Modul security-core und heißt OperationVisibilityService. Sein Kern besteht aus einer einzigen Filtermethode:

public final class OperationVisibilityService {
 
   private final SecuredOperationRegistry registry;
 
   public List<SecuredOperationDescriptor> visibleFor(SecuritySubject subject) {
     if (subject == null) return List.of();
     return registry.all().stream()
         .filter(op -> isAllowed(subject, op))
         .toList();
   }
 
   static boolean isAllowed(SecuritySubject subject, SecuredOperationDescriptor op) {
     if (!subject.roles().containsAll(op.requiredRoles())) return false;
     return subject.permissions().containsAll(op.requiredPermissions());
   }
 }

Eine SecuredOperationRegistry enthält Beschreibungen aller im Backend ausgeführten Operationen — pro Operation einen SecuredOperationDescriptor mit id, label, geforderten Rollen, geforderten Permissions und einer Attributes-Map für Adapter-spezifische Metadaten wie HTTP-Methode und Pfad. Der Service filtert diese Liste nach einem Subject und gibt nur die Einträge zurück, deren Rollen- und Permission-Anforderungen das Subject erfüllt. null als Subject führt zu einer leeren Liste; eine Operation ohne geforderte Rollen oder Permissions ist für jeden authentifizierten Benutzer sichtbar.

Im demo-rest ist eine konkrete Belegung dieser Mechanik zu sehen. Die Klasse DemoOperationRegistry registriert vier Operationen — list-documents, create-document, delete-document, admin-status —, jede mit ihrem HTTP-Verb, ihrem Pfad und der zur Ausführung benötigten Permission aus dem Demo-Modell. Daraus baut sie einen OperationVisibilityService, dessen visibleFor-Methode den GET /api/operations-Endpunkt füllt. Die Konsequenz ist erzählerisch wertvoll: Ein Editor, der den Endpunkt aufruft, erhält drei Operationen zurück; ein Viewer erhält eine; ein Admin erhält vier. Die Filterung geschieht serverseitig, gegen das autoritative Subject des Backends, ohne dass der Aufrufer eine Permission-Liste mitbringen müsste. Die zugehörigen Tests aus dem DemoRestServerTest belegen dies durch direkte Vergleiche der zurückgelieferten Operation-Listen für die drei Demo-Benutzer.

Auf der UI-Seite nutzt die Klasse BackendOperationCard aus dem demo-vaadin-rest-client diese Mechanik in einer schmalen Form. Sie zeigt vier Buttons — alle vier, ohne lokale Sichtbarkeitsentscheidung — und hängt an jeden eine Aktion, die den entsprechenden REST-Aufruf auslöst. Welche Buttons der Benutzer „darf”, entscheidet die Demo zunächst nicht; sie zeigt sie alle. Die echte Antwort kommt vom Backend bei jedem einzelnen Klick:

private static void runWithToken(java.util.function.Consumer<String> action) {
   String token = ClientSecurityContext.token().orElse(null);
   if (token == null) {
     err("No active session — log in again");
     return;
   }
   try {
     action.accept(token);
   } catch (BackendException ex) {
     switch (ex.kind()) {
       case Forbidden       -> err("403 — backend denied: missing permission");
       case Unauthenticated -> err("401 — session expired, please log in");
       case NotFound        -> err("404 — resource not found");
       case BadRequest      -> err("400 — bad request");
       case Conflict        -> err("409 — conflict");
       case ServerError     -> err("500 — backend internal error");
       case Transport       -> err("Transport error: " + ex.getMessage());
       default              -> err("Unexpected backend response");
     }
   }
 }

Drei Eigenschaften sind hier hervorzuheben. Erstens holt sich die Methode den Token aus dem ClientSecurityContext aus Kapitel 8 — derselbe Speicher, in den der RestBackedAuthenticationService beim Login geschrieben hat. Zweitens delegiert sie die eigentliche Aktion an den DemoBackendClient aus Kapitel 7 — denselben Client, der intern den Authorization: Bearer <token>-Header setzt. Drittens behandelt sie eine fehlgeschlagene Backend-Antwort als BackendException, deren Kind-Enum die HTTP-Statuscodes aus Kapitel 5 widerspiegelt — Forbidden für 403, Unauthenticated für 401 und so weiter. Die UI sieht damit nicht den Statuscode selbst, sondern eine semantische Klassifikation der Antwort, und übersetzt sie in eine ruhige, nicht verräterische Notification.

Damit treffen sich an dieser einen Komponente die drei Schichten, die der Artikel bisher einzeln behandelt hat. Die View-Schicht aus Kapitel 9 entscheidet, welche Routen überhaupt erreichbar sind; nur ein authentifizierter Benutzer mit ausreichender Rolle gelangt zu einer View, in der ein BackendOperationCard lebt. Die Endpunkt-Schicht aus den Kapiteln 4 und 6 entscheidet beim tatsächlichen Aufruf, ob die geforderte Permission vorhanden ist; ein Editor, der auf „delete-document“ klickt, erhält einen 403, weil ihm „document:delete“ fehlt. Die Operation-Visibility-Schicht aus diesem Kapitel entscheidet, welche Operationen der Server überhaupt als „sichtbar” deklariert; ein Editor, der GET /api/operations aufruft, sieht in der Antwort kein delete-document, weil das Backend es ausfiltert. Die BackendOperationCard ist die didaktische Klammer, in der diese drei Schichten gemeinsam erprobt werden können.

Abbildung 4

Abbildung 4: Drei-Schichten-Klammer um die BackendOperationCard. View-Schutz, Operation-Visibility und Endpunkt-Prüfung fungieren als gleichberechtigte Schutzebenen für die Komponente. Der Server bleibt in jeder Schicht die autoritative Entscheidungsstelle.

Eine Beobachtung zur Wahl der UI-Strategie. Die BackendOperationCard zeigt bewusst alle vier Buttons, statt sie je nach lokal gespeicherten Permissions auszublenden. Die parallel dazu im selben Modul lebende PermissionDemoCard zeigt die alternative Strategie — Buttons nur dann anzeigen, wenn die Permission im RemoteUser-Snapshot vorhanden ist — und kennzeichnet sie ausdrücklich als UX-Hinweis und nicht als Sicherheitsgrenze. Beide Strategien sind in derselben Demo verfügbar; die Wahl zwischen ihnen ist eine UX-Frage, keine Sicherheitsfrage. Wer auf eine elegante Darstellung Wert legt, blendet aus; wer maximale Klarheit für die Demonstration sucht, lässt alle Buttons stehen und überlässt dem Backend die Antwort. Die Sicherheit gewinnt in beiden Fällen das Backend.

Diese Klammer kehrt in Teil 6 der Serie zurück. Dort wird ein ActionAuthorizationService als dritte Schutzebene eingeführt, der die Vaadin-UI um eine geprüfte Quelle für isAllowed-Entscheidungen erweitert — ohne dass die UI selbst zur Schutzgrenze wird. Bereits in Teil 3 ist sichtbar, dass die UI eine geprüfte Quelle für ihre Sichtbarkeitsentscheidung verdient: die PermissionGuard.hasPermission-Aufrufe gegen den RemoteUser-Snapshot tun bereits das Richtige, sind aber an die Aktualität des gecacheten Snapshots gebunden. Die in Teil 6 eingeführte Schicht löst genau diese Bindung auf, ohne die Schutzgrenze zu verlegen. Der BackendOperationCard aus diesem Kapitel ist die Vorlage, an der Teil 6 sein Argument konkret machen wird.

Damit ist die letzte didaktische Klammer des Artikels geschlossen. Drei Schichten treffen sich an einem einzigen UI-Bauteil, jede für sich klar abgegrenzt, alle drei gleichzeitig im Bild. Was bleibt, ist die Bilanz — was die in den vorigen zehn Kapiteln beschriebene Architektur wirklich trägt, und was sie bewusst noch nicht trägt. Diese Frage greift Kapitel 11 als Abschluss des Artikels auf.

Was die Lösung trägt, und was bewusst noch fehlt

Eine Bilanz lässt sich am ehrlichsten ziehen, wenn sie nicht nur die Erfolge benennt, sondern auch die Stellen, an denen die in den bisherigen Kapiteln beschriebene Architektur bewusst keine Antwort liefert. Beides verdient Aufmerksamkeit, und beides wird in diesem Schlusskapitel knapp behandelt — die Tragsäulen einzeln, weil ohne sie der Artikel nicht trägt, die Lücken einzeln, weil sie zur Tagesordnung der nachfolgenden Teile dieser Serie gehören.

Die erste Tragsäule ist die Symmetrie zwischen Vaadin-Trias und REST-Trias. Was Teil 2 an drei kleinen Vaadin-Klassen — VaadinAccessContextFactory, VaadinAccessDecisionMapper, VaadinSessionSubjectStore — als Übersetzungsschicht zwischen Adapter und Kern eingerichtet hat, findet im REST-Adapter eine fast identische Form: RestAccessContextFactory, HttpStatusDecisionMapper, RestSubjectResolver. Drei Klassen, drei Aufgaben, jede in einem einzigen Satz beschreibbar, jede strukturell zu ihrem Vaadin-Pendant parallel. Diese Symmetrie ist nicht oberflächlich; sie ist die strukturelle Bestätigung dafür, dass die in Teil 2 vorgenommene Trennung zwischen Entscheidung und Vollzug an einer zweiten, fachlich völlig anderen Anbindung beruht. Wer in einem späteren Schritt einen dritten Adapter schreibt — etwa für Messaging oder Hintergrundverarbeitung —, kennt das Muster bereits.

Die zweite Tragsäule ist der gemeinsame SecurityAnnotationScanner. Vaadin- und REST-Adapter teilen ihn nicht durch Wiederverwendung über zwei Codepfade, sondern durch tatsächlich gemeinsamen Quelltext im Kern. Drei Annotationen — @RequiresRole, @RequiresPermission, @ProtectedBy — und der Mechanismus, eigene Annotationen über @SecurityAnnotation einzuführen, decken alle drei in Kapitel 9 demonstrierten Anbindungsstile ab. Eine Anwendung, die heute mit @VisibleFor aus Teil 1 arbeitet, eine andere, die @RequiresPermission aus dem Kern verwendet, und eine dritte, die eine eigene @VisibleForRoles definiert, laufen alle über denselben Scanner und denselben Listener-Pfad. Die Stilbreite entsteht ohne Vielfalt der Mechanismen; sie ist die Folge der Knappheit der Bibliothek.

Die dritte Tragsäule ist die serverseitige Operation-Visibility, die in Kapitel 10 am BackendOperationCard sichtbar wurde. Die in Teil 1 ehrlich benannte Aussage, dass UI-Sichtbarkeit keine Schutzgrenze ist, kehrt am Ende des Artikels in präziserer Form wieder: Eine UI darf eine geprüfte Auskunft zur Sichtbarkeit nutzen, sofern diese Auskunft vom Server stammt. Der OperationVisibilityService filtert die Liste der ausführbaren Operationen, der GET /api/operations-Endpunkt gibt sie an die UI, und der BackendOperationCard zeigt sie ohne lokale Permission-Logik an. Das eigentliche Recht zur Ausführung liegt weiterhin am Endpunkt, nicht an der UI. Diese Schichtung — UI-Hinweis, Endpunktprüfung, Server-autoritative Operation-Visibility — bildet die didaktische Klammer des gesamten Artikels.

Demgegenüber stehen Lücken, die das Repository in seiner heutigen Form nicht schließen. Die augenscheinlichste lebt im UserStorage des demo-rest. Benutzer und Kennwörter werden dort als Klartext in einer ConcurrentHashMap gespeichert — admin/admin, editor/editor, viewer/viewer. Das ist kein Detail, das im Produktionsbetrieb übersehen werden dürfte; es ist die in Teil 1 als bewusst markierte Demo-Vereinfachung, die sich durch den gesamten Artikel zieht. Die Bibliothek bietet zwar einen PasswordHasher mit PBKDF2-HMAC-SHA256 an, der für die Bootstrap-Anlage des ersten Administrators bereits genutzt wird; die laufende Authentifizierung der vorpopulierten Demo-Benutzer greift jedoch unverändert auf Klartext zu. Diese Lücke ist die zentrale Tagesordnungsposition in Teil 4 der Serie, der den Bootstrap und die Erstanlage als eigenständiges Thema behandelt.

Die zweite Lücke besteht im fehlenden Audit. Wer in einer produktiven Anwendung wissen möchte, wer wann welche Operation aufgerufen hat oder welche Permission verweigert wurde, findet im heutigen Repository keine Auditspuren. Erfolgreiche und fehlgeschlagene Logins werden ebenso wenig protokolliert wie 401- oder 403-Antworten. Audit ist kein Teil der Bibliothek; es ist ein typisches Querschnittsthema, das in eine eigene Schicht gehört und in Teil 5 als Bestandteil der Produktionshärtung eingeführt wird.

Die dritte Lücke ist der fehlende Brute-Force-Schutz. Der Login-Endpunkt akzeptiert beliebig viele Anfragen pro Sekunde; eine LoginAttemptPolicy mit Sperrzähler, Sperrzeit und exponentiellem Backoff existiert nicht. Auch dies ist eine bewusste Vereinfachung der Demo, kein Mangel an der Architektur. Teil 5 wird diese Schicht als zweiten Baustein der Produktionshärtung einführen.

Die vierte Lücke betrifft den Logout. Der demo-rest revoziert beim POST /api/logout den Token im DemoTokenStore — das ist korrekt, aber bezogen auf eine einzelne Sitzung. Hat ein Benutzer parallel mehrere Sitzungen offen, etwa in einem zweiten Browserfenster oder über die CLI, bleibt jede dieser Sitzungen unangetastet. Ein koordinierter Logout, der alle Sitzungen eines Benutzers gleichzeitig beendet, ist nicht implementiert; es gibt im DemoTokenStore keinen Index nach Benutzer, der eine solche Operation ohne sequenzielles Durchsuchen aller Token ermöglichen würde. Auch dies ist Sache von Teil 5.

Die fünfte und letzte Lücke ist das fehlende Token-Refresh. Der demo-vaadin-rest-client hält den Token im ClientSecurityContext bis zum Logout oder bis zum nächsten 401; eine Mechanik, die einen kurzlebigen Access-Token gegen einen länger lebenden Refresh-Token einlöst, existiert nicht. Für die Demo ist das angemessen, da die Token-Laufzeit an die Lebensdauer des Backends gebunden ist. Eine produktive Anwendung mit JWT-Validierung und kurzlebigen Tokens würde an dieser Stelle eine Refresh-Mechanik einsetzen, die in der Bibliothek bewusst nicht implementiert ist.

Die fünf benannten Lücken bilden zusammen die Tagesordnung der Teile 4 und 5 dieser Serie. Teil 4 wird den Bootstrap als eigenständiges Thema behandeln und damit die erste Lücke — die Klartext-Authentifizierung der Demo-Benutzer — schließen, indem er den ersten Administrator über drei Adapter (Vaadin-/setup-View, REST-Endpunkt, CLI) anlegt und den PasswordHasher konsequent einsetzt. Teil 5 wird die Produktionshärtung behandeln und Audit, Brute-Force-Schutz, koordinierten Logout sowie die thematisch verwandte Session-Verwaltung in eigene Schichten aufteilen.

Was Teil 3 in seinen elf Kapiteln gezeigt hat, ist damit nicht der Endpunkt, sondern die Brücke. Derselbe Kern, der in Teil 2 freigelegt wurde, beherbergt heute zwei Adapter sowie zwei vollständige Vaadin-Demos. Was darauf folgt, ist die ehrliche Schließung der Lücken, die das heutige Repository — bewusst — noch offen lässt.

Happy Coding

Sven

Total
0
Shares
Previous Post

Vaadin Security, Teil 3.2 – Der REST-Adapter im Detail: drei Klassen, zwei Filter, ein Bearer-Token

Next Post

Destructoring ist die Zukunft von Javas Encapsulation

Related Posts