In einer typischen Vaadin-Anwendung gibt es Bereiche, die nicht jeder sehen soll. Eine Administrationsoberfläche zum Beispiel, oder eine Auswertung, die ausschließlich für eine bestimmte Benutzergruppe gedacht ist. Der naheliegende Reflex ist, den entsprechenden Menüpunkt einfach nur denjenigen Benutzern anzuzeigen, die ihn auch verwenden dürfen. Die Benutzer ohne passende Rolle sehen den Tab nicht, klicken ihn nicht an, und das Problem scheint gelöst.
Es ist nicht gelöst. Was wie eine Sicherheitsmaßnahme aussieht, ist Benutzerführung. Der ausgeblendete Tab verhindert keinen Zugriff, er verhindert nur, dass jemand versehentlich darauf klickt. Wer die zugehörige URL kennt oder errät, gibt sie in die Adressleiste ein und steht direkt vor der View, die eigentlich geschützt sein sollte. Die eigentliche Schutzgrenze liegt nicht im sichtbaren Menü, sondern im Navigationsfluss auf der Serverseite. Erst wenn der Server beim Aufruf einer View prüft, ob der aktuelle Benutzer sie betreten darf, entsteht eine belastbare Sicherheitsentscheidung.
Der Quelltext zu diesem Artikel befindet sich
auf GitHub unter https://3g3.eu/vaadin-security
Diese Artikelserie nähert sich Security in Vaadin Flow von genau dieser Beobachtung aus. Bewusst nicht über einen Framework-Vergleich, nicht über die Frage „Spring Security oder Jakarta EE“, sondern über das konkrete Problem an der konkreten View. Eine kleine, explizite Security-Schicht, die das JDK und Vaadin als Basis nutzt und ohne weitere Abhängigkeiten auskommt. Aus dieser Schicht entwickelt sich später ein generischer Security-Kern, und aus diesem Kern entsteht im weiteren Verlauf der Schutz für REST-Endpunkte – konkret für den REST-Service des URL-Shortener-Projekts, den die Vaadin-Oberfläche konsumiert und der seine Zugriffsentscheidungen selbst treffen muss.
Der vorliegende Artikel ist der erste Teil dieser Serie und behandelt rollenbasierten Zugriff auf Views innerhalb des Vaadin-Navigation-Lifecycle. Weitere Teile folgen.
UI-Sichtbarkeit ist keine Schutzgrenze
Ein geeigneter Ausgangspunkt für die Diskussion ist die Hauptansicht der Demo-Anwendung. MainView ist ein klassisches Vaadin-AppLayout mit einem Menü auf der linken Seite, in dem unterschiedliche Workspaces über Tabs erreichbar sind. Die Methode, die dieses Menü aufbaut, heißt createMainMenu(), und ihr Verhalten erweckt auf den ersten Blick den Eindruck einer Sicherheitsmaßnahme. Sie ermittelt für jeden Tab, ob der aktuelle Benutzer berechtigt ist, ihn zu sehen, und fügt den Tab nur in diesem Fall hinzu.
if (isCurrentUserAuthorizedFor(ADMIN)) tabs.add(adminTab());
if (isCurrentUserAuthorizedFor(ADMIN, NERD)) tabs.add(nerdTab());
if (isCurrentUserAuthorizedFor(USER)) tabs.add(userTab());
Damit entspricht der Code dem, was üblicherweise als „Rollensteuerung im UI“ bezeichnet wird. Ein Benutzer mit der Rolle USER sieht den User-Tab, jedoch weder den Admin- noch den Nerd-Tab. Der Code ist gut lesbar, er bündelt die Entscheidung an einer einzigen Stelle, und er liefert das erwartete Ergebnis: Die Oberfläche zeigt jedem Benutzer ausschließlich die Bereiche, die für ihn vorgesehen sind. In vielen Codebases endet die Auseinandersetzung mit Zugriffskontrolle an dieser Stelle.
Entscheidend ist jedoch nicht, was diese Lösung leistet, sondern was sie nicht leistet. Sie entscheidet darüber, welche Tabs in welchem Browser-Fenster gerendert werden. Sie entscheidet nicht darüber, wer die zugehörigen Views aufrufen darf. Diese beiden Aussagen klingen ähnlich, unterscheiden sich aber grundlegend. Ein Tab ist eine Komfortfunktion auf der Oberfläche; eine View ist ein serverseitiger Endpunkt, erreichbar über eine URL. Sobald ein Benutzer die URL einer geschützten View kennt – sei es aus einer früheren Sitzung mit anderer Rolle, aus einer weitergegebenen Verlinkung oder durch schlichtes Erraten –, genügt die Eingabe in die Adressleiste, und der Server liefert die View aus. Das fehlende Tab im Menü ändert daran nichts.
Bemerkenswert an der Demo ist, dass createMainMenu() für die Rollenabfrage dieselbe Datenquelle nutzt, auf der später auch der serverseitige Schutz beruht. Hinter isCurrentUserAuthorizedFor(...) steht ein Zugriff auf das in der Vaadin-Session abgelegte Subject, also derselbe Mechanismus, der gleich bei der Auswertung der View-Annotationen wieder begegnen wird. Die UI-Variante ist somit weder fehlerhaft programmiert noch konzeptionell uninformiert. Sie ist lediglich an der falschen Stelle angesiedelt. Sie verwendet die richtigen Informationen für die richtige Entscheidung, trifft diese Entscheidung jedoch in einer Schicht, die ein Angreifer durch schlichtes Übergehen vollständig umgeht.
Damit ist die Aufgabe für Kapitel 2 vorgezeichnet. Wenn nicht das Menü die Schutzgrenze bildet, muss sie an anderer Stelle liegen – nämlich genau dort, wo eine View tatsächlich betreten wird.
Die echte Schutzgrenze: Navigation serverseitig prüfen
Wenn das Menü nicht die Schutzgrenze bildet, stellt sich die Frage, an welchem Punkt der Anwendung diese Grenze tatsächlich gezogen wird. Die Antwort ergibt sich aus der Beobachtung, dass jede View über eine eigene URL erreichbar ist. Sobald ein Benutzer eine solche URL aufruft, beginnt auf der Serverseite ein wohldefinierter Vorgang: Vaadin ermittelt das Navigationsziel, instanziiert die zugehörige Komponente und stellt sie aus. Genau in diesem Vorgang muss die Sicherheitsentscheidung verankert sein, denn er findet ohne Zutun des Browsers statt und ist die einzige Stelle, an der mit Sicherheit gesagt werden kann, dass eine View tatsächlich betreten werden soll.
In der Demo-Anwendung lässt sich diese Verankerung an der AdminView ablesen. Die Klasse selbst ist von minimalem Umfang, ihre Aussage liegt in den beiden Annotationen über dem Klassenkopf.
@Route(AdminView.NAV)
@VisibleFor({AuthorizationRole.ADMIN})
public class AdminView
extends Composite<Div> {
public static final String NAV = "admin";
public AdminView() {
getContent().add(new Span("AdminView"));
}
}
Die erste Annotation, @Route, ist Vaadin-Standard und legt fest, unter welcher URL die View erreichbar ist. Die zweite Annotation, @VisibleFor, ist projektspezifisch und beschreibt, welchen Rollen der Zugriff vorbehalten ist. Beide Annotationen stehen gleichrangig nebeneinander, und genau diese Gleichrangigkeit ist beabsichtigt: Die View beschreibt sich selbst sowohl in ihrer technischen Erreichbarkeit als auch in ihrer fachlichen Zugänglichkeit. Wer die View liest, sieht in einer einzigen Zeile, welche Rollen sie betreten dürfen, ohne den Inhalt der Klasse oder einen externen Konfigurationsort konsultieren zu müssen.
Bemerkenswert ist die Voreinstellung, die sich aus diesem Muster ergibt. In derselben Demo-Anwendung existiert eine PublicView, die ausschließlich mit @Route versehen ist und keine @VisibleFor-Annotation trägt. Sie ist damit für jeden Benutzer zugänglich, ohne Anmeldung und ohne Rollenprüfung. Die Architektur verlangt also nicht, dass jede View etwas über ihre Zugänglichkeit aussagt; sie verlangt es nur dort, wo eine Einschränkung gewünscht ist. Eine View ohne Restriktionsannotation ist öffentlich, eine View mit Restriktionsannotation ist geschützt. Diese asymmetrische Voreinstellung – das Schweigen einer View bedeutet Zugänglichkeit, das Sprechen bedeutet Einschränkung – ist eine bewusste Designentscheidung, die der Lesbarkeit zugutekommt: Die geschützten Views sind diejenigen, die explizit als geschützt markiert sind, und sie lassen sich im Code mit einer einfachen Suche nach der Annotation auffinden.
An dieser Stelle drängt sich allerdings eine Frage auf, deren Beantwortung dieses Kapitel offenlässt. Eine Annotation in Java ist zunächst nichts weiter als ein Stück Metadaten am Klassenkopf. Sie wirkt nicht von selbst. Damit aus @VisibleFor({AuthorizationRole.ADMIN}) eine tatsächliche Schutzwirkung entsteht, muss irgendwo im System eine Komponente vorhanden sein, die diese Annotation liest und in eine Entscheidung übersetzt. Wo sitzt diese Komponente, was prüft sie genau, und woher weiß sie, welcher Benutzer der aktuelle ist? Diese Fragen führen geradewegs in die folgenden Kapitel.
Subject in der Vaadin-Session
Die offene Frage aus Kapitel 2 lässt sich in zwei Teilfragen zerlegen. Eine Annotation an einer View beschreibt, wer sie betreten darf; bevor diese Beschreibung mit der Realität abgeglichen werden kann, muss bekannt sein, wer der aktuelle Benutzer überhaupt ist. Diese Information ist nicht im Methodenaufruf enthalten, der eine View aufruft, und sie ist auch nicht aus der Annotation abzuleiten. Sie muss serverseitig vorgehalten werden, an einer Stelle, die zwischen zwei aufeinanderfolgenden HTTP-Anfragen denselben Benutzer wiedererkennt. In einer Vaadin-Anwendung ist die natürliche Stelle dafür die Vaadin-Session, und genau dort hinterlegt die Demo das Subject.

