Cannot solve 403 Forbidden when using open API with spring boot and swagger and allowing other requests

594 Views Asked by At

I've looked in so many questions on here, but with no solutions that work for me.
I have a service that I want to run open api and the swagger ui on.
I have no problems running them without security, and when I try add basic authentication with the code below it works as I'd like, BUT it also puts all my other API's behind the same authentication, which I do not want. I thought I could add an additional requestMatcher like this:

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.authorizeHttpRequests()
                    .requestMatchers("/swagger-ui/**",
                                     "/v3/api-docs/**",
                                     "/v3/api-docs",
                                     "/swagger-ui.html")
                    .hasRole("USER")
                    .requestMatchers("/**").permitAll()
                    .and()
                    .httpBasic(Customizer.withDefaults());
        return httpSecurity.build();
    }

When I do this, my other API's are. accessible without security as I expect, and when I go to the swagger ui I get the popup, I can log in, but then the get request to /v3/api-config fails with a 403 forbidden error or a 400 error, depending on where the permitAll requestMatcher is located.
What is the correct way to do this?
I want the swagger api's to be behind basic auth, but all other api's to be accessible by anyone.
I cannot find an existing answer to help with this.
My current security config is below, this blocks everything and allows swagger after authentication:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    public static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.authorizeHttpRequests()
                    .requestMatchers("/swagger-ui/**",
                                     "/v3/api-docs/**",
                                     "/v3/api-docs",
                                     "/swagger-ui.html")
                    .hasRole("USER")
                    .and()
                    .httpBasic(Customizer.withDefaults());
        return httpSecurity.build();

    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails user = User.withUsername("user")
                               .password(passwordEncoder.encode("password"))
                               .roles("USER")
                               .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
2

There are 2 best solutions below

0
On

So this was a weird one. In the service I'm working on we have an endpoint called /error.
Spring has a default error controller which also has an endpoint called /error.
This was never a problem before, but apparently with the inclusion of spring-security something changed and it became a problem.
Spring couldn't figure out which resource to use for whatever it was trying to do, and so selected the wrong one, which resulted in weird behaviours.
Interestingly there were no actual errors.
In the debug logs I noticed that we were calling /error for some reason (still can't figure that out) and there was a warning that the expected body wasn't in the request, which was odd because we weren't calling that resource, and the GET request that was failing didn't have a body.
It wasn't until we turned on trace logs that we could see that there were two conflicting /error resources, and spring was picking out resource instead of the one it was looking for.
We renamed our error resource to something else and all the issues went away.
I find it weird that spring doesn't complain properly when that happens though. Surely that sort of conflict requires more than a trace log?
In any case, it is working now.

1
On

For having authentication on swagger only, i've implemented two different filter chains. One for swagger and the other for the whole service. this is how my SecurityConfig looks like:

http.addFilterBefore(ssoAuthFilter, UsernamePasswordAuthenticationFilter.class);

        http.authorizeHttpRequests(c ->
                            c.requestMatchers("/swagger-ui").hasAnyAuthority("STAFF","ADMIN","CASHIER","TENANTADMIN")
                                    .requestMatchers("/api-docs/swagger-config").permitAll()
                                    .requestMatchers("/swagger-ui/login").permitAll()
                                    .requestMatchers("/actuator/health").permitAll()
                                    .requestMatchers("/actuator/prometheus").permitAll()
                                    .requestMatchers("/api-docs").hasAnyAuthority("STAFF","ADMIN", "CASHIER", "TENANTADMIN")
                                    .requestMatchers("/api-docs/eod").hasAnyAuthority("ADMIN", "STAFF", "TENANTADMIN", "CASHIER")
                                    .requestMatchers("/api-docs/statistics").hasAnyAuthority("ADMIN", "STAFF", "CASHIER", "TENANTADMIN")
                                    .dispatcherTypeMatchers( DispatcherType.ERROR ).permitAll()
                            .anyRequest().authenticated()
        );

        http.formLogin(form -> form
                .loginPage("/swagger-ui/login")
                .loginProcessingUrl("/swagger-ui/login")
                .successHandler(myAuthenticationSuccessHandler())
                .failureHandler(myAuthenticationFailureHandler())
                .permitAll()
        );

        http.addFilterBefore(swaggerAuthFilter, UsernamePasswordAuthenticationFilter.class).exceptionHandling(handling -> {
            handling.accessDeniedHandler(accessDeniedHandler());
            handling.authenticationEntryPoint(new CustomHttp403ForbiddenEntryPoint());
        });

        http.authenticationProvider(swaggerAuthProvider);

As you can see i've SSOAuthFilter for my whole service and SwaggerAuthFilter for swagger purposes only.

You'll also need a AuthenticationProvider:

   public class SwaggerAuthProvider implements AuthenticationProvider {
        private UserService userService;
    
        @Autowired
        public SwaggerAuthProvider(UserService userService) {
            this.userService = userService;
        }
    
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            String username = authentication.getName();
            String password = authentication.getCredentials().toString();
            Optional<WhoAmIResponse> whoAmIResponse = userService.authenticate(new LoginRequest(username,password));
    
            if (whoAmIResponse.isEmpty()) {
                log.debug("Authentication failed. Invalid credentials.");
                throw new BadCredentialsException("Authentication failed. Invalid credentials.");
            }
    
            // Create a new authenticated token with the JWT token
            return new JwtAuthenticationToken(
                    whoAmIResponse.get().getToken(),
                    username, password,
                    List.of(
                            new SimpleGrantedAuthority(
                                whoAmIResponse.get().getType().toUpperCase()
                            )
                    )
            );
        }
    
        @Override
        public boolean supports(Class<?> authentication) {
            return authentication.isAssignableFrom(JwtAuthenticationToken.class);
        }
    }

