Search code examples
spring-mvcspring-securityspring-security-oauth2

Supporting concurrent full-stack MVC (session) authentication as well as stateless JWT authentication in Spring Authorization Server (0.2.3+)


When creating an Authorization server, I have a relatively simple AS. It supports:

  • User Registration (WebMVC)
  • FormLogin (WebMVC)
  • Forgot Password (WebMVC)
  • Managing RegisteredClient (WebMVC) - a place where someone can manage their API clients that exchange for access tokens to access other resource servers.

I also have some API @RestController endpoints; however, I'm observing that I cannot make JWT authenticated requests to them as the authentication process isn't working for those as I'm getting a session-based .formlogin() style display of the login page content rendered out with a 200 OK rather than what I'd expect -- a 401 or 403 or 200 OK but with RESTful application/json structured answers.

How can I simultaneously support the Session-based WebMVC flows as well as REST controller endpoints that rely on JWT authentication?

import org.junit.jupiter.api.Order;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class DefaultSecurityConfiguration {

    public static final int FILTER_PRECEDENCE = -5;

    @Bean
    @Order(FILTER_PRECEDENCE)
    SecurityFilterChain defaultSecurityFilterChain(final HttpSecurity http) throws Exception {
        return http.authorizeRequests(authorizeRequests ->
                        authorizeRequests
                                .mvcMatchers("/favicon.ico", "/favicon-16x16.png", "/favicon-32x32.png", "/mstile-150x150.png", "/apple-touch-icon.png", "/", "/assets/**", "/login/**", "/webjars/**", "/register", "/register-form", "/actuator/health", "/reset-password", "/reset-password-2", "/error")
                                .permitAll()
                ).formLogin(oauth2 ->
                        oauth2
                                .loginPage("/login")
                                .loginProcessingUrl("/login")
                                .defaultSuccessUrl("/")
                                .failureUrl("/login?error=1")
                                .permitAll()
                )
                .build();
    }
}

and here's my AuthorizationServerConfiguration's filter chain configuration:

@Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(final HttpSecurity http) throws Exception {
        final OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer<>();

        final RequestMatcher endpointsMatcher = authorizationServerConfigurer
                .getEndpointsMatcher();


        http
                .requestMatcher(endpointsMatcher)
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().authenticated()
                )
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
                .apply(authorizationServerConfigurer);

        return http.formLogin(Customizer.withDefaults()).build();
    }

and, for giggles, here's a sample REST endpoint that I expect to work:

@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/oauth/account")
public class AccountController {

    public static final String ACCOUNT_ID_IS_REQUIRED = "Account Id is required.";
    private final UserDetailRepository userDetailRepository;
    private final AccountService accountService;

    /**
     * Return List of all account and clients of logged in user
     *
     * @param authentication
     * @return
     */
    @GetMapping
    public List<AccountResponseDTO> findAllUserAccounts(final Authentication authentication) {
        final User user = this.userDetailRepository.findByUsername(authentication.getName()).get();
        return this.accountService.findAccountsByUserId(user.getId());
    }

Beyond the simple, I'm also curious why in the example for a custom-consent server there are repeated declarations for .formLogin() in the two different security filters. I've been playing with this quite a bit and I'm a bit stumped on the Security Filter overrides.

If I have differences in .formLogin() I observe defaults or intermittent glitchiness. If I remove .formLogin on a higher-valued @Order then login doesn't work, but I get a working REST endpoint. If I have .formLogin() in both spots I have a nicely working WebMVC side of things; however then my REST controller Authentications are returning just the JWT's bearer value rather than the Authentication Principal. Weird stuff going on here -- any help would be appreciated!


Solution

  • Consider configuring 3 security filter chains. First one is for auth, second is for calls having bearer token, and the last one for all other MVC requests:

       @Bean
       @Order(1)
       @SuppressWarnings("unused")
       public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
          OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer<>();
          RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
    
          return http
                .requestMatcher(endpointsMatcher)
                .authorizeRequests(authorizeRequests ->
                      authorizeRequests.anyRequest().authenticated()
                )
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .apply(authorizationServerConfigurer)
                .oidc(oidc -> oidc
                      .clientRegistrationEndpoint(Customizer.withDefaults())
                )
                .and()
                .formLogin(Customizer.withDefaults()).build();
       }
    
       @Bean
       @Order(2)
       @SuppressWarnings("unused")
       public SecurityFilterChain resourceServerOauthFilterChain(HttpSecurity http) throws Exception {
             http
                .requestMatcher(request -> {
                   String headerValue = request.getHeader("Authorization");
                   return headerValue != null && headerValue.startsWith("Bearer");
                })
                .authorizeRequests()
                   .anyRequest().authenticated()
                .and()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .oauth2ResourceServer().jwt(Customizer.withDefaults());
    
          return http.build();
       }
    
       @Bean
       @Order(3)
       @SuppressWarnings("unused")
       SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
          return http.authorizeRequests(authorizeRequests ->
                      authorizeRequests
                            .mvcMatchers("/favicon.ico", "/favicon-16x16.png", "/favicon-32x32.png", "/mstile-150x150.png", "/apple-touch-icon.png", "/", "/assets/**", "/login/**", "/webjars/**", "/register", "/register-form", "/actuator/health", "/reset-password", "/reset-password-2", "/error")
                            .permitAll().anyRequest().authenticated()
                )
                .formLogin(oauth2 ->
                      oauth2
                            .loginPage("/login")
                            .loginProcessingUrl("/login")
                            .defaultSuccessUrl("/")
                            .failureUrl("/login?error=1")
                            .permitAll()
                )
                .build();
       }
    

    As for your second question, I think that every filter chain have it's own filter set. Since login is implemented as a filter too, it should be added to the corresponding filter chains.

    Update: seems that you have incorrect import for Order annotation: import org.junit.jupiter.api.Order;