Adding Authentication to a Java REST API

Adding Authentication to a Java REST API

Using Ory Kratos

Introduction

This post is another follow-up in the series of articles covering the technical process of building a fully fletched Java REST API for task management:

So far, we have built the API itself, we added a storage layer using MongoDB and in the last post, we added logging functionalities as well as metrics collected through the use of Prometheus. Now, we are going to add another crucial part for every API implementation - authentication. To achieve this we are going to use another open-source product called Kratos that will provide all the standard user management features for us - registration, login, logout, email verification, etc. The authentication for the task management API will be token-based - that is the API client will register and login with Kratos and thus will receive a session token generated by Kratos. This session token will then be passed in the HTTP Authorization header in each call to the API. The task management service will extract this token from the request and call Kratos API to convert it to the corresponding user session. The extracted user will be used when creating new tasks so that we can keep a record of who created the task itself.

Current State

In the current setup, the local deployment consists of three containers:

  • one for running the task management service logic

  • one for running the storage layer - a MongoDB instance that stores the list of all tasks

  • one for running the metrics logic - a Prometheus instance as well as a Prometheus job that periodically pulls metrics from the task management service

Docker is used as the container runtime environment and docker-compose is used to orchestrate the multi-container local deployment.

From a business logic perspective, tasks are currently anonymized - there is no notion of a task creator.

Target State

What we want to achieve at the end of this tutorial is the diagram below. The green colour is used to indicate a new component or piece of software.

From the diagram, we can see three major changes:

  • new containers for deploying Kratos

    • one for running the Kratos service itself

    • one for running an auxiliary pre-built self-service Kratos UI - we will use this for registering a new user

    • one for running a mock email server to be able to send and receive emails in a local setup without using a real email provider - we will use this for verifying the email of the registered user

  • new request filter used to implement authentication by using Kratos API

  • a new field in the Task entity used to store a reference to the user that created the task itself

Implementation

To get to the target state, we are going to break the implementation into small steps so that the process of adding authentication can be easily understood as you follow along this tutorial.

Adding Authentication Skeleton Logic

The first step is to add a basic authentication skeleton logic:

  • extracting an authentication token from the HTTP request

  • converting it to the newly introduced user entity

  • using the user ID as a reference for the new createdBy field for each task

We start by adding the new createdBy reference to the Task entity class. This will represent the ID of the user who created the task.

 public class Task {

    ...

    private final String createdBy;

    ...

    public static class TaskBuilder {

        ...

        private TaskBuilder(String title, String description, String createdBy) {
            validateArgNotNullOrBlank(title, "title");
            validateArgNotNullOrBlank(description, "description");
            validateArgNotNullOrBlank(createdBy, "createdBy");

            this.title = title;
            this.description = description;
            this.createdBy = createdBy;
            this.identifier = UUID.randomUUID().toString();
            this.createdAt = Instant.now();
            this.completed = false;
        }

        ...

    }
}

Then, we introduce the new User entity class. For now, we only need to retain the user ID. We won't need any more data about the user itself.

public class User {

    private final String userID;

    public User(String userID) {
        validateArgNotNull("userID", userID);

        this.userID = userID;
    }

    ...

}

Now that we have the new entity class in place, we can change the service class logic to accept a User instance as the creator for the given task. This user instance will represent the authenticated user calling the API.

public class TaskManagementService {

    ...    

    public Task create(String title, String description, User creator) {
        validateArgNotNull(creator, "creator");

        Task task = Task.builder(title, description, creator.getUserID()).build();

        repository.save(task);

        LOGGER.info("Successfully created new task {}", task);

        return task;
    }

    ...

}

The three code blocks above pretty much summarize the changes needed to be done for the business logic layer. As for the storage layer, a few small changes are needed to ensure that the new createdBy field is stored in the database:

    public static class MongoDBTask {

        ...

        @BsonProperty("created_by")
        private String createdBy;

        ...

        public String getCreatedBy() {
            return createdBy;
        }

        public void setCreatedBy(String createdBy) {
            this.createdBy = createdBy;
        }

        ...

    }

Now, let's move to the API layer. We start by introducing a new request filter, namely the AuthenticationFilter responsible for extracting the authentication token from the HTTP Authorization Header, converting it to a user instance and propagating the logged in user through the request context as a property. Because we don't have Kratos setup yet, we are going to implement a temporary logic that uses the provided authentication token as the user ID. The rest of the logic is pretty straightforward - fetching the HTTP header, validating the header value and performing a few string manipulations to extract the token from it.

@Priority(3)
@Provider
public class AuthenticationFilter implements ContainerRequestFilter {

