Inverno Framework Web Application Security Guide
Author: Jeremy Kuhn
What you'll learn
This guide shows how to secure access to an Inverno Web application.
We will guide you through setting up a form-based login in an Inverno Web application using a Redis user repository.
On successful login, a JSON Web Signature token will be generated and returned to the Web browser in a cookie, this token will then be used to access protected services and resources.
What you'll need
- A Java™ Development Kit (OpenJDK) at least version 16.
- Apache Maven at least version 3.6.
- An Integrated Development Environment (IDE) such as Eclipse or IDEA although any text editor will do.
- An Inverno Web application to secure, such as the Inverno Ticket application you should have created when you followed the Inverno Framework Full-Stack application Guide.
- A basic understanding of Redis data store.
- A basic understanding of Inverno's Web application development (see web module documentation in the Reference documentation).
- A basic understanding of JSON Object Signing and Encryption concepts.
This guide directly follows the Inverno Framework Full-Stack application Guide. In this guide you will secure the Inverno Ticket application that was created then.
The objective is to protect the access to every services or resources exposed in the application by requiring an authentication. Since you clearly don't want to ask a user to manually enter credentials each time a service or a resource is accessed, authentication will be done in two steps: first a user will have to authenticate on a login page by providing proper credentials (username/password) submitted in a form to a login action which then returns a token to the client (Web browser) sent in every subsequent requests to the application.
The login action will authenticate login credentials against a Redis user repository. A redis data store should already be configured in the Ticket application making things easier.
The token will be a JSON Web Signature object that can be easily authenticated by validating a digital signature. This token can be seen as a visitor badge that one would have obtained form a security desk at the entrance of building after showing proper credentials (e.g. ID card...).
The complete Inverno Ticket application can be found in GitHub.
Step 1: Declare Inverno security dependencies
The first thing to do is to add dependencies to Inverno security modules. In order to secure a Web application using form-based login and a JWS token you basically need to add dependencies to Inverno security-http module, which provides services to secure Web applications, and to Inverno securiy-jose module, which provides services for creating and validating JWS, JWE or JWT tokens.
These dependencies should be first added to the pom.xml
build descriptor of the project:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.inverno.dist</groupId>
<artifactId>inverno-parent</artifactId>
<version>1.11.0</version>
</parent>
<groupId>io.inverno.guide</groupId>
<artifactId>ticket</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
...
<dependency>
<groupId>io.inverno.mod</groupId>
<artifactId>inverno-security-http</artifactId>
</dependency>
<dependency>
<groupId>io.inverno.mod</groupId>
<artifactId>inverno-security-jose</artifactId>
</dependency>
...
</dependencies>
</project>
You can now add dependencies to io.inverno.mod.security.http
and io.inverno.mod.security.jose
modules in the module-info.java
descriptor.
@io.inverno.core.annotation.Module
module io.inverno.guide.ticket {
...
requires io.inverno.mod.security.http;
requires io.inverno.mod.security.jose;
...
}
You should now be all set and you can start securing the application.
Note that the Inverno security module, which defines core security API and services, is provided as a transitive dependency.
Step 2: Setup the Login page
Let's start by setting up the application login page that will be used by users to log into the application. The Inverno security-http module provides a whitelabel login page through the FormLoginPageHandler
, it contains a simple login form which allows users to submit login credentials (username/password) to the login action.
The login action is implemented in the LoginActionHandler
which basically authenticates the submitted credentials using a FormCredentialsExtractor
to extract LoginCredentials
from the request and an Authenticator
capable of authenticating LoginCredentials
. Users credentials will be authenticated against a Redis user repository, as a result a UserAuthenticator
backed by a RedisUserRepository
shall be used. Since the User
stored in the repository can also provide identity information, PersonIdentity
information will also be stored and exposed in order to be used later in the application whenever there is a need for identification.
On successful authentication, a JSON Web Signature object wrapping the resulting UserAuthentication<PersonIdentity>
should be created and returned in a cookie to the Web browser.
You can start by creating bean UserRepositoryWrapper
to expose the RedisUserRepository
. This will abstract the actual user repository from the user authenticator and ease maintenance if the UserRepository
implementation must be changed at some point. A RedisUserRepository
requires a RedisClient
and an ObjectMapper
which should already be provided in the application by the redis and the boot modules respectively.
package io.inverno.app.ticket.internal.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.inverno.core.annotation.Bean;
import io.inverno.core.annotation.Wrapper;
import io.inverno.mod.redis.RedisClient;
import io.inverno.mod.security.authentication.user.RedisUserRepository;
import io.inverno.mod.security.authentication.user.User;
import io.inverno.mod.security.authentication.user.UserRepository;
import io.inverno.mod.security.identity.PersonIdentity;
import java.util.function.Supplier;
@Wrapper @Bean( name = "userRepository" )
public class UserRepositoryWrapper implements Supplier<UserRepository<PersonIdentity, User<PersonIdentity>>> {
private final RedisClient<String, String> redisClient;
private final ObjectMapper mapper;
public UserRepositoryWrapper(RedisClient<String, String> redisClient, ObjectMapper mapper) {
this.redisClient = redisClient;
this.mapper = mapper;
}
@Override
public UserRepository<PersonIdentity, User<PersonIdentity>> get() {
return new RedisUserRepository<>(this.redisClient, this.mapper);
}
}
Now you need to define a JSON Web Key (JWK) for your application, this key will be used to create, parse and verify the JSON Web Signature tokens. The Inverno security-jose module provides the JWKService
which can be used to generate a JWK
or load it from the configuration. To keep things simple, let's assume a new key will be generated each time the application is started.
The algorithm used to digitally sign JWS will be a simple symmetric algorithm, such as HS512
. The JWSService
that will be used to manipulate JWS objects has the ability to automatically look up keys by id from a JWKStore
. So let's create an InMemoryJWKStore
bean to store keys in memory and a trusted JWK
bean registered into the JWKStore
.
package io.inverno.app.ticket.internal.security;
import io.inverno.core.annotation.Bean;
import io.inverno.core.annotation.Init;
import io.inverno.core.annotation.Wrapper;
import io.inverno.mod.security.jose.jwa.OCTAlgorithm;
import io.inverno.mod.security.jose.jwk.InMemoryJWKStore;
import io.inverno.mod.security.jose.jwk.JWK;
import io.inverno.mod.security.jose.jwk.JWKService;
import io.inverno.mod.security.jose.jwk.JWKStore;
import java.util.function.Supplier;
@Wrapper @Bean( name = "jwk" )
public class JWKWrapper implements Supplier<JWK> {
private final JWKService jwkService;
public JWKWrapper(JWKService jwkService) {
this.jwkService = jwkService;
}
@Override
public JWK get() {
return this.jwkService.oct().generator()
.keyId("tkt")
.algorithm(OCTAlgorithm.HS512.getAlgorithm())
.generate()
.map(JWK::trust)
.flatMap(jwk -> jwkService.store().set(jwk).thenReturn(jwk))
.block();
}
@Wrapper @Bean
public static class JWKStoreWrapper implements Supplier<JWKStore> {
@Override
public JWKStore get() {
return new InMemoryJWKStore();
}
}
}
Note that the generated key must be explicitly trusted because a generated keys are not trusted by default. Untrusted keys cannot be used to create or verify JWS objects.
You may wonder why we had to create two beans and why not just the
JWKStore
bean with aJWK
socket or an init method for initializing the JWK. The reason is actually quite simple, since theJWKStore
is injected in theJWKService
, it is not possible to create or inject theJWK
in theJWKStore
using theJWKService
as this would lead to a cycle in the bean dependency graph.
From there, it is quite simple to load a JWK from configuration, it only requires to use a builder()
instead of a generator()
and provide the symmetric key value in the configuration.
You can now create the SecurityConfigurer
bean that will be used to configure security for the whols application. For now, it shall implement WebRoutesConfigurer
in order to define routes to the login page using the FormLoginPageHandler
and the login action using the LoginActionHandler
:
package io.inverno.app.ticket.internal.security;
import io.inverno.mod.base.resource.MediaTypes;
import io.inverno.mod.http.base.Method;
import io.inverno.mod.security.accesscontrol.AccessController;
import io.inverno.mod.security.authentication.LoginCredentialsMatcher;
import io.inverno.mod.security.authentication.user.User;
import io.inverno.mod.security.authentication.user.UserAuthentication;
import io.inverno.mod.security.authentication.user.UserAuthenticator;
import io.inverno.mod.security.authentication.user.UserRepository;
import io.inverno.mod.security.http.context.SecurityContext;
import io.inverno.mod.security.http.form.FormCredentialsExtractor;
import io.inverno.mod.security.http.form.FormLoginPageHandler;
import io.inverno.mod.security.http.form.RedirectLoginFailureHandler;
import io.inverno.mod.security.http.form.RedirectLoginSuccessHandler;
import io.inverno.mod.security.http.login.LoginActionHandler;
import io.inverno.mod.security.http.login.LoginSuccessHandler;
import io.inverno.mod.security.http.token.CookieTokenLoginSuccessHandler;
import io.inverno.mod.security.identity.PersonIdentity;
import io.inverno.mod.security.jose.jwa.OCTAlgorithm;
import io.inverno.mod.security.jose.jws.JWSAuthentication;
import io.inverno.mod.security.jose.jws.JWSService;
import io.inverno.mod.web.server.WebRoutable;
import io.inverno.mod.web.server.WebRoutesConfigurer;
@WebRoutes({
@WebRoute(path = { "/login" }, method = { Method.GET }),
@WebRoute(path = { "/login" }, method = { Method.POST }),
})
@Bean( visibility = Bean.Visibility.PRIVATE )
public class SecurityConfigurer implements WebRoutesConfigurer<SecurityContext<PersonIdentity, AccessController>> {
private final UserRepository<PersonIdentity, User<PersonIdentity>> userRepository;
private final JWSService jwsService;
public SecurityConfigurer(UserRepository<PersonIdentity, User<PersonIdentity>> userRepository, JWSService jwsService) {
this.userRepository = userRepository;
this.jwsService = jwsService;
}
@Override
public void configure(WebRoutable<SecurityContext<PersonIdentity, AccessController>, ?> routes) {
routes
.route() // 1
.path("/login")
.method(Method.GET)
.handler(new FormLoginPageHandler<>())
.route() // 2
.path("/login")
.method(Method.POST)
.handler(new LoginActionHandler<>( // 3
new FormCredentialsExtractor(), // 4
new UserAuthenticator<>(this.userRepository, new LoginCredentialsMatcher<>()) // 5
.failOnDenied() // 6
.flatMap(authentication -> this.jwsService.<UserAuthentication<PersonIdentity>>builder(UserAuthentication.class) // 7
.header(header -> header
.keyId("tkt")
.algorithm(OCTAlgorithm.HS512.getAlgorithm())
)
.payload(authentication)
.build(MediaTypes.APPLICATION_JSON)
.map(JWSAuthentication::new)
),
LoginSuccessHandler.of( // 8
new CookieTokenLoginSuccessHandler<>(),
new RedirectLoginSuccessHandler<>()
),
new RedirectLoginFailureHandler<>() // 9
));
}
}
Above configurer deserves some deeper explanations:
- The
GET /login
web route is declared to serve the whitelabel login page using theFormLoginPageHandler
. - The
POST /login
web route is declared to process the login action, it is basically targeted by the form in the login page. - The login action is handled by the
LoginActionHandler
. - A
FormCredentialsExtractor
is used to extract theLoginCredentials
from the request. - A
UserAuthenticator
using the user repository is used to authenticate the submitted credentials using aLoginCredentialsMatcher
to match credentials. - The authenticator authenticates the credentials and returns a resulting
Authentication
which can be either denied (failed authentication), anonymous (no authentication) or authenticated (successful authentication). ThefailOnDenied()
operator basically fails the process in case of denied authentication and no further processing is then performed, resulting in anUnauthorizedException
being thrown. - If successful, the resulting
UserAuthentication
is mapped into aJWSAuthentication
which basically holds aJWS
with the authentication as payload and created using thetkt
key previously stored in theJWKStore
. - On successful login, a
CookieTokenLoginSuccessHandler
is used to return anAUTH-TOKEN
cookie to the Web browser containing the JWS compact representation. TheRedirectLoginSuccessHandler
is also used to redirect the user to its original request or to/
if the login page was directly accessed. - Finally, a
RedirectLoginFailureHandler
is used to redirect the user to the login page in case of denied authentication.
You should also notice that the exchange context type declared in WebRoutesConfigurer
is SecurityContext<PersonIdentity, AccessController>
. Although access to the SecurityContext
is not required by the FormLoginPageHandler
or the LoginActionHandler
, it is required if you want to expose a logout action using the LogoutActionHandler
in order to be able to release the security context.
Let's define a /logout
route using the LogoutActionHandler
to log out users:
package io.inverno.app.ticket.internal.security;
...
import io.inverno.mod.security.http.form.FormAuthenticationErrorInterceptor;
import io.inverno.mod.security.http.form.RedirectLogoutSuccessHandler;
import io.inverno.mod.security.http.login.LogoutActionHandler;
import io.inverno.mod.security.http.login.LogoutSuccessHandler;
import io.inverno.mod.security.http.token.CookieTokenLogoutSuccessHandler;
import reactor.core.publisher.Mono;
@WebRoutes({
@WebRoute(path = { "/login" }, method = { Method.GET }),
@WebRoute(path = { "/login" }, method = { Method.POST }),
@WebRoute(path = { "/logout" }, method = { Method.GET }, produces = { "application/json" }),
})
@Bean( visibility = Bean.Visibility.PRIVATE )
public class SecurityConfigurer implements WebRoutesConfigurer<SecurityContext<PersonIdentity, AccessController>> {
private final UserRepository<PersonIdentity, User<PersonIdentity>> userRepository;
private final JWSService jwsService;
public SecurityConfigurer(UserRepository<PersonIdentity, User<PersonIdentity>> userRepository, JWSService jwsService) {
this.userRepository = userRepository;
this.jwsService = jwsService;
}
@Override
public void configure(WebRoutable<SecurityContext<PersonIdentity, AccessController>, ?> routes) {
routes
...
.route()
.path("/logout")
.produces(MediaTypes.APPLICATION_JSON)
.handler(new LogoutActionHandler<>(
authentication -> Mono.empty(),
LogoutSuccessHandler.of(
new CookieTokenLogoutSuccessHandler<>(),
new RedirectLogoutSuccessHandler<>()
)
));
}
}
The above logout action basically does nothing to release the security context since the application stores its state in a JWS and is basically stateless. On successful logout, which should then always be the case, the cookie is removed by the CookieTokenLogoutSuccessHandler
and the Web browser redirected to /
using the RedirectLogoutSuccessHandler
.
The login page should now be all set with login and logout actions and you can move on by securing access to services and resources.
Step 3: Secure applications services and resources
You should now identify the services or resources in the application that should be protected and require an authentication to access.
The Ticket application basically exposes the following routes:
/
which points to the application welcome page./api/**
which points to the REST services exposed by thePlanWebController
and theTicketWebController
./static/**
which points to the static resources underweb_root
resource./webjars/**
which points to WebJars resources./open-api/**
which points to the OpenAPI specifications./login
which points to the login page./logout
which points to the logout action
All these routes except the /login
page, which must remain accessible to any user, should be protected against unauthenticated or anonymous access. In order to do that, you need to intercept requests to these routes and reject those that do do not contain valid credentials, namely the AUTH-TOKEN
cookie containing a compact JWS normally created in the login action. In case valid credentials have been provided, a SecurityContext
should be created and injected in the exchange context, the security context is then accessible to subsequent interceptors and route handlers.
You must then modify the SecurityConfigurer
and make it implement WebInterceptorsConfigurer<InterceptingSecurityContext<PersonIdentity, AccessController>>
in order to configure the security interceptors on the protected routes.
package io.inverno.app.ticket.internal.security;
...
import io.inverno.mod.base.reflect.Types;
import io.inverno.mod.security.http.AccessControlInterceptor;
import io.inverno.mod.security.http.SecurityInterceptor;
import io.inverno.mod.security.http.context.InterceptingSecurityContext;
import io.inverno.mod.security.http.token.CookieTokenCredentialsExtractor;
import io.inverno.mod.security.jose.jws.JWSAuthenticator;
import io.inverno.mod.web.server.WebInterceptable;
import io.inverno.mod.web.server.WebInterceptorsConfigurer;
import java.util.List;
@WebRoutes({
@WebRoute(path = { "/login" }, method = { Method.GET }),
@WebRoute(path = { "/login" }, method = { Method.POST }),
@WebRoute(path = { "/logout" }, method = { Method.GET }, produces = { "application/json" }),
})
@Bean( visibility = Bean.Visibility.PRIVATE )
public class SecurityConfigurer implements WebRoutesConfigurer<SecurityContext<PersonIdentity, AccessController>>, WebInterceptorsConfigurer<InterceptingSecurityContext<PersonIdentity, AccessController>> {
...
@Override
public void configure(WebInterceptable<InterceptingSecurityContext<PersonIdentity, AccessController>, ?> interceptors) {
interceptors
.intercept() // 1
.path("/")
.path("/api/**")
.path("/static/**")
.path("/webjars/**")
.path("/open-api/**")
.path("/logout")
.interceptors(List.of(
SecurityInterceptor.of( // 2
new CookieTokenCredentialsExtractor(), // 3
new JWSAuthenticator<UserAuthentication<PersonIdentity>>( // 4
this.jwsService,
Types.type(UserAuthentication.class).type(PersonIdentity.class).and().build()
)
.failOnDenied() // 5
.map(jwsAuthentication -> jwsAuthentication.getJws().getPayload()) // 6
),
AccessControlInterceptor.authenticated() // 7
));
}
}
- All routes except the
/login
route are intercepted. - The
SecurityInterceptor
which is the main security interceptor responsible for authenticating credentials and creating the security context is declared first. - It uses a
CookieTokenCredentialsExtractor
to extractTokenCredentials
from theAUTH-TOKEN
cookie in the request. - A
JWSAuthenticator
backed by theJWSService
is then used to parse and verify the compact JWS token which should wrap the originalUserAuthentication<PersonIdentity>
that was created when authenticating the user credentials in the login action. Note that the type of the JWS payload must be specified explicitly for the underlyingObjectMapper
to decode it (and also to benefit from static checking). - The
failOnDenied()
operator is used to stop the processing if the resultingJWSAuthentication
was denied either because the token was invalid (e.g. not a JWS or bad signature) or because the wrapped authentication is actually denied (which should never happen since a token is only generated on successful login credentials authentication), resulting in anUnauthorizedException
being thrown. - The original
UserAuthentication<PersonIdentity>
is then unwrapped, the security interceptor then creates aSecurityContext
with the original authentication instead of theJWSAuthentication
. - Finally, we make sure only authenticated security context can access the protected service or resource. This basically means that only users that provided valid login credentials have access to the application.
Note that the exchange context type declated in the WebInterceptorsConfigurer
is InterceptingSecurityContext<PersonIdentity, AccessController>
, the InterceptingSecurityContext
is required by the SecurityInterceptor
in order to populate the SecurityContext
.
Using a JWS
token has many advantages: first it is easy to verify that the provided credentials are valid by verifying the digital signature using tkt
key and then, the original UserAuthentication<PersonIdentity>
could have been restored. It provides user's identity and roles that can then be used directly to get the user's PersonIdentity
or obtain a RoleBasedAccessController
without having to query the user repository again.
Keep in mind that this is a basic use case that might not be secured enough as JWS tokens thus created never expire which should be seen as a security issue in most systems. It should then be wise to consider using a JSON Web Token which supports expiry or implement such expiration mechanism explicitly.
It should now be impossible to access a protected resource without being authenticated, however a user won't be automatically redirected to the login page when receiving an unauthorized (401) error. This can be done by intercepting UnauthorizedException
in the error router.
You must then modify the SecurityConfigurer
one more time to make it implement ErrorWebRouterConfigurer<ExchangeContext>
in order to intercept unauthorized errors and use a FormAuthenticationErrorInterceptor
to redirect the user to the login page:
package io.inverno.app.ticket.internal.security;
...
import io.inverno.mod.http.base.ExchangeContext;
import io.inverno.mod.http.base.UnauthorizedException;
import io.inverno.mod.web.server.ErrorWebRouter;
import io.inverno.mod.web.server.ErrorWebRouterConfigurer;
@WebRoutes({
@WebRoute(path = { "/login" }, method = { Method.GET }),
@WebRoute(path = { "/login" }, method = { Method.POST }),
@WebRoute(path = { "/logout" }, method = { Method.GET }, produces = { "application/json" }),
})
@Bean( visibility = Bean.Visibility.PRIVATE )
public class SecurityConfigurer implements WebRoutesConfigurer<SecurityContext<PersonIdentity, AccessController>>, WebInterceptorsConfigurer<InterceptingSecurityContext<PersonIdentity, AccessController>>, ErrorWebRouterConfigurer<ExchangeContext> {
...
@Override
public void configure(ErrorWebRouter<ExchangeContext> errorRouter) {
errorRouter
.intercept()
.path("/")
.error(UnauthorizedException.class)
.interceptor(new FormAuthenticationErrorInterceptor<>())
.applyInterceptors(); // We must apply interceptors to intercept white labels error routes which are already defined
}
}
The Inverno Ticket application is a single page application, so this redirection can be limited to the welcome page /
, accessing any other resources will simply result in an authorized (401) response.
The Ticket application should now be properly secured and only authenticated users shall be allowed to access the application.
Step 4: Expose the user's identity
You might now want to display the user's identity in the application UI. To do so you need to expose the identity on the server.
But first you must resolve the user's identity from the UserAuthentication
, this can be done easily by adding a UserIdentityResolver
to the SecurityInterceptor
:
package io.inverno.app.ticket.internal.security;
...
import io.inverno.mod.security.identity.UserIdentityResolver;
@WebRoutes({
@WebRoute(path = { "/login" }, method = { Method.GET }),
@WebRoute(path = { "/login" }, method = { Method.POST }),
@WebRoute(path = { "/logout" }, method = { Method.GET }, produces = { "application/json" }),
})
@Bean( visibility = Bean.Visibility.PRIVATE )
public class SecurityConfigurer implements WebRoutesConfigurer<SecurityContext<PersonIdentity, AccessController>>, WebInterceptorsConfigurer<InterceptingSecurityContext<PersonIdentity, AccessController>>, ErrorWebRouterConfigurer<ExchangeContext> {
...
@Override
public void configure(WebInterceptable<InterceptingSecurityContext<PersonIdentity, AccessController>, ?> interceptors) {
interceptors
.intercept()
.path("/")
.path("/api/**")
.path("/static/**")
.path("/webjars/**")
.path("/open-api/**")
.path("/logout")
.interceptors(List.of(
SecurityInterceptor.of(
new CookieTokenCredentialsExtractor(),
new JWSAuthenticator<UserAuthentication<PersonIdentity>>(
this.jwsService,
Types.type(UserAuthentication.class).type(PersonIdentity.class).and().build()
)
.failOnDenied()
.map(jwsAuthentication -> jwsAuthentication.getJws().getPayload()),
new UserIdentityResolver<>()
),
AccessControlInterceptor.authenticated()
));
}
}
Here the user's identity is resolved for all routes but it is also possible to resolve it only for the routes that need it, simply by defining different security interceptors.
The user's identity should now be accessible from the SecurityContext
in the exchange, you can then create the following SecurityController
to expose the user's identity to the UI:
package io.inverno.app.ticket.internal.security;
import io.inverno.core.annotation.Bean;
import io.inverno.mod.base.resource.MediaTypes;
import io.inverno.mod.http.base.Method;
import io.inverno.mod.security.accesscontrol.AccessController;
import io.inverno.mod.security.http.context.SecurityContext;
import io.inverno.mod.security.identity.Identity;
import io.inverno.mod.web.server.annotation.WebController;
import io.inverno.mod.web.server.annotation.WebRoute;
@Bean( visibility = Bean.Visibility.PRIVATE )
@WebController( path = "/api/security" )
public class SecurityController {
@WebRoute( path = "/identity", method = Method.GET, produces = MediaTypes.APPLICATION_JSON )
public Identity identity(SecurityContext<? extends Identity, ? extends AccessController> securityContext) {
return securityContext.getIdentity().get();
}
}
User's identity is now exposed at http://localhost:8080/api/security/identity. The UI can also be modified to display the user's identity.
One might say that the JWS token actually contains user's identity information and that there is then no need to expose them on the server, however an
AUTH-TOKEN
cookie must be HTTP only and therefore cannot be read from the UI.
Step 5: Run the application
Before you can run the application, you need to define a user in the Redis user repository. Although it is possible to do this by creating an entry in the Redis data store using a simple Redis client, it is easier and safer to rely on the UserRepository
to do this.
So let's modify the TicketApp#main()
method and use the UserRepository
bean to create user jsmith
if it does not already exist when the application is starting:
package io.inverno.app.ticket;
import io.inverno.core.annotation.Bean;
import io.inverno.core.v1.Application;
import io.inverno.mod.configuration.ConfigurationKey;
import io.inverno.mod.configuration.ConfigurationProperty;
import io.inverno.mod.configuration.ConfigurationSource;
import io.inverno.mod.configuration.source.BootstrapConfigurationSource;
import io.inverno.mod.security.authentication.password.RawPassword;
import io.inverno.mod.security.authentication.user.User;
import io.inverno.mod.security.identity.PersonIdentity;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.util.List;
import java.util.function.Supplier;
public class TicketApp {
...
public static void main(String[] args) throws IOException {
final BootstrapConfigurationSource bootstrapConfigurationSource = new BootstrapConfigurationSource(TicketApp.class.getModule(), args);
Ticket ticketApp = bootstrapConfigurationSource
...
ticketApp.userRepository().getUser("jsmith")
.switchIfEmpty(Mono.defer(() -> ticketApp.userRepository().createUser(User.of("jsmith")
.password(new RawPassword("password"))
.identity(new PersonIdentity("jsmith", "John", "Smith", "jsmith@inverno.io"))
.build())
))
.block();
}
}
You might have noticed that the password is specified in clear text as a RawPassword
, but it is actually stored securely in the Redis data store using Password-Based Key Derivation Function 2 by default. This can be changed by defining a different password encoder in the RedisUserRepository
.
The user repository can also be exposed in a Web controller in order easily manage users from the application UI.
You should now be able to run the application using Docker Compose as described in the full-stack application Guide or directly using Docker to start a Redis datastore and the Inverno Maven plugin to run the application.
$ docker run -d -p6379:6379 redis
$ mvn inverno:run
...
[INFO] --- inverno-maven-plugin:1.5.2:run (default-cli) @ inverno-ticket ---
[INFO] Running project: io.inverno.app.ticket@1.2.0-SNAPSHOT...
[═══════════════════════════════════════════════ 100 % ══════════════════════════════════════════════]
2022-08-10 16:15:39,961 INFO [main] i.i.a.t.TicketApp - Active profile: default
2022-08-10 16:15:40,075 INFO [main] i.i.c.v.Application - Inverno is starting...
╔════════════════════════════════════════════════════════════════════════════════════════════╗
║ , ~~ , ║
║ , ' /\ ' , ║
║ , __ \/ __ , _ ║
║ , \_\_\/\/_/_/ , | | ___ _ _ ___ __ ___ ___ ║
║ , _\_\/_/_ , | | / _ \\ \ / // _ \ / _|/ _ \ / _ \ ║
║ , __\_/\_\__ , | || | | |\ \/ /| __/| | | | | | |_| | ║
║ , /_/ /\/\ \_\ , |_||_| |_| \__/ \___||_| |_| |_|\___/ ║
║ , /\ , ║
║ , \/ , -- 1.5.9 -- ║
║ ' -- ' ║
╠════════════════════════════════════════════════════════════════════════════════════════════╣
║ Java runtime : OpenJDK Runtime Environment ║
║ Java version : 17.0.2+8-86 ║
║ Java home : /home/jkuhn/Devel/jdk/jdk-17.0.2 ║
║ ║
║ Application module : io.inverno.app.ticket ║
║ Application version : 1.2.0-SNAPSHOT ║
║ Application class : io.inverno.app.ticket.TicketApp ║
║ ║
║ Modules : ║
║ * ... ║
╚════════════════════════════════════════════════════════════════════════════════════════════╝
2022-08-10 16:15:40,080 INFO [main] i.i.a.t.Ticket - Starting Module io.inverno.app.ticket...
2022-08-10 16:15:40,081 INFO [main] i.i.m.b.Boot - Starting Module io.inverno.mod.boot...
2022-08-10 16:15:40,342 INFO [main] i.i.m.b.Boot - Module io.inverno.mod.boot started in 261ms
2022-08-10 16:15:40,342 INFO [main] i.i.m.r.l.Lettuce - Starting Module io.inverno.mod.redis.lettuce...
2022-08-10 16:15:40,404 INFO [main] i.i.m.r.l.Lettuce - Module io.inverno.mod.redis.lettuce started in 61ms
2022-08-10 16:15:40,405 INFO [main] i.i.m.s.j.Jose - Starting Module io.inverno.mod.security.jose...
2022-08-10 16:15:40,490 INFO [main] i.i.m.s.j.Jose - Module io.inverno.mod.security.jose started in 85ms
2022-08-10 16:15:40,490 INFO [main] i.i.m.w.Server - Starting Module io.inverno.mod.web.server...
2022-08-10 16:15:40,491 INFO [main] i.i.m.h.s.Server - Starting Module io.inverno.mod.http.server...
2022-08-10 16:15:40,491 INFO [main] i.i.m.h.b.Base - Starting Module io.inverno.mod.http.base...
2022-08-10 16:15:40,496 INFO [main] i.i.m.h.b.Base - Module io.inverno.mod.http.base started in 5ms
2022-08-10 16:15:40,701 INFO [main] i.i.m.h.s.i.HttpServer - HTTP Server (epoll) listening on http://0.0.0.0:8080
2022-08-10 16:15:40,701 INFO [main] i.i.m.h.s.Server - Module io.inverno.mod.http.server started in 210ms
2022-08-10 16:15:40,702 INFO [main] i.i.m.w.Server - Module io.inverno.mod.web.server started in 211ms
2022-08-10 16:15:40,707 INFO [main] i.i.a.t.Ticket - Module io.inverno.app.ticket started in 627ms
2022-08-10 16:15:40,708 INFO [main] i.i.c.v.Application - Application io.inverno.app.ticket started in 739ms
Now if you try to access to the application by opening http://localhost:8080 in your Web browser, you should be automatically redirected to the login page:
In order to access the application, you must enter valid login credentials, for instance jsmith/password
, you should get redirected to the original request /
and user's identity should be displayed in the UI:
Once authenticated the user's identity can be retrieved by requesting http://localhost:8080/api/security/identity:
$ curl -i -H "cookie: AUTH-TOKEN=eyJhbGciOiJIUzUxMiIsImtpZCI6InRrdCJ9.eyJpZGVudGl0eSI6eyJAYyI6Ii5QZXJzb25JZGVudGl0eSIsInVpZCI6ImpzbWl0aCIsImZpcnN0TmFtZSI6IkpvaG4iLCJsYXN0TmFtZSI6IlNtaXRoIiwiZW1haWwiOiJqc21pdGhAaW52ZXJuby5pbyJ9LCJ1c2VybmFtZSI6ImpzbWl0aCIsImF1dGhlbnRpY2F0ZWQiOnRydWUsImFub255bW91cyI6ZmFsc2UsImdyb3VwcyI6W119.mz2ez72LFj-m9flJ181kczlUgyTvDhJc64s4vfyKy8_6DHDSkMFp5jo_C3eydrOM-o3hwvA8IjCdGaK2P9ZAOw" http://localhost:8080/api/security/identity
HTTP/1.1 200 OK
content-type: application/json
content-length: 105
{"@c":".PersonIdentity","uid":"jsmith","firstName":"John","lastName":"Smith","email":"jsmith@inverno.io"}
Congratulations! You've just secured access to an Inverno Web application using a form-based login and JSON Web signature tokens.