Moses Besong
Moses Besong

Reputation: 65

How can test a Spring Boot controller method annoted with @PreAuthorized(hasAnyAuthority(...))

My controller class is a follows:

 @PostMapping(path="/users/{id}")
    @PreAuthorize("hasAnyAuthority('CAN_READ')")
    public @ResponseBody ResponseEntity<User> getUser(@PathVariable int id) {
        ...
    }

I have the following Resource Server config

@Configuration
public class ResourceServerCofig implements ResourceServerConfigurer {

    private static final String RESOURCE_ID = "test";

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.cors()
            .and()
            .csrf().disable()
            .authorizeRequests()
            .antMatchers("/public/**").permitAll()
            .anyRequest().authenticated();
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId(RESOURCE_ID);

    }
}

Finally my test looks like this:

 @RunWith(SpringRunner.class)
  @WebMvcTest(ClientController.class)
  public class ClientControllerTest {

      @Autowired
      private MockMvc mockMvc;

      @WithMockUser(authorities={"CAN_READ"})
      @Test
      public void should_get_user_by_id() throws Exception {
          ...

          mockMvc.perform(MockMvcRequestBuilders.get("/user/1")).
              andExpect(MockMvcResultMatchers.status().isOk()).
              andExpect(MockMvcResultMatchers.header().string(HttpHeaders.CONTENT_TYPE, "application/json")).
              andExpect(MockMvcResultMatchers.jsonPath("$.name").value("johnson"));
      }
  }

Issue is I always get a 401 HTTP status with message unauthorized","error_description":"Full authentication is required to access this resource.

How can should I write tests for @PreAuthorized annotated controller methods methods?

Upvotes: 2

Views: 4602

Answers (1)

RUARO Thibault
RUARO Thibault

Reputation: 2850

I've spent part of the day figuring out how to solve this. I ended up with a solution that I think is not so bad, and could help many.

Based on what you tried to do in your test, you can do a mockMvc to test your controller. Notice that the AuthorizationServer is not called. You stay only in your Resource server for the tests.

  • Create a bean InMemoryTokenStore that will be used in the OAuth2AuthenticationProcessingFilter to authenticate your user. What is great is that you can add tokens to your InMemoryTokenStore before executing your tests. The OAuth2AuthenticationProcessingFilter will authenticate the user based on the token he is using and will not call any remote server.
@Configuration
public class AuthenticationManagerProvider {

    @Bean
    public TokenStore tokenStore() {
        return new InMemoryTokenStore();
    }
}
  • The annotation @WithMockUser does not work with OAuth2. Indeed, the OAuth2AuthenticationProcessingFilter always checks your token, wi no regard to the SecurityContext. I suggest to use the same approach as @WithMockUser, but using an annotation you create in your code base. (To have some easy to maintain and clean tests):
    @WithMockOAuth2Scope contains almost all the parameters you need to customize your authentication. You can delete those you will never use, but I put a lot to make sure you see the possibilities. (Put those 2 classes in your test folder)
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockOAuth2ScopeSecurityContextFactory.class)
public @interface WithMockOAuth2Scope {

    String token() default "";

    String clientId() default "client-id";

    boolean approved() default true;

    String redirectUrl() default "";

    String[] responseTypes() default {};

    String[] scopes() default {};

    String[] resourceIds() default {};

    String[] authorities() default {};

    String username() default "username";

    String password() default "";

    String email() default "";
}

Then, we need a class to interpret this annotation and fill our `InMemoryTokenStore with the data you need for your test.

@Component
public class WithMockOAuth2ScopeSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2Scope> {

    @Autowired
    private TokenStore tokenStore;

    @Override
    public SecurityContext createSecurityContext(WithMockOAuth2Scope mockOAuth2Scope) {

        OAuth2AccessToken oAuth2AccessToken = createAccessToken(mockOAuth2Scope.token());
        OAuth2Authentication oAuth2Authentication = createAuthentication(mockOAuth2Scope);
        tokenStore.storeAccessToken(oAuth2AccessToken, oAuth2Authentication);

        return SecurityContextHolder.createEmptyContext();
    }


    private OAuth2AccessToken createAccessToken(String token) {
        return new DefaultOAuth2AccessToken(token);
    }

    private OAuth2Authentication createAuthentication(WithMockOAuth2Scope mockOAuth2Scope) {

        OAuth2Request oauth2Request = getOauth2Request(mockOAuth2Scope);
        return new OAuth2Authentication(oauth2Request,
                getAuthentication(mockOAuth2Scope));
    }

    private OAuth2Request getOauth2Request(WithMockOAuth2Scope mockOAuth2Scope) {
        String clientId = mockOAuth2Scope.clientId();
        boolean approved = mockOAuth2Scope.approved();
        String redirectUrl = mockOAuth2Scope.redirectUrl();
        Set<String> responseTypes = new HashSet<>(asList(mockOAuth2Scope.responseTypes()));
        Set<String> scopes = new HashSet<>(asList(mockOAuth2Scope.scopes()));
        Set<String> resourceIds = new HashSet<>(asList(mockOAuth2Scope.resourceIds()));

        Map<String, String> requestParameters = Collections.emptyMap();
        Map<String, Serializable> extensionProperties = Collections.emptyMap();
        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(mockOAuth2Scope.authorities());

        return new OAuth2Request(requestParameters, clientId, authorities,
                approved, scopes, resourceIds, redirectUrl, responseTypes, extensionProperties);
    }

    private Authentication getAuthentication(WithMockOAuth2Scope mockOAuth2Scope) {
        List<GrantedAuthority> grantedAuthorities = AuthorityUtils.createAuthorityList(mockOAuth2Scope.authorities());

        String username = mockOAuth2Scope.username();
        User userPrincipal = new User(username,
                mockOAuth2Scope.password(),
                true, true, true, true, grantedAuthorities);

        HashMap<String, String> details = new HashMap<>();
        details.put("user_name", username);
        details.put("email", mockOAuth2Scope.email());

        TestingAuthenticationToken token = new TestingAuthenticationToken(userPrincipal, null, grantedAuthorities);
        token.setAuthenticated(true);
        token.setDetails(details);

        return token;
    }

}
  • Once everything is setup, create a simple Test class under src/test/java/your/package/. This class will do the mockMvc operations, and use the @ WithMockOAuth2Scope to create the token you need for your test.
@WebMvcTest(SimpleController.class)
@Import(AuthenticationManagerProvider.class)
class SimpleControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockOAuth2Scope(token = "123456789",
            authorities = "CAN_READ")
    public void test() throws Exception {
        mockMvc.perform(get("/whoami")
                .header("Authorization", "Bearer 123456789"))
                .andExpect(status().isOk())
                .andExpect(content().string("username"));
    }
}

I came up with this solutions thanks to:

For the curious:
When testing, Spring loads a InMemoryTokenStore, and give you the possibility to take one you provide (as a @Bean). When running in production, for my case, Spring uses a RemoteTokenStore, which calls the remote Authorization server to check the token (http://authorization_server/oauth/check_token).
When you decide to use OAuth2, Spring fires the OAuth2AuthenticationProcessingFilter. This was my entry point during all me debugging sessions.

I've learned a lot, thank you for this.
You can find the source code here.

Hope it will help !

Upvotes: 4

Related Questions