5

Java 11 and Spring Security 2.7.x here. I am trying to upgrade my config away from the (deprecated) WebSecurityConfigurerAdapter-based implementation to one using SecurityFilterChain.

What's important about my implementation is that I have the ability to define and configure/wire up my own:

  • Authentication Filter (UsernamePasswordAuthenticationFilter impl)
  • Authorization Filter (BasicAuthenticationFilter impl)
  • Custom authentication error handler (AuthenticationEntryPoint impl)
  • Custom authorization error handler (AccessDeniedHandler impl)

Here's my current setup based on reading a bunch of blogs and articles:

public class ApiAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;
    private ObjectMapper objectMapper;
    private ApiAuthenticationFactory authenticationFactory;
    private TokenService tokenService;

    public ApiAuthenticationFilter(
            AuthenticationManager authenticationManager,
            TokenService tokenService,
            ObjectMapper objectMapper,
            ApiAuthenticationFactory authenticationFactory) {

        super(authenticationManager);
        this.tokenService = tokenService;
        this.objectMapper = objectMapper;
        this.authenticationFactory = authenticationFactory;

        init();

    }

    private void init() {
        setFilterProcessesUrl("/v1/auth/sign-in");
    }

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {

        try {

            SignInRequest signInRequest = objectMapper.readValue(request.getInputStream(), SignInRequest.class);

            Authentication authentication = authenticationFactory
                    .createAuthentication(signInRequest.getEmail(), signInRequest.getPassword());

            // perform authentication and -- if successful -- populate granted authorities
            return authenticationManager.authenticate(authentication);

        } catch (IOException e) {
            throw new BadCredentialsException("malformed sign-in request payload", e);
        }

    }

    @Override
    protected void successfulAuthentication(
            HttpServletRequest  request,
            HttpServletResponse response,
            FilterChain filterChain,
            Authentication authentication) {

        // called if-and-only-if the attemptAuthentication method above is successful

        ApiAuthentication apiAuthentication = (ApiAuthentication) authentication;
        TokenPair tokenPair = tokenService.generateTokenPair(apiAuthentication);
        response.setStatus(HttpServletResponse.SC_OK);
        try {
            response.getWriter().write(objectMapper.writeValueAsString(tokenPair));
        } catch (IOException e) {
            throw new ApiServiceException(e);
        }

    }

}

public class ApiAuthorizationFilter extends BasicAuthenticationFilter implements SecurityConstants {

    private ApiAuthenticationFactory authenticationFactory;
    private AuthenticationService authenticationService;
    private String jwtSecret;

