It’s possible to restrict access for selected Hilla views, based on roles defined for the logged-in user. This article explains how to do this.
To follow the examples here, you’ll need a Hilla application with authentication enabled. The Authentication With Spring Security page will help you to get started.
Define Roles with Spring Security
Roles are a set of string attributes representing the authorities that are assigned to a user. In Spring Security, the user details used for authentication also specify roles.
Typically, roles are defined in authority strings prefixed with ROLE_. After successful authentication, these are accessible via the GrantedAuthority objects returned by Authentication.getAuthorities(). See the Authentication With Spring Security page for examples of configuration.
Using Roles in TypeScript
A convenient way to use roles for access control in TypeScript views is to add a Hilla endpoint that gets user information, including roles, from Java during authentication. To do this, first define a bean representing information about the user:
Source code
UserInfo.java
package com.vaadin.demo.fusion.security.authentication; import jakarta.annotation.Nonnull; import java.util.Collection; import java.util.Collections; /** * User information used in client-side authentication and authorization. To be * saved in browsers’ LocalStorage for offline support. */ public class UserInfo { @Nonnull private String name; @Nonnull private Collection<String> authorities; public UserInfo(String name, Collection<String> authorities) { this.name = name; this.authorities = Collections.unmodifiableCollection(authorities); } public String getName() { return name; } public Collection<String> getAuthorities() { return authorities; } }
Next, add the endpoint to get a UserInfo containing authorities for the logged-in user on the client side:
Source code
UserInfoService.java
package com.vaadin.demo.fusion.security.authentication; import com.vaadin.hilla.BrowserCallable; import jakarta.annotation.Nonnull; import jakarta.annotation.security.PermitAll; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import java.util.List; import java.util.stream.Collectors; /** * Provides information about the current user. */ @BrowserCallable public class UserInfoService { @PermitAll @Nonnull public UserInfo getUserInfo() { Authentication auth = SecurityContextHolder.getContext() .getAuthentication(); final List<String> authorities = auth.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); return new UserInfo(auth.getName(), authorities); } }
Then, change the authentication implementation in TypeScript to get the user information from the endpoint. Change the auth.ts defined in Authentication With Spring Security as follows:
Source code
auth.ts
// Uses the Vaadin provided login an logout helper methods import { login as loginImpl, type LoginOptions, type LoginResult, logout as logoutImpl, type LogoutOptions, } from '@vaadin/hilla-frontend'; import type UserInfo from 'Frontend/generated/com/vaadin/demo/fusion/security/authentication/UserInfo'; import { UserInfoService } from 'Frontend/generated/endpoints'; interface Authentication { user: UserInfo; timestamp: number; } let authentication: Authentication | undefined; const AUTHENTICATION_KEY = 'authentication'; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; /** * Forces the session to expire and removes user information stored in * `localStorage`. */ export function setSessionExpired() { authentication = undefined; // Delete the authentication from the local storage localStorage.removeItem(AUTHENTICATION_KEY); } // Get authentication from local storage const storedAuthenticationJson = localStorage.getItem(AUTHENTICATION_KEY); if (storedAuthenticationJson !== null) { const storedAuthentication = JSON.parse(storedAuthenticationJson) as Authentication; // Check that the stored timestamp is not older than 30 days const hasRecentAuthenticationTimestamp = new Date().getTime() - storedAuthentication.timestamp < THIRTY_DAYS_MS; if (hasRecentAuthenticationTimestamp) { // Use loaded authentication authentication = storedAuthentication; } else { // Delete expired stored authentication setSessionExpired(); } } /** * Login wrapper method that retrieves user information. * * Uses `localStorage` for offline support. */ export async function login( username: string, password: string, options: LoginOptions = {} ): Promise<LoginResult> { return await loginImpl(username, password, { ...options, async onSuccess() { // Get user info from endpoint const user = await UserInfoService.getUserInfo(); authentication = { user, timestamp: new Date().getTime(), }; // Save the authentication to local storage localStorage.setItem(AUTHENTICATION_KEY, JSON.stringify(authentication)); }, }); } /** * Login wrapper method that retrieves user information. * * Uses `localStorage` for offline support. */ export async function logout(options: LogoutOptions = {}) { return await logoutImpl({ ...options, onSuccess() { setSessionExpired(); }, }); } /** * Checks if the user is logged in. */ export function isLoggedIn() { return !!authentication; } /** * Checks if the user has the role. */ export function isUserInRole(role: string) { if (!authentication) { return false; } return authentication.user.authorities.includes(`ROLE_${role}`); }
Add an isUserInRole() helper, which enables role-based access control checks for the UI.
Source code
auth.ts
// Uses the Vaadin provided login an logout helper methods import { login as loginImpl, type LoginOptions, type LoginResult, logout as logoutImpl, type LogoutOptions, } from '@vaadin/hilla-frontend'; import type UserInfo from 'Frontend/generated/com/vaadin/demo/fusion/security/authentication/UserInfo'; import { UserInfoService } from 'Frontend/generated/endpoints'; interface Authentication { user: UserInfo; timestamp: number; } let authentication: Authentication | undefined; const AUTHENTICATION_KEY = 'authentication'; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; /** * Forces the session to expire and removes user information stored in * `localStorage`. */ export function setSessionExpired() { authentication = undefined; // Delete the authentication from the local storage localStorage.removeItem(AUTHENTICATION_KEY); } // Get authentication from local storage const storedAuthenticationJson = localStorage.getItem(AUTHENTICATION_KEY); if (storedAuthenticationJson !== null) { const storedAuthentication = JSON.parse(storedAuthenticationJson) as Authentication; // Check that the stored timestamp is not older than 30 days const hasRecentAuthenticationTimestamp = new Date().getTime() - storedAuthentication.timestamp < THIRTY_DAYS_MS; if (hasRecentAuthenticationTimestamp) { // Use loaded authentication authentication = storedAuthentication; } else { // Delete expired stored authentication setSessionExpired(); } } /** * Login wrapper method that retrieves user information. * * Uses `localStorage` for offline support. */ export async function login( username: string, password: string, options: LoginOptions = {} ): Promise<LoginResult> { return await loginImpl(username, password, { ...options, async onSuccess() { // Get user info from endpoint const user = await UserInfoService.getUserInfo(); authentication = { user, timestamp: new Date().getTime(), }; // Save the authentication to local storage localStorage.setItem(AUTHENTICATION_KEY, JSON.stringify(authentication)); }, }); } /** * Login wrapper method that retrieves user information. * * Uses `localStorage` for offline support. */ export async function logout(options: LogoutOptions = {}) { return await logoutImpl({ ...options, onSuccess() { setSessionExpired(); }, }); } /** * Checks if the user is logged in. */ export function isLoggedIn() { return !!authentication; } /** * Checks if the user has the role. */ export function isUserInRole(role: string) { if (!authentication) { return false; } return authentication.user.authorities.includes(`ROLE_${role}`); }
Routes with Access Control
To enable allowed roles to be specified on the view routes, define an extended type ViewRoute, that has a rolesAllowed string, like so:
Source code
routes.ts
import type { Commands, Route } from '@vaadin/router'; import { isUserInRole } from './auth'; // Enable declaring additional data on the routes export type ViewRoute = Route & { title?: string; children?: ViewRoute[]; rolesAllowed?: string[]; }; export function isAuthorizedViewRoute(route: ViewRoute) { if (route.rolesAllowed) { return route.rolesAllowed.find((role) => isUserInRole(role)); } return true; } export const routes: ViewRoute[] = [ { path: 'protected', component: 'protected-view', title: 'Protected', rolesAllowed: ['ADMIN'], action: async (context, commands: Commands) => { const route = context.route as ViewRoute; if (!isAuthorizedViewRoute(route)) { return commands.prevent(); } await import('./protected-view'); return undefined; }, }, ];
Add a method to check access for the given route by iterating rolesAllowed, using isUserInRole(), as follows:
Source code
routes.ts
import type { Commands, Route } from '@vaadin/router'; import { isUserInRole } from './auth'; // Enable declaring additional data on the routes export type ViewRoute = Route & { title?: string; children?: ViewRoute[]; rolesAllowed?: string[]; }; export function isAuthorizedViewRoute(route: ViewRoute) { if (route.rolesAllowed) { return route.rolesAllowed.find((role) => isUserInRole(role)); } return true; } export const routes: ViewRoute[] = [ { path: 'protected', component: 'protected-view', title: 'Protected', rolesAllowed: ['ADMIN'], action: async (context, commands: Commands) => { const route = context.route as ViewRoute; if (!isAuthorizedViewRoute(route)) { return commands.prevent(); } await import('./protected-view'); return undefined; }, }, ];
Then use the method added in the route action to redirect on unauthorized access like this:
Source code
routes.ts
import type { Commands, Route } from '@vaadin/router'; import { isUserInRole } from './auth'; // Enable declaring additional data on the routes export type ViewRoute = Route & { title?: string; children?: ViewRoute[]; rolesAllowed?: string[]; }; export function isAuthorizedViewRoute(route: ViewRoute) { if (route.rolesAllowed) { return route.rolesAllowed.find((role) => isUserInRole(role)); } return true; } export const routes: ViewRoute[] = [ { path: 'protected', component: 'protected-view', title: 'Protected', rolesAllowed: ['ADMIN'], action: async (context, commands: Commands) => { const route = context.route as ViewRoute; if (!isAuthorizedViewRoute(route)) { return commands.prevent(); } await import('./protected-view'); return undefined; }, }, ];
Hiding Unauthorized Menu Items
Filter the route list using the isAuthorizedViewRoute() helper defined earlier. Then use the filtered list of routes as menu items:
Source code
main-view.ts
import { html, LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { Router } from '@vaadin/router'; import { isAuthorizedViewRoute, routes } from './routes'; export const router = new Router(document.querySelector('#outlet')); @customElement('main-view') export class MainView extends LitElement { protected render() { return html` <nav> ${repeat( this.menuRoutes, (route) => html`<a href="${router.urlForPath(route.path)}">${route.title}</a>` )} ; </nav> `; } private get menuRoutes() { return routes.filter((route) => route.title).filter(isAuthorizedViewRoute); } }