Hilla mit Keycloak und dem Resource Owner Password Flow von OAuth 2.0

René Wilby | 07.05.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 Resource Owner Password Flow

Dieser Artikel beschreibt, wie für Hilla eine Authentifizierung auf Basis des Resource Owner Password Flow von OAuth 2.0 realisiert werden kann. Der Resource Owner Password Flow eignet sich gut für die Authentifizierung von Anwender:innen von Web-Apps im geschützten Unternehmenskontext. 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 Resource Owner Password Flow in Verbindung mit Hilla und Keycloak folgendermaßen ab:

UUsse(er1(r)3()R2e)UqsuSeehrsontwamHLeiolg+lianP-aWFseosbrw-moArHpdHipilll(la6a(()45))R(e7AAq)ucutceUhessestensrt-U-iTsIcoenakrfte-oenIKnKefeyoyccllooaakk
  1. Ein User öffnet die Hilla Web-App im Browser über http://localhost:8080.
  2. Hilla rendert ein Login-Formular:

Hilla Login

  1. Das Login-Formular wird mit den Anmeldeinformationen an das Hilla-Backend gesendet.
  2. Das Hilla-Backend sendet die Anmeldeinformationen an Keycloak. Keycloak authentifiziert den User.
POST http://localhost:11111/realms/test/protocol/openid-connect/token
  grant_type=password
  username=<USERNAME>
  password=<PASSWORD>
  client_id=hilla
  client_secret=****
  1. Nach erfolgter Authentifizierung sendet Keycloak einen 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 Vergleich zum Authorization Code Flow erfolgt beim Resource Owner Password Flow kein Login durch Anwender:innen über eine Login-Seite, die Keycloak bereitstellt. Stattdessen kann die Web-App selber ein Login-Formular anbieten, über das sich die Anwender:innen anmelden. Dies kann unter Umständen erwünscht sein, um bspw. die Web-App nicht zu verlassen und damit Anwender:innen ggf. zu verunsichern.

Noch zwei wichtige Hinweise zum Resource Owner Password Flow an dieser Stelle:

  • Der Resource Owner Password Flow sollte nur dann zum Einsatz kommen, wenn zwischen dem Client und dem Resource Server eine sichere und vertrauenswürdige Verbindung besteht. Für Business-Web-Apps im Unternehmenskontext und in Verbindung mit Hilla ist diese sichere und vertrauenswürdige Verbindung meiner Meinung nach in der Regel gegeben.
  • Der Resource Owner Password Flow ist im Entwurf für die kommende Version 2.1 von OAuth nicht mehr enthalten. Welche Auswirkungen dies auf die langfristige Unterstützung des Resource Owner Password Flows in Frameworks wie Spring Security und Authorization Servern wie Keycloak haben wird, ist derzeit noch nicht abzusehen.

Im weiteren Verlauf des Artikels wird beschrieben, wie Keycloak und Hilla zu konfigurieren sind, um den gezeigten Resource Owner Password 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:

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:

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 Direct access grants ausgewählt werden, da dies dem oben gezeigten Resource Owner Password Flow entspricht.

Keycloak - Client erstellen - Authentication

Weitere Angaben müssen bei der Erstellung des Clients nicht gemacht werden.

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:

podman exec -it keycloak /opt/keycloak/bin/kc.sh export --file /tmp/realm-export.json --realm test --users realm_file
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:

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-resource-owner-password-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 @hilla/cli init hilla-oauth-resource-owner-password-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 werden zunächst die folgenden Angaben in der application.properties ergänzt:

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=password

spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:11111/realms/test
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username

Nun wird die Datei SecurityConfig.java erstellt:

import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@EnableWebSecurity
@Configuration
public class SecurityConfig extends VaadinWebSecurity {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize ->
                authorize.requestMatchers(
                        new AntPathRequestMatcher("/images/*.png"),
                        new AntPathRequestMatcher("/line-awesome/**/*.svg")
                ).permitAll()
        );
        super.configure(http);
        http.oauth2Client(Customizer.withDefaults());
        setLoginView(http, "/login");
    }
}

