Database authentication

Learn how to secure a Micronaut application using Database authentication.

Authors: Sergio del Amo

Micronaut Version: 4.4.2

1. Getting Started

In this guide, we will create a Micronaut application written in Java with session and database authentication.

The following sequence illustrates the authentication flow:

session based auth

2. What you will need

To complete this guide, you will need the following:

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the Application

Create an application using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app example.micronaut.micronautguide \
    --features=data-jdbc,flyway,postgres,views-thymeleaf,validation,security-session,reactor \
    --build=gradle \
    --lang=java \
    --test=junit
If you don’t specify the --build argument, Gradle is used as the build tool.
If you don’t specify the --lang argument, Java is used as the language.
If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.

The previous command creates a Micronaut application with the default package example.micronaut in a directory named micronautguide.

If you use Micronaut Launch, select Micronaut Application as application type and add data-jdbc, flyway, postgres, views-thymeleaf, validation, security-session, and reactor features.

If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features and apply those changes to your application.

4.1. Configure Access for a Data Source

We will use Micronaut Data JDBC to access the MySQL data source.

Add the following required dependencies:

build.gradle
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-jdbc")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
runtimeOnly("org.postgresql:postgresql")

Locally, the database will be provided by [Test Resources].

src/main/resources/application.properties
datasources.default.db-type=postgres
datasources.default.dialect=POSTGRES
datasources.default.schema-generate=NONE
datasources.default.driver-class-name=org.postgresql.Driver
1 Create datasource called default.
2 Set the dialect and driver class name.

With the configured data source we will be able to access the data using Micronaut JDBC API, which will be shown further in the guide.

4.2. Database Migration with Flyway

We need a way to create the database schema. For that, we use Micronaut integration with Flyway.

Flyway automates schema changes, significantly simplifying schema management tasks, such as migrating, rolling back, and reproducing in multiple environments.

Add the following snippet to include the necessary dependencies:

build.gradle
implementation("io.micronaut.flyway:micronaut-flyway")

We will enable Flyway in the Micronaut configuration file and configure it to perform migrations on one of the defined data sources.

src/main/resources/application.properties
flyway.datasources.default.enabled=true
1 Enable Flyway for the default datasource.
Configuring multiple data sources is as simple as enabling Flyway for each one. You can also specify directories that will be used for migrating each data source. Review the Micronaut Flyway documentation for additional details.

Flyway migration will be automatically triggered before your Micronaut application starts. Flyway will read migration commands in the resources/db/migration/ directory, execute them if necessary, and verify that the configured data source is consistent with them.

Create the following migration files with the database schema creation:

src/main/resources/db/migration/V1__schema.sql
CREATE TABLE role (
    id BIGSERIAL PRIMARY KEY NOT NULL,
    authority varchar(255) NOT NULL
);
CREATE TABLE "user" (
    id BIGSERIAL primary key NOT NULL,
    username varchar(255) NOT NULL,
    password varchar(255) NOT NULL,
    enabled BOOLEAN NOT NULL,
    account_expired BOOLEAN NOT NULL,
    account_locked BOOLEAN NOT NULL,
    password_expired BOOLEAN NOT NULL
);
CREATE TABLE user_role(
    id_role_id BIGINT NOT NULL,
    id_user_id BIGINT NOT NULL,
    FOREIGN KEY (id_role_id) REFERENCES role(id),
    FOREIGN KEY (id_user_id) REFERENCES "user"(id),
    PRIMARY KEY (id_role_id, id_user_id)
);

5. Security Session

To use Micronaut Security session, add the following dependency:

build.gradle
implementation("io.micronaut.security:micronaut-security-session")

6. Security Configuration

Add this configuration to application.properties:

src/main/resources/application.properties
(1)
micronaut.security.authentication=session
(2)
micronaut.security.redirect.login-success=/
(3)
micronaut.security.redirect.login-failure=/user/authFailed
1 Set micronaut.security.authentication to session. It sets the necessary beans for login and logout using session based authentication.
2 After the user logs in, redirect them to the Home page.
3 If the login fails, redirect them to /user/authFailed

7. Validation

To use Micronaut Validation, add the following dependencies:

build.gradle
implementation("io.micronaut.validation:micronaut-validation")
annotationProcessor("io.micronaut.validation:micronaut-validation")

8. Micronaut Reactor

To use Project Reactor, add the following dependency:

build.gradle
implementation("io.micronaut.reactor:micronaut-reactor")

9. Entities

9.1. Role

Create Role domain class to store authorities within our application.

src/main/java/example/micronaut/entities/Role.java
package example.micronaut.entities;

import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import jakarta.validation.constraints.NotBlank;

@MappedEntity (1)
public record Role(@Nullable
                   @Id (2)
                   @GeneratedValue (3)
                   Long id,
                   @NotBlank (4)
                   String authority) {
}
1 Annotate the class with @MappedEntity to map the class to the table defined in the schema.
2 Specifies the ID of an entity
3 Specifies that the property value is generated by the database and not included in inserts
4 Use jakarta.validation.constraints Constraints to ensure the data matches your expectations.

9.2. User

Create a UserState interface to model the user state.

src/main/java/example/micronaut/UserState.java
package example.micronaut;

public interface UserState {
    String getUsername();

    String getPassword();

    boolean isEnabled();

    boolean isAccountExpired();

