Adding a Login Screen & Authenticating Users
Right now, the entire CRM application and all endpoint calls are available to the whole world. Restrict access to the system by adding a login screen and blocking unauthorized access to endpoints and views.
Hilla uses stateless JWT-based authentication. Stateless servers can handle more users and are easy and cheap to scale.
This chapter covers:
-
Spring Security setup,
-
securing Hilla endpoints,
-
handling login and logout,
-
creating a login view,
-
restricting unauthorized access to views,
-
handling expired server sessions.
Securing the Backend with Spring Security
Add the required security dependencies to pom.xml
.
Spring Boot configures Spring Security automatically to protect all traffic.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
Stop and rerun the application to pick up the new dependencies.
You are greeted by a login screen, with no way of logging in. You need to configure Spring Security to work with a Hilla application.
Create an Application-Specific Secret Key
Stateless authentication is based on JSON Web Tokens (JWT).
The tokens are cryptographically signed and require an application-specific secret key.
You can use the openssl
command line tool to create a secret.
Store the secret in a separate configuration file that’s excluded from Git.
Create the properties file and exclude it from Git with the following commands:
mkdir -p config/local/
echo "
# Contains secrets that shouldn’t go into the repository
config/local/" >> .gitignore
echo "app.secret=$(openssl rand -base64 32)" > config/local/application.properties
Caution
|
Secret Key Considerations
|
Configure Stateless JWT-Based Authentication
Create a new package, com.example.application.security
.
In the new package, create a configuration file and read in the secret key as follows:
package com.example.application.security;
import java.util.Base64;
import jakarta.crypto.spec.SecretKeySpec;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends VaadinWebSecurity {
@Value("${app.secret}")
private String appSecret;
}
Override the HttpSecurity
configuration method.
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
setLoginView(http, "/login");
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
setStatelessAuthentication(http,
new SecretKeySpec(Base64.getDecoder().decode(appSecret), JwsAlgorithms.HS256),
"com.example.application");
}
The configuration sets up form-based login for the /login
path.
It then configures the server to be stateless and configures JWT authentication with the provided secret key.
Lastly, create an in-memory test user with the username user
and password userpass
:
@Bean
public UserDetailsManager userDetailsService() {
return new InMemoryUserDetailsManager(
// the {noop} prefix tells Spring that the password is not encoded
User.withUsername("user").password("{noop}password").roles("USER").build()
);
}
Warning
|
Never use hard-coded credentials.
You should never use hard-coded credentials in a real application.
The Security documentation has examples of setting up LDAP or SQL-based user management.
|
Securing Hilla Endpoints
Hilla endpoints are secured by default.
Up to now, anonymous access to the endpoint has explicitly been allowed by the @AnonymousAllowed
annotation on the endpoint.
Replace the annotation with @PermitAll
to allow access to all logged-in users.
See Security Options in the security configuration documentation for a complete list of the annotations.
@Endpoint
@PermitAll
public class CrmEndpoint {
}
Handling Login & Logout
You need to log in to restore access to the application. For login, you need these things:
-
Login state tracking and login/logout functionality in the
UiStore
. -
A login view.
-
A guard on the router to prevent unauthorized access to views.
-
An
autorun
observer to navigate users to the correct place after login/logout.
Begin by adding the login state handling and actions to UiStore
.
Import the required login methods at the top of the file.
import {
login as serverLogin,
logout as serverLogout,
} from '@hilla/frontend';
import { crmStore } from './app-store';
Next, add a new observable for the login state.
Initialize the state to true
.
The middleware in the next step resets it to false
if the user isn’t authenticated.
loggedIn = true;
Lastly, add three new actions:
async login(username: string, password: string) {
const result = await serverLogin(username, password);
if (!result.error) {
this.setLoggedIn(true);
} else {
throw new Error(result.errorMessage || 'Login failed');
}
}
async logout() {
await serverLogout();
this.setLoggedIn(false);
}
setLoggedIn(loggedIn: boolean) {
this.loggedIn = loggedIn;
if (loggedIn) {
crmStore.initFromServer();
}
}
The login()
action uses the imported serverLogin()
function to log in on the server.
If all goes well, it sets the loggedIn
state to true
, otherwise it throws an error.
The logout()
action logs the user out of the server, and sets the loggedIn
state to false
.
Both actions use the internal setter action setLoggedIn()
.
It tells crmStore
to initialize from the server upon login.
Creating a Login View
Now that you have the login infrastructure in place, you can create a login view to handle user logins.
Create a new file, frontend/views/login/login-view.ts
.
import { uiStore } from 'Frontend/stores/app-store';
import { html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { View } from 'Frontend/views/view';
import { LoginFormLoginEvent } from '@vaadin/login/vaadin-login-form.js';
import '@vaadin/login/vaadin-login-form.js';
@customElement('login-view')
export class LoginView extends View {
@state()
private error = false;
connectedCallback() {
super.connectedCallback();
this.classList.add('flex', 'flex-col', 'items-center', 'justify-center');
uiStore.setLoggedIn(false);
}
render() {
return html`
<h1>Hilla CRM</h1>
<vaadin-login-form
no-forgot-password
@login=${this.login}
.error=${this.error}
></vaadin-login-form>
`;
}
async login(e: LoginFormLoginEvent) {
try {
await uiStore.login(e.detail.username, e.detail.password);
} catch (err) {
this.error = true;
}
}
}
The login view follows the same pattern as the two views you already have.
It uses a @state
property to track errors.
This state is only relevant for the Hilla Login Form component, so it’s not worth putting it in a MobX store; the component state is sufficient.
It sets the loggedIn
state to false
any time it’s shown.
The Hilla login form component is bound to the login()
method, which delegates to the login
action in the uiStore
.
If login succeeds, the store updates the login state.
If not, set the error
property and the login form shows an error message.
Next, register the login view and add logic to redirect users after logging in.
Add imports for the login view and other dependencies below the existing imports in routes.ts
.
import { Commands, Context, Route, Router } from '@vaadin/router';
import { uiStore } from './stores/app-store';
import { autorun } from 'mobx';
import './views/login/login-view';
The login view is imported statically, adding it to the main application bundle. This is because you know the user needs the login view on their first request and don’t want to incur a second server round trip to fetch it.
Next, add login
and logout
route handling:
export const routes: ViewRoute[] = [
{
path: 'login',
component: 'login-view',
},
{
path: 'logout',
action: (_: Context, commands: Commands) => {
uiStore.logout();
return commands.redirect('/login');
},
},
{
path: '',
component: 'main-layout',
children: views,
},
];
The logout
route isn’t mapped to any component.
Instead, it uses an action to call the uiStore
to log out and redirect the user back to the login page.
Restricting Unauthorized Access to Views
You can also use the action API to create an authorization guard that redirects users to the login page if they aren’t logged in, and saves the requested path in the process.
const authGuard = async (context: Context, commands: Commands) => {
if (!uiStore.loggedIn) {
// Save requested path
sessionStorage.setItem('login-redirect-path', context.pathname);
return commands.redirect('/login');
}
return undefined;
};
The authGuard
action redirects users to login
if the loggedIn
state is false.
It saves the requested path in the browser sessionStorage
so navigation can resume after login.
Add the authGuard
action to the main-layout
route definition:
{
path: '',
component: 'main-layout',
children: views,
action: authGuard,
},
Lastly, add an autorun
that observes the uiStore.loggedIn
state and redirects the user appropriately when the state changes.
autorun(() => {
if (uiStore.loggedIn) {
Router.go(sessionStorage.getItem('login-redirect-path') || '/');
} else {
if (location.pathname !== '/login') {
sessionStorage.setItem('login-redirect-path', location.pathname);
Router.go('/login');
}
}
});
On login, the autorun
redirects to the path that was initially requested, if available, otherwise it redirects to the root path.
On logout, it saves the current path, so users can return to it once they are logged in again.
Handling Expired Logins
The JWT token expires 30 minutes after the last server communication.
You can configure the JWT expiration time as needed.
The application should detect when the authentication expires and set the loggedIn
state to false
.
This triggers the autorun
configured previously, and redirects the user to the login page.
Hilla supports middleware that can intercept endpoint calls.
Create a piece of middleware that listens for the HTTP 401 response code, signifying that the authentication has expired, in frontend/connect-client.ts
:
import {
MiddlewareContext,
MiddlewareNext,
ConnectClient,
} from '@hilla/frontend';
import { uiStore } from './stores/app-store';
const client = new ConnectClient({
prefix: 'connect',
middlewares: [
async (context: MiddlewareContext, next: MiddlewareNext) => {
const response = await next(context);
// Log out if the authentication has expired
if (response.status === 401) {
uiStore.logout();
}
return response;
},
],
});
export default client;
The middleware checks the response status and calls the uiState.logout()
action if it gets a 401 response code.
Adding a Logout Link
Add a logout link to the header in the main layout to allow users to log out.
<header slot="navbar" class="w-full flex items-center px-m">
<vaadin-drawer-toggle></vaadin-drawer-toggle>
<h1 class="text-l m-m">Hilla CRM</h1>
<a href="/logout" class="ms-auto">Log out</a>
</header>
Run the application.
You should now be greeted by a login screen.
Use user
/userpass
to log in and verify that everything works.
