Search code examples
spring-bootspring-securitythymeleaf

Static resources are not loaded with Spring Security


I have a custom authentication mechanism for my app. I have token authentication for /api/** endpoints and login form for /manager/** and /viewer/**. I have @RestController for api and @Contoller web pages. The problem occures when I access the pages - the static content doesn't load and request are responded with net::ERR_ABORTED 401 (Unauthorized). The request for the page (/viewer/home) also responded with 401 but has actual html body which is then rendered and dtos are also loaded. Worth mentioning that calling in browser http://localhost:8080/css/operator.css returns a css file without errors.

    @GetMapping("/viewer/home")
    public String operatorHome(Model model) {
        // logic...
        model.addAttribute("files", dtos);
        return "operator-home";
    }

The security configuration is:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Autowired
    private AuthenticationService authService;

    @Bean
    @Order(1)
    public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/manager/**").hasAuthority(Role.MANAGER.name())
                        .requestMatchers("/viewer/**").hasAuthority(Role.OPERATOR.name())
                        .requestMatchers("/resources/**", "/static/**", "/css/**", "/js/**", "/images/**").permitAll()
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                        .anyRequest().permitAll()
                )
                .formLogin((form) -> form.successHandler(new CustomAuthenticationSuccessHandler()))
                .logout(LogoutConfigurer::permitAll)
                .httpBasic(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/api/**").hasAuthority(Role.USER.name())
                        .anyRequest().permitAll()
                )
                .addFilterBefore(new TokenAuthenticationFilter(authService), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .httpBasic(Customizer.withDefaults())
        ;
        return http.build();
    }

    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(authService);
    }

    @Bean
    CustomUserDetailsService customUserDetailsService() {
        return new CustomUserDetailsService();
    }

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

The problem persists even if I comment out second filterchain (api) and leave anyRequest().permitAll(). I tested rest api in Postman and security works just fine there.

HTML:

    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> <!-- this is fine -->
    <link rel="stylesheet" type="text/css" th:href="@{/css/operator.css}"> <!-- this is not loaded --> 

Logs for retreiving the HTML page:

DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] o.s.security.web.FilterChainProxy        : Securing GET /viewer/home
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] w.c.HttpSessionSecurityContextRepository : Retrieved SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=test_operator, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[OPERATOR]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=9E3858A4DC3CD548374F4874C21539D6], Granted Authorities=[OPERATOR]]]
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] o.s.security.web.FilterChainProxy        : Secured GET /viewer/home
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] o.s.web.servlet.DispatcherServlet        : GET "/viewer/home", parameters={}
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.lavkatech.audiorecognition.controller.WebController#operatorHome(Model)
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] org.hibernate.SQL                        : select ao1_0.id,ao1_0.audio_len,ao1_0.checked_by_id,ao1_0.checked_on,ao1_0.file_loc,ao1_0.file_name,ao1_0.file_size,ao1_0.file_text,ao1_0.is_checked,ao1_0.op_end_time,ao1_0.op_start_time,ao1_0.requested_by_value from files ao1_0
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] o.s.w.s.v.ContentNegotiatingViewResolver : Selected 'text/html' given [text/html, application/xhtml+xml, image/avif, image/webp, image/apng, application/xml;q=0.9, */*;q=0.8, application/signed-exchange;v=b3;q=0.7]
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-5] o.s.web.servlet.DispatcherServlet        : Completed 401 UNAUTHORIZED
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-4] o.s.security.web.FilterChainProxy        : Securing GET /css/operator.css
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-4] o.s.security.web.FilterChainProxy        : Secured GET /css/operator.css
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-4] o.s.web.servlet.DispatcherServlet        : GET "/css/operator.css", parameters={}
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-4] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped to ResourceHttpRequestHandler [classpath [META-INF/resources/], classpath [resources/], classpath [static/], classpath [public/], ServletContext [/]]
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-4] o.s.web.servlet.DispatcherServlet        : Completed 401 UNAUTHORIZED
DEBUG 13628 --- [XXXXXXXXXXXX] [nio-8080-exec-4] w.c.HttpSessionSecurityContextRepository : Retrieved SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=test_operator, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[OPERATOR]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=9E3858A4DC3CD548374F4874C21539D6], Granted Authorities=[OPERATOR]]]

Solution

  • As was pointed out by @dur in the comments the second filter wasn't ever reached. Also two filtes were initialized in the configuration.
    1:

    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(authService);
    }
    

    2:

    .addFilterBefore(new TokenAuthenticationFilter(authService), UsernamePasswordAuthenticationFilter.class)
    

    Because the filter was exposed in a bean it was used for the autorization together with Dao which is specified in the webFilterChain as formLogin. To handle it I used securityMatcher (like here) to distinguish the responsible filter chain.

    http.securityMatcher( request ->
        Optional.ofNullable(
            request.getHeader(HttpHeaders.AUTHORIZATION))
            .map(h -> h.startsWith("Bearer ")
        ).orElse(false)
    );
    

    also got rid of the bean with filter and swapped the order. The complete security config is:

    @Configuration
    @EnableWebSecurity
    public class WebSecurityConfig {
    
        @Autowired
        private AuthenticationService authService;
    
        @Bean
        @Order(1)
        public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
            http.securityMatcher( request ->
                    Optional.ofNullable(
                                    request.getHeader(HttpHeaders.AUTHORIZATION))
                            .map(h -> h.startsWith("Bearer ")
                            ).orElse(false)
            );
            http
                    .csrf(AbstractHttpConfigurer::disable)
                    .authorizeHttpRequests((authorize) -> authorize
                            .requestMatchers("/api/**").hasAuthority(Role.USER.name())
                            .anyRequest().authenticated()
                    )
                    .addFilterBefore(new TokenAuthenticationFilter(authService), UsernamePasswordAuthenticationFilter.class)
                    .sessionManagement((session) -> session
                            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    )
                    .httpBasic(Customizer.withDefaults())
            ;
            return http.build();
        }
    
        @Bean
        @Order(2)
        public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
            http
                    .csrf(AbstractHttpConfigurer::disable)
                    .authorizeHttpRequests((authorize) -> authorize
                            .requestMatchers("/manager/**").hasAuthority(Role.MANAGER.name())
                            .requestMatchers("/viewer/**").hasAuthority(Role.OPERATOR.name())
                            .requestMatchers("/resources/**", "/static/**", "/css/**", "/js/**", "/images/**").permitAll()
                            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                            .anyRequest().authenticated()
                    )
                    .formLogin((form) -> form.successHandler(new CustomAuthenticationSuccessHandler()))
                    .logout(LogoutConfigurer::permitAll)
                    .httpBasic(Customizer.withDefaults());
    
            return http.build();
        }
    
        @Bean
        CustomUserDetailsService customUserDetailsService() {
            return new CustomUserDetailsService();
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder(10);
        }
    }