Die Implementierung dieser Ablage findet sich in der Schnittstelle SessionAccessor. Sie kapselt zwei Operationen, das Schreiben und das Lesen des Subjects, und sie tut das auf eine Weise, die über das übliche „in die Session legen“ hinausgeht.
static <T> Result<T> currentSubject() {
return Result.ofNullable(VaadinSession.getCurrent()
.getAttribute(subjectType()));
}
static <T> void setCurrentSubject(T subject) {
Objects.requireNonNull(subject);
VaadinSession.getCurrent()
.setAttribute(subjectType(), subject);
}
Drei Eigenschaften dieser wenigen Zeilen verdienen Aufmerksamkeit. Die erste ist der Schlüssel, unter dem das Subject in der Session abgelegt wird. Es handelt sich nicht um einen frei gewählten String, sondern um den konkreten Java-Typ des Subjects, geliefert durch die Methode subjectType(). Damit ist die Ablage typsicher und kollisionsfrei: Zwei unterschiedliche Subject-Typen können sich niemals in die Quere kommen, und die Rückgabe von getAttribute(subjectType()) ist ohne Cast auswertbar. Die zweite Eigenschaft betrifft die Herkunft dieses Typs. Er ist nicht hartcodiert, sondern wird beim Klassenladen über einen AuthenticationServiceProvider per Java-ServiceLoader ermittelt. Der generische Kern erfährt also durch eine Konfiguration in META-INF/services, welcher projektspezifische Typ als Subject dient, ohne ihn importieren zu müssen. Im Demo-Projekt ist dieser Typ MyUser, in einem anderen Projekt könnte es eine völlig andere Klasse sein. Die dritte Eigenschaft ist die Rückgabe von currentSubject(). Sie liefert kein nacktes Objekt und kein Optional, sondern ein Result<T>, einen funktionalen Container, der zwischen „vorhanden“ und „nicht vorhanden“ unterscheidet, ohne den Aufrufer zu zwingen, gegen null zu prüfen. Damit lässt sich der Fall „kein Benutzer in der Session“ als legitimer Zustand behandeln, etwa für die Anzeige öffentlicher Views, ohne in defensiven Null-Prüfungen zu versinken.
Die Schreibseite dieser Ablage hat in der Demo nur einen einzigen produktiven Aufrufer. Im MyLoginView.checkCredentials() wird nach erfolgreicher Prüfung der Anmeldedaten das zugehörige Benutzerobjekt ermittelt und über setCurrentSubject(userByCredentials(credentials)) in die Sitzung eingetragen. Erst in diesem Moment beginnt für die Anwendung die Existenz eines bekannten Benutzers; davor liefert currentSubject() ein leeres Result. Auf der Leseseite hingegen finden sich mehrere Aufrufer, denn jede Stelle, die wissen muss, wer der aktuelle Benutzer ist, greift letztlich hierauf zurück – das MainView-Menü aus Kapitel 1 ebenso wie die Listener und Evaluatoren der folgenden Kapitel.
Damit ist die zweite Hälfte der Ausgangsfrage geklärt. An der View hängt die Beschreibung, wer sie betreten darf; in der Vaadin-Session liegt die Information, wer der Anfragende tatsächlich ist. Was fehlt, ist die Komponente, die beides zusammenführt. Bevor diese Komponente betrachtet werden kann, lohnt es sich allerdings, einen genaueren Blick auf die Annotation selbst zu werfen, denn ihre Wirkung beruht auf einem Mechanismus, der nicht offensichtlich ist.
Die Meta-Annotation @VisibleFor
Die Annotation @VisibleFor, die in Kapitel 2 an der AdminView aufgetaucht ist, gehört zur Demo-Anwendung und nicht zur Sicherheitsbibliothek. Sie ist projektspezifisch, und genau das ist der Punkt: Eine andere Anwendung kann eine andere Annotation mit einem anderen Namen und einer anderen Werteliste definieren, ohne dass die Bibliothek geändert werden muss. Damit dieser Mechanismus funktioniert, braucht es eine Konvention, durch die die Bibliothek erkennen kann, welche Annotation überhaupt eine Restriktion darstellt und wie sie auszuwerten ist. Diese Konvention ist selbst eine Annotation.