    @Override
    public void filter(ContainerRequestContext requestContext) {
        String authHeader = requestContext.getHeaderString("Authorization");
        if (authHeader == null) {
            return;
        }

        authHeader = authHeader.trim();
        if (!authHeader.startsWith("Bearer")) {
            throw new AuthenticationException("invalid authentication scheme - please use a Bearer token");
        }

        String authToken = authHeader.replaceFirst("Bearer", "").trim();
        User user = getUserByAuthToken(authToken);
        requestContext.setProperty("user", user);
    }

    private User getUserByAuthToken(String authToken) {
        // FIXME for now use the auth token as user ID until integration with user service API is implemented
        return new User(authToken);
    }
}

Something to notice here is that we use priority 3 which is more than what we've used in the previous tutorial for the logging and the metrics filters. This ensures that the logging and metrics request filters are still going to run first and only then the authentication request filter will be executed. This is important to properly capture the request processing start time - see this part of the previous tutorial for more details.

As with any other filters, we need to register it in the application config so that it's used at runtime:

@ApplicationPath("/api")
public class ApplicationConfig extends ResourceConfig {
    @Inject
    public ApplicationConfig(ServiceLocator serviceLocator) {
        ...
        register(LoggingFilter.class);
        register(MetricsFilter.class);
        register(AuthenticationFilter.class);

        ...
    }
}

Now that a user instance is included in the request context, we can use it in the task management resource class which implements the API endpoints:

@Path("/tasks")
public class TaskManagementResource {
    private final TaskManagementService service;

    ...

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public Response createTask(TaskCreateRequest taskCreateRequest, @Context ContainerRequestContext requestContext) {
        validateArgNotNull(taskCreateRequest, "task-create-request-body");

        Task task = service.create(taskCreateRequest.getTitle(), taskCreateRequest.getDescription());
        User user = (User) requestContext.getProperty("user");
        Task task = service.create(taskCreateRequest.getTitle(), taskCreateRequest.getDescription(), user);

        ...
    }

    ...

}

Two key changes here:

  • we used the Context annotation to instruct Jersey to inject the ContainerRequestContext instance when invoking the endpoint logic

  • we used the request context to read the user property set by the authentication filter and propagate the User instance to the business logic service class

With this last step, the authentication skeleton logic is finalized. A few things that I've skipped explaining for clarity are a couple of auxiliary changes:

  • adding a new AuthenticationException to handle exceptional behaviour during authentication - e.g. using the Basic scheme in the HTTP Authorization header rather than Bearer which is the standard for token-based authentication

  • adding a new exception mapper that converts uncaught authentication exceptions to responses with 401 Unauthorized status codes

The full commit for what we just covered can be found here.

Kratos Container Setup

The next step for this tutorial is to add the Kratos configuration for local deployment. I am not planning to go into details about Kratos and how it works to keep this blog post simple and focused on the purpose of building a Java REST API. However, it is worth mentioning that the setup and configuration that we are using are all based on the quickstart tutorial provided by Ory on the official Kratos documentation page - https://www.ory.sh/docs/kratos/quickstart. Feel free to explore the Kratos documentation for more details.

All we need to know for this tutorial is that Kratos requires its own configuration file for deployment as well as at least one JSON schema configuration file that defines the default user identity.

We start by defining the user schema. For now, we don't need anything else apart from an email property. If you are not familiar with the JSON schema language, please refer to the official documentation.

{
  "$id": "http://localhost/schemas/user.schema.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "User",
  "type": "object",
  "properties": {
    "traits": {
      "type": "object",
      "properties": {
        "email": {
          "type": "string",
          "format": "email",
          "title": "Email",
          "minLength": 3,
          "ory.sh/kratos": {
            "credentials": {
              "password": {
                "identifier": true
              }
            },
            "verification": {
              "via": "email"
            },
            "recovery": {
              "via": "email"
            }
          }
        }
      },
      "required": [
        "email"
      ],
      "additionalProperties": false
    }
  }
}

Next, we add the Kratos configuration file. Again, this is very specific to Kratos itself, so please refer to the official documentation for more details. An important bit to notice though is that we use the user schema defined above as the default identity schema (check the line with default_schema_id).

version: v0.13.0

serve:
  public:
    base_url: http://127.0.0.1:4433/
    cors:
      enabled: true
  admin:
    base_url: http://kratos:4434/