    boolean isAccountLocked();

    boolean isPasswordExpired();
}

Create User domain class to store users within our application.

src/main/java/example/micronaut/entities/User.java
package example.micronaut.entities;

import example.micronaut.UserState;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.data.annotation.GeneratedValue;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import jakarta.validation.constraints.NotBlank;

@MappedEntity (1)
public record User(@Nullable
                   @Id
                   @GeneratedValue
                   Long id, (2)
                   @NotBlank
                   String username, (3)
                   @NotBlank
                   String password, (3)
                   boolean enabled,
                   boolean accountExpired,
                   boolean accountLocked,
                   boolean passwordExpired) implements UserState {

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    @Override
    public boolean isAccountExpired() {
        return accountExpired;
    }

    @Override
    public boolean isAccountLocked() {
        return accountLocked;
    }

    @Override
    public boolean isPasswordExpired() {
        return false;
    }
}
1 Annotate the class with @MappedEntity to map the class to the table defined in the schema.
2 Specifies the ID of an entity
3 Specifies that the property value is generated by the database and not included in inserts
4 Use jakarta.validation.constraints Constraints to ensure the data matches your expectations.

9.3. UserRole

The UserRole table uses a composite key which we model with UserRoleId.

src/main/java/example/micronaut/entities/UserRoleId.java
package example.micronaut.entities;

import io.micronaut.data.annotation.Embeddable;
import io.micronaut.data.annotation.Relation;

import java.util.Objects;

@Embeddable (1)
public class UserRoleId {

    @Relation(value = Relation.Kind.MANY_TO_ONE) (2)
    private final User user;

    @Relation(value = Relation.Kind.MANY_TO_ONE) (2)
    private final Role role;

    public UserRoleId(User user, Role role) {
        this.user = user;
        this.role = role;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        UserRoleId userRoleId = (UserRoleId) o;
        return role.id().equals(userRoleId.getRole().id()) &&
                user.id().equals(userRoleId.getUser().id());
    }

    @Override
    public int hashCode() {
        return Objects.hash(role.id(), user.id());
    }

    public User getUser() {
        return user;
    }

    public Role getRole() {
        return role;
    }
}
1 Specifies that the bean is embeddable.
2 You can specify a relationship (one-to-one, one-to-many, etc.) with the @Relation annotation.

Create a UserRole which stores a many-to-many relationship between User and Role.

src/main/java/example/micronaut/entities/UserRole.java
package example.micronaut.entities;

import io.micronaut.data.annotation.EmbeddedId;
import io.micronaut.data.annotation.MappedEntity;

@MappedEntity (1)
public class UserRole {

    @EmbeddedId (2)
    private final UserRoleId id;

    public UserRole(UserRoleId id) {
        this.id = id;
    }

    public UserRoleId getId() {
        return id;
    }
}
1 Annotate the class with @MappedEntity to map the class to the table defined in the schema.
2 Composite primary keys can be defined using @EmbeddedId annotation.

10. Repositories

10.1. Role Repository

Create a repository for the Role entity.

src/main/java/example/micronaut/repositories/RoleJdbcRepository.java
package example.micronaut.repositories;

import example.micronaut.entities.Role;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import java.util.Optional;

@JdbcRepository(dialect = Dialect.POSTGRES) (1)
public interface RoleJdbcRepository extends CrudRepository<Role, Long> { (2)

    Role save(String authority);

    Optional<Role> findByAuthority(String authority);
}
1 @JdbcRepository with a specific dialect.
2 By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations.

10.2. User Repository

Create a repository for the User entity.

src/main/java/example/micronaut/repositories/UserJdbcRepository.java
package example.micronaut.repositories;

import example.micronaut.entities.User;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import jakarta.validation.constraints.NotBlank;

import java.util.Optional;

@JdbcRepository(dialect = Dialect.POSTGRES) (1)
public interface UserJdbcRepository extends CrudRepository<User, Long> { (2)

    Optional<User> findByUsername(@NonNull @NotBlank String username);
}
1 @JdbcRepository with a specific dialect.
2 By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations.

10.3. User Role Repository

Create a repository for the UserRole entity.

src/main/java/example/micronaut/repositories/UserRoleJdbcRepository.java
package example.micronaut.repositories;

import example.micronaut.entities.UserRole;
import example.micronaut.entities.UserRoleId;
import io.micronaut.data.annotation.Query;
import io.micronaut.data.jdbc.annotation.JdbcRepository;
import io.micronaut.data.model.query.builder.sql.Dialect;
import io.micronaut.data.repository.CrudRepository;
import jakarta.validation.constraints.NotBlank;

import java.util.List;

@JdbcRepository(dialect = Dialect.POSTGRES) (1)
public interface UserRoleJdbcRepository extends CrudRepository<UserRole, UserRoleId> { (2)

    @Query("""
    SELECT authority FROM role 
    INNER JOIN user_role ON user_role.id_role_id = role.id 
    INNER JOIN "user" user_ ON user_role.id_user_id = user_.id 
    WHERE user_.username = :username""") (3)
    List<String> findAllAuthoritiesByUsername(@NotBlank String username);
}
1 @JdbcRepository with a specific dialect.
2 By extending CrudRepository you enable automatic generation of CRUD (Create, Read, Update, Delete) operations.
3 You can use the @Query annotation to specify an explicit query.

11. Password Encoder