    public ApiAuthorizationFilter(
            AuthenticationManager authenticationManager,
            ApiAuthenticationFactory authenticationFactory,
            AuthenticationService authenticationService,
            @Value("${myapp.jwt-secret}") String jwtSecret) {

        super(authenticationManager);
        this.authenticationFactory = authenticationFactory;
        this.authenticationService = authenticationService;
        this.jwtSecret = jwtSecret;

    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws IOException, ServletException {

        String authHeader = request.getHeader(AUTHORIZATION_HEADER);

        // allow the request through if no valid auth header is set; spring security
        // will throw access denied exceptions downstream if the request is for an
        // authenticated url
        if (authHeader == null || !authHeader.startsWith(BEARER_TOKEN_PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        // otherwise an auth header was specified so lets take a look at it and grant access based
        // on what we find
        try {

            DecodedJWT decodedJWT = JwtUtils.verifyToken(authHeader.replace(BEARER_TOKEN_PREFIX, ""), jwtSecret);
            String subject = decodedJWT.getSubject();
            ApiAuthentication authentication = authenticationFactory.createAuthentication(subject, null);

            // TODO: I believe I need to look up granted authorities here and set them

            authenticationService.setCurrentAuthentication(authentication);
            filterChain.doFilter(request, response);

        } catch (JWTVerificationException jwtVerificationEx) {
            throw new AccessDeniedException("access denied", jwtVerificationEx);
        }

    }

}

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfigV2 {

    private boolean securityDebug;

    private ObjectMapper objectMapper;
    private ApiAuthenticationFactory authenticationFactory;
    private TokenService tokenService;

    private AuthenticationService authenticationService;
    private String jwtSecret;
    private ApiUnauthorizedHandler unauthorizedHandler;
    private ApiSignInFailureHandler signInFailureHandler;

    private BCryptPasswordEncoder passwordEncoder;
    private RealmService realmService;

    @Autowired
    public SecurityConfigV2(
            @Value("${spring.security.debug:false}") boolean securityDebug,
            ObjectMapper objectMapper,
            ApiAuthenticationFactory authenticationFactory,
            TokenService tokenService,
            AuthenticationService authenticationService,
            @Value("${myapp.authentication.jwt-secret}") String jwtSecret,
            ApiUnauthorizedHandler unauthorizedHandler,
            ApiSignInFailureHandler signInFailureHandler,
            BCryptPasswordEncoder passwordEncoder,
            RealmService realmService) {
        this.securityDebug = securityDebug;
        this.objectMapper = objectMapper;
        this.authenticationFactory = authenticationFactory;
        this.tokenService = tokenService;
        this.authenticationService = authenticationService;
        this.jwtSecret = jwtSecret;
        this.unauthorizedHandler = unauthorizedHandler;
        this.signInFailureHandler = signInFailureHandler;
        this.passwordEncoder = passwordEncoder;
        this.realmService = realmService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {

        // build authentication manager
        AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManagerBuilder.class)
            .userDetailsService(realmService)
            .passwordEncoder(passwordEncoder)
            .and()
            .build();   // <-- calling it once up here, to get an AuthenticationManager instance

        // enable CSRF
        // TODO: enable once you are ready to provide 'CSRF tokens'
        //  https://stackoverflow.com/a/75646727/5235665
        httpSecurity.csrf().disable();

        // add CORS filter
        httpSecurity.cors();

        // add anonoymous/permitted paths (that is: what paths are allowed to bypass authentication)
        httpSecurity.authorizeRequests()
            .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
            .antMatchers(HttpMethod.GET, "/actuator/health").permitAll()
            .antMatchers(HttpMethod.POST, "/v*/tokens/refresh").permitAll();

        // restrict all other paths and set them to authenticated
        httpSecurity.authorizeRequests().anyRequest().authenticated();

        // add authn + authz filters -- using AuthenticationManager instance here
        httpSecurity.addFilter(apiAuthenticationFilter(authenticationManager));
        httpSecurity.addFilter(apiAuthorizationFilter(authenticationManager));

        // configure exception-handling for authn and authz
        httpSecurity.exceptionHandling().accessDeniedHandler(unauthorizedHandler);
        httpSecurity.exceptionHandling().authenticationEntryPoint(signInFailureHandler);

        // configure stateless http sessions (appropriate for RESTful web services)
        httpSecurity.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // and building it a 2nd time here, to complete the filter
        // but I believe this is what causes the error
        return httpSecurity.build();
    }

    public ApiAuthenticationFilter apiAuthenticationFilter(AuthenticationManager authenticationManager) {

        ApiAuthenticationFilter authenticationFilter = new ApiAuthenticationFilter(
                authenticationManager, tokenService, objectMapper, authenticationFactory);
        return authenticationFilter;

    }

    public ApiAuthorizationFilter apiAuthorizationFilter(AuthenticationManager authenticationManager) {

        ApiAuthorizationFilter authorizationFilter = new ApiAuthorizationFilter(
            authenticationManager,
            authenticationFactory,
            authenticationService,
            jwtSecret);

        return authorizationFilter;

    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.debug(securityDebug)
            .ignoring()
            .antMatchers("/css/**", "/js/**", "/img/**", "/lib/**", "/favicon.ico");
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {

        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        corsConfiguration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));

        UrlBasedCorsConfigurationSource corsConfigurationSource = new UrlBasedCorsConfigurationSource();
        corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);

        return corsConfigurationSource;

    }

}

When I start up my app I am getting:

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 
'org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration': 
Unsatisfied dependency expressed through method 'setFilterChains' parameter 0; nested exception is 
org.springframework.beans.factory.BeanCreationException: 
Error creating bean with name 'filterChain' defined 
in class path resource [myapp/ws/security/v2/SecurityConfigV2.class]: 
Bean instantiation via factory method failed; nested exception is 
org.springframework.beans.BeanInstantiationException: 
Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: 
Factory method 'filterChain' threw exception; 
nested exception is org.springframework.security.config.annotation.AlreadyBuiltException: 
This object has already been built

The Google Gods say this is because I'm calling httpSecurity.build() twice which is not allowed. However:

  • My authn and authz filter require an AuthenticationManager instance; and
  • It seems that the only way (please tell me if I'm wrong!) to get an AuthenticationManager instance is to run httpSecurity.build(); but
  • I need the authn/authz filter before I can call httpSecurity.build()

Can anyone help nudge me across the finish line here? Thanks for any and all help!

hotmeatballsoup
  • 385
  • 6
  • 58
  • 136
  • 1
    you can start by reading the spring security documentation and implement the handling of JWTs in accordance with the official documentation instead of implementing something custom from possibly some random bad blogpost. Spring has had built in support for JWTs since 2017 https://docs.spring.io/spring-security/reference/5.8/servlet/oauth2/resource-server/index.html its impossible for us to help you if havn't followed the official docs. So start by reading them – Toerktumlare Mar 19 '23 at 00:44
  • Hi @Toerktumlare if you need Spring Security in one of your future Spring Boot projects, I can confirm that Andrey's `AbstractHttpConfigurer`-based solution below works for me perfectly, he's been a true **life saver**! – hotmeatballsoup Mar 25 '23 at 01:23
  • both of your solutions are overly complex and not following any of the rfc's for security. Custom security is bad practice, and just because something "works" does not mean it is correct. Good luck with your custom security solution. – Toerktumlare Mar 25 '23 at 02:20

1 Answers1

1

We are doing something like:

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
  AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManagerBuilder.class)
    .userDetailsService(realmService)
    .passwordEncoder(passwordEncoder);

...