Die Definition von @VisibleFor umfasst nur wenige Zeilen, ihre Aussage liegt jedoch nicht im eigenen Inhalt, sondern in der Annotation, die sie selbst trägt.
@Retention(RetentionPolicy.RUNTIME)
@NavigationAnnotation(MyRoleAccessEvaluator.class)
public @interface VisibleFor {
AuthorizationRole[] value();
}
@VisibleFor ist mit @NavigationAnnotation annotiert und übergibt dieser Meta-Annotation eine Klasse – in diesem Fall MyRoleAccessEvaluator. Genau hier wird die Verbindung zwischen einer fachlichen Beschreibung („sichtbar für Admin“) und ihrer Auswertung („wie wird das geprüft“) hergestellt, ohne dass die View davon etwas mitbekommt. Die View sieht nur @VisibleFor und kümmert sich nicht darum, wer diese Annotation interpretiert. Die Bibliothek wiederum sieht nur @NavigationAnnotation und kümmert sich nicht darum, wie die konkrete Restriktionsannotation heißt oder welche Werte sie erlaubt.
Die Gegenseite dieser Konvention ist die Definition von @NavigationAnnotation selbst, die in der Bibliothek liegt.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface NavigationAnnotation {
Class<? extends AccessEvaluator<? extends Annotation>> value();
}
Zwei Aspekte sind hier bemerkenswert. Das @Target(ElementType.ANNOTATION_TYPE) macht @NavigationAnnotation ausschließlich auf andere Annotationen anwendbar; sie kann nicht versehentlich an eine Klasse oder Methode gehängt werden. Das value()-Element gibt eine Klasse zurück, die AccessEvaluator implementiert, und macht damit den Zusammenhang zwischen Annotation und Auswerter formal explizit. Eine Restriktionsannotation, die mit @NavigationAnnotation markiert ist, benennt zwingend den zuständigen Evaluator, und die Bibliothek kann sich darauf verlassen, dass diese Information vorhanden und korrekt typisiert ist.
Die Stelle, an der dieser Mechanismus tatsächlich ausgewertet wird, liegt im AuthorizationListener. Seine Methode accessEvaluatorPair() erhält eine View-Klasse und liefert das Pärchen aus konkreter Restriktionsannotation und Evaluator-Typ, das zu dieser View gehört.
private Optional<AnnotationAccessEvaluatorPair<Annotation>>
accessEvaluatorPair(Class<?> classToCheck) {
List<Annotation> list = stream(classToCheck.getAnnotations())
.filter(hasRestrictionAnnotation)
.collect(toList());
switch (list.size()) {
case 0: return Optional.empty();
case 1:
final Annotation annotation = list.get(0);
NavigationAnnotation navigationAnnotation =
annotation.annotationType().getAnnotation(NavigationAnnotation.class);
Class<? extends AccessEvaluator<? extends Annotation>> accessEvaluator =
navigationAnnotation.value();
return Optional.of(new AnnotationAccessEvaluatorPair(annotation, accessEvaluator));
default:
throw new IllegalStateException(
"more than one NavigationAnnotation not allowed at " + classToCheck);
}
}
private final Predicate<Annotation> hasRestrictionAnnotation =
annotation -> annotation.annotationType()
.isAnnotationPresent(NavigationAnnotation.class);
Der Ablauf ist geradlinig. Aus der Liste aller Annotationen einer View werden diejenigen herausgefiltert, deren Annotationstyp seinerseits mit @NavigationAnnotation markiert ist – das ist genau das Kriterium, das @VisibleFor von beliebigen anderen Annotationen wie @Route oder @Retention unterscheidet. Aus dieser gefilterten Liste wird die einzige zulässige Restriktionsannotation entnommen, deren Meta-Annotation gelesen und der dort hinterlegte Evaluator-Typ extrahiert. Das Ergebnis ist ein AnnotationAccessEvaluatorPair, das die konkrete Annotation (mit ihren Werten, etwa [ADMIN]) und die Evaluator-Klasse (MyRoleAccessEvaluator.class) zusammenbindet. Erst diese Bindung ermöglicht es dem Listener, später beide Informationen an den Evaluator zu übergeben.
Die IllegalStateException im default-Zweig ist keine Verlegenheitslösung, sondern eine bewusste Designentscheidung. Die Implementierung lässt höchstens eine Restriktionsannotation pro View zu. Auf den ersten Blick wirkt das einschränkend – warum sollte eine View nicht gleichzeitig durch Rollen und Permissions geschützt werden dürfen? Die Antwort liegt in der Auswertbarkeit. Sobald zwei Restriktionsannotationen nebeneinanderstehen, stellt sich sofort die Frage nach ihrer Verknüpfung: Müssen beide erfüllt sein, oder reicht eine? Soll die eine die andere überstimmen? Was geschieht, wenn die Evaluatoren widersprüchliche Entscheidungen liefern? Jede Antwort darauf ist eine Festlegung, die an der View nicht sichtbar wäre und die den Mechanismus an dieser Stelle deutlich komplizierter machen würde. Die Demo entscheidet sich dafür, diese Komplexität nicht zuzulassen, und macht dadurch das Lesen einer geschützten View einfacher: Eine View trägt genau eine Aussage über ihre Zugänglichkeit. Wer Rollen und Permissions kombinieren möchte, definiert dafür eine eigene Restriktionsannotation, die beides in einem einzigen Vertrag zusammenführt – und überlässt deren Auswertung einem entsprechend gebauten Evaluator.
Ein letztes Detail des AuthorizationListener verdient Erwähnung, weil es zur Annotationsauflösung gehört. Die Methode accessEvaluatorPair() wird nicht bei jeder Navigation neu aufgerufen, sondern nur einmal pro View-Klasse. Der Listener hält dafür eine ConcurrentHashMap<Class<?>, Optional<AnnotationAccessEvaluatorPair<Annotation>>> und greift über computeIfAbsent darauf zu. Damit wird die Reflection auf den Klassenheader pro View genau einmal ausgeführt; bei jedem weiteren Besuch derselben View liefert der Cache das fertige Pärchen ohne erneute Auswertung. Das ist eine kleine, aber wirksame Optimierung, die im Produktivbetrieb spürbar ist, weil Navigation in einer Vaadin-Anwendung ein häufiger Vorgang ist.
Damit ist beschrieben, wie aus einer Annotation am Klassenkopf der Verweis auf den zuständigen Auswerter wird. Was noch fehlt, ist die Stelle, an der dieser Auswerter tatsächlich aufgerufen wird – und die parallele Frage, wer überhaupt prüft, ob ein Benutzer angemeldet ist, bevor die Frage nach Rollen sinnvoll wird. Beide Fragen führen zu derselben Architekturentscheidung der Demo, die im folgenden Kapitel behandelt wird.
Zwei Fragen, zwei Listener
Wer eine geschützte View aufruft, muss zwei Bedingungen erfüllen. Erstens muss er überhaupt angemeldet sein, damit die Anwendung weiß, mit wem sie es zu tun hat. Zweitens muss seine Anmeldung ihm Zugang zu genau dieser View gewähren, die Rollen oder Berechtigungen müssen also passen. Diese beiden Bedingungen sehen aus wie zwei Teile derselben Frage, sie sind es aber nicht. Die erste ist eine reine Existenzfrage – ist ein Subject vorhanden? Die zweite ist eine Vertragsfrage – erfüllt das vorhandene Subject die Anforderung der View? Die Demo trennt diese beiden Fragen architektonisch sauber, indem sie zwei voneinander unabhängige BeforeEnterListener einsetzt.

