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.

This chapter covers:

  • Spring Security setup.

  • Securing Vaadin 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 Spring Security dependency to pom.xml. Spring Boot configures it automatically it to protect all traffic.

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-security</artifactId>
</dependency>

If you run the application now, you are greeted by a login screen, with no way of logging in. Configure Spring Security to work with a Vaadin Fusion app.

Create a new package com.example.application.security. In the new package, create a configuration file:

package com.example.application.security;

import com.vaadin.flow.spring.security.VaadinWebSecurityConfigurerAdapter;

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;

@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends VaadinWebSecurityConfigurerAdapter {

}

Override the HttpSecurity configuration method.

@Override
protected void configure(HttpSecurity http) throws Exception {
  super.configure(http);
  setLoginView(http, "/login");
}

The configuration sets up form-based login for the /login path.

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 Vaadin Endpoints

Vaadin endpoints are secured by default. Until now, anonymous access to the endpoint has explicitly been allowed by the @AnonymousAllowed annotation on the endpoint.

Remove this annotation from the endpoint to require authentication.

@Endpoint
public class CrmEndpoint {
}

If you run the application now, you notice that you no longer see any contacts. That’s because the backend is refusing the endpoint call.

Handling Login and Logout

You need to log in to restore access to the endpoints. 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 to navigate users to the correct place after login/logout.

Begin by adding the login state handling and actions to UiStore. Import the needed login methods at the top of the file.

import {
 login as serverLogin,
 logout as serverLogout,
} from "@vaadin/flow-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 there is no active session on the server.

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);
}

private 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 '@vaadin/vaadin-login/vaadin-login-form';
import { View } from '../view';

@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>Vaadin CRM</h1>
     <vaadin-login-form
       no-forgot-password
       @login="${this.login}"
       .error="${this.error}"
     >
     </vaadin-login-form>
   `;
 }

 async login(e: CustomEvent) {
   try {
     await uiStore.login(e.detail.username, e.detail.password);
   } catch (e) {
     this.error = true;
   }
 }
}

The login view follows the same pattern as the two views you already have. It has a @state for handling errors. This state is only relevant for the Vaadin 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 Vaadin 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 "./views/login/login-view";
import { Commands, Context, Route, Router } from '@vaadin/router';
import { uiStore } from './stores/app-store';
import { autorun } from 'mobx';

Notice that the login view is imported statically, adding it to the main application bundle. This is because you know the user will need 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,
 },
];

Notice that 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 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 a 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 Server Sessions

The Spring Security setup uses a server-based session. The session expires after a period of inactivity, or if the server node is shut down. The application should detect when the session expires and set the loggedIn state to false. This triggers the autorun configured above, and redirects the user to the login page.

Vaadin Fusion supports middleware that can intercept endpoint calls. Create a middleware that listens for the HTTP 401 response code, signifying that the session has expired, frontend/connect-client.ts:

import { MiddlewareContext } from "@vaadin/flow-frontend";
import { MiddlewareNext } from "@vaadin/flow-frontend";
import { ConnectClient } from "@vaadin/flow-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 session 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">Vaadin 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 login and verify that everything works.