When providing APIs to the web, we sometimes (or most of the times) want to manage whos, whichs and wheres. Or in other words who can access which data where.
Some theory
That's where authorization comes into play. Some (especially me, until I googled it) think that authorization is the same thing as authentication, but behold: this is not the case.
When we talk about authentication, we mean the process where the client introduces itself to the provider.
For example: the part where we type in our username and password.
The result is most of the times some form of entry pass. For example a session, an API Key or something else.
Authorization on the other hand, handles which authenticated user can access what type of resource. A very common example for this process is a role system.
Let's get to work
Now that we know what we want to achieve, let's take a look at our Java code.
Java offers many ways to associate a so-called filter - a component, that gets executed around a request - with a route or a resource.
Many of these include a web.xml or similar complicated matters.
That's why we will focus on a annotation based approach:
What we want to achieve:
@Path("")
public class ExampleResource {
@GET
@Path("all")
@Produces(MediaType.APPLICATION_JSON)
@Authorization("example:read")
public String getAll() throws Exception{
return "{ \"message\": \"Hello World\"}";
}
}
The good thing is that most javax.ws.rs
implementations already implement most of these features.
The only thing we will need to implement is the @Authorization
annotation.
1. The annotation interface
To create an annotation, we need an interface declaring it. This looks like this:
// Retention declares the time of evaluation.
// As we want to have it evaluated at runtime, we declare it runtime.
@Retention(RUNTIME)
@Target({TYPE, METHOD}) // This annotation specifies where this annotation can be used.
public @interface Authorization {
/**
* List of roles that are permitted access.
*/
String[] value();
}
2. Annotation Handler Class
Okay, now we have an annotation. ...but it does nothing at all right now.
That's why we need to declare a filter to handle annotated resources:
/**
* When deploying your application as .war or .jar into a web server,
* this annotation declares the class as provider for a filter.
*/
@Provider
public class AuthorizationFilter implements ContainerRequestFilter
{
@Override
public void filter(ContainerRequestContext requestContext) {
// TODO: Fill
}
}
Our filter implements the ContainerRequestFilter
interface from the Java REST Services extension. This interface requires a method called 'filter'.
This method will be called, when our filter is being applied to the request. It is being passed an instance of ContainerRequestContext
, which provides metadata about the request.
For more information, check out the Official JavaDoc!
Our filter will handle authorization in its filter
method.
But at first we need to check if the called resource is decorated with our annotation. To achieve this, we will provide our class with the ResourceInfo
.
This is an object which holds information about the Java method, that is being evaluated in this request call.
public class AuthorizationFilter implements ContainerRequestFilter
{
// Provides information about the called resource
@Context
private ResourceInfo resourceInfo;
@Override
public void filter(ContainerRequestContext requestContext) {
// TODO: Fill
}
}
The @Context
annotation tells the server to provide this class with the corresponding instance of RequestContext
automatically. So we don't have to worry about how this information gets here. Neat!
To keep the used identifiers consistent and to make future changes easier, we should also declare constant variables with our header and our scheme:
...
// Provides information about the called resource
@Context
private ResourceInfo resourceInfo;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String AUTHORIZATION_SCHEME = "Bearer";
@Override
public void filter(ContainerRequestContext requestContext) {
// TODO: Fill
}
...
Nice! Now let's get to work:
At first we need to check for the annotation:
...
@Override
public void filter(ContainerRequestContext requestContext) {
Method calledMethod = resourceInfo.getResourceMethod();
if(calledMethod.isAnnotationPresent(Authorization.class) {
// Handle authorization
}
}
...
Now that we know that our annotation is present, we need to check if the authorization header in our request is present. If not, we can just stop processing the request and return a 403
.
...
if(calledMethod.isAnnotationPresent(Authorization.class) {
String authorization = requestContext.getHeaderString(AUTHORIZATION_HEADER);
//If no authorization information present; block access
if(authorization == null || authorization.isEmpty())
{
throw new ForbiddenException("Resource Forbidden");
}
}
...
- Annotation: Present
- Header: Filled
- Access Requirements: not yet fetched
We will do this by accessing the array of Strings we passed to the annotation.
...
if(calledMethod.isAnnotationPresent(Authorization.class) {
...
Authorization authAnnotation = calledMethod.getAnnotation(Authorization.class);
Set<String> accessRequirements = new HashSet<>(Arrays.asList(authAnnotation.value()));
}
...
Ok. In the incoming request, the authorization header is formatted in the following way:
Header Name | Header Value |
---|---|
Authorization | Bearer |
This means, that our token is being preceded by the word Bearer
. So we need to split the content of the authorization header to access the token:
...
if(calledMethod.isAnnotationPresent(Authorization.class) {
...
Authorization authAnnotation = calledMethod.getAnnotation(Authorization.class);
Set<String> accessRequirements = new HashSet<>(Arrays.asList(authAnnotation.value()));
final String key = authorization.replaceFirst(AUTHENTICATION_SCHEME + " ", "");
}
...
Alrighty! Now that we have the token we need to check the access rights assigned to it. Normally we would call some form of data storage or something similar at this point. This really is out of the boundaries of this post though.
That's why we will implement a static map with our user data.
// Provides information about the called resource
@Context
private ResourceInfo resourceInfo;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String AUTHORIZATION_SCHEME = "Bearer";
private static final Map<String, Set<String>> userData = new HashMap<String, Set<String>>() {{
put("admin", new HashSet<String>(){{
add("example:read");
}});
put("example_user", new HashSet<String>() {{
add("example:write");
}});
}};
@Override
public void filter(ContainerRequestContext requestContext) {
...
}
...
This will give us two tokens. One with the right to read in our example resource and one with the right to write the example resource.
For the endpoint we have at the beginning of this post, we specified example:read as access requirement. This means, that only the admin user can access the secured resource.
Token | Access Rights |
---|---|
admin | [example:read] |
example_user | [example:write] |
Now we should check, if the sent token has the correct access rights. To make our code more readable, we should really put this check in its own method:
...
private boolean authorize(final String key, final Set<String> accessRights)
{
boolean isAllowed = false;
Set<String> permittedActions = userData.get(key);
if(permittedActions != null && permittedActions.stream().anyMatch(accessRights::contains))
isAllowed = true;
return isAllowed;
}
}
And check for its result
...
Set<String> accessRequirements = new HashSet<>(Arrays.asList(authAnnotation.value()));
final String key = authorization.replaceFirst(AUTHENTICATION_SCHEME + " ", "");
if(!authorized(key, acessRequirements))
throw new ForbiddenException("Resource forbidden");
}
That's it! That's our authorization handling. If you want the complete code, check out this gist.
Now that we have our annotation and our annotation handling, we need to provide our solution to the server.
As this is part of a series, I will refer to the type of server we implemented in Part 1.
In this case, we just add it to the resources declared in our resource config:
// Main.java
...
ResourceConfig resourceConfig = new ResourceConfig();
resourceConfig.register(ExampleResource.class); // our rest resource
// the parser for JSON and XML request bodies
resourceConfig.register(JacksonFeature.class);
resourceConfig.register(AuthorizationFilter.class);
Now we have a way to secure our API.
Enjoy!
Image Credits:
Photo by Siarhei Horbach on Unsplash
Top comments (0)