[HttpServer Series] Authentication  and Role based Authorization (RBAC)

Photo by Onur Binay on Unsplash

[HttpServer Series] Authentication and Role based Authorization (RBAC)

In the final step in the process of building out an HTTP based application is to put in place a Role based Access Control mechanism. So, in this article, I am going to walk you through the 20 odd commits, over 15+ days of brooding, exploring technology options, re-evaluating current choices, building and throwing away code, all with the single objective, where it all started. Build a web application with as little magic, third-party libraries and annotations, as possible. Stay true to writing more of Java and less of learning other (as I call it) Annotation based languages.

You can see the detailed merge request for the code changes, the repository for the entire code or the series for all the articles on the journey.

Getting rid of the Router

The Router we built over the last few articles, gave us more control, but also ended up duplicating a lot of work already put in the HttpServer's core API design. So, I experimented with converting its functionality into an implementation of HttpHandler, that acted as a way to simplify the handling of the HttpExchange and other complexities discussed earlier in the series.

The HttpServer already has a mechanism to set up Authenticator against each HttpContext . Its a two step process:

  • Create the Context based on a path

  • Associate a Authenticator with the Context

And, when dealing with a lot of contexts, the association code can get repetitive. So, in comes RouteBuilder , a fluent api design to wire up apis, and Authenticator, into the context. Here is the updated code in App.java with the use of this api now:

RouteBuilder.withServer(server)
     .createContext("/", new StaticFileHandler("src/main/resources", true)::handle);

RouteBuilder.withServer(server)
    .createContext("/api/ping", PingHandler::handle);

RouteBuilder.withServer(server)
    .setAuthenticator(userRoleBasicAuthenticator)
    .createContext("/api/user/", UserHandler::handle)
    .createContext("/api/admin", AdminHandler::handle);

You can see how the /api endpoints are getting wired into a BasicAuthenticator that understands User Roles. More on that in a bit.

This design allows space for differentiating between the Authentication for the API context (Basic), and the WebApp (Session based, when we build it).

Authentication, and Roles

Before we talk about the authenticator used in the code example above, let's look at one of the key requirements for any application, a Role based Authorization system. In almost all cases, there are roles assigned to each user that has login rights. And different parts of the application are available based on their roles.

The HttpPrincipal class from the HttpServer package does not have that feature. So, I built a UserRolePrincipal which looks like this:

public class UserRolePrincipal extends HttpPrincipal {

  private final List<String> roles;

  public UserRolePrincipal(final String username, final String realm, final List<String> roles) {
    super(username, realm);
    this.roles = Collections.unmodifiableList(roles);
  }

  public List<String> getRoles() {
    return roles;
  }

  @Override
  public int hashCode() {
    final int prime = 31;
    int result = super.hashCode();
    result = prime * result + ((roles == null) ? 0 : roles.hashCode());
    return result;
  }

  @Override
  public boolean equals(Object obj) {
    if (!super.equals(obj))
      return false;
    return (obj instanceof UserRolePrincipal other && roles.equals(other.roles));
  }
}

As you can see, it only adds the roles to the base class. Relies on it for everything else.

To construct this Principal object, we need a corresponding Authenticator. I wanted the new Authenticator to be completely compatible with the HttpServer api. No reason to anything otherwise. We are just adding a "Role", right. So, I designed it as a Decorator. Here is the code for the UserRoleAuthenticator :

public abstract class UserRoleAuthenticator extends Authenticator {

  final Authenticator authenticator;

  protected UserRoleAuthenticator(Authenticator authenticator) {
    Objects.requireNonNull(authenticator);
    this.authenticator = authenticator;
  }

  /**
   * @return a list of roles for the given username, never null
   */
  public abstract List<String> getUserRoles(String username);

  @Override
  public Result authenticate(HttpExchange exchange) {
    var result = this.authenticator.authenticate(exchange);

    if (result instanceof Success s) {
      var p = s.getPrincipal();
      var userRolePrincipal = new UserRolePrincipal(p.getUsername(), p.getRealm(), getUserRoles(p.getUsername()));
      return new Success(userRolePrincipal);
    }

    return result;
  }
}