Der erste Listener kümmert sich ausschließlich um die Authentifizierung. Er ist als abstrakte Basisklasse LoginListener in der Bibliothek hinterlegt; das Demo-Projekt liefert mit MyLoginListener eine konkrete Ausprägung, die festlegt, welche Annotation als Marker dient (VisibleFor.class), welche View die Anmeldemaske darstellt (MyLoginView.class) und welche View nach erfolgreicher Anmeldung als Standardziel verwendet wird (MainView.class). Die Auswertungslogik selbst sitzt in der Basisklasse.
@Override
public void beforeEnter(BeforeEnterEvent beforeEnterEvent) {
final Class<?> navigationTarget = beforeEnterEvent.getNavigationTarget();
final boolean isLoginView = navigationTarget.equals(loginNavigationTarget());
final boolean isRestrictedPage = navigationTarget.isAnnotationPresent(restrictionAnnotation());
if (isRestrictedPage) {
isARestrictedPage(beforeEnterEvent, isLoginView);
} else {
notARestrictedTarget(navigationTarget);
}
}
Die entscheidende Zeile ist die Prüfung navigationTarget.isAnnotationPresent(restrictionAnnotation()). Sie fragt ausschließlich, ob die Annotation an der View vorhanden ist, und nicht, welche Werte sie trägt. Eine View mit @VisibleFor({ADMIN}) und eine View mit @VisibleFor({USER, NERD}) sind aus Sicht des LoginListener ununterscheidbar – beide sind geschützt, beide verlangen einen angemeldeten Benutzer. Welche Rolle dieser Benutzer haben muss, interessiert den Listener an dieser Stelle nicht. Liegt eine geschützte View vor und ist kein Subject in der Sitzung, leitet der Listener auf die Anmeldemaske weiter; ist umgekehrt ein Subject vorhanden und der Benutzer landet auf der Anmeldemaske selbst, leitet der Listener ihn auf das Standardziel weiter.
Der zweite Listener, AuthorizationListener, beantwortet die andere Frage. Seine beforeEnter-Methode ist eine Zeile, weil die eigentliche Arbeit in checkAccessibility liegt, deren Annotationsauflösung bereits in Kapitel 4 betrachtet wurde.
@Override
public void beforeEnter(BeforeEnterEvent event) {
checkAccessibility(event, event.getNavigationTarget());
}
Was dort im Anschluss an die Annotationsauflösung geschieht, ist die Übergabe der Annotation samt Werten an einen Evaluator. Die Annotation wird also nicht mehr nur auf Anwesenheit geprüft, sondern als Datenträger gelesen: Der Evaluator erhält das value()-Array ([ADMIN] oder [USER, NERD]) und kann die geforderten Rollen mit den Rollen des Subjects abgleichen. Diese Auswertung ist das Thema von Kapitel 6.
In dieser Doppelung liegt die eigentliche Pointe der Architektur. Beide Listener interessieren sich für dieselbe Annotation, lesen sie aber auf unterschiedliche Weise. Für den LoginListener ist sie ein Marker – ihre bloße Anwesenheit signalisiert, dass eine Authentifizierung erforderlich ist. Für den AuthorizationListener ist sie ein Vertrag – ihre Werte tragen die fachliche Aussage, gegen die geprüft wird. Diese Asymmetrie erlaubt eine klare Trennung der Zuständigkeiten, ohne dass die Annotation selbst in zwei Annotationen aufgeteilt werden müsste. Eine View beschreibt sich mit einer einzigen Aussage, und je nach Lesart dieser Aussage entstehen zwei unterschiedliche Entscheidungen.
Damit beide Listener zur Laufzeit verfügbar sind, müssen sie in den Vaadin-Lebenszyklus eingehängt werden. Vaadin selbst bringt dafür den VaadinServiceInitListener mit, der beim Start des Servlets aufgerufen wird und über den sich UIInitListener und BeforeEnterListener registrieren lassen. Die Bibliothek nutzt diesen Mechanismus auf zwei verschiedene Weisen. Der AuthorizationListener ist selbst ein VaadinServiceInitListener und wird direkt registriert; er hängt sich beim UI-Init als BeforeEnterListener ein. Der LoginListener hingegen muss erst in der konkreten Form des Projekts (MyLoginListener) gefunden werden, bevor er angehängt werden kann. Genau diese Aufgabe übernimmt der ApplicationServiceInitListener.
public class ApplicationServiceInitListener
implements VaadinServiceInitListener, HasLogger {
private Registration loginRegistration;
@Override
public void serviceInit(ServiceInitEvent e) {
e.getSource()
.addUIInitListener((UIInitListener) uiInitEvent -> {
UI ui = uiInitEvent.getUI();
logger().info("init LoginListener for .. " + ui.getRouter());
final LoginListener loginListener = new LoginListenerProvider().load();
loginRegistration = ui.addBeforeEnterListener(loginListener);
});
}
}
Das Muster ist klar: Beim Start des Vaadin-Service wird ein UIInitListener registriert, der bei jeder neuen UI den projektspezifischen LoginListener über LoginListenerProvider().load() ermittelt und als BeforeEnterListener an die UI hängt. Die Auflösung „welche konkrete Implementierung des LoginListener ist eigentlich gemeint“ findet zur Laufzeit statt und erlaubt es, die Anwendung ohne Codeänderung in der Bibliothek mit einer eigenen Login-Logik zu versehen.
An diesem Punkt lohnt es sich, den Mechanismus hinter ServiceProvider einmal sichtbar zu machen, der in Kapitel 3 bei der Auflösung des Subject-Typs nur kurz erwähnt wurde. Er ist eine schmale Schicht über dem JDK-eigenen ServiceLoader.
public interface ServiceProvider<T> extends HasLogger {
static <T> Function<Class<T>, T> loadService() {
return (service) -> {
Iterator<T> iterator = ServiceLoader.load(service).iterator();
Iterable<T> iterable = () -> iterator;
final Set<T> set = stream(iterable.spliterator(), false).collect(toSet());
if (set.isEmpty()) {
throw new RuntimeException("no implementation found for interface " + service.getName());
}
if (set.size() > 1) {
throw new RuntimeException("to many implementations found for interface " + service.getName());
}
return set.iterator().next();
};
}
T load();
}
Bemerkenswert ist die strikte Anforderung an die Anzahl der Implementierungen. Der ServiceLoader selbst toleriert beliebig viele Implementierungen und überlässt es dem Aufrufer, eine auszuwählen; der ServiceProvider der Bibliothek setzt hier eine schärfere Regel und akzeptiert ausschließlich genau eine Implementierung. Ist keine vorhanden, schlägt der Aufruf fehl, weil die Anwendung ohne projektspezifische Implementierung nicht funktionieren kann. Sind mehrere vorhanden, schlägt der Aufruf ebenfalls fehl, weil die Demo eine eindeutige Bindung verlangt: ein LoginListener pro Anwendung, ein AuthenticationService pro Anwendung, ein AuthorizationService pro Anwendung. Diese Eindeutigkeit ist eine bewusste Entscheidung gegen die häufige Frustration, dass der ServiceLoader stillschweigend die erste gefundene Implementierung wählt und Konfigurationskonflikte erst spät und schwer auffindbar werden.
Welche Implementierungen tatsächlich verwendet werden, steht in den META-INF/services-Dateien des Demo-Projekts. Sie machen die Bindung zwischen Vertrag und Implementierung sichtbar:
META-INF/services/org.rapidpm.vaadin.security.authorization.LoginListener
-> demo.app.security.MyLoginListener
META-INF/services/org.rapidpm.vaadin.security.authorization.api.AuthenticationService
-> demo.app.security.services.MyAuthenticationService
META-INF/services/org.rapidpm.vaadin.security.authorization.api.AuthorizationService
-> demo.app.security.services.MyAuthorizationService
META-INF/services/org.rapidpm.vaadin.security.authorization.api.AccessEvaluator
-> demo.app.security.roles.MyRoleAccessEvaluator
In der Bibliothek selbst liegt parallel dazu der Vaadin-eigene Eintrag, über den die beiden Listener-Komponenten der Bibliothek registriert werden:
META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener
-> org.rapidpm.vaadin.security.ApplicationServiceInitListener
-> org.rapidpm.vaadin.security.authorization.impl.AuthorizationListener
Damit ist die Verkabelung vollständig. Vaadin findet beim Start zwei VaadinServiceInitListener und ruft beide auf. Der erste, ApplicationServiceInitListener, ermittelt den projektspezifischen LoginListener und hängt ihn an jede neue UI. Der zweite, AuthorizationListener, hängt sich selbst an jede neue UI. Das Demo-Projekt liefert über seine eigenen META-INF/services-Dateien die konkreten Implementierungen für LoginListener, AuthenticationService, AuthorizationService und AccessEvaluator. Keine dieser Bindungen steht im Java-Code; alle stehen in Konfigurationsdateien, die zur Laufzeit gelesen werden. Die Bibliothek kennt das Demo-Projekt nicht, das Demo-Projekt benötigt keine Vererbung von Bibliotheksklassen außer der minimalen Konfigurationsbasis.