  httpSecurity.addFilter(apiAuthenticationFilter(ctx -> httpSecurity.getSharedObject(AuthenticationManager.class)));

...

}

public ApiAuthenticationFilter apiAuthenticationFilter(AuthenticationManagerResolver<?> authenticationManagerResolver) {
  return new ApiAuthenticationFilter(
    authenticationManagerResolver, 
    tokenService, 
    objectMapper, 
    authenticationFactory
  );
}

Since instance of AuthenticationManager is required in runtime only, that is enough to pass supplier to filter during configuration phase


UPD.

Well, now it is clear why you need a reference to AuthenticationManager.

First option:

In case of ApiAuthorizationFilter you are actually do not need to extend BasicAuthenticationFilter - just let spring-security to do it's job and enable basic authentication via httpSecurity.httpBasic(). For ApiAuthenticationFilter it is possible to pass AuthenticationManagerResolver or Supplier<AuthenticationManager> to it:

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
  httpSecurity.getSharedObject(AuthenticationManagerBuilder.class)
    .userDetailsService(realmService)
    .passwordEncoder(passwordEncoder);

...

  httpSecurity.addFilter(apiAuthenticationFilter(() -> httpSecurity.getSharedObject(AuthenticationManager.class)));

...

}

public ApiAuthenticationFilter apiAuthenticationFilter(Supplier<AuthenticationManager> authenticationManagerSupplier) {
  return new ApiAuthenticationFilter(
    authenticationManagerSupplier, 
    tokenService, 
    objectMapper, 
    authenticationFactory
  );
}

public class ApiAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;
    private Supplier<AuthenticationManager> authenticationManagerSupplier;
    private ObjectMapper objectMapper;
    private ApiAuthenticationFactory authenticationFactory;
    private TokenService tokenService;

    public ApiAuthenticationFilter(
            Supplier<AuthenticationManager> authenticationManagerSupplier,
            TokenService tokenService,
            ObjectMapper objectMapper,
            ApiAuthenticationFactory authenticationFactory) {

        super();
        this.tokenService = tokenService;
        this.objectMapper = objectMapper;
        this.authenticationFactory = authenticationFactory;

        init();

    }

    private void init() {
        setFilterProcessesUrl("/v1/auth/sign-in");
    }

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {

        try {

            SignInRequest signInRequest = objectMapper.readValue(request.getInputStream(), SignInRequest.class);

            Authentication authentication = authenticationFactory
                    .createAuthentication(signInRequest.getEmail(), signInRequest.getPassword());

            // perform authentication and -- if successful -- populate granted authorities
            return getAuthenticationManager().authenticate(authentication);

        } catch (IOException e) {
            throw new BadCredentialsException("malformed sign-in request payload", e);
        }

    }

    @Override
    protected void successfulAuthentication(
            HttpServletRequest  request,
            HttpServletResponse response,
            FilterChain filterChain,
            Authentication authentication) {

        // called if-and-only-if the attemptAuthentication method above is successful

        ApiAuthentication apiAuthentication = (ApiAuthentication) authentication;
        TokenPair tokenPair = tokenService.generateTokenPair(apiAuthentication);
        response.setStatus(HttpServletResponse.SC_OK);
        try {
            response.getWriter().write(objectMapper.writeValueAsString(tokenPair));
        } catch (IOException e) {
            throw new ApiServiceException(e);
        }

    }

    @Override
    protected AuthenticationManager getAuthenticationManager() {
        if (this.authenticationManager == null) {
            this.authenticationManager = authenticationManagerSupplier.get();
        }
        return this.authenticationManager;
    }

}

Second option

Write your own implementation of AbstractHttpConfigurer, should look like:

