Hilla Documentation

Adding a Login Screen and 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 is 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
  • The secret key must be unique for your application. Keep it secret.

  • Use different keys in the development, staging, and production environments.

  • Do not commit the secret key into the repository.

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 javax.crypto.spec.SecretKeySpec;
import com.vaadin.flow.spring.security.VaadinWebSecurityConfigurerAdapter;
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 VaadinWebSecurityConfigurerAdapter {

  @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:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  auth.inMemoryAuthentication().withUser("user").password("{noop}userpass").roles("USER");
}
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 and 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 is not 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 is not worth putting it in a MobX store; the component state is sufficient. It sets the loggedIn state to false any time it is 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';

Notice that 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 do not 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,
  },
];

Notice that the logout route is not 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 are not 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.

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.