selfservice:
  default_browser_return_url: http://127.0.0.1:4455/
  allowed_return_urls:
    - http://127.0.0.1:4455

  methods:
    password:
      enabled: true
    code:
      enabled: true

  flows:
    error:
      ui_url: http://127.0.0.1:4455/error

    settings:
      ui_url: http://127.0.0.1:4455/settings
      privileged_session_max_age: 15m
      required_aal: highest_available

    recovery:
      enabled: true
      ui_url: http://127.0.0.1:4455/recovery
      use: code

    verification:
      enabled: true
      ui_url: http://127.0.0.1:4455/verification
      use: code
      after:
        default_browser_return_url: http://127.0.0.1:4455/

    logout:
      after:
        default_browser_return_url: http://127.0.0.1:4455/login

    login:
      lifespan: 10m
      ui_url: http://127.0.0.1:4455/login

    registration:
      lifespan: 10m
      ui_url: http://127.0.0.1:4455/registration
      after:
        password:
          hooks:
            - hook: session
            - hook: show_verification_ui

log:
  level: debug
  format: text

secrets:
  cookie:
    - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
  cipher:
    - 32-LONG-SECRET-NOT-SECURE-AT-ALL

identity:
  default_schema_id: user
  schemas:
    - id: user
      url: file:///etc/config/kratos/user.schema.json

courier:
  smtp:
    connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true

Finally, we add the container setup to the existing docker-compose file, so that Kratos is included in our local deployment.

version: "3.9"
services:
  ...
  kratos:
    image: oryd/kratos:v1.0.0
    container_name: taskmanagementservice-kratos
    depends_on:
      - kratos-migrate
      - mailslurper
    ports:
      - "4433:4433"
      - "4434:4434"
    environment:
      - DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true
      - LOG_LEVEL=trace
    command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
    volumes:
      - .data/kratos:/var/lib/sqlite/
      - ./kratos.yml:/etc/config/kratos/kratos.yml
      - ./user.schema.json:/etc/config/kratos/user.schema.json
    networks:
      - intranet
  kratos-migrate:
    image: oryd/kratos:v1.0.0
    container_name: taskmanagementservice-kratos-migrate
    environment:
      - DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true&mode=rwc
    command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
    volumes:
      - .data/kratos:/var/lib/sqlite/
      - ./kratos.yml:/etc/config/kratos/kratos.yml
      - ./user.schema.json:/etc/config/kratos/user.schema.json
    networks:
      - intranet
  kratos-selfservice-ui-node:
    image: oryd/kratos-selfservice-ui-node:v1.0.0
    container_name: taskmanagementservice-kratos-selfservice-ui-node
    ports:
      - "4455:4455"
    environment:
      - KRATOS_PUBLIC_URL=http://taskmanagementservice-kratos:4433/
      - KRATOS_BROWSER_URL=http://127.0.0.1:4433/
      - PORT=4455
    networks:
      - intranet
  mailslurper:
    image: oryd/mailslurper:latest-smtps
    container_name: taskmanagementservice-mailslurper
    ports:
      - "4436:4436"
      - "4437:4437"
    networks:
      - intranet
networks:
  intranet:

Important bits to notice in this change are:

  • we are adding 4 new containers to our local setup

    • kratos is the container running the Kratos API itself

    • kratos-migrate is a single-command container used for applying SQL migrations the first time the deployment is started, after which the container is no longer used

    • kratos-self-service-ui-node is a pre-built UI for self-service user management provided by the Ory Kratos team themselves - we use this to make user registration easier for the purpose of this tutorial (ideally, we would build our own UI - Kratos is quite flexible when it comes to UI choice)

    • mailslurper is the container that will handle email verification during registration; Kratos will use this as a stub for sending and receiving emails so that we don't have to configure a real email provider

  • we are binding the Kratos configuration files defined above to the corresponding locations on the containers where Kratos would expect to find them

  • we are instructing Kratos to use SQLite as storage layer

This concludes the changes needed to deploy Kratos locally. The full commit can be found here.

To test the deployment we can use the standard docker-compose build commands we used in the previous blog posts.

To start the deployment and rebuild the task management service container:

docker-compose up --build -d

To stop the deployment:

 docker-compose down

Integration with Kratos API

Now, that we have the skeleton logic in place along with the local deployment setup for Kratos we can integrate the two services. First, we define the interface for the user-related functionalities needed by the task management service:

public interface UserManagementService {
    Optional<User> getUserByAuthToken(String authToken) throws IOException;
}

For now, we only need a single method that accepts an authentication token as input and returns an optional user. The return is optional because the auth token might be invalid or might not correspond to any active user session.

Then, we add a Kratos-based implementation of this interface along with the Kratos client maven dependency:

    ...
    <dependencies>
        ...
        <dependency>
            <groupId>sh.ory.kratos</groupId>
            <artifactId>kratos-client</artifactId>
            <version>0.13.1</version>
        </dependency>
    </dependencies>
    ...