Was bisher betrachtet wurde, beschreibt also die Infrastruktur: Wer fragt, wann wird gefragt, wo liegt das Subject, wo liegt der Evaluator, wie kommt alles zusammen. Die eigentliche Sicherheitsentscheidung, also der Vergleich zwischen den geforderten und den vorhandenen Rollen, hat noch nicht stattgefunden. Sie ist Aufgabe des Evaluators, und sie ist das Thema des folgenden Kapitels.
Vom Annotation-Wert zur Access-Entscheidung
Am Ende von Kapitel 5 stand die Frage, was geschieht, wenn der AuthorizationListener die Annotation samt Werten an einen Evaluator übergibt. Die Antwort verteilt sich auf zwei Klassen, die in unterschiedlichen Modulen liegen und deren Aufteilung selbst Teil der Aussage ist. Das Demo-Projekt liefert mit MyRoleAccessEvaluator die fachliche Übersetzung zwischen seinem eigenen Rollenmodell und dem generischen Vokabular der Bibliothek. Die Bibliothek selbst liefert mit RoleBasedAccessEvaluator die eigentliche Vergleichslogik. Die Demo enthält keine Vergleichslogik, der Kern enthält keine projektspezifischen Begriffe; beide arbeiten zusammen, ohne sich gegenseitig zu kennen.
public class MyRoleAccessEvaluator
extends RoleBasedAccessEvaluator<VisibleFor, MyUser>
implements HasLogger {
@Override
public Set<RoleName> requiredRoles(VisibleFor annotation) {
return stream(annotation.value()).map(Enum::name)
.map(RoleName::new)
.collect(Collectors.toSet());
}
@Override
public String alternativeNavigationTarget(Location location,
Class<?> navigationTarget,
VisibleFor annotation) {
return (currentSubject().isPresent())
? MainView.NAV
: MyLoginView.NAV;
}
}
Die beiden Methoden requiredRoles und alternativeNavigationTarget sind die einzigen Stellen, an denen das Demo-Projekt fachlich Stellung bezieht. requiredRoles übersetzt das value()-Array der Annotation – also die AuthorizationRole-Enums – in ein Set<RoleName>, das die Bibliothek versteht. Diese Übersetzung wirkt trivial, leistet aber die entscheidende Entkopplung: Der Kern arbeitet ausschließlich mit RoleName-Objekten, und welche Werte ein konkretes Projekt hineinsteckt, bleibt dem Projekt überlassen. alternativeNavigationTarget hingegen entscheidet, wohin der Benutzer geleitet wird, falls der Zugriff scheitert, und macht dabei eine differenzierte Aussage: Ist ein Subject in der Sitzung, geht es auf die MainView, fehlt es, geht es auf die MyLoginView. Wer eingeloggt ist, aber für eine bestimmte View nicht berechtigt, landet im normalen Anwendungskontext zurück; wer gar nicht eingeloggt ist, landet auf der Anmeldemaske.

