Previous Post : Part Five: Lost Password
Get the Code: https://github.com/iainporter/rest-java
Part Two dealt with signing up and authenticating users using the conventional email and password approach. Part Four also covered authentication but using the OAuth protocol. This post will cover authorization of services.
To that end what we want to accomplish is:
- Identify who is making the request
- Have a high degree of confidence that they are who they say they are
- Limit access to only those resources that they are allowed to access
- Prevent malicious tampering of requests or session hijacking
SSL
Ideally all API traffic should be over a secure connection using SSL. If that is not feasible then at the very least all requests that would be open to MITM attacks should be secured, such as account creation, login, etc
Security Filter
All API requests first go through a security filter. An authorization service is registered with the filter on startup. Depending on the level of security you are comfortable with and how much effort you want to subject clients to will determine the choice of service. I'll cover that later in the post. First we need to register the filter with the servlet container.
In src/main/webapp/WEB-INF/web.xml a ResourceFilterFactory is added to the config
<init-param> <param-name>com.sun.jersey.spi.container.ResourceFilters</param-name> <param-value>com.porterhead.rest.filter.ResourceFilterFactory</param-value> </init-param>
The ResourceFilterFactory extends the Jersey class RolesAllowedResourceFilterFactory. In it we register the security filter and ensure it is the first filter in line.
@Component @Provider public class ResourceFilterFactory extends RolesAllowedResourceFilterFactory { @Autowired private SecurityContextFilter securityContextFilter; @Override public List<ResourceFilter> create(AbstractMethod am) { List<ResourceFilter> filters = super.create(am); if (filters == null) { filters = new ArrayList<ResourceFilter>(); } List<ResourceFilter> securityFilters = new ArrayList<ResourceFilter>(filters); //put the Security Filter first in line securityFilters.add(0, securityContextFilter); return securityFilters; } }
The SecurityContextFilter class gathers some information from the request and then delegates to the AuthorizationService implementation to handle authorizing the request and loading the user. If the request is valid a user is returned unless the resource is not access controlled in which case no authorization headers will be required. The user is wrapped in a SecurityContext and added to the ContainerRequest.
public ContainerRequest filter(ContainerRequest request) { String authToken = request.getHeaderValue(HEADER_AUTHORIZATION); String requestDateString = request.getHeaderValue(HEADER_DATE); String nonce = request.getHeaderValue(HEADER_NONCE); AuthorizationRequestContext context = new AuthorizationRequestContext(request.getPath(), request.getMethod(), requestDateString, nonce, authToken); ExternalUser externalUser = authorizationService.authorize(context); request.setSecurityContext(new SecurityContextImpl(externalUser)); return request; }
Role Based Access
Using JAX-RS annotations we can protect resources based on roles. It is the task of the SecurityContext implementation that was added to the Container Request in the above step that will answer the question of whether the user has the role.
public boolean isUserInRole(String role) { if(role.equalsIgnoreCase(Role.anonymous.name())) { return true; } if(user == null) { throw new InvalidAuthorizationHeaderException(); } return user.getRole().equalsIgnoreCase(role); }
If anyone can access the resource, as in the case of user/login then it is annotated with @PermitAll
@PermitAll @Path("login") @POST
If we want to limit access then we use the annotation @RolesAllowed with a list of allowed roles
@RolesAllowed({"authenticated"}) @Path("{userId}") @GET public Response getUser(@Context SecurityContext sc, @PathParam("userId") String userId) { ExternalUser userMakingRequest = (ExternalUser)sc.getUserPrincipal(); ExternalUser user = userService.getUser(userMakingRequest, userId); return Response.ok().entity(user).build(); }
Note that we can also use annotations to retrieve the user that was wrapped in the SecurityContext as part of the Authorization procedure. We can then check that this user can not only access this resource method but also at the service level whether they can access the user object that is being requested. In this situation we want to check that they can only request getUser on their own instance. We could add "administrative" role access and allow that user to access any user instance.
Session Token Authorization
The simplest method of authorization is to get a session token on login or sign up and pass that back on every request. This is obviously not secure enough to be transmitted over anything other than an SSL connection. It can be useful for testing services quickly and easily with curl without the overhead of signing the request each time.
public ExternalUser authorize(AuthorizationRequestContext securityContex) { String token = securityContext.getAuthorizationToken(); ExternalUser externalUser = null; if(token == null) { return externalUser; } User user = userRepository.findBySession(token); if(user == null) { throw new AuthorizationException("Session token not valid"); } AuthorizationToken authorizationToken = user.getAuthorizationToken(); if (authorizationToken.getToken().equals(token)) { externalUser = new ExternalUser(user); } return externalUser; }
We extract the token from the request header and query the User Repository for a User with that Session token.
Testing Session Token Authorization
1. In src/main/resources/properties/app.properties set the following property
security.authorization.requireSignedRequests=false
2. Start the application by executing
gradle tomcatRun
3. Create a user with the curl statement
curl -v -H "Content-Type: application/json" -X POST -d '{"user":
{"firstName":"Foo","lastName":"Bar","emailAddress":"foobar@example.com"},
"password":"password"}' http://localhost:8080/java-rest/user
You should receive back an AuthenticatedUserToken similar to this
< HTTP/1.1 201 Created
< Server: Apache-Coyote/1.1
< Location: http://localhost:8080/java-rest/user/d26079db-62e9-4819-964a-be954d2c47ed
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Tue, 29 Jan 2013 19:48:05 GMT
{"userId":"d26079db-62e9-4819-964a-be954d2c47ed","token":"86e8be75-3eac-45aa-acc3-f043333c8608"}
4. For Session token authorization we only need the token part. Now construct a curl statement based on the location url in the response to GET a user similar to the following
curl -v -H "Content-Type: application/json" -X GET -H "Authorization: 86e8be75-3eac-45aa-acc3-f043333c8608" 'http://localhost:8080/java-rest/user/d26079db-62e9-4819-964a-be954d2c47ed'
You should receive a response similar to this one
< HTTP/1.1 200 OK
< Server: Apache-Coyote/1.1
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Tue, 29 Jan 2013 19:58:13 GMT
{"id":"d26079db-62e9-4819-964a-be954d2c47ed","firstName":"Foo","lastName":"Bar","emailAddress":"foobar@example.com","socialProfiles":[],"name":"foobar@example.com","verified":false}
Request Signing Authorization
A surer method of securing requests that does not require an SSL connection other than for passing back and forth credentials is to sign requests. This implementation is based somewhat on the OAuth spec, although does not go as far as to require the request body to be signed. That can easily be plugged in though for an added layer of security.
On login or sign up an AuthenticatedUserToken is returned. An AuthenticatedUserToken is comprised of:
- userId - passed with every role-based request as a means to identify the user.
- token - used as a shared secret between the client and the application to verify the hash of a token passed with the request.
To generate a hashed token a String is composed of the following
- The session token
- A : separator
- The relative url of the resource (i.e. user/ff7ffcf0-cfe0-4c1e-9971-3b934612b154) followed by ,
- The HTTP method (GET, POST, PUT, DELETE) followed by ,
- A time stamp of the request in Iso8061 format followed by ,
- A unique nonce token generated by the client
This string is then hashed using SHA-256 and then Base64 encoded. The Authorization header is composed of the user token followed by a : separator and then the hashed signature
In order for the server to reconstitute the string the timestamp and nonce must also be passed as headers.
The main methods to authorize signed requests
public ExternalUser authorize(AuthorizationRequestContext context) { ExternalUser externalUser = null; if (context.getAuthorizationToken() != null && context.getRequestDateString() != null && context.getNonceToken() != null) { String userId = null; String hashedToken = null; String[] token = context.getAuthorizationToken().split(":"); if (token.length == 2) { userId = token[0]; hashedToken = token[1]; //make sure date and nonce is valid validateRequestDate(context.getRequestDateString()); validateNonce(context.getNonceToken()); User user = userRepository.findByUuid(userId); if (user != null) { externalUser = new ExternalUser(user); if (!isAuthorized(externalUser, context, hashedToken)) { throw new AuthorizationException("Request rejected due to an authorization failure"); } } } } return externalUser; } private boolean isAuthorized(User user, AuthorizationRequestContext authorizationRequest, String hashedToken) { Assert.notNull(user); Assert.notNull(authorizationRequest.getAuthorizationToken()); String unEncodedString = composeUnEncodedRequest(authorizationRequest); AuthorizationToken authorizationToken = user.getAuthorizationToken(); String userTokenHash = encodeAuthToken(authorizationToken.getToken(), unEncodedString); if (hashedToken.equals(userTokenHash)) { return true; } LOG.error("Hash check failed for hashed token: {} for the following request: {} for user: {}", new Object[]{authorizationRequest.getAuthorizationToken(), unEncodedString, user.getId()}); return false; }
If the request headers are present then we attempt to authorize the request.
The timestamp is checked to ensure it conforms within the configurable boundaries of the server clock.
The nonce value is checked to ensure it is unique
The user is loaded and we iterate over their session tokens and attempt to verify the request token using the shared secret.
Testing Signed Request Authorization
1. In src/main/resources/properties/app.properties set the following property
security.authorization.requireSignedRequests=true
2. Start the application by executing
gradle tomcatRun
3. Create a user with the curl statement
curl -v -H "Content-Type: application/json" -X POST -d '{"user":
{"firstName":"Foo","lastName":"Bar","emailAddress":"foobar@example.com"},
"password":"password"}' http://localhost:8080/java-rest/user
4. Construct a signed request to call user/id GET using the returned AuthenticatedUserToken
{"userId":"ff7b93ad-27d0-49f6-90bd-9937951e5fcc","token":"6eebc0ea-b637-4033-925b-3b3cba9880e4"}
First the string to hash:
- 6eebc0ea-b637-4033-925b-3b3cba9880e4 (the session token)
- user/ff7b93ad-27d0-49f6-90bd-9937951e5fcc (the resource url)
- GET (The Http Method)
- 2013-01-30T10:50:00+00:00 (Timestamp)
- 22e327d732 (nonce value)
The full string
6eebc0ea-b637-4033-925b-3b3cba9880e4:user/ff7b93ad-27d0-49f6-90bd-9937951e5fcc,GET,2013-01-30T10:50:00+00:00,22e327d732
Hash the string using SHA-256 and then Base64 encode the result. There is a utility class at com.incept5.rest.util.HashUtil that you can use.
This should render a result similar to
ncYoA5n5s2nFSm7qyvf5hDgL4pmmPOUP3zo/UYfaQKg=
5. Construct the curl statement with relevant headers which should look similar to the following
curl -v -H "Content-Type: application/json" -H "Authorization: ff7b93ad-27d0-49f6-90bd-9937951e5fcc:ncYoA5n5s2nFSm7qyvf5hDgL4pmmPOUP3zo/UYfaQKg=" -H "x-java-rest-date:2013-01-30T10:50:00+00:00" -H "nonce:22e327d732" -X GET -d localhost http://localhost:8080/java-rest/user/ff7b93ad-27d0-49f6-90bd-9937951e5fcc
After executing the curl statement you should get back something like:
< HTTP/1.1 200 OK
< Server: Apache-Coyote/1.1
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Wed, 30 Jan 2013 10:48:56 GMT
{"id":"ff7b93ad-27d0-49f6-90bd-9937951e5fcc","firstName":"Foo","lastName":"Bar","emailAddress":"foobar@example.com","socialProfiles":[],"name":"foobar@example.com","verified":false}
Tampering with any of the header values or resubmitting the request should result in a failure such as
< HTTP/1.1 403 Forbidden
< Server: Apache-Coyote/1.1
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Wed, 30 Jan 2013 10:52:26 GMT
{"errorCode":"40301","consumerMessage":"Not authorized","applicationMessage":"Nonce value is not unique"}
The application has some javascript functions for creating signed requests on the client side (see src/main/webapp/js/javarest.js)
That now covers everything that you need to create and manage simple user accounts in a REST API. The next post will focus on configuring the application for production