This is a real world use case to be used only with similar restrictions. We had a lot of constraint in our development (see the requirements) but we had to implement an effettive solution to limit the user access to the REST resources on the backend.

Requirements / Constraints

Implementation

Database to store the user information and with only the information retrieved from the authentication provider (username and token), we decided to store the usernames of the authorized users in the properties loaded with the application (config file or enviornment if defined).

In Spring we created a new filter that throw an error if a non authorized user try to call a REST method that (according to the convention) should update the data.

The configuration

We store the usernames and the filtered methods in the configuration (file, server). This allow us to update the users list without rebuilding our application:

In your application.properties:


#To grant the rights to all users use the character: *
security.users.right.write=${SERVER_PARAM_USERS_WRITE:marco,tom,jerry}
security.methods.secured=POST,PUT,DELETE


// methods to be secured, typically 'POST', 'DELETE', 'PUT'
@Value("#{'${security.methods.secured}'.split(',')}")
private List<String> securedMethodList;

// usernames of the allowed users
@Value("#{'${security.users.right.write}'.split(',')}")
private List<String> userList;

We allow to ‘disable’ the security feature with a special key to use in the configuration:


// special char to grant access to all the users
private static final String ALLOW_ALL_USERS="*";

The filter implementation

When the filter is called at each HttpRequest the method doFilter is called. In this method we have to verify if user is trying to access a secured resource and, in this case, if his username is in the list of the authorized users.


public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // we get the authenticated user from the context
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        // if the method is not secured (e.g. 'GET') or all the users are allowed to access
        // we skip the validation
        if (securedMethodList.contains(((HttpServletRequest)request).getMethod())
            && !userList.get(0).equals(ALLOW_ALL_USERS)){

            if (authentication.getPrincipal() != null) {
                // the username is the 'principal' some implementations are contains the UserDetails
                if (!userList.contains(authentication.getPrincipal().toString())) {
                    // if is not in the list of allowed users we return a response with status 403
                    throwAccessError((HttpServletResponse) response, HttpServletResponse.SC_FORBIDDEN);
                    return;
                }
            } else {
                // the user is not authenticated, we return a response with status 401
                throwAccessError((HttpServletResponse) response, HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }
        // the user is allowed to access the ressource
        chain.doFilter(request, response);
    }

Tests

Spring is very helpful when it comes to tests. It comes with mocks for every component of our code.


@RunWith(SpringRunner.class)
@SpringBootTest(classes = {WriteAccessRightFilter.class})
public class SecurityTest {

    @Autowired
    private WriteAccessRightFilter writeAccessRightFilter;

    private MockFilterChain mockFilterChain;
    private MockHttpServletRequest mockHttpServletRequest;
    private MockHttpServletResponse mockHttpServletResponse;

    @Value("#{'${security.users.right.write}'.split(',')}")
    private List<String> usersAllowed;


    @Before
    public void setUp() {

        mockFilterChain = new MockFilterChain();
        mockHttpServletRequest = new MockHttpServletRequest();
        mockHttpServletResponse = new MockHttpServletResponse();
        // we redefine the authorized users
        // it allows us to test the special char '*'
        writeAccessRightFilter.setUserList(Arrays.asList("marco","ToM"));
    }

    @Test
    @WithMockOAuth2User(username = "marco")
    public void userWithWritingRightsTest() throws IOException, ServletException {
        
        // the request is of type 'POST' -> it's secured
        mockHttpServletRequest.setMethod("POST");
        
        // with doFilter we call our Filter that is in the chain
        writeAccessRightFilter.doFilter(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain);

       // we verify that our Filter returned 200 (ok) 
       assertEquals(200, mockHttpServletResponse.getStatus());
    }


    @Test
    @WithMockOAuth2User(username = "DefaultUser")
    public void userWithoutWritingRightsTest() throws IOException, ServletException {
        mockHttpServletRequest.setMethod("POST");
      
        writeAccessRightFilter.doFilter(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain);

        // the filter should return an error (403) if the user is not allowed to call a POST resource
        assertEquals(403, mockHttpServletResponse.getStatus());
    }

     /**
    We test that a user without writing rights has access to the GET resource 
    **/

    @Test
    @WithMockOAuth2User(username = "DefaultUser")
    public void userWithoutWritingRightsCanReadTest() throws IOException, ServletException {
        mockHttpServletRequest.setMethod("GET");
       
        writeAccessRightFilter.doFilter(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain);

        assertEquals(200, mockHttpServletResponse.getStatus());
    }

    /**
    We test that using '*' every authenticated user can access to the secured REST resources
    **/

    @Test
    @WithMockOAuth2User(username = "DefaultUser")
    public void allUsersAreAllowedToWriteTest() throws IOException, ServletException {
        // we give writing rights to everybody authenticated
        writeAccessRightFilter.setUserList(Arrays.asList("*"));
        mockHttpServletRequest.setMethod("DELETE");

        writeAccessRightFilter.doFilter(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain);

        assertEquals(200, mockHttpServletResponse.getStatus());
    }
}

Because we don’t use the default UsernamePasswordAuthenticationToken we have to define a custom @WithMockOAuth2User similar to @WithMockUser :


public class WithMockOAuth2SecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2User>{

    @Override
    public SecurityContext createSecurityContext(WithMockOAuth2User user) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("None");
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.username(), null, authorities);

        Authentication authentication = new OAuth2Authentication(getOauth2Request(), authenticationToken);
        context.setAuthentication(authentication);
        return context;
    }

    private OAuth2Request getOauth2Request () {
        String clientId = "oauth-client-id";
        Map<String, String> requestParameters = Collections.emptyMap();
        boolean approved = true;
        String redirectUrl = "http://test.ch";
        Set<String> responseTypes = Collections.emptySet();
        Set<String> scopes = Collections.emptySet();
        Set<String> resourceIds = Collections.emptySet();
        Map<String, Serializable> extensionProperties = Collections.emptyMap();
        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("None");

        OAuth2Request oAuth2Request = new OAuth2Request(requestParameters, clientId, authorities,
                approved, scopes, resourceIds, redirectUrl, responseTypes, extensionProperties);

        return oAuth2Request;
    }
}


@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockOAuth2SecurityContextFactory.class)
public @interface WithMockOAuth2User {

    String username() default "muster";
}