public class ApiSecurityBuilderConfigurer<H extends HttpSecurityBuilder<H>>
        extends AbstractHttpConfigurer<ApiSecurityBuilderConfigurer<H>, H> {

    private TokenService tokenService;

    private ObjectMapper objectMapper;

    private ApiAuthenticationFactory apiAuthenticationFactory;
    
    public ApiSecurityBuilderConfigurer<H> tokenService(TokenService tokenService) {
        this.tokenService = tokenService;
        return this;
    }

    public ApiSecurityBuilderConfigurer<H> objectMapper(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
        return this;
    }

    public ApiSecurityBuilderConfigurer<H> apiAuthenticationFactory(ApiAuthenticationFactory ApiAuthenticationFactory) {
        this.ApiAuthenticationFactory = ApiAuthenticationFactory;
        return this;
    }

    @Override
    public void configure(H builder) throws Exception {
        AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
        builder.addFilter(apiAuthenticationFilter(authenticationManager));
    }

    public ApiAuthenticationFilter apiAuthenticationFilter(AuthenticationManager authenticationManager) {
        return new ApiAuthenticationFilter(authenticationManager, tokenService, objectMapper, authenticationFactory);
    }

}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
  httpSecurity.apply(new ApiSecurityBuilderConfigurer())
    .tokenService(tokenService)
    .objectMapper(objectMapper)
    .apiAuthenticationFactory(apiAuthenticationFactory);

...

}
Andrey B. Panfilov
  • 4,324
  • 2
  • 12
  • 18
  • Thanks @Andrey B. Panfilov (+1) two followup questions for you if you don't mind! **(1)** Did you have to code it this way because you ran into the same issue that I am facing? And **(2)** I think your code looks great but your version of the `ApiAuthenticationFilter` is requiring an instance of an `AuthenticationManagerResolver`, _not_ an `AuthenticationManager`. Can I see what the code looks like inside your `ApiAuthenticationFilter` class that convert the resolver into a manager? Thanks again! – hotmeatballsoup Mar 20 '23 at 18:06
  • Also your code doesn't compile, at least with Spring 5.6, as the `passwordEncoder(...)` builder method returns a `DaoAuthenticationConfigurer` which cannot be assigned to an instance of `AuthenticationManager`. – hotmeatballsoup Mar 20 '23 at 20:41
  • OK I played around with your code sample and got it working with a few notable exceptions. For one I don't assign the result of `httpSecurity.getSharedObject(AuthenticationManagerBuilder.class).userDetailsService(realmService).passwordEncoder(passwordEncoder);` to anything, I just let it configure. 2nd When passing in the lambda to the provider methods, I use `ctx -> httpSecurity.getSharedObject(Authentication.class)` instead. With this configuration, it works! Thank you! If you are able/willing to update your code example and explain what is happening, I'll happily give you the bounty+check! – hotmeatballsoup Mar 21 '23 at 01:17
  • 1
    @hotmeatballsoup would you mind describing (update the Q) what classes are managed by you and what aren't, and what do you want to achieve. At first glance the problem is you are trying to mix together different security (and spring security) concepts and most probably filters could be moved to the more specific `SecurityConfigurer`, it looks like you are trying to build smth. pluggable (at least you seem to expect that `AuthenticationManager` is configured somewhere else), in that case I believe there is another option to make configuration clearer. – Andrey B. Panfilov Mar 21 '23 at 01:39
  • Thanks @Andrey (+1 again) I have added the 2 filters (1 for authentication and the other for authorization) if that helps you out any. I have full control/management over the source code, but like you say, it is entirely possible I'm mixing together different security concepts. At the end of the day, I need my security configuration to: (1) disable CSRF, (2) define my CORS configuration as-is, (3) allow me to wire in my own authentication and authorization filters, (4) define which HTTP methods and paths are permitted vs authenticated and (5) configure a stateless security session... – hotmeatballsoup Mar 21 '23 at 18:13
  • ...and I'm good with whatever simple config achieves all of that! Thanks again! – hotmeatballsoup Mar 21 '23 at 18:13
  • 1
    @hotmeatballsoup I have updated the A with some possible options. – Andrey B. Panfilov Mar 21 '23 at 23:33
  • Thanks again @Andrey B. Panfilov but I do need the custom `AuthorizationFilter`. I understand that for now `httpSecurity.httpBasic()` may cover my bases but I'd like to see a working example with a custom authorization filter. And it looks like any `BasicAuthenticationFilter` impls **require** an up-front `AuthenticationManager` in its constructor, so your (**amazing!**) suggestion to use Suppliers doesn't work. Any other ideas? – hotmeatballsoup Mar 23 '23 at 13:27
  • Is there a way to dynamically instantiate and add that filter _after_ `httpSecurity.build()` runs? – hotmeatballsoup Mar 23 '23 at 13:33
  • @hotmeatballsoup The "Second option" in my A demonstrates how to implement that. – Andrey B. Panfilov Mar 24 '23 at 14:32
  • Boom! Got it working, thank you so much Andrey! – hotmeatballsoup Mar 25 '23 at 01:22