Create an interface to handle password encoding:

src/main/java/example/micronaut/PasswordEncoder.java
package example.micronaut;

import jakarta.validation.constraints.NotBlank;

public interface PasswordEncoder {

    String encode(@NotBlank String rawPassword);

    boolean matches(@NotBlank String rawPassword,
                    @NotBlank String encodedPassword);
}

To provide an implementation, first include a dependency to Spring Security Crypto to ease password encoding.

Add the dependencies:

build.gradle
implementation("org.springframework.security:spring-security-crypto:6.2.0")
implementation("org.slf4j:jcl-over-slf4j")

Then, write the implementation:

src/main/java/example/micronaut/BCryptPasswordEncoderService.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import jakarta.inject.Singleton;
import jakarta.validation.constraints.NotBlank;

@Singleton (1)
class BCryptPasswordEncoderService implements PasswordEncoder {

    org.springframework.security.crypto.password.PasswordEncoder delegate = new BCryptPasswordEncoder();

    public String encode(@NotBlank @NonNull String rawPassword) {
        return delegate.encode(rawPassword);
    }

    @Override
    public boolean matches(@NotBlank @NonNull String rawPassword,
                           @NotBlank @NonNull String encodedPassword) {
        return delegate.matches(rawPassword, encodedPassword);
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.

11.1. Register Service

Create a service to register a user.

Create RegisterService

src/main/java/example/micronaut/RegisterService.java
package example.micronaut;

import example.micronaut.entities.Role;
import example.micronaut.entities.User;
import example.micronaut.entities.UserRole;
import example.micronaut.entities.UserRoleId;
import example.micronaut.exceptions.UserAlreadyExistsException;
import example.micronaut.repositories.RoleJdbcRepository;
import example.micronaut.repositories.UserJdbcRepository;
import example.micronaut.repositories.UserRoleJdbcRepository;
import io.micronaut.core.annotation.Nullable;
import jakarta.inject.Singleton;
import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotBlank;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

@Singleton (1)
public class RegisterService {

    private static final boolean DEFAULT_ENABLED = true;
    private static final boolean DEFAULT_ACCOUNT_EXPIRED = false;

    private static final boolean DEFAULT_ACCOUNT_LOCKED = false;

    private static final boolean DEFAULT_PASSWORD_EXPIRED = false;
    private final RoleJdbcRepository roleService;
    private final UserJdbcRepository userJdbcRepository;
    private final UserRoleJdbcRepository userRoleJdbcRepository;
    private final PasswordEncoder passwordEncoder;

    public RegisterService(RoleJdbcRepository roleGormService,
                    UserJdbcRepository userJdbcRepository,
                    PasswordEncoder passwordEncoder,
                    UserRoleJdbcRepository userRoleJdbcRepository) {
        this.roleService = roleGormService;
        this.userJdbcRepository = userJdbcRepository;
        this.userRoleJdbcRepository = userRoleJdbcRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public void register(@NotBlank String username,
                         @NotBlank String rawPassword) {
        register(username, rawPassword, Collections.emptyList());
    }

    @Transactional (2)
    public void register(@NotBlank String username,
                         @NotBlank String rawPassword,
                         @Nullable List<String> authorities) {
        Optional<User> userOptional = userJdbcRepository.findByUsername(username);
        if (userOptional.isPresent()) {
            throw new UserAlreadyExistsException();
        }
        User user = userJdbcRepository.save(createUser(username, rawPassword));
        if (user != null && authorities != null) {
            for (String authority : authorities) {
                Role role = roleService.findByAuthority(authority).orElseGet(() -> roleService.save(authority));
                UserRoleId userRoleId = new UserRoleId(user, role);
                if (userRoleJdbcRepository.findById(userRoleId).isEmpty()) {
                    userRoleJdbcRepository.save(new UserRole(userRoleId));
                }
            }
        }
    }

    private User createUser(String username, String rawPassword) {
        final String encodedPassword = passwordEncoder.encode(rawPassword);
        return new User(null,
                username,
                encodedPassword,
                DEFAULT_ENABLED,
                DEFAULT_ACCOUNT_EXPIRED,
                DEFAULT_ACCOUNT_LOCKED,
                DEFAULT_PASSWORD_EXPIRED);
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 You can declare a method or class as transactional with the jakarta.transaction.Transactional annotation.

If the user already exists, we throw an UserAlreadyExistsException.

src/main/java/example/micronaut/exceptions/UserAlreadyExistsException.java
package example.micronaut.exceptions;

public class UserAlreadyExistsException extends RuntimeException {
}

12. Delegating Authentication Provider

We will set up a AuthenticationProvider a described in the next diagram.

delegating authentication provider

Next, we create interfaces and implementations for each of the pieces of the previous diagram.

12.1. User Fetcher

Create an interface to retrieve a UserState given a username.

src/main/java/example/micronaut/UserFetcher.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import jakarta.validation.constraints.NotBlank;

import java.util.Optional;

interface UserFetcher {

    Optional<UserState> findByUsername(@NotBlank @NonNull String username);
}

Provide an implementation:

src/main/java/example/micronaut/UserFetcherService.java
package example.micronaut;

import example.micronaut.repositories.UserJdbcRepository;
import io.micronaut.core.annotation.NonNull;
import jakarta.inject.Singleton;
import jakarta.validation.constraints.NotBlank;

import java.util.Optional;

@Singleton (1)
class UserFetcherService implements UserFetcher {

    private final UserJdbcRepository userJdbcRepository;

    UserFetcherService(UserJdbcRepository userJdbcRepository) { (2)
        this.userJdbcRepository = userJdbcRepository;
    }

    @Override
    public Optional<UserState> findByUsername(@NotBlank @NonNull String username) {
        return userJdbcRepository.findByUsername(username).map(UserState.class::cast);
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 Use constructor injection to inject a bean of type UserJdbcRepository.

12.2. Authorities Fetcher

Create an interface to retrieve roles given a username.

src/main/java/example/micronaut/AuthoritiesFetcher.java
package example.micronaut;

import java.util.List;

public interface AuthoritiesFetcher {

    List<String> findAuthoritiesByUsername(String username);
}

Provide an implementation:

src/main/java/example/micronaut/AuthoritiesFetcherService.java
package example.micronaut;

import example.micronaut.repositories.UserRoleJdbcRepository;
import jakarta.inject.Singleton;

import java.util.List;

@Singleton (1)
class AuthoritiesFetcherService implements AuthoritiesFetcher {

    private final UserRoleJdbcRepository userRoleJdbcRepository;

    AuthoritiesFetcherService(UserRoleJdbcRepository userRoleJdbcRepository) { (2)
        this.userRoleJdbcRepository = userRoleJdbcRepository;
    }

    @Override
    public List<String> findAuthoritiesByUsername(String username) {
        return userRoleJdbcRepository.findAllAuthoritiesByUsername(username);
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 Use constructor injection to inject a bean of type UserRoleJdbcRepository.

12.3. Authentication Provider

Create an authentication provider which uses the interfaces you wrote in the previous sections.

src/main/java/example/micronaut/DelegatingAuthenticationProvider.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.security.authentication.AuthenticationException;
import io.micronaut.security.authentication.AuthenticationFailed;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.provider.HttpRequestReactiveAuthenticationProvider;
import reactor.core.publisher.FluxSink;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import org.reactivestreams.Publisher;
import jakarta.inject.Named;
import jakarta.inject.Singleton;

import java.util.List;
import java.util.concurrent.ExecutorService;

import static io.micronaut.security.authentication.AuthenticationFailureReason.ACCOUNT_EXPIRED;
import static io.micronaut.security.authentication.AuthenticationFailureReason.ACCOUNT_LOCKED;
import static io.micronaut.security.authentication.AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH;
import static io.micronaut.security.authentication.AuthenticationFailureReason.PASSWORD_EXPIRED;
import static io.micronaut.security.authentication.AuthenticationFailureReason.USER_DISABLED;
import static io.micronaut.security.authentication.AuthenticationFailureReason.USER_NOT_FOUND;

@Singleton (1)
class DelegatingAuthenticationProvider<B> implements HttpRequestReactiveAuthenticationProvider<B> {

    private final UserFetcher userFetcher;
    private final PasswordEncoder passwordEncoder;
    private final AuthoritiesFetcher authoritiesFetcher;
    private final Scheduler scheduler;

    DelegatingAuthenticationProvider(UserFetcher userFetcher,
                                     PasswordEncoder passwordEncoder,
                                     AuthoritiesFetcher authoritiesFetcher,
                                     @Named(TaskExecutors.BLOCKING) ExecutorService executorService) { (2)
        this.userFetcher = userFetcher;
        this.passwordEncoder = passwordEncoder;
        this.authoritiesFetcher = authoritiesFetcher;
        this.scheduler = Schedulers.fromExecutorService(executorService);
    }

    @Override
    @NonNull
    public  Publisher<AuthenticationResponse> authenticate(
            @Nullable HttpRequest<B> requestContext,
            @NonNull AuthenticationRequest<String, String> authenticationRequest
    ) {
        return Flux.<AuthenticationResponse>create(emitter -> {
            UserState user = fetchUserState(authenticationRequest);
            AuthenticationFailed authenticationFailed = validate(user, authenticationRequest);
            if (authenticationFailed != null) {
                emitter.error(new AuthenticationException(authenticationFailed));
            } else {
                emitter.next(createSuccessfulAuthenticationResponse(user));
                emitter.complete();
            }
        }, FluxSink.OverflowStrategy.ERROR).subscribeOn(scheduler); (3)
    }

    private AuthenticationFailed validate(UserState user, AuthenticationRequest<?, ?> authenticationRequest) {
        AuthenticationFailed authenticationFailed = null;
        if (user == null) {
            authenticationFailed = new AuthenticationFailed(USER_NOT_FOUND);

        } else if (!user.isEnabled()) {
            authenticationFailed = new AuthenticationFailed(USER_DISABLED);

        } else if (user.isAccountExpired()) {
            authenticationFailed = new AuthenticationFailed(ACCOUNT_EXPIRED);

        } else if (user.isAccountLocked()) {
            authenticationFailed = new AuthenticationFailed(ACCOUNT_LOCKED);

        } else if (user.isPasswordExpired()) {
            authenticationFailed = new AuthenticationFailed(PASSWORD_EXPIRED);

        } else if (!passwordEncoder.matches(authenticationRequest.getSecret().toString(), user.getPassword())) {
            authenticationFailed = new AuthenticationFailed(CREDENTIALS_DO_NOT_MATCH);
        }

        return authenticationFailed;
    }

    private UserState fetchUserState(AuthenticationRequest<?, ?> authRequest) {
        final Object username = authRequest.getIdentity();
        return userFetcher.findByUsername(username.toString()).orElse(null);
    }

    private AuthenticationResponse createSuccessfulAuthenticationResponse(UserState user) {
        List<String> authorities = authoritiesFetcher.findAuthoritiesByUsername(user.getUsername());
        return AuthenticationResponse.success(user.getUsername(), authorities);
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 Inject the BLOCKING executor service.
3 subscribeOn method schedules the operation on a virtual thread or in the I/O thread pool.

13. Views

To use the Thymeleaf Java template engine to render views in a Micronaut application, add the following dependency on your classpath.

build.gradle
implementation("io.micronaut.views:micronaut-views-thymeleaf")

14. Views Fieldset

In this guide, we use the FieldsetGenerator API:

The FieldsetGenerator API simplifies the generation of an HTML Fieldset representation for a given type or instance. It leverages the introspection builder support.

To use it, add the following dependency on your classpath.

build.gradle
implementation("io.micronaut.views:micronaut-views-fieldset")

15. Thymeleaf Fragments

The application uses several thymeleaf fragments, which are not shown in this tutorial but which you can obtain when you download the Solution

If you select thymleaf in Micronaut Launch, those fragments get generated as well.

16. View Model Processor

Create a ViewModelProcessor to add a logout form to the model if the user is authenticated.

src/main/java/example/micronaut/LogoutFormViewModelProcessor.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.endpoints.LogoutControllerConfiguration;
import io.micronaut.security.utils.SecurityService;
import io.micronaut.views.ModelAndView;
import io.micronaut.views.fields.Fieldset;
import io.micronaut.views.fields.Form;
import io.micronaut.views.fields.FormGenerator;
import io.micronaut.views.fields.elements.InputSubmitFormElement;
import io.micronaut.views.fields.messages.Message;
import io.micronaut.views.model.ViewModelProcessor;
import jakarta.inject.Singleton;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

@Singleton (1)
class LogoutFormViewModelProcessor  implements ViewModelProcessor<Map<String, Object>> { (2)

    private static final String MODEL_KEY = "logoutForm";

    private final SecurityService securityService;
    private final Form logoutForm;

    LogoutFormViewModelProcessor(FormGenerator formGenerator, (3)
                                 SecurityService securityService,
                                 LogoutControllerConfiguration logoutControllerConfiguration) {
        this.securityService = securityService;
        this.logoutForm = formGenerator.generate(logoutControllerConfiguration.getPath(),
                new Fieldset(Collections.emptyList(), Collections.emptyList()), InputSubmitFormElement
                        .builder()
                        .value(Message.of("Logout", "logout.submit"))
                        .build());
    }

    @Override
    public void process(@NonNull HttpRequest<?> request, @NonNull ModelAndView<Map<String, Object>> modelAndView) {
        if (securityService.isAuthenticated()) {
            Map<String, Object> viewModel = modelAndView.getModel().orElseGet(() -> {
                final HashMap<String, Object> newModel = new HashMap<>(1);
                modelAndView.setModel(newModel);
                return newModel;
            });
            try {
                viewModel.putIfAbsent(MODEL_KEY, logoutForm);
            } catch (UnsupportedOperationException ex) {
                final HashMap<String, Object> modifiableModel = new HashMap<>(viewModel);
                modifiableModel.putIfAbsent(MODEL_KEY, logoutForm);
                modelAndView.setModel(modifiableModel);
            }
        }
    }
}
1 Use jakarta.inject.Singleton to designate a class as a singleton.
2 The ViewModelProcessor API provides a simple way to modify and enhance a model prior to being sent to a template engine for rendering.
3 The FormGenerator API simplifies the generation of an HTML Form representation for a give type or instance.

17. Login Form

Create a Java Record to model the login form:

src/main/java/example/micronaut/controllers/LoginForm.java
package example.micronaut.controllers;

import io.micronaut.serde.annotation.Serdeable;
import io.micronaut.views.fields.annotations.InputPassword;
import jakarta.validation.constraints.NotBlank;

@Serdeable (1)
public record LoginForm(@NotBlank String username, (2)
                        @InputPassword @NotBlank String password) {  (3)
}
1 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.
2 Use jakarta.validation.constraints Constraints to ensure the data matches your expectations.
3 Specify a field is a password input.

18. SignUp Form

Create a Java Record to model the signup form:

src/main/java/example/micronaut/controllers/SignUpForm.java
package example.micronaut.controllers;

import example.micronaut.constraints.PasswordMatch;
import io.micronaut.serde.annotation.Serdeable;
import io.micronaut.views.fields.annotations.InputPassword;
import jakarta.validation.constraints.NotBlank;

@PasswordMatch (1)
@Serdeable (2)
public record SignUpForm(@NotBlank String username, (3)
                         @InputPassword @NotBlank String password, (4)
                         @InputPassword @NotBlank String repeatPassword) { (4)
}
1 PasswordMatch is a custom constraint annotation which we will create shortly.
2 Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.
3 Use jakarta.validation.constraints Constraints to ensure the data matches your expectations.
4 Specify a field is a password input.

18.1. Custom Validation Annotation

Create the PasswordMatch annotation:

src/main/java/example/micronaut/constraints/PasswordMatch.java
package example.micronaut.constraints;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = PasswordMatchValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatch {

    String MESSAGE = "example.micronaut.constraints.PasswordMatch.message";

    String message() default "{" + MESSAGE + "}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

18.2. Validation Factory

Creates validator for PasswordMatch and SignUpForm:

src/main/java/example/micronaut/constraints/PasswordMatchValidator.java
package example.micronaut.constraints;

import example.micronaut.controllers.SignUpForm;
import io.micronaut.core.annotation.Introspected;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

@Introspected (2)
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, SignUpForm> {

    @Override
    public boolean isValid(SignUpForm value, ConstraintValidatorContext context) {
        if (value.password() == null && value.repeatPassword() == null) {
            return true;
        }
        if (value.password() != null && value.repeatPassword() == null) {
            return false;
        }
        if (value.password() == null) {
            return false;
        }
        return value.password().equals(value.repeatPassword());
    }
}

callout:introspected

18.3. Validation Messages

Create a default message for the PasswordMatch constraint:

src/main/java/example/micronaut/constraints/PasswordMatchMessages.java
package example.micronaut.constraints;

import io.micronaut.context.StaticMessageSource;
import jakarta.inject.Singleton;

@Singleton (1)
public class PasswordMatchMessages extends StaticMessageSource {

    public static final String PASSWORD_MATCH_MESSAGE = "Passwords do not match";

    private static final String MESSAGE_SUFFIX = ".message";

    public PasswordMatchMessages() {
        addMessage(PasswordMatch.class.getName() + MESSAGE_SUFFIX, PASSWORD_MATCH_MESSAGE);
    }
}
  • Use jakarta.inject.Singleton to designate a class as a singleton.

19. Controllers

19.1. HomeController

Create a controller to render an HTML Page in the root of the application:

src/main/java/example/micronaut/controllers/HomeController.java
package example.micronaut.controllers;

import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.views.View;

import java.util.Collections;
import java.util.Map;

@Controller (1)
public class HomeController {

    @Produces(MediaType.TEXT_HTML) (2)
    @Secured(SecurityRule.IS_ANONYMOUS) (3)
    @Get (4)
    @View("home.html") (5)
    Map<String, Object> index() {
        return Collections.emptyMap();
    }
}
1 The class is defined as a controller with the @Controller annotation mapped to the path /.
2 Set the response content-type to HTML with the @Produces annotation.
3 Annotate with io.micronaut.security.Secured to configure secured access. The SecurityRule.IS_ANONYMOUS expression will allow access without authentication.
4 The @Get annotation maps the method to an HTTP GET request.
5 Use View annotation to specify which template to use to render the response.

It uses the following Thymeleaf template:

src/main/resources/views/home.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::title},~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <title></title>
    <script></script>
</head>
<body>
<main>
    <th:block th:if="${security}">
        <h2>username: <span th:text="${security.name}"></span></h2>
        <form th:replace="~{fieldset/form :: form(${logoutForm})}"></form>
    </th:block>
    <ul th:unless="${security}">
        <li><a href="/user/auth">Login</a></li>
        <li><a href="/user/signUp">SignUp</a></li>
    </ul>
</main>
</body>
</html>

19.2. UserController

Create a controller to render the login and signup pages:

src/main/java/example/micronaut/controllers/UserController.java
package example.micronaut.controllers;

import example.micronaut.RegisterService;
import example.micronaut.exceptions.UserAlreadyExistsException;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.*;
import io.micronaut.http.annotation.Error;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import io.micronaut.views.ModelAndView;
import io.micronaut.views.View;
import io.micronaut.views.fields.FormGenerator;
import io.micronaut.views.fields.messages.Message;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import java.net.URI;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

@Secured(SecurityRule.IS_ANONYMOUS) (1)
@Controller(UserController.PATH) (2)
public class UserController {

    public static final String PATH = "/user";
    private static final String PATH_AUTH = "/auth";
    private static final String PATH_AUTH_FAILED = "/authFailed";
    private static final String PATH_SIGN_UP = "/signUp";
    private final static String MODEL_KEY_FORM = "form";
    private static final String MODEL_KEY_ERROR = "error";
    private static final String PATH_SIGNUP = "/user/signUp";
    private static final String VIEW_SIGNUP = "/user/signup.html";
    private static final String VIEW_AUTH = "/user/auth.html";
    private static final String PATH_LOGIN = "/login";
    private static final Message MESSAGE_LOGIN_FAILED =
            Message.of("That username or password is incorrect. Please try again.", "login.failed");
    private static final Message MESSAGE_SIGNUP_FAILED =
            Message.of("Sorry, someone already has that username.", "signup.failed");
    private final FormGenerator formGenerator;
    private final RegisterService registerService;
    private final URI uriAuth;
    private final Map<String, Object> authModel;
    private final Map<String, Object> signUpModel;
    private final Map<String, Object> signUpFailedModel;
    private final Map<String, Object> authFailedModel;

    public UserController(FormGenerator formGenerator, RegisterService registerService) { (3)
        this.formGenerator = formGenerator;
        this.registerService = registerService;
        this.uriAuth = UriBuilder.of(PATH).path(PATH_AUTH).build();
        this.signUpModel = Collections.singletonMap(MODEL_KEY_FORM,
                formGenerator.generate(PATH_SIGNUP, SignUpForm.class));
        this.signUpFailedModel = Map.of(MODEL_KEY_FORM,
                formGenerator.generate(PATH_SIGNUP, SignUpForm.class), MODEL_KEY_ERROR, MESSAGE_SIGNUP_FAILED);
        this.authModel = Collections.singletonMap(MODEL_KEY_FORM,
                formGenerator.generate(PATH_LOGIN, LoginForm.class));
        Map<String, Object> model = new HashMap<>(auth());
        model.put(MODEL_KEY_ERROR, MESSAGE_LOGIN_FAILED);
        this.authFailedModel = model;
    }

    @Produces(MediaType.TEXT_HTML) (4)
    @Get(PATH_AUTH) (5)
    @View(VIEW_AUTH) (6)
    public Map<String, Object> auth() {
        return authModel;
    }

    @Produces(MediaType.TEXT_HTML) (4)
    @Get(PATH_AUTH_FAILED) (7)
    @View(VIEW_AUTH) (6)
    public Map<String, Object> authFailed() {
        return authFailedModel;
    }

    @ExecuteOn(TaskExecutors.BLOCKING) (8)
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED) (9)
    @Produces(MediaType.TEXT_HTML) (4)
    @Post(PATH_SIGN_UP) (10)
    public HttpResponse<?> signUpSave(@NotNull @Valid @Body SignUpForm signUpForm) {
        try {
            registerService.register(signUpForm.username(), signUpForm.password());
        } catch (UserAlreadyExistsException e) {
            return HttpResponse.unprocessableEntity().body(new ModelAndView<>(VIEW_SIGNUP, signUpFailedModel));
        }
        return HttpResponse.seeOther(uriAuth);
    }

    @Produces(MediaType.TEXT_HTML) (4)
    @Get(PATH_SIGN_UP) (11)
    @View(VIEW_SIGNUP) (6)
    public Map<String, Object> signUp() {
        return signUpModel;
    }

    @Error(exception = ConstraintViolationException.class) (12)
    public HttpResponse<?> onConstraintViolationException(HttpRequest<?> request, ConstraintViolationException ex) { (13)
        if (request.getPath().equals(PATH_SIGNUP)) {
            return request.getBody(SignUpForm.class)
                    .map(signUpForm -> HttpResponse.ok()
                            .body(new ModelAndView<>(VIEW_SIGNUP,
                                    Collections.singletonMap(MODEL_KEY_FORM,
                                            formGenerator.generate(PATH_SIGNUP, signUpForm, ex)))))
                    .orElseGet(HttpResponse::serverError);
        }
        return HttpResponse.serverError();
    }
}
1 Annotate with io.micronaut.security.Secured to configure secured access. The SecurityRule.IS_ANONYMOUS expression will allow access without authentication.
2 The class is defined as a controller with the @Controller annotation mapped to the path /user.
3 The FormGenerator API simplifies the generation of an HTML Form representation for a give type or instance.
4 Set the response content-type to HTML with the @Produces annotation.
5 The @Get annotation maps the auth method to an HTTP GET request on /auth.
6 Use View annotation to specify which template to use to render the response.
7 The @Get annotation maps the authFailed method to an HTTP GET request on /authFailed.
8 It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop.
9 A Micronaut controller action consumes application/json by default. Consuming other content types is supported with the @Consumes annotation or the consumes member of any HTTP method annotation.
10 The @Post annotation maps the signUpSave method to an HTTP POST request on /signUp.
11 The @Get annotation maps the signUp method to an HTTP GET request on /signUp.
12 You can specify an exception to be handled locally with the @Error annotation.
13 You can access the original HttpRequest which triggered the exception.

The UserController controller uses the following templates for the login form:

src/main/resources/views/user/auth.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::title},~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <title></title>
    <script></script>
</head>
<body>
<main>
    <form th:replace="~{fieldset/form :: form(${form})}"></form>
    <th:block  th:if="${error}"><div th:replace="~{alerts/danger :: danger(${error})}"></div></th:block>
    <p class="mt-3"><a href="/user/signUp">Signup</a></p>
</main>
</body>
</html>

The UserController uses the following templates for the signup form:

src/main/resources/views/user/signup.html
<!DOCTYPE html>
<html lang="en" th:replace="~{layout :: layout(~{::title},~{::script},~{::main})}" xmlns:th="http://www.thymeleaf.org">
<head>
    <title></title>
    <script></script>
</head>
<body>
<main>
    <form th:replace="~{fieldset/form :: form(${form})}"></form>
    <th:block  th:if="${error}"><div th:replace="~{alerts/danger :: danger(${error})}"></div></th:block>
</main>
</body>
</html>

20. Running the Application

To run the application, use the ./gradlew run command, which starts the application on port 8080.

You can register a user, sign in and logout:

databaseAuthentication

21. GraalVM Reflection Metadata

Thymleaf access via Reflection several class of Micronaut Views.

Reflection metadata can be provided to the native-image builder by providing JSON files stored in the META-INF/native-image/<group.id>/<artifact.id> project directory.

Create a new file src/main/resources/META-INF/native-image/example.micronaut.micronautguide/reflect-config.json:

src/main/resources/META-INF/native-image/example.micronaut.micronautguide/reflect-config.json
[
  {
    "name": "io.micronaut.views.fields.Fieldset",
    "queryAllDeclaredMethods": true,
    "methods": [
      {
        "name": "errors",
        "parameterTypes": []
      },
      {
        "name": "fields",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.Form",
    "queryAllDeclaredMethods": true,
    "methods": [
      {
        "name": "action",
        "parameterTypes": []
      },
      {
        "name": "fieldset",
        "parameterTypes": []
      },
      {
        "name": "method",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.FormElement",
    "queryAllDeclaredMethods": true
  },
  {
    "name": "io.micronaut.views.fields.HtmlTag",
    "queryAllDeclaredMethods": true,
    "methods": [
      {
        "name": "toString",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.InputType",
    "queryAllDeclaredMethods": true,
    "methods": [
      {
        "name": "toString",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.elements.FormElementAttributes",
    "queryAllDeclaredMethods": true,
    "methods": [
      {
        "name": "hasErrors",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.elements.InputFormElement",
    "queryAllDeclaredMethods": true,
    "methods": [
      {
        "name": "getTag",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.elements.InputPasswordFormElement",
    "queryAllDeclaredMethods": true,
    "queryAllPublicMethods": true,
    "methods": [
      {
        "name": "errors",
        "parameterTypes": []
      },
      {
        "name": "getType",
        "parameterTypes": []
      },
      {
        "name": "id",
        "parameterTypes": []
      },
      {
        "name": "label",
        "parameterTypes": []
      },
      {
        "name": "maxLength",
        "parameterTypes": []
      },
      {
        "name": "minLength",
        "parameterTypes": []
      },
      {
        "name": "name",
        "parameterTypes": []
      },
      {
        "name": "pattern",
        "parameterTypes": []
      },
      {
        "name": "placeholder",
        "parameterTypes": []
      },
      {
        "name": "readOnly",
        "parameterTypes": []
      },
      {
        "name": "required",
        "parameterTypes": []
      },
      {
        "name": "size",
        "parameterTypes": []
      },
      {
        "name": "value",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.elements.InputStringFormElement",
    "queryAllDeclaredMethods": true
  },
  {
    "name": "io.micronaut.views.fields.elements.InputSubmitFormElement",
    "queryAllDeclaredMethods": true,
    "queryAllPublicMethods": true,
    "methods": [
      {
        "name": "getType",
        "parameterTypes": []
      },
      {
        "name": "value",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.elements.InputTextFormElement",
    "queryAllDeclaredMethods": true,
    "queryAllPublicMethods": true,
    "methods": [
      {
        "name": "errors",
        "parameterTypes": []
      },
      {
        "name": "getType",
        "parameterTypes": []
      },
      {
        "name": "id",
        "parameterTypes": []
      },
      {
        "name": "label",
        "parameterTypes": []
      },
      {
        "name": "maxLength",
        "parameterTypes": []
      },
      {
        "name": "minLength",
        "parameterTypes": []
      },
      {
        "name": "name",
        "parameterTypes": []
      },
      {
        "name": "pattern",
        "parameterTypes": []
      },
      {
        "name": "placeholder",
        "parameterTypes": []
      },
      {
        "name": "readOnly",
        "parameterTypes": []
      },
      {
        "name": "required",
        "parameterTypes": []
      },
      {
        "name": "size",
        "parameterTypes": []
      },
      {
        "name": "value",
        "parameterTypes": []
      }
    ]
  },
  {
    "name": "io.micronaut.views.fields.messages.Message",
    "queryAllDeclaredMethods": true,
    "methods": [
      {
        "name": "code",
        "parameterTypes": []
      },
      {
        "name": "defaultMessage",
        "parameterTypes": []
      }
    ]
  }
]

22. Generate a Micronaut Application Native Executable with GraalVM

We will use GraalVM, the polyglot embeddable virtual machine, to generate a native executable of our Micronaut application.

Compiling native executables ahead of time with GraalVM improves startup time and reduces the memory footprint of JVM-based applications.

Only Java and Kotlin projects support using GraalVM’s native-image tool. Groovy relies heavily on reflection, which is only partially supported by GraalVM.

22.1. GraalVM installation

The easiest way to install GraalVM on Linux or Mac is to use SDKMan.io.

Java 17
sdk install java 17.0.8-graal
Java 17
sdk use java 17.0.8-graal

For installation on Windows, or for manual installation on Linux or Mac, see the GraalVM Getting Started documentation.

The previous command installs Oracle GraalVM, which is free to use in production and free to redistribute, at no cost, under the GraalVM Free Terms and Conditions.

Alternatively, you can use the GraalVM Community Edition:

Java 17
sdk install java 17.0.8-graalce
Java 17
sdk use java 17.0.8-graalce

22.2. Native executable generation

To generate a native executable using Gradle, run:

./gradlew nativeCompile

The native executable is created in build/native/nativeCompile directory and can be run with build/native/nativeCompile/micronautguide.

It is possible to customize the name of the native executable or pass additional parameters to GraalVM:

build.gradle
graalvmNative {
    binaries {
        main {
            imageName.set('mn-graalvm-application') (1)
            buildArgs.add('--verbose') (2)
        }
    }
}
1 The native executable name will now be mn-graalvm-application
2 It is possible to pass extra arguments to build the native executable

23. Next steps

Explore more features with Micronaut Guides.

Learn more about:

24. Help with the Micronaut Framework

The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.

25. License

All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…​).