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.