Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Security basics

Right now anyone can create or delete an article. Authentication answers "who is this?", authorization answers "what may they do?". In Micronaut both are handled by the Micronaut Security module. We start with the simplest way to log in, HTTP Basic: the login and password travel in a header of every request, and curl does this with the -u option.

Adding the module

The generator feature is called security-jwt (JWT will be needed in the next lesson). In pom.xml add the dependency io.micronaut.security:micronaut-security-jwt with compile scope and the processor io.micronaut.security:micronaut-security-processor with the version ${micronaut.security.version} in <annotationProcessorPaths>. mn feature-diff --features=security-jwt shows the exact fragment.

Right after adding it, everything is closed:

curl -i http://localhost:8080/articles
HTTP/1.1 401 Unauthorized
 
{"_links":{"self":[{"href":"/articles","templated":false}]},"_embedded":{"errors":[{"message":"Unauthorized"}]},"message":"Unauthorized"}

This is a safe default: access is opened explicitly.

Checking the login and password

Micronaut does not know where users are stored. A bean implementing HttpRequestAuthenticationProvider tells it:

// src/main/java/com/example/BlogAuthenticationProvider.java
package com.example;
 
import io.micronaut.core.annotation.Nullable;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.AuthenticationFailureReason;
import io.micronaut.security.authentication.AuthenticationRequest;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider;
import jakarta.inject.Singleton;
 
import java.util.List;
import java.util.Map;
 
@Singleton
public class BlogAuthenticationProvider<B> implements HttpRequestAuthenticationProvider<B> {
 
    private record User(String password, List<String> roles) {
    }
 
    private final Map<String, User> users = Map.of(
            "anna", new User("changeme", List.of("ADMIN")),
            "oleg", new User("changeme", List.of("AUTHOR")));
 
    @Override
    public AuthenticationResponse authenticate(@Nullable HttpRequest<B> request,
                                               AuthenticationRequest<String, String> authRequest) {
        User user = users.get(authRequest.getIdentity());
        if (user != null && user.password().equals(authRequest.getSecret())) {
            return AuthenticationResponse.success(authRequest.getIdentity(), user.roles());
        }
        return AuthenticationResponse.failure(AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH);
    }
}

getIdentity() is the login and getSecret() is the password. On success we return the name and the roles, which are permission labels. In this learning example the users sit in a Map; a real application loads them from the database and stores passwords only as a hash (BCrypt, for example).

Access rules

The @Secured annotation goes on a class or a method:

// src/main/java/com/example/ArticleController.java
@ExecuteOn(TaskExecutors.BLOCKING)
@Controller("/articles")
@Secured(SecurityRule.IS_AUTHENTICATED)
public class ArticleController {
 
    // the articleService field and constructor stay as before
 
    @Secured(SecurityRule.IS_ANONYMOUS)
    @Get
    public List<Article> list(@QueryValue @Nullable String q) {
        return q == null ? articleService.findAll() : articleService.search(q);
    }
 
    @Secured("ADMIN")
    @Delete("/{id}")
    public HttpResponse<Void> delete(@PathVariable Long id) {
        articleService.delete(id);
        return HttpResponse.noContent();
    }
}
ValueWho gets in
SecurityRule.IS_ANONYMOUSEveryone, even without logging in
SecurityRule.IS_AUTHENTICATEDAnyone who has logged in
"ADMIN"Only users with this role

An annotation on a method beats the one on the class. So everyone can read articles, logged-in users can write, and only an administrator can delete. Mark HelloController and AboutController with @Secured(SecurityRule.IS_ANONYMOUS) as a whole.

Trying it out

curl -i -u oleg:changeme -X POST http://localhost:8080/articles \
  -H "Content-Type: application/json" -d '{"title":"Secured","body":"Text"}'
curl -i -u oleg:changeme -X DELETE http://localhost:8080/articles/1
curl -i -u anna:changeme -X DELETE http://localhost:8080/articles/1

Oleg creates an article (201 Created) but cannot delete it, getting 403 Forbidden: he is logged in but lacks the role. Anna deletes it with 204 No Content. With a wrong password the answer is 401 Unauthorized.

To find out who is logged in, use an Authentication parameter in a controller method:

// src/main/java/com/example/MeController.java
package com.example;
 
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.rules.SecurityRule;
 
import java.util.Map;
 
@Controller("/me")
@Secured(SecurityRule.IS_AUTHENTICATED)
public class MeController {
 
    @Get
    public Map<String, Object> me(Authentication authentication) {
        return Map.of("name", authentication.getName(), "roles", authentication.getRoles());
    }
}

The request curl -u anna:changeme http://localhost:8080/me returns {"name":"anna","roles":["ADMIN"]}.

Common mistake

After adding the module, the old tests stop passing too: POST /articles without a password now gets 401. Pass the credentials in the test request:

// src/test/java/com/example/ArticleControllerTest.java
HttpRequest.POST("/articles", new ArticleForm("Testing", "Body"))
        .basicAuth("anna", "changeme")

And to check the protection itself, add a test that expects HttpStatus.UNAUTHORIZED for a request without basicAuth.

Thanks for reading!

Donate to the site — we work hard for you.