public class KratosWrapper implements UserManagementService {

    private final FrontendApi kratosFrontendApi;

    @Inject
    public KratosWrapper(FrontendApi kratosFrontendApi) {
        this.kratosFrontendApi = kratosFrontendApi;
    }

    public Optional<User> getUserByAuthToken(String authToken) throws IOException {
        Session session;
        try {
            session = kratosFrontendApi.toSession(authToken, null);
        } catch (ApiException e) {
            if (e.getCode() == Response.Status.UNAUTHORIZED.getStatusCode()) {
                return Optional.empty();
            }

            throw new IOException(e);
        }

        if (session == null) {
            return Optional.empty();
        }

        if (session.getActive() == null || !session.getActive()){
            throw new AuthenticationException("Inactive session returned for the given authentication token.");
        }

        return Optional.of(new User(session.getIdentity().getId()));
    }
}

You might notice that the Kratos FrontentApi instance is injected into the constructor which means we need to add a new Guice module that initializes it:

public class KratosModule extends AbstractModule {

    @Provides
    private FrontendApi provideKratosFrontendApi() {
        ApiClient apiClient = new ApiClient();
        apiClient.setBasePath(System.getenv("Kratos_Base_Path"));

        return new FrontendApi(apiClient);
    }
}

The base path for the API client we configure through an environment variable - we used the same mechanism for configuring the integration between the task management service and the MongoDB instance in a previous tutorial.

The new environment variable we configure in the docker-compose file used for local deployment:

  ...
  webapp:
    build: .
    container_name: taskmanagementservice-api
    depends_on:
      - "mongo"
      - "kratos"
    environment:
      - MongoDB_URI=mongodb://taskmanagementservice-mongodb:27017
      - Kratos_Base_Path=http://taskmanagementservice-kratos:4433
    ports:
      - "8080:8080"
    networks:
      - intranet 
  ...

To connect all the pieces, we need to also bind the UserManagementService interface to the KratosWrapper implementation in the main Guice application module so that any classes that require an instance of the UserManagementService interface will use an instance of the KratosWrapper class.

public class ApplicationModule extends AbstractModule {

    @Override
    public void configure() {
        bind(TaskManagementRepository.class).to(MongoDBTaskManagementRepository.class).in(Singleton.class);
        bind(UserManagementService.class).to(KratosWrapper.class).in(Singleton.class);

        install(new MongoDBModule());
        install(new KratosModule());
    }

}

The final step is to change the dummy AuthenticationFilter logic we added in the first commit so that it uses the UserManagementService to retrieve a User instance given an authentication token.

@Priority(3)
@Provider
public class AuthenticationFilter implements ContainerRequestFilter {

    private final UserManagementService userManagementService;

    @Inject
    public AuthenticationFilter(UserManagementService userManagementService) {
        this.userManagementService = userManagementService;
    }

    @Override
    public void filter(ContainerRequestContext requestContext) throws IOException {
        String authHeader = requestContext.getHeaderString("Authorization");
        if (authHeader == null) {
            return;
        }
        authHeader = authHeader.trim();
        if (!authHeader.startsWith("Bearer")) {
            throw new AuthenticationException("invalid authentication scheme - please use a Bearer token");
        }

        String authToken = authHeader.replaceFirst("Bearer", "").trim();
        Optional<User> user = userManagementService.getUserByAuthToken(authToken);
        user.ifPresent(u -> requestContext.setProperty("user", u));
    }
}

The full commit for this step of the tutorial can be found here.

Testing

Before we start testing, let's re-build the full stack from scratch:

docker-compose down
docker-compose up --build -d

Then, we use the Kratos self-service UI to register a new user by visiting http://127.0.0.1:4455/welcome in the browser.

You will notice that we only need to provide an email address to register because this is what we defined in the user schema. If more fields are added, they will be automatically listed in the registration form we see in this UI.

Choose a username and password and remember them because we will need them later. Keep in mind that Kratos comes in with pre-built password checks by default and won't let you choose a simple password that has already been leaked in data breaches. The email address doesn't matter because emails are only sent locally to mailslurper. For this tutorial I chose:

  • test@test as email address

  • FMcm6K2Lm9N0KqP as password

After signing up, we should mimic the verification of the email address through mailslurper. This can be done by visiting http://127.0.0.1:4436/ in the browser where you will notice a mailbox-like UI with an email with subject "Please verify your email address"

After opening the email and following the verification link you will be presented with a pre-filled form for finalising the verification.

