Table of Contents
- 1. Getting Started
- 2. What you will need
- 3. Solution
- 4. Writing the Application
- 5. Security Session
- 6. Security Configuration
- 7. Validation
- 8. Micronaut Reactor
- 9. Entities
- 10. Repositories
- 11. Password Encoder
- 12. Delegating Authentication Provider
- 13. Views
- 14. Views Fieldset
- 15. Thymeleaf Fragments
- 16. View Model Processor
- 17. Login Form
- 18. SignUp Form
- 19. Controllers
- 20. Running the Application
- 21. GraalVM Reflection Metadata
- 22. Generate a Micronaut Application Native Executable with GraalVM
- 23. Next Steps
- 24. Help with the Micronaut Framework
- 25. License
Database authentication
Learn how to secure a Micronaut application using Database authentication.
Authors: Sergio del Amo
Micronaut Version: 4.6.3
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:
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE (e.g. IntelliJ IDEA)
-
JDK 21 or greater installed with
JAVA_HOME
configured appropriately
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.
-
Download and unzip the source
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=maven \
--lang=java \
--test=junit
If you don’t specify the --build argument, Gradle with the Kotlin DSL 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:
<!-- Add the following to your annotationProcessorPaths element -->
<path>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-processor</artifactId>
</path>
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-jdbc</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.micronaut.sql</groupId>
<artifactId>micronaut-jdbc-hikari</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Locally, the database will be provided by [Test Resources].
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:
<dependency>
<groupId>io.micronaut.flyway</groupId>
<artifactId>micronaut-flyway</artifactId>
<scope>compile</scope>
</dependency>
We will enable Flyway in the Micronaut configuration file and configure it to perform migrations on one of the defined data sources.
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:
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:
<dependency>
<groupId>io.micronaut.security</groupId>
<artifactId>micronaut-security-session</artifactId>
<scope>compile</scope>
</dependency>
6. Security Configuration
Add this configuration to 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:
<dependency>
<groupId>io.micronaut.validation</groupId>
<artifactId>micronaut-validation</artifactId>
<scope>compile</scope>
</dependency>
<!-- Add the following to your annotationProcessorPaths element -->
<path>
<groupId>io.micronaut.validation</groupId>
<artifactId>micronaut-validation</artifactId>
</path>
8. Micronaut Reactor
To use Project Reactor, add the following dependency:
<dependency>
<groupId>io.micronaut.reactor</groupId>
<artifactId>micronaut-reactor</artifactId>
<scope>compile</scope>
</dependency>
9. Entities
9.1. Role
Create Role
domain class to store authorities within our application.
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.
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.
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
.
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
.
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.
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.
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.
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:
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:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>6.2.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<scope>compile</scope>
</dependency>
Then, write the implementation:
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
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
.
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.
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.
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:
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.
package example.micronaut;
import java.util.List;
public interface AuthoritiesFetcher {
List<String> findAuthoritiesByUsername(String username);
}
Provide an implementation:
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.
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.
<dependency>
<groupId>io.micronaut.views</groupId>
<artifactId>micronaut-views-thymeleaf</artifactId>
<scope>compile</scope>
</dependency>
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.
<dependency>
<groupId>io.micronaut.views</groupId>
<artifactId>micronaut-views-fieldset</artifactId>
<scope>compile</scope>
</dependency>
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.
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:
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:
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:
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
:
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:
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);
}
}
1 | 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:
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:
<!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:
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:
<!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:
<!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 ./mvnw mn:run
command, which starts the application on port 8080.
You can register a user, sign in and logout:
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
:
[
{
"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, an advanced JDK with ahead-of-time Native Image compilation, to generate a native executable of this Micronaut application.
Compiling Micronaut applications ahead of time with GraalVM significantly 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
sdk install java 21.0.5-graal
For installation on Windows, or for a 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:
sdk install java 21.0.2-graalce
22.2. Native Executable Generation
To generate a native executable using Maven, run:
./mvnw package -Dpackaging=native-image
The native executable is created in the target
directory and can be run with target/micronautguide
.
It is possible to customize the name of the native executable or pass additional build arguments using the Maven plugin for GraalVM Native Image building. Declare the plugin as following:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.3</version>
<configuration>
<!-- <1> -->
<imageName>mn-graalvm-application</imageName> (1)
<buildArgs>
<!-- <2> -->
<buildArg>-Ob</buildArg>
</buildArgs>
</configuration>
</plugin>
1 | The native executable name will now be mn-graalvm-application . |
2 | It is possible to pass extra build arguments to native-image . For example, -Ob enables the quick build mode. |
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…). |