Diese Konfiguration erweitert VaadinWebSecurity. Dadurch wird sichergestellt, dass die erforderlichen Basis-Konfigurationen für Hilla korrekt gesetzt werden. Weitere Details dazu finden sich hier. Für einige statische Dateien, wie Bilder und Icons, wird ein Request-Matcher definiert, so dass diese Dateien auch ohne Authentifizierung abgerufen werden können. http.oauth2Client(Customizer.withDefaults()) erzeugt einen OAuth 2.0 Client mit Standardeinstellungen, u.a. basierend auf den Angaben in der application.properties. setLoginView(http, "/login") setzt den Pfad für die Login-View im Hilla-Frontend, die im weiteren Verlauf noch erstellt wird.

Eine zentrale Herausforderung bei der Konfiguration des Resource Owner Password Flows in Verbindung mit Spring Security und Hilla ist die korrekte Übergabe der Anmeldeinformationen. Zur Erinnerung: Ein User gibt seine Anmeldeinformationen in ein Login-Formular im Hilla-Frontend ein. Die Anmeldeinformationen müssen dann im Hilla-Backend extrahiert werden und an Spring Security übergeben werden, damit Spring Security die Authentifizierung über den Resource Owner Password Flow in Verbindung mit Keycloak durchführen kann. Mit Hilfe der Informationen aus der Dokumentation von Spring Security wird die Datei SecurityConfig.java zunächst um eine Bean mit der Bezeichnung authorizedClientManager erweitert:

    @Bean
    public OAuth2AuthorizedClientManager authorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider =
                OAuth2AuthorizedClientProviderBuilder.builder()
                        .password()
                        .refreshToken()
                        .build();

        DefaultOAuth2AuthorizedClientManager authorizedClientManager =
                new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        authorizedClientManager.setContextAttributesMapper(authorizeRequest -> {
            Map<String, Object> contextAttributes = new HashMap<>();
            String username = authorizeRequest.getAttribute(OAuth2ParameterNames.USERNAME);
            String password = authorizeRequest.getAttribute(OAuth2ParameterNames.PASSWORD);
            contextAttributes.put(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, username);
            contextAttributes.put(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password);
            return contextAttributes;
        });

        return authorizedClientManager;
    }

Ein OAuth2AuthorizedClientProvider wird über einen passenden Builder erstellt und mittels password() auf den Resource Owner Password Flow konfiguriert. Anschließend wird ein neuer DefaultOAuth2AuthorizedClientManager erzeugt. Mit Hilfe der Methode setContextAttributesMapper werden die Anmeldeinformationen username und password aus dem Anmelde-Request des Hilla-Frontends in den Authentifizierungskontext von Spring Security übergeben. Für die Authentifizierung gegenüber Keycloak müssen die Anmeldeinformationen aus dem Authentifizierungskontext gelesen und korrekt gesetzt werden. Dazu wird in CustomAuthenticationProvider.java ein angepasster AuthenticationProvider erstellt:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
import org.springframework.stereotype.Component;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private OAuth2AuthorizedClientManager authorizedClientManager;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = authentication.getCredentials().toString();

        OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")
                .principal(authentication)
                .attributes(attrs -> {
                    attrs.put(OAuth2ParameterNames.USERNAME, username);
                    attrs.put(OAuth2ParameterNames.PASSWORD, password);
                })
                .build();
        OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest);

        if (authorizedClient != null) {
            return new BearerTokenAuthenticationToken(authorizedClient.getAccessToken().getTokenValue());
        } else {
            return null;
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

Der eigentliche OAuth2AuthorizeRequest wird nun mit den Anmeldeinformationen aus dem Authentifizierungskontext angereichert und abgesendet. War die Authentifizierung erfolgreich, kann der erhaltene Access-Token in einen passenden BearerTokenAuthenticationToken überführt werden.

Der CustomAuthenticationProvider wird nun in der SpringSecurity.java als weitere Bean eingebunden:

    @Bean
    public AuthenticationManager authenticationManager(CustomAuthenticationProvider customAuthenticationProvider) {
        return new ProviderManager(customAuthenticationProvider);
    }

Die erforderlichen Konfigurationen für Spring Security sind damit weitestgehend abgeschlossen. Es folgen nun die erforderlichen Erweiterungen für das Zusammenspiel mit dem Hilla-Frontend.

Erweiterungen für das Frontend

Bevor die Login-View im Frontend erstellt werden kann, muss zunächst der BearerTokenAuthenticationToken in einen User umgewandelt werden, der für die Verwendung in Verbindung mit dem Frontend geeignet ist.

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 dev.hilla.Nonnull;

public class User {

    @Nonnull
    private String username;

    @Nonnull
    private String firstname;

    @Nonnull
    private String lastname;

    @Nonnull
    private String email;

    // Getter

    // Setter
}

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.flow.spring.security.AuthenticationContext;
import dev.hilla.BrowserCallable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;

import java.util.Optional;

@BrowserCallable
@AnonymousAllowed
public class UserAuthenticationService {

    @Autowired
    private AuthenticationContext authenticationContext;

    public Optional<User> getAuthenticatedUser() {
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        if (authentication instanceof BearerTokenAuthenticationToken bearerTokenAuthenticationToken) {
            // TOOD
        }
        return Optional.empty();
    }
}

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 BearerTokenAuthenticationToken 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 Instanz der Klasse BearerTokenAuthenticationToken enthält derzeit lediglich einen Access-Token. Mit diesem Access-Token muss der User-Info-Endpunkt von Keycloak aufgerufen werden, um die erforderlichen Informationen zum angemeldeten User zu erhalten. Der Aufruf des User-Info-Endpunkts erfolgt mit Hilfe eines RestTemplate. Dazu wird zunächst ein RestTemplate als Bean in SecurityConfig.java zur Verfügung gestellt:

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.build();
    }

