LDAP and Database authentication providers
Learn how to create a LDAP and a database authentication provider in a Micronaut App.
Authors: Sergio del Amo
Micronaut Version: 2.0.0.RC1
1 Getting Started
This guide uses Micronaut 2.x. You can read this tutorial for Micronaut 1.x. |
In this guide, you will create a Micronaut app which uses multiple authentication providers - an LDAP and a database authentication providers.
1.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
1.2 Solution
We recommend you to follow the instructions in the next sections and create the app step by step. However, you can go right to the completed example.
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/micronaut-guides/micronaut-database-authentication-provider-groovy.git
Then, cd
into the complete
folder which you will find in the root project of the downloaded/cloned project.
2 Writing the Application
Create a Groovy Micronaut app using the Micronaut Command Line Interface.
mn create-app example.micronaut.complete --lang=groovy
The previous command creates a micronaut app with the default package example.micronaut
in a folder named complete
.
Due to the --lang=groovy
flag, it generates a Groovy Micronaut app that uses the Gradle build system. However, you could use
other build tools such as Maven
or other programming languages such as Java
or Kotlin
.
Add security-jwt
dependency:
compileOnly("io.micronaut.security:micronaut-security-annotations")
implementation("io.micronaut.security:micronaut-security-jwt")
Modify application.yml
to enable security:
micronaut:
security:
authentication: bearer (1)
token:
jwt:
signatures:
secret:
generator: (2)
secret: pleaseChangeThisSecretForANewOne (3)
1 | Set micronaut.security.authentication as bearer |
2 | You can create a SecretSignatureConfiguration named generator via configuration as illustrated above. The generator signature is used to sign the issued JWT claims. |
3 | Change this by your own secret and keep it safe (do not store this in your VCS) |
2.1 Security LDAP
Micronaut supports authentication with LDAP out of the box. To get started, add the security-ldap
dependency to your application.
dependencies {
...
..
.
implementation("io.micronaut.security:micronaut-security-ldap")
}
We are going to use an Online LDAP test server for this guide.
Create several configuration properties matching those of the test LDAP Server.
micronaut:
..
.
security:
...
..
ldap:
default: (1)
context:
server: 'ldap://ldap.forumsys.com:389' (2)
managerDn: 'cn=read-only-admin,dc=example,dc=com' (3)
managerPassword: 'password' (4)
search:
base: "dc=example,dc=com" (5)
groups:
enabled: true (6)
base: "dc=example,dc=com" (7)
1 | The LDAP authentication in Micronaut supports configuration of one or more LDAP servers to authenticate with. You need to name each one. In this tutorial, we use default . |
2 | Each server has it’s own settings and can be enabled or disabled. |
3 | Sets the manager DN |
4 | Sets the manager password. |
5 | Sets the base DN to search. |
6 | Enable group search. |
7 | Sets the base DN to search from. |
2.2 GORM
GORM is a powerful Groovy-based data access toolkit for the JVM. GORM is the data access toolkit used by Grails and provides a rich set of APIs for accessing relational and non-relational data including implementations for Hibernate (SQL), MongoDB, Neo4j, Cassandra, an in-memory ConcurrentHashMap for testing and an automatic GraphQL schema generator.
Add a GORM dependency to the project:
dependencies {
...
..
.
implementation("io.micronaut.beanvalidation:micronaut-hibernate-validator")
implementation("io.micronaut.groovy:micronaut-hibernate-gorm")
runtimeOnly("com.h2database:h2")
runtimeOnly("org.apache.tomcat:tomcat-jdbc")
}
And the database configuration:
hibernate:
hbm2ddl:
auto: update
dataSource:
driverClassName: org.h2.Driver
username: sa
password: ''
url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
2.2.1 Domain Classes
A domain class fulfills the M in the Model View Controller (MVC) pattern and represents a persistent entity that is mapped onto an underlying database table.
2.2.1.1 User
Create a interface UserState
to model the user state.
package example.micronaut
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.domain
import example.micronaut.UserState
import grails.gorm.annotation.Entity
import org.grails.datastore.gorm.GormEntity
@Entity (1)
class User implements GormEntity<User>, UserState { (2)
String email
String username
String password
boolean enabled = true
boolean accountExpired = false
boolean accountLocked = false
boolean passwordExpired = false
static constraints = {
email nullable: false, blank: false
username nullable: false, blank: false, unique: true
password nullable: false, blank: false, password: true
}
static mapping = {
password column: '`password`'
}
}
1 | GORM entities should be annotated with grails.persistence.Entity . |
2 | Use of GormEntity to aid IDE support. |
2.2.1.2 Role
Create Role
domain class to store authorities within our application.
package example.micronaut.domain
import grails.gorm.annotation.Entity
import org.grails.datastore.gorm.GormEntity
@Entity (1)
class Role implements GormEntity<Role> { (2)
String authority
static constraints = {
authority nullable: false, unique: true
}
}
1 | GORM entities should be annotated with grails.persistence.Entity . |
2 | Use of GormEntity to aid IDE support. |
2.2.1.3 UserRole
Create a UserRole
which stores a many-to-many relationship between User
and Role
.
package example.micronaut.domain
import grails.gorm.annotation.Entity
import org.grails.datastore.gorm.GormEntity
@Entity (1)
class UserRole implements GormEntity<UserRole> { (2)
User user
Role role
static constraints = {
user nullable: false
role nullable: false
}
}
1 | GORM entities should be annotated with grails.persistence.Entity . |
2 | Use of GormEntity to aid IDE support. |
2.2.2 Data Services
GORM Data Services take the work out of implemented service layer logic by adding the ability to automatically implement abstract classes or interfaces using GORM logic.
Create various GORM Data services:
package example.micronaut
import example.micronaut.domain.User
import grails.gorm.services.Service
@Service(User) (1)
interface UserGormService {
User save(String email, String username, String password)
User findByUsername(String username)
User findById(Serializable id)
void delete(Serializable id)
int count()
}
1 | Annotate with @Service to designate a GORM Data Services which is registered as a Singleton . |
package example.micronaut
import example.micronaut.domain.Role
import grails.gorm.services.Service
@Service(Role) (1)
interface RoleGormService {
Role save(String authority)
Role find(String authority)
void delete(Serializable id)
}
1 | Annotate with @Service to designate a GORM Data Services which is registered as a Singleton . |
package example.micronaut
import example.micronaut.domain.Role
import example.micronaut.domain.User
import example.micronaut.domain.UserRole
import grails.gorm.services.Query
import grails.gorm.services.Service
@Service(UserRole) (1)
interface UserRoleGormService {
UserRole save(User user, Role role)
UserRole find(User user, Role role)
void delete(Serializable id)
@Query("""select $r.authority
from ${UserRole ur}
inner join ${User u = ur.user}
inner join ${Role r = ur.role}
where $u.username = $username""") (2)
List<String> findAllAuthoritiesByUsername(String username)
}
1 | Annotate with @Service to designate a GORM Data Services which is registered as a Singleton . |
2 | GORM allows Statically-compiled JPA-QL Queries |
2.3 Register Service
We are going to register a user when the app starts up.
package example.micronaut
import groovy.transform.CompileStatic
import io.micronaut.runtime.Micronaut
import javax.inject.Singleton
import io.micronaut.context.event.ApplicationEventListener
import io.micronaut.runtime.server.event.ServerStartupEvent
@CompileStatic
@Singleton
class Application implements ApplicationEventListener<ServerStartupEvent> { (1)
protected final RegisterService registerService
Application(RegisterService registerService) { (2)
this.registerService = registerService
}
@Override
void onApplicationEvent(ServerStartupEvent event) { (1)
registerService.register("sherlock@micronaut.example", "sherlock", 'elementary', ['ROLE_DETECTIVE']) (3)
}
static void main(String[] args) {
Micronaut.run(Application.class)
}
}
1 | Implements ServerStartupEvent which enables to execute a method when the application starts. |
2 | RegisterService is injected via constructor injection. |
3 | Register a new user when the app starts. |
Create RegisterService
package example.micronaut
import example.micronaut.domain.Role
import example.micronaut.domain.User
import example.micronaut.domain.UserRole
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import javax.inject.Singleton
import javax.validation.constraints.Email
import javax.validation.constraints.NotBlank
@CompileStatic
@Singleton
class RegisterService {
protected final RoleGormService roleGormService
protected final UserGormService userGormService
protected final UserRoleGormService userRoleGormService
protected final PasswordEncoder passwordEncoder
RegisterService(RoleGormService roleGormService,
UserGormService userGormService,
PasswordEncoder passwordEncoder,
UserRoleGormService userRoleGormService) {
this.roleGormService = roleGormService
this.userGormService = userGormService
this.userRoleGormService = userRoleGormService
this.passwordEncoder = passwordEncoder
}
@Transactional
void register(@Email String email, @NotBlank String username, @NotBlank String rawPassword, List<String> authorities) {
User user = userGormService.findByUsername(username)
if ( !user ) {
final String encodedPassword = passwordEncoder.encode(rawPassword)
user = userGormService.save(email, username, encodedPassword)
}
if ( user && authorities ) {
for ( String authority : authorities ) {
Role role = roleGormService.find(authority)
if ( !role ) {
role = roleGormService.save(authority)
}
UserRole userRole = userRoleGormService.find(user, role)
if ( !userRole ) {
userRoleGormService.save(user, role)
}
}
}
}
}
2.4 Delegating Authentication Provider
We are going to setup a AuthenticationProvider a described in the next diagramm.
Next, we create interfaces and implementations for each of the pieces of the previous diagram.
2.4.1 User Fetcher
Create an interface to retrieve a UserState
given a username.
package example.micronaut
import edu.umd.cs.findbugs.annotations.NonNull
import javax.validation.constraints.NotBlank
interface UserFetcher {
UserState findByUsername(@NotBlank @NonNull String username)
}
Provide an implementation:
package example.micronaut
import edu.umd.cs.findbugs.annotations.NonNull
import groovy.transform.CompileStatic
import javax.inject.Singleton
import javax.validation.constraints.NotBlank
@CompileStatic
@Singleton (1)
class UserFetcherService implements UserFetcher {
protected final UserGormService userGormService
UserFetcherService(UserGormService userGormService) { (2)
this.userGormService = userGormService
}
@Override
UserState findByUsername(@NotBlank @NonNull String username) {
userGormService.findByUsername(username) as UserState
}
}
1 | Use javax.inject.Singleton to designate a class a a singleton. |
2 | UserGormService is injected via constructor injection. |
2.4.2 Authorities Fetcher
Create an interface to retrieve roles given a username.
package example.micronaut
interface AuthoritiesFetcher {
List<String> findAuthoritiesByUsername(String username)
}
Provide an implementation:
package example.micronaut
import javax.inject.Singleton
@Singleton (1)
class AuthoritiesFetcherService implements AuthoritiesFetcher {
protected final UserRoleGormService userRoleGormService
AuthoritiesFetcherService(UserRoleGormService userRoleGormService) { (2)
this.userRoleGormService = userRoleGormService
}
@Override
List<String> findAuthoritiesByUsername(String username) {
userRoleGormService.findAllAuthoritiesByUsername(username)
}
}
1 | Use javax.inject.Singleton to designate a class a a singleton. |
2 | UserRoleGormService is injected via constructor injection. |
2.4.3 Password Encoder
Create an interface to handle password encoding:
package example.micronaut
import edu.umd.cs.findbugs.annotations.NonNull
import javax.validation.constraints.NotBlank
interface PasswordEncoder {
String encode(@NotBlank @NonNull String rawPassword)
boolean matches(@NotBlank @NonNull String rawPassword, @NotBlank @NonNull String encodedPassword)
}
To provide an implementation, first include a dependency to Spring Security Crypto to ease password encoding.
...
springSecurityCryptoVersion=5.2.1.RELEASE
...
dependencies {
...
..
.
implementation "org.springframework.security:spring-security-crypto:${springSecurityCryptoVersion}"
}
Then, write the implementation:
package example.micronaut
import edu.umd.cs.findbugs.annotations.NonNull
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import javax.inject.Singleton
import javax.validation.constraints.NotBlank
@Singleton (1)
class BCryptPasswordEncoderService implements PasswordEncoder {
org.springframework.security.crypto.password.PasswordEncoder delegate = new BCryptPasswordEncoder()
String encode(@NotBlank @NonNull String rawPassword) {
delegate.encode(rawPassword)
}
@Override
boolean matches(@NotBlank @NonNull String rawPassword, @NotBlank @NonNull String encodedPassword) {
delegate.matches(rawPassword, encodedPassword)
}
}
1 | Use javax.inject.Singleton to designate a class a a singleton. |
2.4.4 Authentication Provider
Create an authentication provider which uses the interfaces you wrote in the previous sections.
package example.micronaut
import edu.umd.cs.findbugs.annotations.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.AuthenticationFailureReason
import io.micronaut.security.authentication.AuthenticationProvider
import io.micronaut.security.authentication.AuthenticationRequest
import io.micronaut.security.authentication.AuthenticationResponse
import io.micronaut.security.authentication.UserDetails
import io.reactivex.BackpressureStrategy
import io.reactivex.Flowable
import io.reactivex.Scheduler
import io.reactivex.schedulers.Schedulers
import org.reactivestreams.Publisher
import javax.inject.Named
import javax.inject.Singleton
import java.util.concurrent.ExecutorService
@Singleton
class DelegatingAuthenticationProvider implements AuthenticationProvider {
protected final UserFetcher userFetcher
protected final PasswordEncoder passwordEncoder
protected final AuthoritiesFetcher authoritiesFetcher
protected final Scheduler scheduler
DelegatingAuthenticationProvider(UserFetcher userFetcher,
PasswordEncoder passwordEncoder,
AuthoritiesFetcher authoritiesFetcher,
@Named(TaskExecutors.IO) ExecutorService executorService) { (1)
this.userFetcher = userFetcher
this.passwordEncoder = passwordEncoder
this.authoritiesFetcher = authoritiesFetcher
this.scheduler = Schedulers.from(executorService)
}
@Override
Publisher<AuthenticationResponse> authenticate(@Nullable HttpRequest<?> httpRequest,
AuthenticationRequest<?, ?> authenticationRequest) {
Flowable.create({ emitter ->
UserState user = fetchUserState(authenticationRequest)
Optional<AuthenticationFailed> authenticationFailed = validate(user, authenticationRequest)
if (authenticationFailed.isPresent()) {
emitter.onError(new AuthenticationException(authenticationFailed.get()))
} else {
emitter.onNext(createSuccessfulAuthenticationResponse(authenticationRequest, user))
}
emitter.onComplete()
}, BackpressureStrategy.ERROR)
.subscribeOn(scheduler) (2)
}
protected Optional<AuthenticationFailed> validate(UserState user, AuthenticationRequest authenticationRequest) {
AuthenticationFailed authenticationFailed = null
if (user == null) {
authenticationFailed = new AuthenticationFailed(AuthenticationFailureReason.USER_NOT_FOUND)
} else if (!user.isEnabled()) {
authenticationFailed = new AuthenticationFailed(AuthenticationFailureReason.USER_DISABLED)
} else if (user.isAccountExpired()) {
authenticationFailed = new AuthenticationFailed(AuthenticationFailureReason.ACCOUNT_EXPIRED)
} else if (user.isAccountLocked()) {
authenticationFailed = new AuthenticationFailed(AuthenticationFailureReason.ACCOUNT_LOCKED)
} else if (user.isPasswordExpired()) {
authenticationFailed = new AuthenticationFailed(AuthenticationFailureReason.PASSWORD_EXPIRED)
} else if (!passwordEncoder.matches(authenticationRequest.getSecret().toString(), user.getPassword())) {
authenticationFailed = new AuthenticationFailed(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH)
}
Optional.ofNullable(authenticationFailed)
}
protected UserState fetchUserState(AuthenticationRequest authenticationRequest) {
final String username = authenticationRequest.getIdentity().toString()
userFetcher.findByUsername(username)
}
protected AuthenticationResponse createSuccessfulAuthenticationResponse(AuthenticationRequest authenticationRequest, UserState user) {
List<String> authorities = authoritiesFetcher.findAuthoritiesByUsername(user.getUsername())
new UserDetails(user.getUsername(), authorities)
}
}
1 | The configured I/O executor service is injected |
2 | RxJava’s subscribeOn method is used to schedule the operation on the I/O thread pool |
It is critical that any blocking I/O operations (such as fetching the user from the database in the previous code sample) are offloaded to a separate thread pool that does not block the Event loop. |
2.5 LDAP Authentication Provider test
Create a test which verifies an LDAP user can login.
package example.micronaut
import io.micronaut.http.HttpMethod
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.MediaType
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.security.authentication.UsernamePasswordCredentials
import io.micronaut.security.token.jwt.generator.claims.JwtClaims
import io.micronaut.security.token.jwt.render.AccessRefreshToken
import io.micronaut.security.token.jwt.validator.JwtTokenValidator
import io.micronaut.test.annotation.MicronautTest
import io.reactivex.Flowable
import org.reactivestreams.Publisher
import spock.lang.Shared
import spock.lang.Specification
import javax.inject.Inject
@MicronautTest (1)
class LoginLdapSpec extends Specification {
@Inject
@Client('/')
HttpClient client (2)
@Shared
@Inject
JwtTokenValidator tokenValidator (3)
void '/login with valid credentials returns 200 and access token and refresh token'() {
when:
HttpRequest request = HttpRequest.create(HttpMethod.POST, '/login')
.accept(MediaType.APPLICATION_JSON_TYPE)
.body(new UsernamePasswordCredentials('euler', 'password')) (4)
HttpResponse<AccessRefreshToken> rsp = client.toBlocking().exchange(request, AccessRefreshToken)
then:
rsp.status.code == 200
rsp.body.isPresent()
rsp.body.get().accessToken
}
void '/login with invalid credentials returns UNAUTHORIZED'() {
when:
HttpRequest request = HttpRequest.create(HttpMethod.POST, '/login')
.accept(MediaType.APPLICATION_JSON_TYPE)
.body(new UsernamePasswordCredentials('euler', 'bogus')) (4)
client.toBlocking().exchange(request)
then:
HttpClientResponseException e = thrown(HttpClientResponseException)
e.status.code == 401 (5)
}
void 'access token contains expiration date'() {
when:
HttpRequest request = HttpRequest.create(HttpMethod.POST, '/login')
.accept(MediaType.APPLICATION_JSON_TYPE)
.body(new UsernamePasswordCredentials('euler', 'password')) (4)
HttpResponse<AccessRefreshToken> rsp = client.toBlocking().exchange(request, AccessRefreshToken)
then:
rsp.status.code == 200
rsp.body.isPresent()
when:
String accessToken = rsp.body.get().accessToken
then:
accessToken
when:
Publisher authentication = tokenValidator.validateToken(accessToken) (6)
then:
Flowable.fromPublisher(authentication).blockingFirst()
and: 'access token contains an expiration date'
Flowable.fromPublisher(authentication).blockingFirst().getAttributes().get(JwtClaims.EXPIRATION_TIME)
}
}
1 | Annotate the class with @MicronatTest to let Micronaut starts the embedded server and inject the beans. More info: https://micronaut-projects.github.io/micronaut-test/latest/guide/index.html. |
2 | Inject the HttpClient bean in the application context. |
3 | Inject to TokenValidator bean. |
4 | Creating HTTP Requests is easy thanks to Micronaut’s fluid API. |
5 | If you attempt to access a secured endpoint without authentication, 401 is returned |
6 | Use the tokenValidator bean previously injected. |
2.6 Login Testing
Test /login
endpoint. We verify both LDAP and DB authentication providers work.
package example.micronaut
import io.micronaut.http.HttpMethod
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.MediaType
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.security.authentication.Authentication
import io.micronaut.security.authentication.UsernamePasswordCredentials
import io.micronaut.security.token.jwt.render.AccessRefreshToken
import io.micronaut.security.token.jwt.validator.JwtTokenValidator
import io.micronaut.test.annotation.MicronautTest
import io.reactivex.Flowable
import spock.lang.Shared
import spock.lang.Specification
import javax.inject.Inject
@MicronautTest
class LoginControllerSpec extends Specification {
@Inject
@Client('/')
HttpClient client
@Shared
@Inject
JwtTokenValidator tokenValidator
@Inject
UserGormService userGormService
void 'attempt to access /login without supplying credentials server responds BAD REQUEST'() {
when:
HttpRequest request = HttpRequest.create(HttpMethod.POST, '/login')
.accept(MediaType.APPLICATION_JSON_TYPE)
client.toBlocking().exchange(request)
then:
HttpClientResponseException e = thrown()
e.status.code == 400
}
void '/login with valid credentials for a database user returns 200 and access token'() {
expect:
userGormService.count() > 0
when:
HttpRequest request = HttpRequest.create(HttpMethod.POST, '/login')
.accept(MediaType.APPLICATION_JSON_TYPE)
.body(new UsernamePasswordCredentials('sherlock', 'elementary'))
HttpResponse<AccessRefreshToken> rsp = client.toBlocking().exchange(request, AccessRefreshToken)
then:
noExceptionThrown()
rsp.status.code == 200
rsp.body.isPresent()
rsp.body.get().accessToken
when:
String accessToken = rsp.body.get().accessToken
Authentication authentication = Flowable.fromPublisher(tokenValidator.validateToken(accessToken)).blockingFirst()
then:
authentication.getAttributes()
authentication.getAttributes().containsKey('roles')
authentication.getAttributes().containsKey('iss')
authentication.getAttributes().containsKey('exp')
authentication.getAttributes().containsKey('iat')
}
}
3 Testing the Application
To run the tests:
$ ./gradlew test
$ open build/reports/tests/test/index.html
4 Running the Application
To run the application use the ./gradlew run
command which will start the application on port 8080.