Die Vergleichslogik selbst liegt in der Bibliothek und ist ebenfalls kompakt. Sie folgt einem geradlinigen Schema: Subject ermitteln, dessen Rollen ermitteln, gegen die geforderten Rollen schneiden, Ergebnis verpacken.
@Override
public Access evaluate(Location location, Class<?> navigationTarget, T annotation) {
final Set<RoleName> roleNames = requiredRoles(annotation);
if (roleNames.isEmpty()) return granted();
final Result<U> currentSubject = SessionAccessor.currentSubject();
if (currentSubject.isAbsent())
return restricted(alternativeNavigationTarget(location, navigationTarget, annotation), false);
return currentSubject.stream()
.map(authorizationService::rolesFor)
.flatMap(HasRoles::roleNames)
.filter(roleNames::contains)
.findFirst()
.map(rn -> granted())
.orElse(restricted(alternativeNavigationTarget(location, navigationTarget, annotation), true));
}
Der Ablauf folgt drei Verzweigungen. Verlangt die Annotation keine Rollen, ist der Zugriff frei – das ist der Sonderfall einer geschützten View ohne weitere Anforderung, der in der Demo zwar nicht vorkommt, vom Kern aber sauber behandelt wird. Ist kein Subject in der Sitzung, ist der Zugriff verwehrt, und das alternative Ziel wird ohne Forward angesteuert. Liegt ein Subject vor, werden seine Rollen über den AuthorizationService ermittelt, gegen die geforderten Rollen geschnitten, und sobald eine Übereinstimmung gefunden wird, ist der Zugriff gewährt. Findet sich keine, ist er verwehrt, und das alternative Ziel wird mit Forward angesteuert.