After finishing verification, we will be automatically logged in to the self-service UI. You can browse the UI further if you'd like to explore Kratos, but the next step for this tutorial is to head to the terminal and login through an API flow (a Kratos abstraction for a user action - https://www.ory.sh/docs/kratos/self-service) so that we receive a session token rather than a session cookie which is what happens if we log in through the browser.

API_LOGIN_FLOW_ID=$(curl -s http://127.0.0.1:4433/self-service/login/api | jq -r '.id')
curl -s -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"identifier": "test@test", "password": "FMcm6K2Lm9N0KqP", "method": "password"}' "http://127.0.0.1:4433/self-service/login?flow=$API_LOGIN_FLOW_ID" | jq -r '.session_token'
ory_st_Q9TNLNgzq9zuucBteAkKXaWubZWOE7wK

The short script above initializes an API login flow, fetches the ID of the flow, then uses it to login with the credentials chosen above and finally, it fetches the session token from the response.

Now that we have a session token, we can use it to send authenticated requests to the task management service API.

curl -i -X POST -H "Authorization: Bearer ory_st_Q9TNLNgzq9zuucBteAkKXaWubZWOE7wK" -H "Content-Type: application/json" -d '{"title": "test_title", "description": "test_description"}' -s http://127.0.0.1:8080/api/tasks
HTTP/1.1 201 
Location: http://127.0.0.1:8080/api/tasks/4b47e790-4a63-43b5-8003-c7b50c5b5f2d
Content-Length: 0
Date: Sun, 27 Aug 2023 07:55:54 GMT

If we then fetch the newly created task, we will see the user ID of the creator.

curl -s http://127.0.0.1:8080/api/tasks/4b47e790-4a63-43b5-8003-c7b50c5b5f2d | jq '.'
{
  "identifier": "4b47e790-4a63-43b5-8003-c7b50c5b5f2d",
  "title": "test_title",
  "description": "test_description",
  "createdBy": "45cdab5b-125d-4d31-a1eb-463b816065f2",
  "createdAt": "2023-08-27T07:55:54.441Z",
  "completed": false
}

We can use the Kratos admin API to check who this user is:

 curl -s "http://127.0.0.1:4434/admin/identities/45cdab5b-125d-4d31-a1eb-463b816065f2" | jq '.'
{
  "id": "45cdab5b-125d-4d31-a1eb-463b816065f2",
  "credentials": {
    "password": {
      "type": "password",
      "identifiers": [
        "test@test"
      ],
      "version": 0,
      "created_at": "2023-08-27T07:37:57.12699Z",
      "updated_at": "2023-08-27T07:53:28.338209Z"
    }
  },
  "schema_id": "user",
  "schema_url": "http://127.0.0.1:4433/schemas/dXNlcg",
  "state": "active",
  "state_changed_at": "2023-08-27T07:37:57.067218753Z",
  "traits": {
    "email": "test@test"
  },
  "verifiable_addresses": [
    {
      "id": "caef9cfd-7b97-43d5-b1cd-1d33facd3869",
      "value": "test@test",
      "verified": true,
      "via": "email",
      "status": "completed",
      "verified_at": "2023-08-27T07:38:29.563302796Z",
      "created_at": "2023-08-27T07:37:57.094576Z",
      "updated_at": "2023-08-27T07:37:57.094576Z"
    }
  ],
  "recovery_addresses": [
    {
      "id": "378e3a1f-a0f4-46c8-8d36-0f8fd8ed192e",
      "value": "test@test",
      "via": "email",
      "created_at": "2023-08-27T07:37:57.113134Z",
      "updated_at": "2023-08-27T07:37:57.113134Z"
    }
  ],
  "metadata_public": null,
  "metadata_admin": null,
  "created_at": "2023-08-27T07:37:57.070219Z",
  "updated_at": "2023-08-27T07:37:57.070219Z"
}

As expected, this user ID corresponds to the user we created earlier as part of this tutorial and with this, our end-to-end manual test is completed.

Summary

To summarize, in this tutorial we added authentication to the task management API by implementing the following changes:

  • we added a createdBy reference to the existing Task entity which represents the ID of the user who created the given task

  • we added configuration and local deployment setup for Kratos

  • we added an authentication filter that parses the Authorization HTTP header and calls Kratos API to convert an authentication token to a User entity

We did not cover the Kratos-related abstractions and how Kratos works in general, but I will be happy to go through this in a separate tutorial. For now, my plan for the next post from this series is to introduce a basic level of authorization. The problem we have with the current state of the API is that every user has access to read and update the tasks created by other users. This will be fixed by introducing access control and letting task creators authorize other users to be able to read their tasks if needed.

As always, feel free to ask any questions or leave feedback in the comments section.