In der application.properties wird die URL zum User-Info-Endpunkt von Keycloak hinterlegt:

keycloak.userinfo-uri=http://localhost:11111/realms/test/protocol/openid-connect/userinfo

RestTemplate und URL werden in UserAuthenticationService.java hinzugefügt:

    @Autowired
    private RestTemplate restTemplate;

    @Value("${keycloak.userinfo-uri}")
    private String userinfoUri;

Ebenfalls in UserAuthenticationService.java wird nun die Methode getUserInfo ergänzt, die den User-Info-Endpunkt aufruft:

    private Optional<UserInfo> getUserInfo(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
        headers.setBearerAuth(accessToken);
        HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
        ResponseEntity<UserInfo> userInfoResponse = restTemplate.exchange(userinfoUri, HttpMethod.GET, requestEntity, UserInfo.class);
        UserInfo userInfo = userInfoResponse.getBody();
        if (userInfo != null) {
            return Optional.of(userInfo);
        } else {
            return Optional.empty();
        }
    }

Die dabei verwendete Klasse UserInfo ist wiederum ein einfaches DTO, welches in UserInfo.java mit folgendem Inhalt erstellt wird:

import com.fasterxml.jackson.annotation.JsonProperty;

public class UserInfo {

    private String name;

    @JsonProperty("preferred_username")
    private String preferredUsername;

    @JsonProperty("given_name")
    private String givenName;

    @JsonProperty("family_name")
    private String familyName;

    private String email;

    // Getter

    // Setter
    }

Die Methode getUserInfo wird nun in UserAuthenticationService.java wie folgt verwendet:

    public Optional<User> getAuthenticatedUser() {
        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();
        if (authentication instanceof BearerTokenAuthenticationToken bearerTokenAuthenticationToken) {
            Optional<UserInfo> userInfo = getUserInfo(bearerTokenAuthenticationToken.getToken());
            if (userInfo.isPresent()) {
                return Optional.of(new User(userInfo.get()));
            }
        }
        return Optional.empty();
    }

Damit eine neue Instanz der Klasse User mit Hilfe eines UserInfo-Objektes erzeugt werden kann, ist in User.java noch ein passender Konstruktor zu ergänzen:

    public User(UserInfo userInfo) {
        this.username = userInfo.getPreferredUsername();
        this.firstname = userInfo.getGivenName();
        this.lastname = userInfo.getFamilyName();
        this.email = userInfo.getEmail();
    }

Die Methode getAuthenticatedUser des UserAuthenticationService ist nun so weit vorbereitet, dass sie im nächsten Schritt im Hilla-Frontend eingebunden werden kann.

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 frontend/util angelegt:

import { configureAuth } from '@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.

Login-View hinzufügen

Die zuvor bereits angesprochene Login-View wird nun ebenfalls im Frontend angelegt. Dazu wird die Datei frontend/login/LoginView.tsx mit folgendem Inhalt angelegt:

import { LoginI18n, LoginOverlay, LoginOverlayElement } from '@hilla/react-components/LoginOverlay.js';
import { useState } from 'react';
import { useAuth } from 'Frontend/util/auth.js';
import { useNavigate } from 'react-router-dom';

const loginI18n: LoginI18n = {
    ...new LoginOverlayElement().i18n,
    header: { title: 'Hilla with Keycloak', description: 'Resource Owner Password Flow' },
  };

