0

We are building a RESTful webservice using Spring Boot. We want to have 2-level authentication to secure the endpoints.

First, for every request, we want to check if there is specified apiKey inside the request header, if not, we will deny the request. If the request have a the apiKey, we will go to next authentication using username/password login for some of the requests. There are public endpoints which only need apiKey authentication, and private endpoints which require apiKey auth first, then need username/password auth to access them.

For the apiKey auth, I copied the code here, I can also find many examples regarding username/password authentication.

My question is: how to do Java config inside WebSecurityConfigurerAdapter to combine them together.

Right now I defined 2 config classes extending WebSecurityConfigurerAdapter for those 2 authentication filter, but the request will only go through one of them depending on which one I set as @Order(1).

Thanks.

L Li
  • 11
  • 1
  • 1

1 Answers1

5

This entire answer is backed by a working Spring Boot application with unit tests to confirm it.

If you find this answer helpful, please up vote it.

The short answer is that your security configuration could look like this

    http
        .sessionManagement()
            .disable()
        //application security
        .authorizeRequests()
            .anyRequest().hasAuthority("API_KEY")
            .and()
        .addFilterBefore(new ApiKeyFilter(), HeaderWriterFilter.class)
        .addFilterAfter(new UserCredentialsFilter(), ApiKeyFilter.class)
        .csrf().ignoringAntMatchers(
            "/api-key-only",
            "/dual-auth"
    )
        ;
        // @formatter:on
    }

}

Let me tell you a little bit what is going on. I encourage you to review my sample, specifically the unit tests that cover many of your scenarios.

We have two levels of security 1. Every API must be secured by ApiKey 2. Only some APIs must be secured by UserCredentials

In my example project I opted for the following solution

  1. I use a WebSecurityConfigurerAdapter to meet the ApiKey requirement

    .authorizeRequests()
        .anyRequest().hasAuthority("API_KEY")
    
  2. I use method level security by enabling it

    @EnableGlobalMethodSecurity(prePostEnabled = true)

and then requiring it in my controller

    @PreAuthorize("hasAuthority('USER_CREDENTIALS')")
    public String twoLayersOfAuth() {
        //only logic here
    }

The ApiKey filter is super simple

public class ApiKeyFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

        final String authorization = request.getHeader("Authorization");
        final String prefix = "ApiKey ";
        if (hasText(authorization) && authorization.startsWith(prefix)) {
            String key = authorization.substring(prefix.length());
            if ("this-is-a-valid-key".equals(key)) {
                RestAuthentication<SimpleGrantedAuthority> authentication = new RestAuthentication<>(
                    key,
                    Collections.singletonList(new SimpleGrantedAuthority("API_KEY"))
                );
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request, response);

    }
}

and the second tier of authentication even simple (and it relies on the first tier to have performed)

public class UserCredentialsFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        final String userCredentials = request.getHeader("X-User-Credentials");
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if ("valid-user".equals(userCredentials) && authentication instanceof RestAuthentication) {
            RestAuthentication<SimpleGrantedAuthority> restAuthentication =
                (RestAuthentication<SimpleGrantedAuthority>)authentication;
            restAuthentication.addAuthority(new SimpleGrantedAuthority("USER_CREDENTIALS"));
        }
        filterChain.doFilter(request, response);

    }
}

Please note: How each filter is not concerned about what happens when there is no authentication or insufficient authentication. That is all taken care of for you. Your filter only has to validate correct data;

Spring, Spring Boot and Spring Security have some stellar testing facilities.

I can invoke api-only endpoint with both level of security

    mvc.perform(
        post("/api-key-only")
            .header("Authorization", "ApiKey this-is-a-valid-key")
            .header("X-User-Credentials", "valid-user")
    )
        .andExpect(status().isOk())
        .andExpect(authenticated()
            .withAuthorities(
                asList(
                    new SimpleGrantedAuthority("API_KEY"),
                    new SimpleGrantedAuthority("USER_CREDENTIALS")
                )
            )
        )
        .andExpect(content().string("API KEY ONLY"))
    ;

or I can pass the first level of security and be rejected by the 2nd

    mvc.perform(
        post("/dual-auth")
            .header("Authorization", "ApiKey this-is-a-valid-key")
    )
        .andExpect(status().is4xxClientError())
        .andExpect(authenticated()
            .withAuthorities(
                asList(
                    new SimpleGrantedAuthority("API_KEY")
                )
            )
        )
    ;

of course, we always have a happy path

    mvc.perform(
        post("/dual-auth")
            .header("Authorization", "ApiKey this-is-a-valid-key")
            .header("X-User-Credentials", "valid-user")
    )
        .andExpect(status().isOk())
        .andExpect(content().string("DUAL AUTH"))
        .andExpect(authenticated()
            .withAuthorities(
                asList(
                    new SimpleGrantedAuthority("API_KEY"),
                    new SimpleGrantedAuthority("USER_CREDENTIALS")
                )
            )
        )
    ;
Filip Hanik VMware
  • 1,484
  • 6
  • 8
  • 1
    The sample code is great and it works for my needs. I'm new to Spring Security, originally I thought maybe I could define 2 security configures each for one of the authenticate method using different antMatchers() for the endPoints. But from my testing it would not work for overlapping endPoints. Did I do some wrong or it is true that I cannot solve my problem this way? – L Li Jan 14 '19 at 14:32
  • 1
    You can definitely solve the problem in many different ways. Including using matchers for different paths. Your filters would have to have matchers inside of them so that they know when to trigger. You can use the [RequestMatcher](https://github.com/spring-projects/spring-security/blob/master/web/src/main/java/org/springframework/security/web/util/matcher/RequestMatcher.java#L34) interface to do all kind of clever things. The only caveat is that if it is too complex to figure out, it will be too hard to maintain – Filip Hanik VMware Jan 14 '19 at 16:24
  • 1
    I found some issue when using your sample code, the http GET method works fine, but when I try to use on POST method, even I don't set @PreAuthorize for that endPoints, and I include apiKey in the header, I still get {"timestamp":"2019-01-14T18:09:15.148+0000","status":403,"error":"Forbidden","message":"Forbidden","path":"/users/sign-up"} – L Li Jan 14 '19 at 18:12
  • 2
    Update: I found I need disable csrf in the httpSecury config to make POST method work. Like "httpSecurity.csrf().disable()". – L Li Jan 14 '19 at 19:48
  • 1
    Thanks for the update, I will change the sample to POST and disable CSRF – Filip Hanik VMware Jan 14 '19 at 21:43