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.
Vaadin Fusion uses stateless JWT-based authentication. Stateless servers can handle more users and they are easy and cheap to scale.
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 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 re-run 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 Vaadin Fusion app.
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
|
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 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.
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 needed login methods at the top of the file.
import {
login as serverLogin,
logout as serverLogout,
} from '@vaadin/fusion-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>Vaadin 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 for tracking 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 { 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 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 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 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 authentication has expired, frontend/connect-client.ts
:
import {
MiddlewareContext,
MiddlewareNext,
ConnectClient,
} from '@vaadin/fusion-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">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.