diff --git a/.github/styles/Vaadin/Abbr.yml b/.github/styles/Vaadin/Abbr.yml index 4c2925cdd5..474d07fae8 100644 --- a/.github/styles/Vaadin/Abbr.yml +++ b/.github/styles/Vaadin/Abbr.yml @@ -24,6 +24,8 @@ exceptions: - JAR - JPA - JSON + - JWT + - LDAP - NPM - PATH - PDF diff --git a/.github/styles/Vocab/Docs/accept.txt b/.github/styles/Vocab/Docs/accept.txt index 67dc2b26ca..b535772e0f 100644 --- a/.github/styles/Vocab/Docs/accept.txt +++ b/.github/styles/Vocab/Docs/accept.txt @@ -2,6 +2,7 @@ app async [bB]oolean +[cC]acheable classpath configurator Ctrl diff --git a/articles/fusion/pwa/pwa-cache-client-side-data.asciidoc b/articles/fusion/pwa/cache-client-side-data.asciidoc similarity index 100% rename from articles/fusion/pwa/pwa-cache-client-side-data.asciidoc rename to articles/fusion/pwa/cache-client-side-data.asciidoc diff --git a/articles/fusion/pwa/offline-authentication-check.asciidoc b/articles/fusion/pwa/offline-authentication-check.asciidoc new file mode 100644 index 0000000000..5023e53a1e --- /dev/null +++ b/articles/fusion/pwa/offline-authentication-check.asciidoc @@ -0,0 +1,39 @@ +--- +title: Offline Authentication Checks +order: 8 +layout: page +--- + += Security Aspects of Offline Authentication Checks + +Take the following security aspects into account when you do authentication checks while the user is offline + +== Authenticity +Do you need to protect the local user's data on the device from whoever else may have access to the same device? If that is a requirement, then you will end up adding a `passcode` in addition to the regular online login (can be replaced with a fingerprint/face id auth through the WebAuthn API on the devices that support those). This is what many mobile bank apps do. +It is to encrypt any user data that is cached locally for offline use. +If you do go that route, most likely you will end up having a helper library that you can call to check if the user is logged in or not. + +Arguably, not many apps would need to securely verify the user authenticity offline, and most can check if an online login happened in the not-so-distant past. + + - For that the simplest approach would be setting a local storage flag after a user has successfully logged in online (and clear it when the user logs out). +During offline navigation, the user auth then would be checked from the local storage. +The <<../security/custom-spring-login#, Adding a Custom Login Form with Spring Security>> article describes how to do that. +It is worth to classify that this as a UX improvement, not as a security feature, because it is easy to bypass through the browser developer tools. + +- And if you need to add a more reliable expiration mechanism, you could use signed tokens (for example, JWT) so that the client can strongly verify the timestamp of the last login. +Though, it is still not bulletproof because the user may change the date on the device. + +== Authorization +When it comes to authorization (checking roles), in reality, it is only feasible on the backend. +The reason is simple: the backend is the only place that can reliably guard the data the user is not authorized to view/edit. +Once some data has reached the client, you have to assume it may be tampered with. +When it comes to, for example, checking whether a user has a role to access a certain view, the client-side implementation is mostly about the UX. +You would need a way to check if the user has the permissions to navigate to a view, but this way does not need to be a secure one. +In case if the user tampers with the permission on their device, they may end up navigating to that view anyway, but they can not see any sensitive data because that would be securely guarded by the backend. + +What you may end up doing in case when you want to keep more than a plain `isLoggedIn` flag, is keeping a list of user roles in plain text in the local storage, and checking against that list in offline navigation. +If the user changes their roles through the developer tools, they could see the views they are not allowed to see (if the view templates are stored in the browser cache), but they can not see any data there. + +Using secure tokens, like JWT, makes the list of roles secure. +It is cryptographically signed and the client application may verify that it has not been tampered with. +While such tokens are useful when accessing the backend because they allow the backend to be stateless, there are no particular benefits for client-side offline authentication. diff --git a/articles/fusion/security/custom-spring-login.asciidoc b/articles/fusion/security/custom-spring-login.asciidoc new file mode 100644 index 0000000000..ed9268170f --- /dev/null +++ b/articles/fusion/security/custom-spring-login.asciidoc @@ -0,0 +1,131 @@ +--- +title: Adding a Custom Login Form with Spring Security +order: 91 +layout: page +--- + += Adding a Custom Login Form with Spring Security + +Instead of using the <>, you may want to use your own customized form. +This article describes how to do that. + +== Dependencies and Server Configuration + +The dependencies and server configuration are the same as described in <>, with a difference that you need to specify the URL of the custom login view. + +.`SecurityConfig.java` +[source, java] +---- +@EnableWebSecurity +@Configuration +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(HttpSecurity http) throws Exception { + // Ignore the login processing url and vaadin endpoint calls + http.csrf().ignoringAntMatchers("/login", "/connect/**"); + + // specify the URL of the login view, the value of the parameter + // is the defined route for the login view component. + http.formLogin().loginPage("login"); + } +} +---- + +== Custom Login View + +The easiest way to make a login view is to use the `` component. +Vaadin provides a `login` helper method for Spring Security based authentication that you can use for the login action. + +.`frontend/login-view.ts` +[source, typescript] +---- +include::{root}/frontend/demo/fusion/authentication/login-view.ts[group=TypeScript] +---- +After the login view is defined, you should also define a route for the login view component, for example in the `index.ts` file. +Note, the `path` for the login view component should match the one defined in `SecurityConfig` for `http.formLogin().loginPage()`. + +.`frontend/index.ts` +[source, typescript] +---- +const routes = [ + { + path: '/login', + component: 'login-view' + }, + // more routes +} +---- +== Redirect the User to the Login View +To protect a view from unauthenticated users, that is, redirect unauthenticated users to the login view, you can use route action. + +.`frontend/index.ts` +[source, typescript] +---- +const routes = [ + ... + { + path: '/my-view', + action: (_: Router.Context, commands: Router.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. + +.`frontend/index.ts` +[source, typescript] +---- +const routes = [ + ... + { + path: '/', + action: (_: Router.Context, commands: Router.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 +https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage[localStorage] to check if the user is logged in. The `lastLoginTimestamp` variable needs to be reset when logging out. + +The authentication helper methods in the code examples are grouped in a separate TypeScript file as shown below. + +.`frontend/auth.ts` +[source, typescript] +---- +include::{root}/frontend/demo/fusion/authentication/auth.ts[group=TypeScript] +---- + +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 avoid a full page reload the application needs to have a `/logout` route like the one below. +It can be triggered with a link like `Log out`. + +.`frontend/index.ts` +[source, typescript] +---- +path: '/logout', +action: async (_: Context, commands: Commands) => { + // use the logout helper method. + await logout(); + return commands.redirect('/'); +} +---- diff --git a/articles/fusion/security/handle-session-expiration.asciidoc b/articles/fusion/security/handle-session-expiration.asciidoc new file mode 100644 index 0000000000..f58466c05f --- /dev/null +++ b/articles/fusion/security/handle-session-expiration.asciidoc @@ -0,0 +1,33 @@ +--- +title: Handling Session Expiration +order: 141 +layout: page +--- + += Handling Session Expiration + +Use the built-in <<../advanced/fusion-advanced-client-middleware#, middleware>> `InvalidSessionMiddleWare` to detect session expiration, for example, to show a login view to the user. + +== How to Use the InvalidSessionMiddleWare? + +The `InvalidSessionMiddleWare` requires a `OnInvalidSessionCallback` as a constructor parameter. +The `OnInvalidSessionCallback` is a function that takes no parameters and should return a promise of `LoginResult`. +`LoginResult` contains the metadata of a login result, including: +- error: indicating if the login attempt has failed or not. +- token: in case of success login, it is the CSRF token, which can be extracted from the `index.html` page. +- errorTitle: a short text of the login error. +- errorMessage: a more detailed explanation of the login error. + + +.Example using `InvalidSessionMiddleWare` +[.example] +-- +[source, typescript] +---- +include::{root}/frontend/demo/fusion/authentication/handle-session-expiration/connect-client.ts[] +---- +[source, typescript] +---- +include::{root}/frontend/demo/fusion/authentication/handle-session-expiration/login-overlay.ts[] +---- +-- \ No newline at end of file diff --git a/articles/fusion/security/spring-login.asciidoc b/articles/fusion/security/spring-login.asciidoc index 593be80d8c..fe669867eb 100644 --- a/articles/fusion/security/spring-login.asciidoc +++ b/articles/fusion/security/spring-login.asciidoc @@ -52,14 +52,16 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { + // Ignore the login processing url and vaadin endpoint calls + http.csrf().ignoringAntMatchers("/login", "/connect/**"); + // Use default spring login form http.formLogin(); - http.csrf().disable(); } } ---- [NOTE] -If CSRF is still needed for other endpoints in the application, it is possibe to disable CSRF just for Vaadin endpoints: `http.csrf().ignoringAntMatchers("/connect/**")` +If CSRF is still needed for other endpoints in the application, it is possible to disable CSRF just for Vaadin endpoints: `http.csrf().ignoringAntMatchers("/connect/**")` → *Step 3* - Allow public access to Vaadin static resources. @@ -114,10 +116,10 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { + // Ignore the login processing url and vaadin endpoint calls + http.csrf().ignoringAntMatchers("/login", "/connect/**"); // Use default spring login form http.formLogin(); - // Vaadin already handles csrf. - http.csrf().disable(); } @Override @@ -137,7 +139,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { } ---- -== Client configuration +== Client Configuration Add links in the main layout for login and logout. diff --git a/articles/theming/using-themes.asciidoc b/articles/theming/using-themes.asciidoc index 7b1ddc3fb4..6abf024e34 100644 --- a/articles/theming/using-themes.asciidoc +++ b/articles/theming/using-themes.asciidoc @@ -8,7 +8,8 @@ layout: page ifdef::web[] endif::web[] -The built-in themes are all customisable and provide a great starting point for an application. Completely custom themes can also be built from scratch. +The built-in themes are all customizable and provide a great starting point for an application. +Completely custom themes can also be built from scratch. ifdef::web[] endif::web[] @@ -45,7 +46,7 @@ include::src/main/js/ImportStyleSheets.js[tags=client-side-theme, indent=0, grou == Custom Themes -There are two ways to customize themes: +Themes can be customized in two ways: 1. Customize existing themes 2. Create a completely new theme @@ -74,7 +75,7 @@ include::src/main/java/com/vaadin/flow/tutorial/theme/UsingComponentThemes.java[ ---- In client-side views/templates, to get a minimal starting point, import the un-themed version of each component. -For Vaadin components, the un-themed versions are located in the [filename]#src# folder of each component package. +For Vaadin components, the un-themed versions are located in the [filename]`src` folder of each component package. [source,javascript] ---- diff --git a/frontend/demo/fusion/authentication/auth.ts b/frontend/demo/fusion/authentication/auth.ts new file mode 100644 index 0000000000..46ddc82e27 --- /dev/null +++ b/frontend/demo/fusion/authentication/auth.ts @@ -0,0 +1,46 @@ +// Uses the Vaadin provided login an logout helper methods +import { login as loginImpl, logout as logoutImpl } from '@vaadin/flow-frontend'; +import type { LoginResult } from '@vaadin/flow-frontend'; + +// check if user is logged in or not by checking if there +// is an login event in the past 30 days +const LAST_LOGIN_TIMESTAMP = 'lastLoginTimestamp'; +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +const lastLoginTimestamp = localStorage.getItem(LAST_LOGIN_TIMESTAMP); +const hasRecentLoginTimestamp = (lastLoginTimestamp && + (new Date().getTime() - new Date(+lastLoginTimestamp).getTime()) < THIRTY_DAYS_MS) || false; + +let _isLoggedIn = hasRecentLoginTimestamp; + +export async function login(username: string, password: string): Promise { + if (_isLoggedIn) { + return { error: false } as LoginResult; + } else { + // Use the Vaadin provided login helper method to + // obtain the login result + const result = await loginImpl(username, password); + if (!result.error) { + _isLoggedIn = true; + // update the last login timestamp in the local storage + localStorage.setItem(LAST_LOGIN_TIMESTAMP, new Date().getTime() + '') + } + return result; + } +} + +export async function logout() { + _isLoggedIn = false; + // clear the last login timestamp from the local storage + // when logging out. + localStorage.removeItem(LAST_LOGIN_TIMESTAMP); + return await logoutImpl(); +} + +export function isLoggedIn() { + return _isLoggedIn; +} + +export function setSessionExpired() { + _isLoggedIn = false; + localStorage.removeItem(LAST_LOGIN_TIMESTAMP); +} \ No newline at end of file diff --git a/frontend/demo/fusion/authentication/handle-session-expiration/connect-client.ts b/frontend/demo/fusion/authentication/handle-session-expiration/connect-client.ts new file mode 100644 index 0000000000..4cfbfaadee --- /dev/null +++ b/frontend/demo/fusion/authentication/handle-session-expiration/connect-client.ts @@ -0,0 +1,13 @@ +import { ConnectClient, InvalidSessionMiddleware } from '@vaadin/flow-frontend'; +import {setSessionExpired} from '../auth'; +const client = new ConnectClient({ + prefix: 'connect', + middlewares: [ + new InvalidSessionMiddleware(async () => { + setSessionExpired(); + const { LoginView } = await import('./login-overlay'); + return LoginView.showOverlay(); + }) + ] +}); +export default client; diff --git a/frontend/demo/fusion/authentication/handle-session-expiration/login-overlay.ts b/frontend/demo/fusion/authentication/handle-session-expiration/login-overlay.ts new file mode 100644 index 0000000000..5a00f4f47f --- /dev/null +++ b/frontend/demo/fusion/authentication/handle-session-expiration/login-overlay.ts @@ -0,0 +1,32 @@ +import { customElement, LitElement } from 'lit-element'; +import { Router } from '@vaadin/router'; +import { LoginResult } from '@vaadin/flow-frontend'; + +@customElement('login-view') +export class LoginView extends LitElement { + + private returnUrl = '/'; + + // @ts-ignore + private onSuccess = (_: LoginResult) => { Router.go(this.returnUrl) }; + + private static overlayResult?: Promise; + + // Show the login view as an overlay, when the session has + // expired, and a user tries to invoke an endpoint call. + // Close the login overly once the login attempt has succeeded. + static async showOverlay(): Promise { + if (this.overlayResult) { + return this.overlayResult; + } + const overlay = new this(); + return this.overlayResult = new Promise(resolve => { + overlay.onSuccess = result => { + this.overlayResult = undefined; + overlay.remove(); + resolve(result); + } + document.body.append(overlay); + }); + } +} diff --git a/frontend/demo/fusion/authentication/login-view.ts b/frontend/demo/fusion/authentication/login-view.ts new file mode 100644 index 0000000000..1d3c228e2d --- /dev/null +++ b/frontend/demo/fusion/authentication/login-view.ts @@ -0,0 +1,40 @@ +import { customElement, html, LitElement, property } from 'lit-element'; +import { LoginResult } from '@vaadin/flow-frontend'; +import { login } from './auth'; +import { Router, AfterEnterObserver, RouterLocation } from '@vaadin/router'; + +@customElement('login-view') +export class LoginView extends LitElement implements AfterEnterObserver { + @property({ type: Boolean }) + private error = false; + + // the url to redirect to after a successful login + private returnUrl = '/'; + + private onSuccess = (_: LoginResult) => { Router.go(this.returnUrl) }; + + render() { + return html` + + + `; + } + + async login(event: CustomEvent): Promise { + 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 || this.returnUrl; + } +}