In a previous post I walked through setting up an OAuth2 provider service using Spring Security. I used JSR-250 role-based annotations to protect access to resources. An example of this is the /v1.0/me API to return user details:
@RolesAllowed({"ROLE_USER"})
@GET
public ApiUser getUser(final @Context SecurityContext securityContext) {
User requestingUser = loadUserFromSecurityContext(securityContext);
if(requestingUser == null) {
throw new UserNotFoundException();
}
return new ApiUser(requestingUser);
}
What happens when we create a new role for admin users called ROLE_ADMIN?
This user should have the same access rights as ROLE_USER as well as other restrictive rights not available to regular users. The temptation would be to simply add another role to the list of allowed roles:
@RolesAllowed({"ROLE_USER", "ROLE_ADMIN"})
The better solution would be to keep the access as ROLE_USER but allow anyone with a role that extends ROLE_USER to access the resource. To do that we would have to implement hierarchical roles and the access decision mechanism in Spring Security would need to know how to handle this hierarchy.
Creating an access controlled resource
I'll create a simple service that can be accessed by anyone with the role of ROLE_GUEST. Users with a higher level of access will have a role of ROLE_USER and can access anything that ROLE_GUEST users can. The url of the resource will be /v1.0/samples.@Path("/v1.0/samples")
@Component
@Produces({MediaType.APPLICATION_JSON})
@Consumes({MediaType.APPLICATION_JSON})
public class SampleResource extends BaseResource {
@RolesAllowed({"ROLE_GUEST"})
@GET
public Response getSample(@Context SecurityContext sc) {
User user = loadUserFromSecurityContext(sc);
return Response.ok().entity("{\"message\":\"" + user.getEmailAddress() +
" is authorized to access\"}").build();
}
}
The resource is in a package that Jersey does not yet know about so it has to be registered with the com.porterhead.RestResourceApplication class
packages("com.porterhead.resource", "com.porterhead.user.resource",
"com.porterhead.sample");
Next Spring needs to know about the class in order to apply method level security so we need to add the package to component scanning in application-context.xml:
<context:component-scan base-package="com.porterhead.sample"/>
Writing a failing functional test
Before implementing the changes I'll add a failing functional test that will register a user and then attempt to access the service.
This should result in a 401 status as users are assigned a ROLE_USER role on registration and the system does not know anything about ROLE_GUEST.
public void testHierarchicalRole() {
//sign up a user with role of ROLE_USER
def username = createRandomUserName()
httpSignUpUser(getCreateUserRequest(username, TEST_PASSWORD))
//login and get the oauth token
def loginResponse = httpGetAuthToken(username, TEST_PASSWORD)
//get the resource that requires a role of ROLE_GUEST
def sampleResponse = getRestClient().get(path: "/v1.0/samples",
contentType: ContentType.JSON, headers: ['Authorization': "Bearer "
+ loginResponse.responseData["access_token"])
assertEquals(200, sampleResponse.status)
return Response.ok().entity("{\"message\":\"" + username.toLowerCase()
+ " is authorized to access\"}").build();
}
Execute the tests:
./gradlew integrationTest
This will result in ONE failure which you can view at
oauth2-provider/build/reports/tests/index.html.
You can also try it out with curl
Start the application by executing ./gradlew tomcatRun and ensure you have mongodb running
First create a user:
curl -v -X POST -H "Content-Type: application/json" \
-H "Authorization: Basic MzUzYjMwMmM0NDU3NGY1NjUwNDU2ODdlNTM0ZTdkNmE6Mjg2OTI0Njk3ZTYxNWE2NzJhNjQ2YTQ5MzU0NTY0NmM=" \
-d '{"user":{"emailAddress":"foo@example.com"}, "password":"password"}' \
'http://localhost:8080/oauth2-provider/v1.0/users'
-H "Authorization: Basic MzUzYjMwMmM0NDU3NGY1NjUwNDU2ODdlNTM0ZTdkNmE6Mjg2OTI0Njk3ZTYxNWE2NzJhNjQ2YTQ5MzU0NTY0NmM=" \
-d '{"user":{"emailAddress":"foo@example.com"}, "password":"password"}' \
'http://localhost:8080/oauth2-provider/v1.0/users'
Extract the access token from the response and send the request for samples:
curl -v -X GET \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <the access token from user registration>" \
'http://localhost:8080/oauth2-provider/v1.0/samples'
-H "Authorization: Bearer <the access token from user registration>" \
'http://localhost:8080/oauth2-provider/v1.0/samples'
You should see a response similar to this:
HTTP/1.1 401 Unauthorized
Server Apache-Coyote/1.1 is not blacklisted
Server: Apache-Coyote/1.1
Content-Type: application/json
Content-Length: 168
Date: Tue, 14 Oct 2014 19:10:26 GMT
{"errorCode":"401","consumerMessage":"You do not have the appropriate privileges to access this resource","applicationMessage":"Access is denied","validationErrors":[]}
Server Apache-Coyote/1.1 is not blacklisted
Server: Apache-Coyote/1.1
Content-Type: application/json
Content-Length: 168
Date: Tue, 14 Oct 2014 19:10:26 GMT
{"errorCode":"401","consumerMessage":"You do not have the appropriate privileges to access this resource","applicationMessage":"Access is denied","validationErrors":[]}
The Role hierarchy class
We only need a simple hierarchy with ROLE_ADMIN extending ROLE_USER which in turn extends ROLE_GUEST.
Fortunately we don't have to do too much work as Spring already has an implementation that is fit for this purpose. We just need to add a bean definition with our role hierarchies to security-configuration.xml.
<bean id="roleHierarchy" class="org.springframework.security.access.hierarchicalroles.
RoleHierarchyImpl"> <property name="hierarchy"> <value> ROLE_ADMIN > ROLE_USER ROLE_USER > ROLE_GUEST </value> </property> </bean>
Customising JSR-250 Voter class
The spring implementation of JSR-250 is almost there but does not know how to handle hierarchies. Spring has another class (RoleHierarchyVoter) that knows how to handle hierarchical roles so we can use some of that to extract the roles.So if we subclass Jsr250Voter and modify the vote method so that it extracts the hierarchical roles that should do the trick.
public class HierarchicalJsr250Voter extends Jsr250Voter {
private RoleHierarchy roleHierarchy = null;
public HierarchicalJsr250Voter(RoleHierarchy roleHierarchy) {
Assert.notNull(roleHierarchy, "RoleHierarchy must not be null");
this.roleHierarchy = roleHierarchy;
}
@Override
public int vote(Authentication authentication, Object object,
Collection<ConfigAttribute> definition) {
boolean jsr250AttributeFound = false;
for (ConfigAttribute attribute : definition) {
if (Jsr250SecurityConfig.PERMIT_ALL_ATTRIBUTE.equals(attribute)) {
return ACCESS_GRANTED;
}
if (Jsr250SecurityConfig.DENY_ALL_ATTRIBUTE.equals(attribute)) {
return ACCESS_DENIED;
}
if (supports(attribute)) {
jsr250AttributeFound = true;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : extractAuthorities(authentication)) {
if (attribute.getAttribute().equals(authority.getAuthority())) {
return ACCESS_GRANTED;
}
}
}
}
return jsr250AttributeFound ? ACCESS_DENIED : ACCESS_ABSTAIN;
}
Collection<? extends GrantedAuthority> extractAuthorities(
Authentication authentication) {
return roleHierarchy.getReachableGrantedAuthorities(
authentication.getAuthorities());
}
Wire the new class into the application context
Adding the bean definition to security-configuration.xml:
<bean id="roleVoter" class="com.porterhead.security.HierarchicalJsr250Voter"> <constructor-arg ref="roleHierarchy" /> </bean>
And the final piece of the puzzle is to change the bean that is being referenced by the AccessDecisionManager:
<bean id="accessDecisionManager"
class="org.springframework.security.access.vote.UnanimousBased" xmlns="http://www.springframework.org/schema/beans"> <property name="decisionVoters"> <list> <ref bean="roleVoter"/> </list> </property> </bean>
Testing the changes
First make sure the integration test passes by executing ./gradlew integrationTestTesting with curl should produce a response similar to this:
HTTP/1.1 200 OK
Server Apache-Coyote/1.1 is not blacklisted
Server: Apache-Coyote/1.1
Content-Type: application/json
Content-Length: 42
Date: Tue, 14 Oct 2014 19:54:28 GMT
{"message":"You are authorized to access"}
Server Apache-Coyote/1.1 is not blacklisted
Server: Apache-Coyote/1.1
Content-Type: application/json
Content-Length: 42
Date: Tue, 14 Oct 2014 19:54:28 GMT
{"message":"You are authorized to access"}
Other posts in this series
- Part 1: An introduction to writing REST Services in Java
- Email Verification
- Lost Password
- Moving to Production
- JSR 303 Validation
- Securing REST Services with Spring Security and OAuth2