Overview
Micronaut provides built-in security features with minimal configuration. This guide covers authentication, authorization, JWT, OAuth2, and custom security implementations in Micronaut.
1. Project Setup
Dependencies
<properties> <micronaut.version>4.2.0</micronaut.version> </properties> <dependencies> <dependency> <groupId>io.micronaut</groupId> <artifactId>micronaut-http-client</artifactId> </dependency> <dependency> <groupId>io.micronaut.security</groupId> <artifactId>micronaut-security</artifactId> </dependency> <dependency> <groupId>io.micronaut.security</groupId> <artifactId>micronaut-security-jwt</artifactId> </dependency> <dependency> <groupId>io.micronaut.security</groupId> <artifactId>micronaut-security-oauth2</artifactId> </dependency> <dependency> <groupId>io.micronaut.sql</groupId> <artifactId>micronaut-jdbc-hikari</artifactId> </dependency> <dependency> <groupId>io.micronaut.data</groupId> <artifactId>micronaut-data-jdbc</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies>
2. Configuration
application.yml
micronaut:
application:
name: micronaut-security-demo
security:
enabled: true
token:
jwt:
signatures:
secret:
generator:
secret: "${JWT_GENERATOR_SIGNATURE_SECRET:pleaseChangeThisSecretForANewOne}"
endpoints:
login:
enabled: true
oauth:
enabled: true
oauth2:
clients:
google:
client-id: "${OAUTH_GOOGLE_CLIENT_ID:}"
client-secret: "${OAUTH_GOOGLE_CLIENT_SECRET:}"
scopes: openid, profile, email
github:
client-id: "${OAUTH_GITHUB_CLIENT_ID:}"
client-secret: "${OAUTH_GITHUB_CLIENT_SECRET:}"
scopes: read:user, user:email
intercept-url-map:
- pattern: /public/**
http-method: GET
access:
- isAnonymous()
- pattern: /auth/me
http-method: GET
access:
- isAuthenticated()
- pattern: /admin/**
http-method: GET
access:
- hasRole('ADMIN')
- pattern: /users/**
http-method: GET
access:
- hasRole('USER')
redirect:
login-success: /auth/me
login-failure: /auth/error
unauthorized: /auth/unauthorized
forbidden: /auth/forbidden
datasources:
default:
url: jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
username: sa
password: ""
driver-class-name: org.h2.Driver
jackson:
serialization-inclusion: ALWAYS
3. Domain Models
User Entity
package com.example.domain;
import io.micronaut.data.annotation.*;
import java.time.LocalDateTime;
import java.util.Set;
@MappedEntity
public class User {
@Id
@GeneratedValue
private Long id;
@Column(unique = true)
private String username;
private String email;
private String password;
private String firstName;
private String lastName;
@Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "user")
private Set<UserRole> roles;
private boolean enabled = true;
private boolean accountExpired = false;
private boolean accountLocked = false;
private boolean credentialsExpired = false;
@DateCreated
private LocalDateTime dateCreated;
@DateUpdated
private LocalDateTime lastUpdated;
// Constructors
public User() {}
public User(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public Set<UserRole> getRoles() { return roles; }
public void setRoles(Set<UserRole> roles) { this.roles = roles; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isAccountExpired() { return accountExpired; }
public void setAccountExpired(boolean accountExpired) { this.accountExpired = accountExpired; }
public boolean isAccountLocked() { return accountLocked; }
public void setAccountLocked(boolean accountLocked) { this.accountLocked = accountLocked; }
public boolean isCredentialsExpired() { return credentialsExpired; }
public void setCredentialsExpired(boolean credentialsExpired) { this.credentialsExpired = credentialsExpired; }
public LocalDateTime getDateCreated() { return dateCreated; }
public void setDateCreated(LocalDateTime dateCreated) { this.dateCreated = dateCreated; }
public LocalDateTime getLastUpdated() { return lastUpdated; }
public void setLastUpdated(LocalDateTime lastUpdated) { this.lastUpdated = lastUpdated; }
public String getFullName() {
return firstName + " " + lastName;
}
}
Role Entity
package com.example.domain;
import io.micronaut.data.annotation.*;
@MappedEntity
public class Role {
@Id
@GeneratedValue
private Long id;
@Column(unique = true)
private String name;
private String description;
// Constructors
public Role() {}
public Role(String name, String description) {
this.name = name;
this.description = description;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
User-Role Relationship
package com.example.domain;
import io.micronaut.data.annotation.*;
@MappedEntity
public class UserRole {
@Id
@GeneratedValue
private Long id;
@Relation(value = Relation.Kind.MANY_TO_ONE)
private User user;
@Relation(value = Relation.Kind.MANY_TO_ONE)
private Role role;
// Constructors
public UserRole() {}
public UserRole(User user, Role role) {
this.user = user;
this.role = role;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
}
4. Repository Layer
package com.example.repository;
import com.example.domain.User;
import com.example.domain.Role;
import io.micronaut.data.annotation.Query;
import io.micronaut.data.annotation.Repository;
import io.micronaut.data.repository.CrudRepository;
import java.util.Optional;
@Repository
public interface UserRepository extends CrudRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u JOIN FETCH u.roles WHERE u.username = :username")
Optional<User> findByUsernameWithRoles(String username);
}
@Repository
public interface RoleRepository extends CrudRepository<Role, Long> {
Optional<Role> findByName(String name);
}
5. Password Encoder
package com.example.security;
import at.favre.lib.crypto.bcrypt.BCrypt;
import io.micronaut.context.annotation.Primary;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
@Primary
public class BCryptPasswordEncoder implements PasswordEncoder {
private static final Logger log = LoggerFactory.getLogger(BCryptPasswordEncoder.class);
private static final int BCRYPT_COST = 12;
@Override
public String encode(String rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("Raw password cannot be null");
}
return BCrypt.withDefaults().hashToString(BCRYPT_COST, rawPassword.toCharArray());
}
@Override
public boolean matches(String rawPassword, String encodedPassword) {
if (rawPassword == null || encodedPassword == null) {
return false;
}
BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
return result.verified;
}
}
6. Authentication Provider
package com.example.security;
import com.example.domain.User;
import com.example.repository.UserRepository;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.*;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.Optional;
@Singleton
public class UserAuthenticationProvider implements AuthenticationProvider {
private static final Logger log = LoggerFactory.getLogger(UserAuthenticationProvider.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserAuthenticationProvider(UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest,
AuthenticationRequest<?, ?> authenticationRequest) {
return Mono.create(emitter -> {
String username = authenticationRequest.getIdentity().toString();
String password = authenticationRequest.getSecret().toString();
log.debug("Attempting authentication for user: {}", username);
try {
Optional<User> userOpt = userRepository.findByUsernameWithRoles(username);
if (userOpt.isEmpty()) {
log.warn("User not found: {}", username);
emitter.error(new AuthenticationException(new AuthenticationFailed("Invalid credentials")));
return;
}
User user = userOpt.get();
// Check account status
if (!user.isEnabled()) {
log.warn("Account disabled for user: {}", username);
emitter.error(new AuthenticationException(new AuthenticationFailed("Account disabled")));
return;
}
if (user.isAccountLocked()) {
log.warn("Account locked for user: {}", username);
emitter.error(new AuthenticationException(new AuthenticationFailed("Account locked")));
return;
}
if (user.isAccountExpired()) {
log.warn("Account expired for user: {}", username);
emitter.error(new AuthenticationException(new AuthenticationFailed("Account expired")));
return;
}
if (user.isCredentialsExpired()) {
log.warn("Credentials expired for user: {}", username);
emitter.error(new AuthenticationException(new AuthenticationFailed("Credentials expired")));
return;
}
// Verify password
if (!passwordEncoder.matches(password, user.getPassword())) {
log.warn("Invalid password for user: {}", username);
emitter.error(new AuthenticationException(new AuthenticationFailed("Invalid credentials")));
return;
}
// Build user details
AuthenticationResponse response = createAuthenticationResponse(user);
log.info("User authenticated successfully: {}", username);
emitter.success(response);
} catch (Exception e) {
log.error("Authentication error for user: {}", username, e);
emitter.error(new AuthenticationException(new AuthenticationFailed("Authentication failed")));
}
});
}
private AuthenticationResponse createAuthenticationResponse(User user) {
// Extract roles
var roles = user.getRoles().stream()
.map(userRole -> userRole.getRole().getName())
.toList();
// Build attributes
var attributes = Collections.<String, Object>singletonMap("email", user.getEmail());
return AuthenticationResponse.success(
user.getUsername(),
roles,
attributes
);
}
}
7. JWT Token Generator
package com.example.security;
import com.example.domain.User;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.token.config.TokenConfiguration;
import io.micronaut.security.token.jwt.generator.claims.ClaimsGenerator;
import io.micronaut.security.token.jwt.generator.claims.JwtClaims;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
@Singleton
public class CustomClaimsGenerator implements ClaimsGenerator {
private static final Logger log = LoggerFactory.getLogger(CustomClaimsGenerator.class);
private final TokenConfiguration tokenConfiguration;
public CustomClaimsGenerator(TokenConfiguration tokenConfiguration) {
this.tokenConfiguration = tokenConfiguration;
}
@Override
public Map<String, Object> generateClaims(Authentication authentication) {
Map<String, Object> claims = new HashMap<>();
// Add standard claims
String username = authentication.getName();
claims.put(JwtClaims.SUBJECT, username);
// Add custom claims
authentication.getAttributes().forEach((key, value) -> {
if (!key.equals(JwtClaims.SUBJECT)) {
claims.put(key, value);
}
});
// Add user-specific claims
addUserSpecificClaims(claims, authentication);
log.debug("Generated JWT claims for user: {}", username);
return claims;
}
@Override
public String getName() {
return tokenConfiguration.getNameKey();
}
private void addUserSpecificClaims(Map<String, Object> claims, Authentication authentication) {
// Add email if available
if (authentication.getAttributes().containsKey("email")) {
claims.put("email", authentication.getAttributes().get("email"));
}
// Add custom claims
claims.put("issuer", "micronaut-security-demo");
claims.put("audience", "micronaut-app");
}
}
8. Security Controllers
Authentication Controller
package com.example.controller;
import io.micronaut.http.annotation.*;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.rules.SecurityRule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
@Controller("/auth")
public class AuthController {
private static final Logger log = LoggerFactory.getLogger(AuthController.class);
@Secured(SecurityRule.IS_ANONYMOUS)
@Get("/public")
public Map<String, String> publicEndpoint() {
return Map.of("message", "This is a public endpoint");
}
@Secured(SecurityRule.IS_AUTHENTICATED)
@Get("/me")
public Map<String, Object> currentUser(Principal principal) {
if (principal instanceof Authentication authentication) {
Map<String, Object> userInfo = new HashMap<>();
userInfo.put("username", authentication.getName());
userInfo.put("roles", authentication.getRoles());
userInfo.put("attributes", authentication.getAttributes());
return userInfo;
}
return Map.of("error", "Unable to get user information");
}
@Secured({"ROLE_ADMIN"})
@Get("/admin")
public Map<String, String> adminEndpoint() {
return Map.of("message", "This is an admin-only endpoint");
}
@Secured({"ROLE_USER"})
@Get("/users")
public Map<String, String> userEndpoint() {
return Map.of("message", "This is a user endpoint");
}
@Secured(SecurityRule.IS_ANONYMOUS)
@Get("/error")
public Map<String, String> authError() {
return Map.of("error", "Authentication failed");
}
@Secured(SecurityRule.IS_ANONYMOUS)
@Get("/unauthorized")
public Map<String, String> unauthorized() {
return Map.of("error", "Unauthorized access");
}
@Secured(SecurityRule.IS_ANONYMOUS)
@Get("/forbidden")
public Map<String, String> forbidden() {
return Map.of("error", "Access forbidden");
}
}
User Management Controller
package com.example.controller;
import com.example.domain.User;
import com.example.repository.UserRepository;
import com.example.security.PasswordEncoder;
import io.micronaut.http.annotation.*;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import java.util.Map;
import java.util.Optional;
@Controller("/api/users")
@Secured(SecurityRule.IS_AUTHENTICATED)
public class UserController {
private static final Logger log = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserController(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Post("/register")
@Secured(SecurityRule.IS_ANONYMOUS)
public Map<String, String> register(@Body @Valid UserRegistrationRequest request) {
// Check if username exists
if (userRepository.existsByUsername(request.getUsername())) {
return Map.of("error", "Username already exists");
}
// Check if email exists
if (userRepository.existsByEmail(request.getEmail())) {
return Map.of("error", "Email already exists");
}
// Create new user
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setFirstName(request.getFirstName());
user.setLastName(request.getLastName());
userRepository.save(user);
log.info("User registered successfully: {}", request.getUsername());
return Map.of("message", "User registered successfully");
}
@Get("/{username}")
@Secured({"ROLE_ADMIN"})
public Optional<User> getUser(@NotBlank String username) {
return userRepository.findByUsername(username);
}
// Request DTO
public static class UserRegistrationRequest {
@NotBlank
private String username;
@NotBlank
private String email;
@NotBlank
private String password;
private String firstName;
private String lastName;
// Getters and Setters
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastLastName; }
}
}
9. Security Event Listeners
package com.example.security;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.event.*;
import io.micronaut.runtime.event.annotation.EventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.inject.Singleton;
@Singleton
public class SecurityEventListener {
private static final Logger log = LoggerFactory.getLogger(SecurityEventListener.class);
@EventListener
public void onLoginSuccess(LoginSuccessfulEvent event) {
Authentication authentication = event.getAuthentication();
log.info("User logged in successfully: {}", authentication.getName());
// You can add additional logic here, such as:
// - Update last login timestamp
// - Log security event to audit trail
// - Send notification
}
@EventListener
public void onLoginFailed(LoginFailedEvent event) {
log.warn("Login failed for principal: {}", event.getSource());
// You can add additional logic here, such as:
// - Increment failed login attempts
// - Lock account after multiple failures
// - Send security alert
}
@EventListener
public void onTokenValidated(TokenValidatedEvent event) {
Authentication authentication = event.getAuthentication();
log.debug("Token validated for user: {}", authentication.getName());
}
@EventListener
public void onLogout(LogoutEvent event) {
log.info("User logged out: {}", event.getSource());
}
}
10. Custom Security Filters
package com.example.security;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.HttpServerFilter;
import io.micronaut.http.filter.ServerFilterChain;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
@Filter("/**")
public class SecurityLoggingFilter implements HttpServerFilter {
private static final Logger log = LoggerFactory.getLogger(SecurityLoggingFilter.class);
@Override
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
long startTime = System.currentTimeMillis();
// Log request details
String requestId = java.util.UUID.randomUUID().toString();
MDC.put("requestId", requestId);
log.info("Incoming request: {} {} from {}",
request.getMethod(),
request.getPath(),
request.getRemoteAddress());
return chain.proceed(request).doOnNext(response -> {
long duration = System.currentTimeMillis() - startTime;
log.info("Request completed: {} {} - Status: {} - Duration: {}ms",
request.getMethod(),
request.getPath(),
response.getStatus().getCode(),
duration);
MDC.clear();
});
}
}
11. Testing Security
Security Test Configuration
package com.example.test;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.*;
import org.junit.jupiter.api.Test;
import jakarta.inject.Inject;
import static org.junit.jupiter.api.Assertions.*;
@MicronautTest
public class SecurityTest {
@Inject
@Client("/")
HttpClient client;
@Test
void testPublicEndpoint() {
HttpResponse<String> response = client.toBlocking()
.exchange("/auth/public", String.class);
assertEquals(HttpStatus.OK, response.getStatus());
}
@Test
void testProtectedEndpointWithoutAuth() {
HttpClientResponseException exception = assertThrows(HttpClientResponseException.class,
() -> client.toBlocking().exchange("/auth/me", String.class));
assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus());
}
@Test
void testAdminEndpointWithoutAdminRole() {
// This would require a test with a non-admin authenticated user
// Implementation depends on your test authentication setup
}
}
12. Application Configuration
Application.java
package com.example;
import io.micronaut.runtime.Micronaut;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
@OpenAPIDefinition(
info = @Info(
title = "Micronaut Security Demo",
version = "1.0",
description = "Demo application showcasing Micronaut Security features"
)
)
public class Application {
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
13. Data Initialization
package com.example.config;
import com.example.domain.Role;
import com.example.domain.User;
import com.example.domain.UserRole;
import com.example.repository.RoleRepository;
import com.example.repository.UserRepository;
import com.example.security.PasswordEncoder;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.event.StartupEvent;
import io.micronaut.runtime.event.annotation.EventListener;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.inject.Inject;
import java.util.Set;
@Singleton
@Requires(notEnv = "test")
public class DataInitializer {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
@Inject
private UserRepository userRepository;
@Inject
private RoleRepository roleRepository;
@Inject
private PasswordEncoder passwordEncoder;
@EventListener
public void init(StartupEvent event) {
if (roleRepository.count() == 0) {
initRoles();
}
if (userRepository.count() == 0) {
initUsers();
}
}
private void initRoles() {
Role userRole = new Role("ROLE_USER", "Regular user");
Role adminRole = new Role("ROLE_ADMIN", "Administrator");
roleRepository.save(userRole);
roleRepository.save(adminRole);
log.info("Initial roles created");
}
private void initUsers() {
Role userRole = roleRepository.findByName("ROLE_USER").orElseThrow();
Role adminRole = roleRepository.findByName("ROLE_ADMIN").orElseThrow();
// Create admin user
User admin = new User("admin", "[email protected]",
passwordEncoder.encode("admin123"));
admin.setFirstName("Admin");
admin.setLastName("User");
userRepository.save(admin);
UserRole adminUserRole = new UserRole(admin, adminRole);
admin.setRoles(Set.of(adminUserRole));
userRepository.update(admin);
// Create regular user
User user = new User("user", "[email protected]",
passwordEncoder.encode("user123"));
user.setFirstName("Regular");
user.setLastName("User");
userRepository.save(user);
UserRole userUserRole = new UserRole(user, userRole);
user.setRoles(Set.of(userUserRole));
userRepository.update(user);
log.info("Initial users created");
}
}
Key Features Implemented
- JWT-based Authentication
- Role-based Authorization
- OAuth2 Integration
- Password Encryption
- Custom Security Claims
- Security Event Handling
- Request Logging
- User Management
- Security Testing
This implementation provides a complete security foundation for Micronaut applications with production-ready features including proper authentication, authorization, auditing, and security best practices.