Diese beiden Varianten – Forward bei vorhandenem Subject, Reroute bei fehlendem Subject – wirken zunächst wie eine Detailfrage, sie haben aber einen handfesten Unterschied. Ein Forward in Vaadin wechselt das Navigationsziel, lässt aber die ursprünglich aufgerufene URL in der Browser-Adressleiste stehen; ein Reroute hingegen verändert auch die Adressleiste und legt einen Eintrag in der Navigations-History an. Wenn ein angemeldeter Benutzer ohne passende Rolle die AdminView aufruft, soll die URL /admin ihm vermutlich erhalten bleiben, damit er sieht, was er eigentlich wollte – das spricht für Forward. Ein nicht angemeldeter Benutzer hingegen soll nach erfolgter Anmeldung nicht versehentlich auf der Anmeldemaske als History-Eintrag landen – das spricht für Reroute. Die Demo trifft hier eine bewusste Unterscheidung, die im Code mit einem einzigen boolean ausgedrückt wird, in der Erfahrung des Benutzers aber spürbar ist.
Access als algebraischer Datentyp
Beide Aufrufe von granted() und restricted(...) liefern eine Instanz von Access. Diese Klasse ist die didaktisch interessanteste Stelle der gesamten Implementierung, weil sie eine klare Trennung zwischen Entscheidung und Ausführung herstellt. Access ist ein algebraischer Datentyp – also ein Typ, dessen Werte aus einer abgeschlossenen Menge von Konstruktoren stammen, hier granted und mehreren Varianten von restricted. Eine Access-Instanz beschreibt vollständig, was mit der Navigation geschehen soll, ohne dass dieses Geschehen schon eingetreten ist.
public abstract class Access implements Serializable {
private Access() { }
public static Access granted() {
return new Access() {
@Override
void exec(BeforeEnterEvent enterEvent) { }
};
}
public static Access restricted(String rerouteTarget, boolean asForward) {
Objects.requireNonNull(rerouteTarget, "rerouteTarget must not be null");
return new Access() {
@Override
void exec(BeforeEnterEvent enterEvent) {
if (asForward) enterEvent.forwardTo(rerouteTarget);
else enterEvent.rerouteTo(rerouteTarget);
}
};
}
// weitere restricted-Varianten: rerouteToError(...), rerouteTo(..., parameters), ...
abstract void exec(BeforeEnterEvent enterEvent);
}
Drei Eigenschaften dieses Designs sind bemerkenswert. Erstens ist der Konstruktor privat, neue Access-Werte können also ausschließlich über die statischen Fabrikmethoden entstehen. Damit ist die Menge der möglichen Entscheidungen abgeschlossen und im Quelltext überschaubar; eine vierte oder fünfte Variante kann nur durch eine Erweiterung der Klasse selbst hinzukommen. Zweitens ist exec paketsichtbar, nicht öffentlich. Aufrufer der Bibliothek können eine Access-Instanz erzeugen und herumreichen, sie aber nicht ausführen – die Ausführung ist dem Listener vorbehalten. Drittens kapselt jede Variante ihren Seiteneffekt in einer anonymen Subklasse: granted() macht nichts, restricted(rerouteTarget, asForward) ruft je nach Flag forwardTo oder rerouteTo, andere Varianten rufen rerouteToError. Der Vaadin-spezifische Aufruf ist also pro Variante eingebaut und nicht zentralisiert.
Die Stelle, an der diese Werte tatsächlich ausgeführt werden, ist eine einzige Zeile am Ende von AuthorizationListener.checkAccessibility(...): access.exec(event). Erst dort verlässt die Entscheidung den Bereich des Wertes und wird zu einer tatsächlichen Vaadin-Aktion. Der Evaluator kennt den BeforeEnterEvent nur als Übergabeparameter und ruft selbst keine seiner Methoden auf; er verpackt sein Ergebnis in einen Access-Wert und überlässt die Ausführung dem Adapter.
Diese Trennung hat zwei praktische Konsequenzen, von denen die zweite den Bogen über die ganze Serie spannt. Praktisch gesehen ist der Evaluator unabhängig vom BeforeEnterEvent testbar: Ein Test ruft evaluate(...) mit einem konstruierten Location und einer konstruierten Annotation auf und prüft, welcher Access-Wert zurückkommt – ohne Vaadin-Servlet, ohne UI, ohne Mocking eines Events. Die zweite, weiterreichende Konsequenz ist, dass der Evaluator damit gar nichts Vaadin-spezifisches enthält außer dem Rückgabetyp. Access selbst trägt die Vaadin-Abhängigkeit, weil seine Varianten auf forwardTo und rerouteTo zugreifen; der Evaluator hingegen erzeugt nur Werte. Wenn in Teil 2 der Schritt zum generischen Kern gemacht wird, ist Access die Klasse, die durch eine Vaadin-unspezifische Variante ersetzt wird, während der Evaluator beinahe unverändert weiterleben kann.
Damit ist der Mechanismus der View-Security vollständig beschrieben. Eine geschützte View trägt eine Annotation, die als Marker für Authentifizierung und als Vertrag für Autorisierung dient; ein Subject lebt in der Vaadin-Session; zwei Listener stellen die beiden Fragen, die bei jedem View-Aufruf zu klären sind; ein Evaluator beantwortet die Vertragsfrage, indem er Werte liefert; und ein Adapter führt diese Werte aus. Was bleibt, ist die Bilanz, was diese Architektur trägt und was sie bewusst nicht leistet.
Was die Lösung trägt – und was bewusst noch fehlt
Die bisher beschriebene Architektur ist nicht groß. Sie umfasst eine überschaubare Zahl von Klassen, sie verzichtet auf externe Bibliotheken, und sie führt keine eigenen Frameworks ein. Trotzdem ergibt sich aus der Summe der Entscheidungen eine Struktur, die deutlich mehr leistet, als ihre einzelnen Bestandteile vermuten lassen. Drei Eigenschaften tragen diese Architektur, und es lohnt sich, sie ausdrücklich zu benennen, bevor in den folgenden Teilen der Serie weitergebaut wird.