And this is how my SwaggerAuthFilter looks like:

public class SwaggerAuthFilter extends OncePerRequestFilter {

    private final MyUserDetailsService userDetailsService;
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Autowired
    public SwaggerAuthFilter(MyUserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    private String checkCookie(Cookie tokenCookie){
        String token = tokenCookie.getValue();
        if(token == null || token.length() < 7){
            log.debug("token problem with length {}", token.length());
        }

        if(token.startsWith("Bearer"))
            return token.substring(7);
        return token;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String url = req.getRequestURI();
        if(
                (!req.getRequestURI().contains("swagger-ui") &&
                !req.getRequestURI().contains("api-docs")) ||
                req.getRequestURI().contains("login")
        ){
            log.debug("request with uri: {} was not associated with swagger filter", url);
            chain.doFilter(req, res);
            return;
        }

        Cookie tokenCookie = WebUtils.getCookie(req, "token");
        if(tokenCookie == null){
            log.debug("token cookie is null, redirecting to login page");
            redirectStrategy.sendRedirect(req, res, "/swagger-ui/login");
            return;
        }
        String token = checkCookie(tokenCookie);

        UsernamePasswordAuthenticationToken authentication;
        try {
            UserDetails userDetails = userDetailsService.loadUserByUsername(token);
            if(userDetails == null){
                log.debug("user retrieved by token in cookie is null, redirecting to login page");
                tokenCookie.setMaxAge(0);
                res.addCookie(tokenCookie);
                redirectStrategy.sendRedirect(req, res, "/swagger-ui/login");
                return;
            }
            authentication =
                    new JwtAuthenticationToken(token,userDetails,
                            null,
                            userDetails.getAuthorities());

            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));

        } catch (AuthException | NullPointerException | IllegalArgumentException e) {
            log.debug("cannot authorize because {}", e.getLocalizedMessage());
            authentication = null;
        }
        SecurityContextHolder.getContext().setAuthentication(authentication); // set the user here
        chain.doFilter(req, res);
    }
}

Hope that this helps you resolve your issue!