Authentifizierung mit Hilla
Hilla ist ein Full-Stack-Web-Framework von Vaadin. Im Backend kommt Spring Boot in Verbindung mit Java zum Einsatz. Im Frontend kommt TypeScript wahlweise mit React oder Lit zum Einsatz. Meiner Meinung nach eignet sich Hilla hervorragend für die Entwicklung von Business-Web-Apps.
Für Web-Apps im Unternehmenskontext spielt das Thema Authentifizierung eine sehr wichtige Rolle. Anwender:innen müssen sich gegenüber einer Web-App authentifizieren und arbeiten anschließend im Kontext bestimmter Berechtigungen, die meist durch Rollen ausgedrückt werden.
Erfreulicherweise unterstützt Hilla sehr viele verschiedene Authentifizierungsmechanismen, da es im Backend auf Spring Security setzt und im Frontend sehr viele nützliche Erweiterungen bereitstellt. Die offizielle Dokumentation von Hilla enthält einen sehr umfangreichen Guide, in dem die Nutzung von Spring Security sehr gut beschrieben ist.
Die Anmeldeinformationen und Daten von Anwender:innen sind im Unternehmenskontext meist in einem zentralen Identity Provider, wie bspw. einem Active Directory, hinterlegt. Meiner Erfahrung nach haben Business-Web-Apps in der Regel keinen direkten Zugriff auf diesen Identity Provider, um bspw. im Rahmen der Authentifizierung die Anmeldeinformationen der Anwender:innen direkt zu überprüfen. Stattdessen setzen viele Unternehmen auf Authentifizierungsverfahren auf Basis von OAuth 2.0 oder OpenID Connect (OIDC).
OAuth 2.0 und der Authorization Code Flow
Dieser Artikel beschreibt, wie für Hilla eine Authentifizierung auf Basis des Authorization Code Flow von OAuth 2.0 realisiert werden kann. Der Authorization Code Flow eignet sich sehr gut für die Authentifizierung von Anwender:innen von Web-Apps. Eine Einführung in OAuth 2.0 ist nicht Bestandteil dieses Artikels. Daher möchte ich an dieser Stelle auf die Dokumentation von auth0.com verweisen. Sie enthält einen sehr guten Überblick über OAuth 2.0 und die verschiedenen Flows bzw. Grant-Types. Als Authorization Server kommt in diesem Artikel Keycloak zum Einsatz. Bezogen auf das folgende Beispiel, welches im weiteren Verlauf erarbeitet wird, läuft der Authorization Code Flow in Verbindung mit Hilla und Keycloak folgendermaßen ab:
- Ein User öffnet die Hilla Web-App im Browser über
http://localhost:8080
. - Hilla stellt einen Request an Keycloak, um einen Authorization-Code zu erhalten. Keyloak läuft in diesem Beispiel unter
http://localhost:11111
.
GET http://localhost:11111/realms/test/protocol/openid-connect/auth?response_type=code&client_id=hilla&scope=openid&state=<STATE>&redirect_uri=http://localhost:8080/login/oauth2/code/keycloak&nonce=<NONCE>
- Keycloak leitet den Request um, so dass nun eine Login-Seite von Keycloak im Browser zu sehen ist. Hier erfolgt die Anmeldung durch den User.
- Die Login-Seite wird mit den Anmeldeinformationen an Keycloak gesendet. Keycloak authentifiziert den User.
POST http://localhost:11111/realms/test/login-actions/authenticate?session_code=<SESSION_CODE>&execution=<EXECUTION>&client_id=hilla&tab_id=<TAB_ID>
username=user
password=****
- Keycloak sendet einen Authorization-Code an Hilla.
GET http://localhost:8080/login/oauth2/code/keycloak?state=<STATE>&session_state=<SESSION_STATE>&iss=http://localhost:11111/realms/test&code=<AUTHORIZATION_CODE>
- Hilla fordert für diesen Authorization-Code einen Access-Token an.
POST http://localhost:11111/realms/test/protocol/openid-connect/token
grant_type=authorization_code
code=<AUTHORIZATION_CODE>
client_id=hilla
client_secret=****
redirect_uri=http://localhost:8080/login/oauth2/code/keycloak
- Keycloak sendet den angeforderten Access-Token in Form eines JSON Web Tokens (JWT) an Hilla. Der Payload des JWT enthält u.a. folgende Daten:
{
...
"exp": 1705659894,
"iat": 1705659594,
"auth_time": 1705659567,
"iss": "http://localhost:11111/realms/test",
"aud": "account",
"sub": "cd026591-5cb2-4089-970b-f317153ae6c8",
"typ": "Bearer",
"azp": "hilla",
"scope": "openid profile email"
...
}
- Hilla ruft den User-Info-Endpunkt von Keycloak auf. Dabei wird der erhaltende Access-Token im HTTP-Header
Authorization: Bearer <ACCESS_TOKEN>
mitgesendet.
GET http://localhost:11111/realms/test/protocol/openid-connect/userinfo
- Keycloak sendet die Informationen zum User an Hilla zurück. Diese User-Informationen können bspw. folgendermaßen aussehen:
{
"sub":"cd026591-5cb2-4089-970b-f317153ae6c8",
"email_verified": true,
"name": "John Doe",
"preferred_username": "jd",
"given_name": "John",
"family_name": "Doe",
"email": "john.doe@example.com"
}
Im weiteren Verlauf des Artikels wird beschrieben, wie Keycloak und Hilla zu konfigurieren sind, um den gezeigten Authorization Code Flow auch selber nachvollziehen zu können.
Keycloak einrichten
Als Container starten
Für das vorliegende Beispiel wird Keycloak lokal in einem Container ausgeführt. Dafür ist eine entsprechende Laufzeit-Umgebung für Container erforderlich. Hier bieten sich bspw. Docker Desktop oder Podman Desktop an.
Nachdem die Laufzeitumgebung installiert und gestartet wurde, kann ein neuer Container mit einer laufenden Keycloak-Instanz wie folgt gestartet werden:
docker|podman run --name keycloak --detach --publish 11111:8080 --env KEYCLOAK_ADMIN=admin --env KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:latest start-dev
Benutzername und -passwort werden als Umgebungsvariablen übergeben. Die Keycloak-Instanz ist anschließend über http://localhost:11111/admin erreichbar.
Zum Stoppen und Löschen des Containers können bei Bedarf folgende Befehle verwendet werden:
docker|podman stop keycloak && podman rm keycloak
Realm erstellen
Die erforderlichen Konfigurationen, die im weiteren Verlauf in Keycloak vorgenommen werden, erfolgen in einem Realm. Es bietet sich an einen neuen Realm mit der Bezeichnung test
zu erstellen. Dazu muss man sich als User admin
am Web-Interface von Keycloak unter http://localhost:11111/admin anmelden.
Der genaue Ablauf zur Erstellung eines Realms ist in der Keycloak-Dokumentation beschrieben. Nachdem der Realm erstellt wurde, wechselt Keycloak automatisch in den neuen Realm test
.
Client erstellen
Anschließend muss ein neuer Client im Realm test
erstellt werden. Als Client-ID sollte dabei die Bezeichnung hilla
verwendet werden. Darüber hinaus muss für diesen Client die Client authentication
aktiviert werden. Dies führt dazu, dass Keycloak ein Client-Secret für diesen Client erstellt. Client-ID und -Secret benötigt man im weiteren Verlauf für die Konfiguration von Spring Security. Als Authentication flow
muss der Standard flow
ausgewählt werden, da dies dem oben gezeigten Authorization Code Flow entspricht.
Zu guter Letzt muss für Valid redirect URI
der Wert http://localhost:8080/login/oauth2/code/keycloak
und für Valid post logout redirect URI
der Wert http://localhost:8080
eingetragen werden. Auch diese Angaben werden später für die Konfiguration von Spring Security benötigt.
Für weitere Details zur Erstellung und Konfiguration von Clients sei auch an dieser Stelle auf die offizielle Keycloak-Dokumentation verwiesen.
User erstellen
Wie eingangs beschrieben, ist Keycloak im Unternehmenskontext in der Regel mit einem Identity Provider verbunden. Um die Komplexität des Beispiels nicht unnötig zu erhöhen, wird hier auf eben diesen verzichtet. Stattdessen wird ein User direkt in Keycloak im Realm test
angelegt.
Nachdem der User erstellt wurde, werden für den User Credentials vergeben. Als Passwort kommt in diesem Beispiel der Wert password
zum Einsatz. Um weitere manuelle Schritte zu sparen, wird das Passwort als nicht-temporär markiert.
Weitere Informationen zur Erstellung und Verwaltung von Usern finden sich in der Keycloak-Dokumentation.
Realm exportieren
Der erstellte Realm und der darin enthaltene Client und User lassen sich bei Bedarf in eine JSON-Datei exportieren, so dass sie zu einem späteren Zeitpunkt wiederverwendet werden können. Dazu ist folgender Befehl im Terminal auszuführen:
docker|podman exec -it keycloak /opt/keycloak/bin/kc.sh export --file /tmp/realm-export.json --realm test --users realm_file
docker|podman cp keycloak:/tmp/realm-export.json /path/to/folder/with/realm-export
Die JSON-Datei realm-export.json
befindet sich anschließend im Verzeichnis /path/to/folder/with/realm-export
. Wenn man den Keycloak-Container zu einem späteren Zeitpunkt erneut startet, kann man die Datei über ein Volume einbinden und beim Start von Keycloak importieren:
docker|podman run --name keycloak --detach --publish 11111:8080 --env KEYCLOAK_ADMIN=admin --env KEYCLOAK_ADMIN_PASSWORD=admin --volume /path/to/folder/with/realm-export:/opt/keycloak/data/import quay.io/keycloak/keycloak:latest start-dev --import-realm
Hilla-Projekt hilla-oauth-authorization-code-flow-keycloak
Da nun die Keycloak-Instanz mit der erforderlichen Konfiguration zur Verfügung steht, wird als Nächstes ein neues Hilla-Projekt erstellt. Dazu kann u.a. die Hilla-CLI verwendet werden:
npx create-vaadin hilla-oauth-authorization-code-flow-keycloak
Im weiteren Verlauf werden nun zunächst die erforderlichen Erweiterungen im Hilla-Backend vorgenommen.
Spring Security Konfiguration
Die erforderlichen Dependencies für Spring Security und OAuth 2.0 werden in der pom.xml
hinzugefügt.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
Als Nächstes wird die Konfiguration für Spring Security erstellt. Dazu wird zunächst die Datei SecurityConfig.java
erstellt:
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
@EnableWebSecurity
@Configuration
public class SecurityConfig extends VaadinWebSecurity {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
setOAuth2LoginPage(http, "/oauth2/authorization/keycloak");
http.logout(logout -> logout.logoutSuccessHandler((new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))));
}
}
Diese Konfiguration erweitert VaadinWebSecurity
. Dadurch wird sichergestellt, dass die erforderlichen Basis-Konfigurationen für Hilla korrekt gesetzt werden. Weitere Details dazu finden sich hier. Mit setOAuth2LoginPage
erhält Hilla die Information, über welche URL die Login-Seite verfügbar ist. Abschließend sorgt der HttpStatusReturningLogoutSuccessHandler
dafür, dass es nach dem Logout nicht zu ungewollten Redirects kommt.
Ergänzend zu dieser Konfiguration sind folgende Angaben in der application.properties
erforderlich:
spring.security.oauth2.client.registration.keycloak.client-id=hilla
spring.security.oauth2.client.registration.keycloak.client-secret=<CLIENT_SECRET>
spring.security.oauth2.client.registration.keycloak.scope=openid
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.redirectUri=http://localhost:8080/login/oauth2/code/keycloak
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:11111/realms/test
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username
Zum jetzigen Zeitpunkt, kann die Anwendung bereits über ./mvnw
gestartet werden. Ruft man die Hilla Web-App im Browser über http://localhost:8080 auf, erfolgt der Authorization Code Flow, so wie zu Beginn dieses Artikels beschrieben.
Erweiterungen für das Frontend
Nach der erfolgten Anmeldung ist in der Hilla Web-App derzeit nicht erkennbar, welcher User angemeldet ist. Auch ein Logout ist derzeit noch nicht möglich. Beides wird im Folgenden hinzugefügt.
UserAuthenticationService
Im Hilla-Backend wird ein Service erstellt, der den aktuell angemeldeten User bereitgestellt. Ein User wird über das folgende einfache DTO User.java
repräsentiert:
import com.vaadin.hilla.Nonnull;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
public class User {
@Nonnull
private String username;
@Nonnull
private String firstname;
@Nonnull
private String lastname;
@Nonnull
private String email;
public User(OidcUser oidcUser) {
this.username = oidcUser.getPreferredUsername();
this.firstname =oidcUser.getGivenName();
this.lastname = oidcUser.getFamilyName();
this.email = oidcUser.getEmail();
}
// Getter
// Setter
}
Im Konstruktor wird eine Instanz der Klasse OidcUser
verwendet. Spring Security stellt uns ein solches Objekt bereit, wenn der User erfolgreich authentifiziert wurde.
Im nächsten Schritt wird nun der Service in UserAuthenticationService.java
implementiert:
import com.example.application.entities.User;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Optional;
@BrowserCallable
@AnonymousAllowed
public class UserAuthenticationService {
public Optional<User> getAuthenticatedUser() {
SecurityContext context = SecurityContextHolder.getContext();
Object principal = context.getAuthentication().getPrincipal();
if (principal instanceof OidcUser oidcUser) {
return Optional.of(new User(oidcUser));
}
return Optional.empty();
}
public String getLogoutUrl() {
SecurityContext context = SecurityContextHolder.getContext();
Object principal = context.getAuthentication().getPrincipal();
if (principal instanceof OidcUser user) {
return UriComponentsBuilder
.fromUriString(user.getIssuer() + "/protocol/openid-connect/logout")
.queryParam("id_token_hint", user.getIdToken().getTokenValue())
.queryParam("post_logout_redirect_uri", "http://localhost:8080").toUriString();
}
return "/logout";
}
}
Die Annotation @BrowserCallable
sorgt dafür, dass Hilla den erforderlichen Endpunkt und den zugehörigen Client-seitigen TypeScript-Code automatisch generiert. Damit ist später keine weitere Programmierung erforderlich, um bspw. die Kommunikationsverbindung zwischen Frontend und Backend herzustellen und Daten zu serialisieren bzw. zu deserialisieren. Die Annotation @AnonymousAllowed
gewährleistet, dass die Methoden innerhalb dieses Services ohne Authentifizierung aufgerufen werden können.
Die Methode getAuthenticatedUser
liefert den aktuell angemeldeten User, der im SecurityContext
von Spring Security gehalten wird. Befindet sich keine Instanz der Klasse OidcUser
im SecurityContext
bedeutet es, dass keine erfolgreiche Anmeldung über OAuth stattgefunden hat, oder dass der User sich bereits wieder abgemeldet hat. In diesem Fall wird ein leeres Optional<User>
zurückgegeben. Dies signalisiert dem Frontend, dass kein User angemeldet ist.
Die Methode getLogoutUrl
stellt eine Keycloak-konforme Logout-URL zur Verfügung. Das Frontend wird diese URL vom Backend abfragen und für die Abmeldung über Keycloak verwenden.
Authentifizierung im Frontend konfigurieren
Im Hilla-Frontend wird ein AuthProvider
hinzugefügt, der die aktuellen Authentifizierungsinformationen aus dem Backend abrufen kann. Dazu wird zunächst die Datei auth.ts
im Verzeichnis src/main/frontend/util
angelegt:
import { configureAuth } from '@vaadin/hilla-react-auth';
import { UserAuthenticationService } from 'Frontend/generated/endpoints';
const auth = configureAuth(UserAuthenticationService.getAuthenticatedUser);
export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;
Hilla stellt dafür die Funktion configureAuth
zur Verfügung. Diese Funktion erhält die Informationen zum angemeldeten User aus dem UserAuthenticationService
. Der exportierte AuthProvider
wird anschließend in der App.tsx
eingebunden:
import router from 'Frontend/routes.js';
import { RouterProvider } from 'react-router-dom';
import { AuthProvider } from 'Frontend/util/auth';
export default function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
)
}
Somit stehen die Informationen zum angemeldeten User in React zur Verfügung.
User anzeigen und Logout ermöglichen
Die Datei src/main/frontend/views/MainLayout.tsx
kann nun erweitert werden, um den aktuell angemeldeten User anzuzeigen und um eine Schaltfläche einzubinden, über die ein Logout realisiert werden kann:
import { AppLayout } from '@vaadin/react-components/AppLayout.js';
import { Avatar } from '@vaadin/react-components/Avatar.js';
import { Button } from '@vaadin/react-components/Button.js';
import { DrawerToggle } from '@vaadin/react-components/DrawerToggle.js';
import Placeholder from 'Frontend/components/placeholder/Placeholder.js';
import { useRouteMetadata } from 'Frontend/util/routing.js';
import { Suspense, useEffect } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from 'Frontend/util/auth.js';
import { UserAuthenticationService } from 'Frontend/generated/endpoints';
const navLinkClasses = ({ isActive }: any) => {
return `block rounded-m p-s ${isActive ? 'bg-primary-10 text-primary' : 'text-body'}`;
};
export default function MainLayout() {
const currentTitle = useRouteMetadata()?.title ?? 'My App';
const { state, logout } = useAuth();
useEffect(() => {
document.title = currentTitle;
}, [currentTitle]);
return (
<AppLayout primarySection="drawer">
<div slot="drawer" className="flex flex-col justify-between h-full p-m">
<header className="flex flex-col gap-m">
<h1 className="text-l m-0">My App</h1>
<nav>
<NavLink className={navLinkClasses} to="/">
Hello World
</NavLink>
<NavLink className={navLinkClasses} to="/about">
About
</NavLink>
</nav>
</header>
<footer className="flex flex-col gap-s">
{state.user ? (
<>
<div className="flex items-center gap-s">
<Avatar theme="xsmall" name={state.user.username} />
{state.user.firstname} {state.user.lastname}
</div>
<Button onClick={async () => {
const logoutUrl = await UserAuthenticationService.getLogoutUrl()
await logout()
window.location.href = logoutUrl
}}>Sign out</Button>
</>
) : (
<a href="/oauth2/authorization/keycloak">Sign in</a>
)}
</footer>
</div>
<DrawerToggle slot="navbar" aria-label="Menu toggle"></DrawerToggle>
<h2 slot="navbar" className="text-l m-0">
{currentTitle}
</h2>
<Suspense fallback={<Placeholder />}>
<Outlet />
</Suspense>
</AppLayout>
);
}
Im Footer-Bereich des Menüs der Hilla Web-App findet man nun Informationen zum angemeldeten User und eine Schaltfläche zum Abmelden.
Rollen-basierte Autorisierung
Die im Beispiel gezeigte Authentifizierung kann sowohl im Backend als auch im Frontend flexibel erweitert werden. In Keycloak können Rollen definiert werden, die dann ebenfalls in den Informationen zum angemeldeten User zur Verfügung gestellt werden können. Diese Rolleninformationen können dann sowohl im Backend als auch im Frontend berücksichtigt werden, um bspw. bestimmte Services oder Routen nur für bestimmte Rollen zur Verfügung zustellen.
Erweiterung Keycloak
In Keycloak wird dafür zunächst ein weiterer User (Jane Roe
) mit einem Passwort erstellt.
Anschließend wird die Konfiguration des bestehenden Clients hilla
erweitert. Im Tab Roles
werden die beiden Rollen MANAGER
und IC
erstellt.
Im Tab Client scopes
muss der Client Scope mit der Bezeichnung hilla-dedicated
bearbeitet werden. Im Tab Mappers
wird der Mapper mit der Bezeichnung client roles
und dem Typ User Client Role
hinzugefügt.
Der Mapper muss im Anschluss noch angepasst werden:
- Im Feld
Client ID
sollte der Werthilla
ausgewählt werden. Dies stellt sicher, dass nur die beiden RollenMANAGER
oderIC
berücksichtigt werden. - Das Feld
Client Role Prefix
enthält den WertROLE_
. - Im Feld
Token Claim Name
kann der Wert aufroles
verkürzt werden. Dies erleichtert später die Verarbeitung im Hilla-Backend. - Die Option
Add to userinfo
muss aktiviert werden. Dies führt dazu, dass der Claimroles
in der Instanz der KlasseOidcUser
imSecurityContext
von Spring Security verfügbar ist.
Zum Abschluss wird dem User John Doe
dann die Rolle IC
zugewiesen. Jane Roe
erhält die Rolle MANAGER
.
Diese Konfigurationsanpassung führt dazu, dass im Access-Token und im User-Info-Endpunkt die zugewiesene Rolle im Claim roles
enthalten ist:
{
...
"roles": [
"ROLE_IC"
],
"name": "John Doe",
"preferred_username": "jd",
"given_name": "John",
"family_name": "Doe",
"email": "john.doe@example.com"
}
Erweiterung Backend
Im Backend wird die bestehende Spring Security-Konfiguration in SecurityConfig.java
um einen GrantedAuthoritiesMapper
erweitert:
@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper() {
return (authorities) -> {
return authorities.stream()
.filter(authority -> OidcUserAuthority.class.isInstance(authority))
.map(authority -> {
OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;
OidcUserInfo userInfo = oidcUserAuthority.getUserInfo();
List<String> roles = userInfo.getClaim("roles");
return roles.stream().map(r -> new SimpleGrantedAuthority(r));
})
.reduce(Stream.empty(), (joinedAuthorities, roleAuthorities) -> Stream.concat(joinedAuthorities, roleAuthorities))
.collect(Collectors.toList());
};
}
Dieser Mapper kümmert sich darum, dass alle zugewiesenen Rollen eines Users aus dem Claim roles
in Spring Security spezifische Authorities umgewandelt werden, die im Anschluss im SecurityContext
von Spring Security zur Verfügung stehen.
Die vorhanden Rolleninformationen können jetzt im Backend verwendet werden, um bspw. festzulegen, welche User Zugriff auf welche Services haben dürfen. Ein Service, der mit der Annotation @BrowserCallable
versehen ist, kann zusätzlich die Annotation @RolesAllowed
tragen, um somit eine Einschränkung in der Aufrufbarkeit aus dem Frontend an die Rollen des aktuell angemeldeten Users zu knüpfen. So kann bspw. die Klasse HelloWorldService
und die darin enthaltene Methode sayHello
so angepasst werden, dass sie nur noch von Usern mit den Rollen ROLE_IC
oder ROLE_MANAGER
aufgerufen werden darf:
import com.vaadin.hilla.BrowserCallable;
import jakarta.annotation.security.RolesAllowed;
@BrowserCallable
public class HelloWorldService {
@RolesAllowed({"ROLE_IC", "ROLE_MANAGER"})
public String sayHello(String name) {
if (name.isEmpty()) {
return "Hello stranger";
} else {
return "Hello " + name;
}
}
}
Damit die Rollen im weiteren Verlauf auch im Frontend verwendet werden können, muss nun das DTO User.java
um ein Feld roles
erweitert werden:
import com.vaadin.hilla.Nonnull;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import java.util.Collection;
public class User {
@Nonnull
private String username;
@Nonnull
private String firstname;
@Nonnull
private String lastname;
@Nonnull
private String email;
@Nonnull
private Collection<String> roles;
public User(OidcUser oidcUser, Collection<String> roles) {
this.username = oidcUser.getPreferredUsername();
this.firstname = oidcUser.getGivenName();
this.lastname = oidcUser.getFamilyName();
this.email = oidcUser.getEmail();
this.roles = roles;
}
// Getter
// Setter
}
Im UserAuthenticationService.java
wird die Methode getAuthenticatedUser
erweitert:
public Optional<User> getAuthenticatedUser() {
SecurityContext context = SecurityContextHolder.getContext();
Object principal = context.getAuthentication().getPrincipal();
if (principal instanceof OidcUser oidcUser) {
Collection<String> roles = context.getAuthentication().getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
return Optional.of(new User(oidcUser, roles));
}
return Optional.empty();
}
Alle zuvor gesetzten Authorities werden aus dem SecurityContext
von Spring Security gelesen und in das neue Feld roles
geschrieben.
Erweiterung Frontend
Das Hilla-Frontend wird zunächst um drei zusätzliche Views erweitert:
- Eine
ManagerView
, die nur für User mit der RolleMANAGER
aufrufbar sein soll. - Eine
ICView
, die nur für User mit der RolleIC
aufrufbar sein soll. - Eine
AccessDeniedView
, die angezeigt wird, wenn der Zugriff auf die angeforderte View bzw. Route für den aktuellen User nicht erlaubt ist.
Alle drei Views enthalten keine zusätzliche Logik. Sie zeigen lediglich einen Text zur Identifizierung, wie bspw. die ICView
:
export default function ICView() {
return (
<div className="flex flex-col h-full items-center justify-center p-l text-center box-border">
<h2>Individual contributor view</h2>
<p>This view is only visible for users with the role 'IC'.</p>
</div>
);
}
Die Routen-Konfiguration für das Frontend erfolgt in src/main/frontend/routes.tsx
. Die Routen-Konfiguration wird mit Hilfe von protectRoutes
abgesichert. Je Route wird die erforderliche Authentifizierung bzw. Autorisierung bestimmt:
import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js';
import MainLayout from 'Frontend/views/MainLayout.js';
import { lazy } from 'react';
import { createBrowserRouter, RouteObject } from 'react-router-dom';
import { protectRoutes } from '@vaadin/hilla-react-auth';
const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js'));
const AccessDeniedView = lazy(async () => import('Frontend/views/error/AccessDeniedView.js'));
const ICView = lazy(async () => import('Frontend/views/ic/ICView.js'));
const ManagerView = lazy(async () => import('Frontend/views/manager/ManagerView.js'));
export const routes: RouteObject[] = protectRoutes([
{
element: <MainLayout />,
handle: { title: 'Main' },
children: [
{ path: '/', element: <HelloWorldView />, handle: { title: 'Hello World', requiresLogin: true } },
{ path: '/about', element: <AboutView />, handle: { title: 'About', requiresLogin: true } },
{ path: '/ic', element: <ICView />, handle: { title: 'IC', rolesAllowed: ['ROLE_IC'] } },
{ path: '/manager', element: <ManagerView />, handle: { title: 'Manager', rolesAllowed: ['ROLE_MANAGER'] } },
{ path: '/access-denied', element: <AccessDeniedView />, handle: { title: 'Access denied' } },
],
},
], "/access-denied");
export default createBrowserRouter(routes);
Zu guter Letzt wird das Menü um die zusätzlichen Einträge für die neuen Views erweitert. Auch dies passiert in Abhängigkeit zur Rolle des aktuellen Users. Dazu wird die Datei src/main/frontend/views/MainLayout.tsx
editiert:
...
export default function MainLayout() {
...
const { state, logout, hasAccess } = useAuth();
...
return (
...
<header className="flex flex-col gap-m">
<h1 className="text-l m-0">My App</h1>
<nav>
<NavLink className={navLinkClasses} to="/">
Hello World
</NavLink>
{hasAccess({ rolesAllowed: ['ROLE_IC'] }) && (
<NavLink className={navLinkClasses} to="/ic">
IC
</NavLink>
)}
{hasAccess({ rolesAllowed: ['ROLE_MANAGER'] }) && (
<NavLink className={navLinkClasses} to="/manager">
Manager
</NavLink>
)}
<NavLink className={navLinkClasses} to="/about">
About
</NavLink>
</nav>
</header>
...
)
}
Startet man die Anwendung nun erneut und meldet sich abwechselnd mit den beiden vorhandenen Usern an und ab, erkennt man die Unterschiede im Menü. Manuelle Aufrufe der Routen /ic
oder /manager
werden auf /access-denied
umgeleitet, wenn der aktuell angemeldete User nicht über die passende Rolle verfügt.
Weiterführende Informationen zur Absicherung von Views im Hilla-Frontend findet man in der Dokumentation von Hilla.
Fazit
Hilla ist ein tolles Full-Stack-Framework zur Entwicklung von Business-Web-Apps. Die Authentifizierung und Autorisierung von Anwender:innen kann dabei im Backend über die bekannten Mechanismen von Spring Security realisiert werden. Die Nutzung des Authorization Code Flows von OAuth 2.0 in Verbindung mit Keycloak ist schnell realisiert und flexibel um eine Rollen-basierte Autorisierung erweiterbar. Im Frontend bietet Hilla viele nützliche Erweiterungen, um die Web-App mit Informationen zum angemeldeten User anzureichern und um einzelne Views bzw. Routen in Abhängigkeit der vorhandenen User-Rollen anzuzeigen.
Der Quellcode für das Beispiel ist bei GitHub verfügbar.