export default function LoginView() {
  const { login } = useAuth();
  const [hasError, setError] = useState<boolean>();
  const navigate = useNavigate();
  
  return (
    <LoginOverlay
      opened
      error={hasError}
      noForgotPassword
      i18n={loginI18n}
      onLogin={async ({ detail: { username, password } }) => {
        const { error } = await login(username, password);

        if (error) {
          setError(true);
        } else {
          navigate('/');
        }
      }}
    />
  );
}

Die Login-View wird darüber hinaus noch in der Datei frontend/routes.tsx hinterlegt:

import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js';
import LoginView from 'Frontend/views/login/LoginView.js';
import MainLayout from 'Frontend/views/MainLayout.js';
import { lazy } from 'react';
import { createBrowserRouter, RouteObject } from 'react-router-dom';

const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js'));

export const routes = [
  {
    element: <MainLayout />,
    handle: { title: 'Main' },
    children: [
      { path: '/', element: <HelloWorldView />, handle: { title: 'Hello World' } },
      { path: '/about', element: <AboutView />, handle: { title: 'About' } },
    ],
  },
  { path: '/login', element: <LoginView /> },
] as RouteObject[];

export default createBrowserRouter(routes);

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 Resource Owner Password Flow, so wie zu Beginn dieses Artikels beschrieben.

User anzeigen und Logout ermöglichen

Die Datei 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 '@hilla/react-components/AppLayout.js';
import { Avatar } from '@hilla/react-components/Avatar.js';
import { Button } from '@hilla/react-components/Button.js';
import { DrawerToggle } from '@hilla/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, useNavigate } from 'react-router-dom';
import { useAuth } from 'Frontend/util/auth.js';

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();
  const navigate = useNavigate();
  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 () => {
                await logout()
                navigate('/login')
              }}>Sign out</Button>
            </>
          )}
        </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.
  • 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 auch in der Response vom Aufruf des User-Info-Endpunktes enthalten 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": [
    "IC"
  ],
  "name": "John Doe",
  "preferred_username": "jd",
  "given_name": "John",
  "family_name": "Doe",
  "email": "john.doe@example.com"
}

Erweiterung Backend

Im Backend müssen sowohl das DTO UserInfo.java als auch das DTO User.java um das Feld roles erweitert werden:

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Collection;

public class UserInfo {

    private String name;

    @JsonProperty("preferred_username")
    private String preferredUsername;

    @JsonProperty("given_name")
    private String givenName;

    @JsonProperty("family_name")
    private String familyName;

    private String email;

    private Collection<String> roles;

    // Getter

    // Setter
}
import dev.hilla.Nonnull;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;

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(OidcUserInfo userInfo, Collection<String> roles) {
        this.username = userInfo.getPreferredUsername();
        this.firstname =userInfo.getGivenName();
        this.lastname = userInfo.getFamilyName();
        this.email = userInfo.getEmail();
        this.roles = roles;
    }

    // Getter

    // Setter
}

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 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 LoginView from 'Frontend/views/login/LoginView.js';
import MainLayout from 'Frontend/views/MainLayout.js';
import { lazy } from 'react';
import { createBrowserRouter, RouteObject } from 'react-router-dom';
import { protectRoutes } from '@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: ['IC'] } },
      { path: '/manager', element: <ManagerView />, handle: { title: 'Manager', rolesAllowed: ['Manager'] } },
      { path: '/access-denied', element: <AccessDeniedView />, handle: { title: 'Access denied' } },
    ],
  },
  { path: '/login', element: <LoginView /> },
], "/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 frontend/views/MainLayout.tsx editiert:

...
export default function MainLayout() {
  ...
  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>
            <NavLink className={navLinkClasses} to="/about">
              About
            </NavLink>
            {state.user?.roles.indexOf('IC') !== -1 && (
              <NavLink className={navLinkClasses} to="/ic">
                IC
              </NavLink>
            )}
            {state.user?.roles.indexOf('Manager') !== -1 && (
              <NavLink className={navLinkClasses} to="/manager">
                Manager
              </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.

Ausblick

Die vorhanden Rolleninformationen können natürlich auch 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 haben, um somit eine Einschränkung in der Aufrufbarkeit aus dem Frontend an die Rollen des aktuell angemeldeten Users zu knüpfen. Weitere Informationen dazu finden sich 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 Resource Owner Password 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.