Search code examples
spring-mvcspring-securityspring-java-config

Spring Security: Why is my custom AccessDecisionVoter not invoked


I'm trying to do URL authorization using a custom AccessDecisionVoter. I don't get any errors and debugging shows that my voter is picked up at start up. However, at runtime, the vote method is not called, thus allowing every authenticated user full access.

Note that, I don't need method security. I'm also not using XML config. That rules out every example ever posted on the internet regarding this topic.

@Configuration
@EnableWebSecurity
@EnableWebMvc
@ComponentScan
@Order(-10)
public class HttpSecurityConfig extends WebSecurityConfigurerAdapter {
    @Value("${trusted_ports}")
    private List<Integer> trustedPorts;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private ServiceIdAwareVoter serviceIdAwareVoter;

    RequestMatcher requestMatcher = new OrRequestMatcher(
        // @formatter:off
        new AntPathRequestMatcher("/**", GET.name()),
        new AntPathRequestMatcher("/**", POST.name()), 
        new AntPathRequestMatcher("/**", DELETE.name()),
        new AntPathRequestMatcher("/**", PATCH.name()), 
        new AntPathRequestMatcher("/**", PUT.name())
        // @formatter:on
    );

    @Override
    protected UserDetailsService userDetailsService() {
        return userDetailsService;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(preAuthProvider());
        auth.authenticationProvider(authProvider());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http.
            httpBasic().and().
            authorizeRequests().anyRequest().fullyAuthenticated().
            accessDecisionManager(accessDecisionManager()).and().
            csrf().disable().
            logout().disable().
            exceptionHandling().and().
            sessionManagement().sessionCreationPolicy(STATELESS).and().
            anonymous().disable().
            addFilterAfter(preAuthFilter(), X509AuthenticationFilter.class).
            addFilter(authFilter());
        // @formatter:on
    }

    AccessDecisionManager accessDecisionManager() {
        return new UnanimousBased(ImmutableList.of(serviceIdAwareVoter));
    }

    Filter preAuthFilter() throws Exception {
        PreAuthenticationFilter preAuthFilter = new PreAuthenticationFilter(trustedPorts);

        preAuthFilter.setAuthenticationManager(super.authenticationManager());

        return preAuthFilter;
    }

    PreAuthenticatedAuthenticationProvider preAuthProvider() {
        PreAuthenticatedAuthenticationProvider preAuthProvider = new PreAuthenticatedAuthenticationProvider();
        UserDetailsByNameServiceWrapper<PreAuthenticatedAuthenticationToken> userDetailsServiceWrapper = new UserDetailsByNameServiceWrapper<>();

        userDetailsServiceWrapper.setUserDetailsService(userDetailsService());

        preAuthProvider.setPreAuthenticatedUserDetailsService(userDetailsServiceWrapper);

        return preAuthProvider;
    }

    Filter authFilter() throws Exception {
        AppIdAppKeyAuthenticationFilter authFilter = new AppIdAppKeyAuthenticationFilter(requestMatcher);
        authFilter.setAuthenticationFailureHandler(new ExceptionStoringAuthenticationFailureHandler());
        authFilter.setAuthenticationSuccessHandler(new UrlForwardingAuthenticationSuccessHandler());

        authFilter.setAuthenticationManager(authenticationManagerBean());

        return authFilter;
    }

    AuthenticationProvider authProvider() {
        AppIdAppKeyAuthenticationProvider authProvider = new AppIdAppKeyAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService());

        return authProvider;
    }

Solution

  • Background:

    After hours of debugging, I found out the root cause of the problem, which is really deep. Part of it is due to the fact that the Spring Security Java config is very poorly documented (for which I've opened a JIRA ticket). Theirs, as well as most online, examples are copy-pasted from XML config whereas the world has stopped using Spring XML config since probably 2010. Another part is due to the fact that REST service security is an afterthought in the Spring Security design and they don't have first-class support for protecting applications that don't have a login page, error page and the usual view layer. Last but not the least is that there were several (mis)configurations in my app which all came together and created a perfect storm of mind-boggling complexity.

    Technical Context:

    Using the authorizeRequests() configures a ExpressionUrlAuthorizationConfigurer which ultimately sets up a UnanimousBased AccessDecisionManager with a WebExpressionVoter. This AccessDecisionManager is called from the FilterSecurityInterceptor if the authentication succeeds (obviously there's no point in authorization if the user fails authentication in the first place).

    Issues:

    • In my AbstractAnnotationConfigDispatcherServletInitializer subclass, which is basically the Java version of the web.xml, I'd configured filters not to intercept forward requests. I'm not going to go into the why here. For the interested, here's an example of how it's done:

      private Dynamic registerCorsFilter(ServletContext ctx) {
          Dynamic registration = ctx.addFilter("CorsFilter", CorsFilter.class);
      
          registration.addMappingForUrlPatterns(getDispatcherTypes(), false, "/*");    
          return registration;
      }
      
      private EnumSet<DispatcherType> getDispatcherTypes() {
          return (isAsyncSupported() ? EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ASYNC)
        : EnumSet.of(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE));
      }
      

    If you take the DispatcherType.FORWARD out of the dispatcher types set, the registered filter doesn't kick in for that kind of request.

    • The authFilter shown in my question extended from UsernamePasswordAuthenticationFilter and had an AuthenticationSuccessHandler which forwarded the request to the destination URL after successful authentication. The default Spring implementation uses a SavedRequestAwareAuthenticationSuccessHandler which does a redirect to a webpage, which is unwanted in the context of a REST app.
    • Due to the above 2 reasons, the FilterSecurityInterceptor was not invoked after successful authentication which in turn, skipped the authorization chain causing the issue in my original post.

    Fix:

    • Get rid of custom dispatcher configuration from web app initializer.
    • Don't do forward, or redirect, from AuthenticationSuccessHandler. Just let the request take it's natural course.
    • The custom voter has a vote method that looks as follows:

      public int vote(Authentication authentication, FilterInvocation fi,
              Collection<ConfigAttribute> attributes) {
      }
      

    The attributes in my case, as shown in my original post, is the string expression fullyAuthenticated. I didn't use it for authorization as I already knew the user to have been authenticated through the various filters in the authentication flow.

    I hope this serves as documentation for all those souls who're suffering from the lack of documentation in Spring Security Java config.