Authentication With Spring Security

Since the downloaded application is a Spring Boot project, the easiest way to enable authentication is by adding Spring Security.

Dependencies

Add the following dependency to the project Maven file.

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

The application is now protected by a default Spring login view just adding the Spring Security dependency. By default it has a single user: 'user' username and random password, printed in the console when the application starts up after adding logging.level.org.springframework.security = DEBUG to the application.properties file.

Server Configuration

To have your own security configuration, create a new configuration class extending the VaadinWebSecurityConfigurerAdapter class, and annotate it to enable security. The VaadinWebSecurityConfigurerAdapter is a helper which extends from the WebSecurityConfigurerAdapter class. It takes care of the configuration for Vaadin requests, so that you can concentrate on your application specific configuration.

@EnableWebSecurity
@Configuration
public class SecurityConfig extends VaadinWebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // Set default security policy that permits Vaadin internal requests and
    // denies all other
    super.configure(http);
    // use a form based login
    http.formLogin();
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // Configure users and roles in memory
    auth.inMemoryAuthentication()
        .withUser("user").password("{noop}user").roles("USER")
        .and()
        .withUser("admin").password("{noop}admin").roles("ADMIN", "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.

Adding Public Views and Resources

Public views need to be added to the configuration before calling super.configure, e.g.

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests().antMatchers("/public-view").permitAll(); // custom matcher
    super.configure(http);
    }
}

Public resources can be added by overriding configure(WebSecurity web):

@Override
  public void configure(WebSecurity web) throws Exception {
      super.configure(web);
      web.ignoring().antMatchers("/images/**"); 
  }

Use a Fusion Login View

Use the <vaadin-login-overlay> component to create the following login view, so that the autocomplete and password features of the browser are used.

import { html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LoginResult } from '@vaadin/flow-frontend';
import { login } from './auth';
import { AfterEnterObserver, RouterLocation } from '@vaadin/router';
import '@vaadin/vaadin-login/vaadin-login-overlay';

@customElement('login-view')
export class LoginView extends LitElement implements AfterEnterObserver {
  @state()
  private error = false;

  // the url to redirect to after a successful login
  private returnUrl?: string;

  private onSuccess = (result: LoginResult) => {
    // If a login redirect was initiated by opening a protected URL, the server knows where to go (result.redirectUrl).
    // If a login redirect was initiated by the client router, this.returnUrl knows where to go.
    // If login was opened directly, use the default URL provided by the server.
    // As we do not know if the target is a resource or a Fusion view or a Flow view, we cannot just use Router.go
    window.location.href = result.redirectUrl || this.returnUrl || result.defaultUrl || '/';
  };

  render() {
    return html`
      <vaadin-login-overlay opened .error="${this.error}" @login="${this.login}">
      </vaadin-login-overlay>
    `;
  }

  async login(event: CustomEvent): Promise<LoginResult> {
    this.error = false;
    // use the login helper method from auth.ts, which in turn uses
    // Vaadin provided login helper method to obtain the LoginResult
    const result = await login(event.detail.username, event.detail.password);
    this.error = result.error;

    if (!result.error) {
      this.onSuccess(result);
    }

    return result;
  }

  onAfterEnter(location: RouterLocation) {
    this.returnUrl = location.redirectFrom;
  }
}

The authentication helper methods in the code examples are grouped in a separate TypeScript file as shown below. It utilises a Fusion login() helper method for Spring Security based authentication.

// Uses the Vaadin provided login an logout helper methods
import { login as loginImpl, LoginResult, logout as logoutImpl } from '@vaadin/flow-frontend';
import { UserInfoEndpoint } from 'Frontend/generated/UserInfoEndpoint';
import UserInfo from 'Frontend/generated/com/vaadin/demo/fusion/security/authentication/UserInfo';

interface Authentication {
  user: UserInfo;
  timestamp: number;
}

let authentication: Authentication | undefined = undefined;

const AUTHENTICATION_KEY = 'authentication';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;

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

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

/**
 * Login wrapper method that retrieves user information.
 *
 * Uses `localStorage` for offline support.
 */
export async function login(username: string, password: string): Promise<LoginResult> {
  const result = await loginImpl(username, password);
  if (!result.error) {
    // Get user info from endpoint
    const user = await UserInfoEndpoint.getUserInfo();
    authentication = {
      user,
      timestamp: new Date().getTime(),
    };

    // Save the authentication to local storage
    localStorage.setItem(AUTHENTICATION_KEY, JSON.stringify(authentication));
  }

  return result;
}

/**
 * Login wrapper method that retrieves user information.
 *
 * Uses `localStorage` for offline support.
 */
export async function logout() {
  setSessionExpired();
  return await logoutImpl();
}

