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:
- Ein User öffnet die Hilla Web-App im Browser über
http://localhost:8080
. - Hilla rendert ein Login-Formular:
- Das Login-Formular wird mit den Anmeldeinformationen an das Hilla-Backend gesendet.
- 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=****
- 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"
...
}
- 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 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.
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.
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:
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.
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. - 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
auch in der Response vom Aufruf des User-Info-Endpunktes enthalten 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": [
"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 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 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.