The Authenticator

With these two in place, in the core router functionality, we now need the Authenticator be instantiated. The AuthenticatorFactory will help create various types of those. To start with, only a Role enabled BasicAuth one.

public interface AuthenticatorFactory {

  public static UserRoleAuthenticator createBasicAuthenticator(UserRepository repository) {
    Objects.requireNonNull(repository, "If UserRepository is null, authentication cannot work");

    var basicAuth = new BasicAuthenticator("sample") {
      @Override
      public boolean checkCredentials(String username, String password) {
        return repository.checkCredentials(username, password);
      }
    };

    return new UserRoleAuthenticator(basicAuth) {    
      @Override
      public List<String> getUserRoles(String username) {
        return repository.fetchRoleList(username);
      }
    };
  }
}

In case you have not already figured out, the UserRepository is expected to be wired up to the right place. Currently hard coded.

public final class UserRepository {
  final Map<String, String> userCredMap;
  final Map<String, List<String>> userRolesMap;

  @SuppressWarnings("java:S1192")
  public UserRepository() {
    userCredMap = Map.of("admin", "admin", "user", "user");
    userRolesMap = Map.of("admin", List.of("ADMIN", "USER"), "user", List.of("USER"));
  }

  /**
   * checks for credential validity
   * @param username
   * @param password
   * @return
   */
  public boolean checkCredentials(String username, String password) {
    return userCredMap.containsKey(username) && userCredMap.get(username).equals(password);
  }

  /**
   * Assumes that the checkCredentials has been called earlier
   * @param username
   * @return
   */
  public List<String> fetchRoleList(String username) {
    return userRolesMap.get(username);
  }
}

The Admin endpoint

The Admin endpoint is where the functionality can be seen in action, and of course the User endpoint too.

  static RouteResponse handle(RouteRequest req) {
    if (!req.hasUserRole("ADMIN")) {
      return RouteResponse.ErrorResponses.UNAUTHORIZED_ENDPOINT_RESPONSE.toValue();
    }

    if (req.method() == RequestMethod.GET) {
      return RouteResponse.withStatus(200).withHtmlBody("<html><body>Hello Admin %s</body></html>".formatted(req.principal().getUsername()));
    }
    return null;
  }

The User endpoint

The User Endpoint has been enhanced to test for a "nested" endpoint, /api/user/me and have the appropriate behaviour in other cases. A good example of how deeper urls can be handled.

  static RouteResponse handle(RouteRequest req) {
    if (!req.hasUserRole("USER")) {
      return RouteResponse.ErrorResponses.UNAUTHORIZED_ENDPOINT_RESPONSE.toValue();
    }

    if (req.method() == RequestMethod.GET && req.uri().getPath().equals("me")) {
      return RouteResponse.withStatus(200).withHtmlBody("<html><body>Hello %s</body></html>".formatted(req.principal().getUsername()));
    }
    return null;
  }

Updating the Tests

As we got rid of the Router, those tests are no longer needed. Removing code is always a happy feeling.

The Integration Tests needed more additions, because now we reply on the WrappedRouter and the HttpServer to serve the behaviour. Those have been updated too.

Learnings

It is, in most cases, perfectly alright to fall back on a complex design, and simplify things. By getting rid of a custom router, we simplified the implementation a lot.

Most web applications these days are served with a Single Page Application, when only an API is needed. So, the need for sessions should also be redundant. The above is a Basic Auth implementation. There are enough examples, It is relatively trivial to go from here into building a JWT based Authenticator too. I could get a good Authenticator generated by ChatGPT using the prompt:

I am using the HttpServer in Java 17 for serving a web application. Can you help me write a JWT authenticator for it using the Authenticator as the base class ?

About the Author

The Author, Navneet Karnani, started coding with Java in 1997, and has been a promoter and enthusiast ever since. He strongly believes in the "Keep It Simple and Stupid", and has incorporated this design philosophy in all the products he has worked on, and built.

Navneet is a freelancer, and can be reached for contract, mentoring, and advisory on technology, and its application in building software products.

Did you find this article valuable?

Support MandrakeTech Blog by becoming a sponsor. Any amount is appreciated!