Micronaut Tutorial: Security
This is the third part of my tutorial to Micronaut Framework. This time we will discuss the most interesting Micronaut security features. I have already described core mechanisms for IoC and dependency injection in the first part of my tutorial, and I have also created a guide to building a simple REST server-side application in the second part.
For more details you may refer to:
Security is an essential part of every web application. Easily configurable, built-in web security mechanisms is something that every single modern micro-framework must have. It is no different with Micronaut. In this part of my tutorial you will learn how to:
- Build custom authentication provider
- Configure and test basic authentication for your HTTP API
- Secure your HTTP API using JSON Web Tokens
- Enable communication over HTTPS
Enabling security
To enable security for Micronaut application you should first include the following dependency into your pom.xml
:
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-security</artifactId>
</dependency>
The next step is to enable security feature through application properties:
micronaut:
security:
enabled: true
Setting the property micronaut.security.enabled
to true
causes enabling security for all the existing controllers. Because we already have the controller that has been used as an example for the previous part of the tutorial, we should disable security for it. To do that I have annotated with @Secured(SecurityRule.IS_ANONYMOUS)
. It allows anonymous access to all endpoints implemented inside the controller.
@Controller("/persons")
@Secured(SecurityRule.IS_ANONYMOUS)
@Validated
public class PersonController { ... }
Basic Authentication Provider
Once you enable Micronaut security, Basic Auth is enabled by default. All you need to do is to implement your custom authentication provider. It has to implement an AuthenticationProvider
interface. In fact, you just need to verify your username and password, which are both passed inside HTTP Authorization
header. Our sample authentication provider uses configuration properties as a user repository. Here’s the fragment of application.yml
file that contains list of user passwords and assigned roles:
credentials:
users:
smith: smith123
scott: scott123
piomin: piomin123
test: test123
roles:
smith: ADMIN
scott: VIEW
piomin: VIEW
test: ADMIN
The configuration properties are injected into UsersStore
configuration bean which is annotated with @ConfigurationProperties
. User passwords are stored inside users
map, while roles inside roles
map. They are both annotated with @MapFormat
and have username as a key.
@ConfigurationProperties("credentials")
public class UsersStore {
@MapFormat
Map<String, String> users;
@MapFormat
Map<String, String> roles;
public String getUserPassword(String username) {
return users.get(username);
}
public String getUserRole(String username) {
return roles.get(username);
}
}
Finally, we may proceed to the authentication provider implementation. It injects a UsersStore
bean that contains a list of users with passwords and roles. The overridden method should return UserDetails
object. The username and password are automatically decoded from base64 taken from Authentication
header and bind to fields identity
and secret
in AuthenticationRequest
method parameter. If input password is the same as stored password it returns UserDetails
object with roles, otherwise throws an exception.
@Singleton
public class UserPasswordAuthProvider implements AuthenticationProvider {
@Inject
UsersStore store;
@Override
public Publisher<AuthenticationResponse> authenticate(AuthenticationRequest req) {
String username = req.getIdentity().toString();
String password = req.getSecret().toString();
if (password.equals(store.getUserPassword(username))) {
UserDetails details = new UserDetails(username, Collections.singletonList(store.getUserRole(username)));
return Flowable.just(details);
} else {
return Flowable.just(new AuthenticationFailed());
}
}
}
Secured Controller
Now, we may create our sample secure REST controller. The following controller is just a copy of previously described controller PersonController
, but it also contains some Micronaut Security annotation. Through @Secured(SecurityRule.IS_AUTHENTICATED)
used on the whole controller it is available only for succesfully authenticated users. This annotation may be overridden on the method level. The method for adding a new person is available only for users having ADMIN
role.
@Controller("/secure/persons")
@Secured(SecurityRule.IS_AUTHENTICATED)
public class SecurePersonController {
List<Person> persons = new ArrayList<>();
@Post
@Secured("ADMIN")
public Person add(@Body @Valid Person person) {
person.setId(persons.size() + 1);
persons.add(person);
return person;
}
@Get("/{id:4}")
public Optional<Person> findById(@NotNull Integer id) {
return persons.stream()
.filter(it -> it.getId().equals(id))
.findFirst();
}
@Version("1")
@Get("{?max,offset}")
public List<Person> findAll(@Nullable Integer max, @Nullable Integer offset) {
return persons.stream()
.skip(offset == null ? 0 : offset)
.limit(max == null ? 10000 : max)
.collect(Collectors.toList());
}
@Version("2")
@Get("?max,offset")
public List<Person> findAllV2(@NotNull Integer max, @NotNull Integer offset) {
return persons.stream()
.skip(offset == null ? 0 : offset)
.limit(max == null ? 10000 : max)
.collect(Collectors.toList());
}
}
To test Micronaut security features used in our controller we will create a JUnit test class containing three methods. All these methods use Micronaut HTTP client for calling target endpoints. It provides basicAuth
method, that allows you to easily pass user credentials. The first test method testAdd
verifies a positive scenario of adding a new person. The test user smith
has ADMIN
role, which is required for calling this HTTP endpoint. In contrast, method testAddFailed
calls the same HTTP endpoint, but with different user scott
, which has VIEW
role. We expect that HTTP 401 is returned by the endpoint. The same user scott
has an access to GET
endpoints, so we expect that test method testFindById
is finished with success.
@MicronautTest
public class SecurePersonControllerTests {
@Inject
EmbeddedServer server;
@Test
public void testAdd() throws MalformedURLException {
HttpClient client = HttpClient.create(new URL("https://" + server.getHost() + ":" + server.getPort()));
Person person = new Person();
person.setFirstName("John");
person.setLastName("Smith");
person.setAge(33);
person.setGender(Gender.MALE);
person = client.toBlocking()
.retrieve(HttpRequest.POST("/secure/persons", person).basicAuth("smith", "smith123"), Person.class);
Assertions.assertNotNull(person);
Assertions.assertEquals(Integer.valueOf(1), person.getId());
}
@Test
public void testAddFailed() throws MalformedURLException {
HttpClient client = HttpClient.create(new URL("https://" + server.getHost() + ":" + server.getPort()));
Person person = new Person();
person.setFirstName("John");
person.setLastName("Smith");
person.setAge(33);
person.setGender(Gender.MALE);
Assertions.assertThrows(HttpClientResponseException.class,
() -> client.toBlocking().retrieve(HttpRequest.POST("/secure/persons", person).basicAuth("scott", "scott123"), Person.class),
"Forbidden");
}
@Test
public void testFindById() throws MalformedURLException {
HttpClient client = HttpClient.create(new URL("https://" + server.getHost() + ":" + server.getPort()));
Person person = client.toBlocking()
.retrieve(HttpRequest.GET("/secure/persons/1").basicAuth("scott", "scott123"), Person.class);
Assertions.assertNotNull(person);
}
}
Enable HTTPS
Our controller is secured, but not the HTTP server. Micronaut by default starts the server with disabled SSL. However, it supports HTTPS out of the box. To enable HTTPS support you should first set property micronaut.ssl.enabled
to true
. By default Micronaut with HTTPS enabled starts on port 8443
, but you can override it using property micronaut.ssl.port
.
We will enable HTTPS only for the single JUnit test class. To do that we first create file src/test/resources/ssl.yml
with the following configuration:
micronaut:
ssl:
enabled: true
buildSelfSigned: true
Micronaut simplifies SSL configuration build for test purposes. It turns out, we don’t have to generate any keystore or certificate if we use property micronaut.ssl.buildSelfSigned
. Otherwise you would have to generate a keystore by yourself. It is not difficult, if you are creating a self-signed certificate. You may use openssl
or keytool
for that. Here’s the appropriate keytool command for generating keystore, however you should point out that it is recommended tool by Micronaut, which recommend using openssl
:
$ keytool -genkey -alias server -keystore server.jks
If you decide to generate self-signed certificate by yourself you have configure them:
micronaut:
ssl:
enabled: true
keyStore:
path: classpath:server.keystore
password: 123456
type: JKS
The last step is to create a JUnit test that uses configuration provided in file ssl.yml
.
@MicronautTest(propertySources = "classpath:ssl.yml")
public class SecureSSLPersonControllerTests {
@Inject
EmbeddedServer server;
@Test
public void testFindById() throws MalformedURLException {
HttpClient client = HttpClient.create(new URL(server.getScheme() + "://" + server.getHost() + ":" + server.getPort()));
Person person = client.toBlocking()
.retrieve(HttpRequest.GET("/secure/persons/1").basicAuth("scott", "scott123"), Person.class);
Assertions.assertNotNull(person);
}
// other tests ...
}
JWT Authentication
To enable JWT token based authentication we first need to include the following dependency into pom.xml
:
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-security-jwt</artifactId>
</dependency>
Token authentication is enabled by default through TokenConfigurationProperties
properties (micronaut.security.token.enabled
). However, we should enable JWT based authentication by setting property micronaut.security.token.jwt.enabled
to true
. This change allows us to use JWT authentication for our sample application. We also need to be able to generate an authentication token used for authorization. To do that we should enable /login
endpoint and set some configuration properties for the JWT token generator. In the following fragment of application.yml
I set HMAC with SHA-256 as hash algorithm for JWT signature generator:
micronaut:
security:
enabled: true
endpoints:
login:
enabled: true
token:
jwt:
enabled: true
signatures:
secret:
generator:
secret: pleaseChangeThisSecretForANewOne
jws-algorithm: HS256
Now, we can call endpoint POST /login
with username and password in JSON body as shown below:
$ curl -X "POST" "http://localhost:8100/login" -H 'Content-Type: application/json; charset=utf-8' -d '{"username":"smith","password":"smith123"}'
{
"username": "smith",
"roles": [
"ADMIN"
],
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzbWl0aCIsIm5iZiI6MTU1NjE5ODAyMCwicm9sZXMiOlsiQURNSU4iXSwiaXNzIjoic2FtcGxlLW1pY3JvbmF1dC1hcHBsaWNhdGlvbiIsImV4cCI6MTU1NjIwMTYyMCwiaWF0IjoxNTU2MTk4MDIwfQ.by0Dx73QIZeF4MDM4A5nHgw8xm4haPJjsu9z45psQrY",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzbWl0aCIsIm5iZiI6MTU1NjE5ODAyMCwicm9sZXMiOlsiQURNSU4iXSwiaXNzIjoic2FtcGxlLW1pY3JvbmF1dC1hcHBsaWNhdGlvbiIsImlhdCI6MTU1NjE5ODAyMH0.2BrdZzuvJNymZlOv56YpUPHYLDdnVAW5UXXNuz3a7xU",
"token_type": "Bearer",
"expires_in": 3600
}
The value of field access_token
returned in the response should be passed as bearer token in the Authorization
header of requests sent to HTTP endpoints. We can any endpoint, for example GET /persons
$ curl -X "GET" "http://localhost:8100/persons" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzbWl0aCIsIm5iZiI6MTU1NjE5ODAyMCwicm9sZXMiOlsiQURNSU4iXSwiaXNzIjoic2FtcGxlLW1pY3JvbmF1dC1hcHBsaWNhdGlvbiIsImV4cCI6MTU1NjIwMTYyMCwiaWF0IjoxNTU2MTk4MDIwfQ.by0Dx73QIZeF4MDM4A5nHgw8xm4haPJjsu9z45psQrY"
We can easily test automatically the scenario described above. I have created UserCredentials
and UserToken
objects for serializing request and deserializing response from /login
endpoint. The token retrieved from response is then passed as bearer token by calling bearerAuth
method on Micronaut HTTP client instance.
@MicronautTest
public class SecurePersonControllerTests {
@Inject
EmbeddedServer server;
@Test
public void testFindByIdUsingJWTToken() throws MalformedURLException {
HttpClient client = HttpClient.create(new URL("https://" + server.getHost() + ":" + server.getPort()));
UserToken token = client.toBlocking().retrieve(HttpRequest.POST("/login", new User Credentials("scott", "scott123")), UserToken.class);
Person person = client.toBlocking()
.retrieve(HttpRequest.GET("/secure/persons/1").bearerAuth(token.getAccessToken()), Person.class);
Assertions.assertNotNull(person);
}
}
Source Code
We were using the same repository as for two previous parts of my Micronaut tutorial: https://github.com/piomin/sample-micronaut-applications.git.
Leave a Reply