/**
 * 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}`);
}

After the login view is defined, you should define a route for it in the routes.ts file. Don’t forget to import the login-view component, otherwise the login view is not visible.

import './login-view';
...
const routes = [
  {
    path: '/login',
    component: 'login-view'
  },
  // more routes
}

Update the SecurityConfig to use the setLogin helper which will set up everything needed for a Fusion based login view:

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

Note, the path for the login view in routes.ts must match the one defined in SecurityConfig.

How to Protect a Fusion View

Access control for Fusion views cannot be based on URL filtering. The Fusion view templates are always in the bundle and can be accessed by anybody, so it is important not to store any sensitive data into the view template. The data should come endpoints, and the endpoints should be protected instead. Read Configuring Security on how to protect endpoints. However, you can still achieve a better user experience by redirecting unauthenticated requests to the login view with the route action.

An example of using the route action:

import { Commands, Context, Route } from '@vaadin/router';
import './my-view';
...
const routes = [
  ...
  {
    path: '/my-view',
    action: (_: Context, commands: Commands) => {
      if (!isLoggedIn()) {
        return commands.redirect('/login');
      }
      return undefined;
    },
    component: 'my-view'
  }
  ...
}

You can also add the route action to the parent layout, so that all the child views are protected. In this case, the login component should be outside of the main layout, that is, not a child of the main layout in the route configuration.

import { Commands, Context, Route } from '@vaadin/router';
import './login-view';
...
const routes = [
  ...
  {
    path: '/login',
    component: 'login-view'
  },
  {
    path: '/',
    action: (_: Context, commands: Commands) => {
      if (!isLoggedIn()) {
        return commands.redirect('/login');
      }
      return undefined;
    },
    component: 'main-layout',
    children: [
      ...
    ]
  }
  ...
}

The isLoggedIn() method in the above code examples uses a lastLoginTimestamp variable stored in the localStorage to check if the user is logged in. The lastLoginTimestamp variable needs to be reset when logging out.

Using localStorage allows navigating to sub views without having to check authentication from the backend on every navigation so that the authentication check could also work offline.

Logout

To handle logging out, you can use the logout() helper defined in auth.ts above. You typically want to use a button for handling logout and not navigation and a route to avoid timing issues between rendering views and logging out. You can, for example, do the following:

<vaadin-button @click="${() => logout()}">Logout</vaadin-button>

Alternatives to Using the Configuration Helper

VaadinWebSecurityConfigurerAdapter.configure(http) configures HTTP security to bypass framework internal resources. If you prefer to roll your own configuration instead of using the helper, the matcher for these resources can be retrieved with VaadinWebSecurityConfigurerAdapter.getDefaultHttpSecurityPermitMatcher(). For example, the VaadinWebSecurityConfigurerAdapter.configure(http) requires all the requests but the Vaadin internal ones to be authenticated, if you want to allow public access to certain views, you can configure it as following:

public static void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .requestMatchers(
          VaadinWebSecurityConfigurerAdapter.getDefaultHttpSecurityPermitMatcher()
        ).permitAll()
        .antMatchers("/public-view").permitAll() // custom matcher
        .anyRequest().authenticated();
        ...
}

Analogously, the matcher for static resources to be ignored is available as VaadinWebSecurityConfigurerAdapter.getDefaultWebSecurityIgnoreMatcher:

public static void configure(WebSecurity web) throws Exception {
    web.ignoring()
       .requestMatchers(
         VaadinWebSecurityConfigurerAdapter.getDefaultWebSecurityIgnoreMatcher())
       .antMatchers("static/**") // custom matcher
       ...
}

Appendix: Production Data Sources

The example using users in memory above is valid for test applications, though, Spring Security offers other implementations for production scenarios.

SQL Authentication

The following example demonstrates how to access a SQL database with tables for users and authorities.

@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  ...

  @Autowired
  private DataSource dataSource;

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // Configure users and roles in a JDBC database
    auth.jdbcAuthentication()
      .dataSource(dataSource)
      .usersByUsernameQuery(
          "SELECT username, password, enabled FROM users WHERE username=?")
      .authoritiesByUsernameQuery(
          "SELECT username, authority FROM from authorities WHERE username=?")
      .passwordEncoder(new BCryptPasswordEncoder());
  }
}

LDAP Authentication

The next examples shows how to configure authentication by using an LDAP repository

@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  ...

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // Obtain users and roles from an LDAP service
    auth.ldapAuthentication()
      .userDnPatterns("uid={0},ou=people")
      .userSearchBase("ou=people")
      .groupSearchBase("ou=groups")
      .contextSource()
      .url("ldap://localhost:8389/dc=example,dc=com")
      .and()
      .passwordCompare()
      .passwordAttribute("userPassword");
  }
}

Do not forget to add the corresponding LDAP client dependency to the project:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-ldap</artifactId>
    <version>5.2.0.RELEASE</version>
</dependency>