Hilla mit Keycloak und dem Authorization Code Flow von OAuth 2.0

René Wilby | 13.02.2024 Min. Lesezeit

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:

UUsse(er1r)RequestHilla(W3e)b(-R4Ae)HpdHipiAilrull(etla2cha)ten(Rtt(6(eoi5)8qc))uLaR(eotAe7R(sgeuq)e9tituq)nheAuAosceUurtcsstietehzAsroacsU-rtc-sIiieTenzosorfansk-ot--eIiCTnnooofndko-eeCnKoKedeyeyccllooaakk
  1. Ein User öffnet die Hilla Web-App im Browser über http://localhost:8080.
  2. 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>
  1. 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.

Keycloak Login

  1. 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=**** 
  1. 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>
  1. 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
  1. 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"
  ...
}
  1. 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
  1. 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.

Keycloak - Client erstellen - Authentication

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.

Keycloak - Client erstellen - Redirect

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.

Keycloak - User erstellen

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.

Keycloak - User erstellen - Passwort

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.

User-Informationen und 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.

Keycloak - Client erweitern - Rollen

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.

Keycloak - Client erweitern - Mapper

Der Mapper muss im Anschluss noch angepasst werden:

  • Im Feld Client ID sollte der Wert hilla ausgewählt werden. Dies stellt sicher, dass nur die beiden Rollen MANAGER oder IC berücksichtigt werden.
  • Das Feld Client Role Prefix enthält den Wert ROLE_.
  • Im Feld Token Claim Name kann der Wert auf roles 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 Claim roles in der Instanz der Klasse OidcUser im SecurityContext von Spring Security verfügbar ist.

Keycloak - User Client Role

Zum Abschluss wird dem User John Doe dann die Rolle IC zugewiesen. Jane Roe erhält die Rolle MANAGER.

Keycloak - User erweitern - Rollen

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 Rolle MANAGER aufrufbar sein soll.
  • Eine ICView, die nur für User mit der Rolle IC 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.