Die erste Tragsäule ist die Trennung der beiden Listener. Authentifizierung und Autorisierung werden nicht in einer einzigen Komponente behandelt, sondern in zwei voneinander unabhängigen Implementierungen, die sich für dieselbe Annotation interessieren, sie aber unterschiedlich lesen. Diese Trennung ist nicht bloß formal, sie hat eine inhaltliche Konsequenz: Die Frage, ob jemand angemeldet ist, lässt sich getrennt von der Frage beantworten, ob ein Angemeldeter zu einer bestimmten View berechtigt ist. Beide Fragen können sich unabhängig voneinander entwickeln, getrennt getestet und getrennt erweitert werden. In einer Anwendung, die später einen anderen Authentifizierungsmechanismus erhält – etwa Token statt Session – muss nur ein Listener angepasst werden, der Autorisierungsteil bleibt unverändert.
Die zweite Tragsäule ist der austauschbare Evaluator, der über ServiceLoader aufgelöst wird. Die Bibliothek kennt das konkrete Demo-Projekt nicht; sie weiß nichts von MyUser, nichts von AuthorizationRole und nichts von MyRoleAccessEvaluator. Sie weiß nur, dass irgendwo eine Implementierung existiert, die zur Laufzeit über die Konfiguration in META-INF/services benannt wird. Diese Bindung erlaubt es, denselben Bibliothekscode in unterschiedlichen Anwendungen einzusetzen, in denen Benutzer, Rollen und Auswertungslogik völlig anders aussehen können. Was sich ändert, sind die Implementierungsdateien des jeweiligen Projekts und die zugehörigen Einträge in META-INF/services. Was gleich bleibt, ist die Bibliothek selbst.
Die dritte Tragsäule ist die Decision als Wert. Eine Sicherheitsentscheidung wird nicht durch einen Seiteneffekt mitgeteilt, sondern als Instanz von Access zurückgegeben, die sämtliche relevanten Informationen kapselt und vom aufrufenden Adapter erst dann ausgeführt wird, wenn die Übergabe an den Vaadin-Lifecycle ansteht. Diese Trennung zwischen Entscheidung und Ausführung macht den Evaluator unabhängig vom konkreten Vaadin-Kontext und seine Logik isoliert testbar. Sie ist zugleich diejenige Eigenschaft, die in den folgenden Teilen der Serie am stärksten weitergetragen wird, weil sie den Begriff einer Sicherheitsentscheidung von der Frage löst, in welcher Umgebung diese Entscheidung getroffen wird.
Diesen drei Tragsäulen stehen Lücken gegenüber, die in der Demo bewusst offen bleiben. Die Anmeldedaten werden in einer einfachen Map im Hauptspeicher gehalten, die Passwörter liegen dort als Klartext-Strings. Es gibt keine Persistenz, kein Hashing und kein Salt. Es gibt keinen Schutz gegen wiederholte Anmeldeversuche, keine Sperrung nach mehrfachen Fehlschlägen, keine zeitliche Verzögerung. Es gibt keine Audit-Spur, kein Log-Eintrag bei verweigertem Zugriff, keine Möglichkeit, im Nachhinein zu rekonstruieren, wer wann welche View aufgerufen hat. Und es gibt keine durchdachte Logout-Behandlung, die über das bloße Entfernen des Subjects aus der Sitzung hinausginge.
Diese Liste wirkt zunächst wie eine Aufzählung von Schwächen, sie ist aber als bewusste Entscheidung zu lesen. Eine produktionstaugliche Anwendung braucht jeden dieser Bausteine, und für jeden gibt es etablierte Lösungen. Sie sind in der Demo absichtlich nicht enthalten, weil ihre Einführung den Blick auf das eigentliche Thema verstellen würde. Die Frage, wie ein Passwort sicher abgelegt wird, ist eine andere als die Frage, wie eine geschützte View vor unautorisiertem Zugriff bewahrt wird. Beide Fragen haben ihre Berechtigung, beide gehören in eine vollständige Anwendung; aber sie zugleich zu beantworten, hieße, beide Antworten zu verkürzen. Die Demo entscheidet sich für die zweite Frage und überlässt die erste den jeweiligen Projekten und ihren Anforderungen.
Damit ist Teil 1 abgeschlossen. Die rollenbasierte View-Security ist in ihrer Struktur beschrieben, ihre tragenden Säulen sind benannt, und ihre bewussten Lücken sind sichtbar gemacht. Die folgenden Teile der Serie greifen die Architektur an verschiedenen Stellen wieder auf. Teil 2 wird zeigen, wie sich die hier beobachteten Trennungen in einen generischen Sicherheitskern überführen lassen, der ohne Vaadin-Bezug auskommt. Teil 3 wird denselben Kern auf den REST-Service eines URL-Shortener-Projekts anwenden und dabei den Schritt von Rollen zu feingranularen Permissions vollziehen. Der gemeinsame Nenner ist die Beobachtung, mit der dieser Artikel begonnen hat: Eine belastbare Sicherheitsentscheidung entsteht serverseitig, an der Stelle, an der ein Zugriff tatsächlich beginnt.