spring-bootspring-sessionhttpsessionspring-security-6

Security Context with HttpSessionSecurityContextRepository always returns 403 after successful authentication, Spring Boot 3.3


Im revising and learning Spring Boot. With the 3.3 latest version, there are issues saving the security context in the session.

What I am trying to do is have a regular Server side stateful session based login with a JavaScript/React front-end. So I want to be able to login using JSON from the browser, but then easily use cookies. Everything seems to be fine, the authentication works, but on subsequent requests I am getting 403 for every protected route. I am using Postman, as this is a dummy project, later I will have my own front-end

I am practicing manual authentication in a Rest Controller endpoint so I can send the username and password as JSON

The authentication bit is working fine but for some reason the security context is not being properly set in the session, although the authentication is happening properly.

Below is all the code and the results from the debugging.

I learnt about persistence here: https://docs.spring.io/spring-security/reference/servlet/authentication/persistence.html

And followed the example here: https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html#understanding-session-management-components

The authentication is working perfectly fine, but the context is not getting properly saved in the session repository even after the successful login and definitely not on subsequent requests.

There isn't much other documentation out there about how to deal with this, the other answers here seem to be very old and not applicable to Spring boot 3.1

Here is all the code:

Main Security config:

@Configuration
@EnableWebSecurity(debug = true)
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private MuserDetailsService muserDetailsService;


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        /**
         * Since I am using method security,
         * I do not need to do too many customizer requests here,
         * But for the basic ones, it seems this is important ?
         * Check notes
         */
        http.authorizeHttpRequests(customizer -> {
            customizer
                .requestMatchers("/", "/public" , "/login", "/all-methods").permitAll()
                .requestMatchers(HttpMethod.GET, "/csrf/latest").permitAll();
        });

        /**
         * CSRF can be disbaled like so:
         */
//      http.csrf((csrf) -> {
//          csrf.disable();
//      });

        /**
         * Adding the custom filters to deal with the filter chain,
         * This filter is added after the CsrfFilter so I can access it
         */
        //http.addFilterAfter(new MyCsrfTokenLazyLoadFilter(), CsrfFilter.class);

        /**
         * Here I am adding the security context repository
         */
//      http.securityContext(context -> {
//          context.requireExplicitSave(true);
//      });

        /**
         * This returns the Security filter chain
         */
        return http.build();
    }

    /**
     * Here i am publishing my AuthenticationManager Bean,
     * It is important to do this in order to secure Api's
     */
    @Bean
    public AuthenticationManager getAuthenticationManager() throws Exception {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(muserDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(authProvider);
    }

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
        return roleHierarchy;
    }

    @Bean
    public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy());
        return expressionHandler;
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }


}

Creating the session beans:

@Configuration
public class SessionBeanConfig {

    /**
     * This does not need to be created as a Bean,
     * I could instantiate it directly in the controller,
     * But I am just doing it here, this will be used to 
     * manually create a session,
     */
    @Bean
    public HttpSessionSecurityContextRepository getSecurityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }

    @Bean
    public SecurityContextHolderStrategy getSecurityContextHolderStrategy() {
        return SecurityContextHolder.getContextHolderStrategy();
    }

}

The Login Api:

@RestController
public class LoginApi {

    @Autowired
    private HttpSessionSecurityContextRepository repo;

    @Autowired
    private SecurityContextHolderStrategy securityContextHolderStrategy;

    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * A simple login request that takes the login and responds with the same info and a message
     * This works, the only issue here is persistence between requests using a regular session
     * @return
     */
    @PostMapping("/login")
    public LoginResponse loginPost(
        @RequestBody LoginRequest loginRequest,
        HttpServletRequest request,
        HttpServletResponse response
     ) {

        Authentication authentication = new UsernamePasswordAuthenticationToken(
            loginRequest.username(),
            loginRequest.password()
        );

        Authentication authenticated = authenticationManager.authenticate(authentication);

        //First I create the empty context after authentication
        SecurityContext context = securityContextHolderStrategy.createEmptyContext();

        //Add the authenticated token to the security context
        context.setAuthentication(authenticated);

        //Add the whole context to the context holder strategy instead of regular SecurityContextHolder
        securityContextHolderStrategy.setContext(context);

        //Now i save the context in the session, needs to be done explicitly in this scenario:
        repo.saveContext(context, request, response);

        

        //Return the new LoginResponse
        return new LoginResponse(
            loginRequest.username(),
            loginRequest.password(),
            true,
            "Login successful!",
            authenticated,
            null
        );
    }

}

Records representing the request/response

public record LoginRequest(
    String username,
    String password
) {
}

public record LoginResponse(
    String username,
    String password,
    Boolean result,
    String message,
    Authentication token,
    SecurityContext securityContext
) {
}

Exposing a CSRF end point to test from postman:

@RestController
@RequestMapping("/csrf")
public class CsrfApi {

    @GetMapping("/latest")
    public CsrfToken getLatest(CsrfToken token) {
        return token;
    }

}

Api end points for testing:

@RestController public class EndPoints {

@GetMapping("/")
public String index() {
    return "Welcome!";
}

@GetMapping("/public")
public String publicIndex() {
    return "Public!";
}

@GetMapping("/private")
@PreAuthorize("isAuthenticated()")
public String privateIndex() {
    return "Private!";
}

@RequestMapping("/all-methods")
public String allMethods() {
    return "All Methods";
}

}

@RestController
@RequestMapping("/child")
@PreAuthorize("hasRole('CHILD')")
public class ChildApi {

    @RequestMapping("")

    public String index() {
        return "Child Api Home!";
    }

    @RequestMapping("/create")
    @PreAuthorize("hasAuthority('CHILD_CREATE')")
    public String create() {
        return "Child Api created";
    }

    @RequestMapping("/read")
    @PreAuthorize("hasAuthority('CHILD_READ')")
    public String read() {
        return "Child Api read";
    }

    @RequestMapping("/update")
    @PreAuthorize("hasAuthority('CHILD_UPDATE')")
    public String update() {
        return "Child Api updated";
    }

    @RequestMapping("/delete")
    @PreAuthorize("hasAuthority('CHILD_DELETE')")
    public String delete() {
        return "Child Api deleted";
    }

}

@RestController
@RequestMapping("/parent")
@PreAuthorize("hasRole('PARENT')")
public class ParentApi {

    @RequestMapping("")
    public String index() {
        return "Parent Api Home!";
    }

    @RequestMapping("/create")
    @PreAuthorize("hasAuthority('PARENT_CREATE')")
    public String create() {
        return "Parent Api created";
    }

    @RequestMapping("/read")
    @PreAuthorize("hasAuthority('PARENT_READ')")
    public String read() {
        return "Parent Api read";
    }

    @RequestMapping("/update")
    @PreAuthorize("hasAuthority('PARENT_UPDATE')")
    public String update() {
        return "Parent Api updated";
    }

    @RequestMapping("/delete")
    @PreAuthorize("hasAuthority('PARENT_DELETE')")
    public String delete() {
        return "Parent Api deleted";
    }

}

@RestController
@RequestMapping("/gp")
@PreAuthorize("hasRole('ROLE_GRANDPARENT')")
public class GrandParentApi {

    @RequestMapping("")
    public String index() {
        return "Grand parent Api Home!";
    }

    @RequestMapping("/create")
    @PreAuthorize("hasAuthority('GRANDPARENT_CREATE')")
    public String create() {
        return "GrandParent Api created";
    }

    @RequestMapping("/read")
    @PreAuthorize("hasAuthority('GRANDPARENT_READ')")
    public String read() {
        return "GrandParent Api read";
    }

    @RequestMapping("/update")
    @PreAuthorize("hasAuthority('GRANDPARENT_UPDATE')")
    public String update() {
        return "GrandParent Api updated";
    }

    @RequestMapping("/delete")
    @PreAuthorize("hasAuthority('GRANDPARENT_DELETE')")
    public String delete() {
        return "GrandParent Api deleted";
    }

}

@RestController
@RequestMapping("/mythical")
@PreAuthorize("hasRole('ROLE_MYTHICAL_USER')")
public class MythicalApi {

    @GetMapping("")
    public String index() {
        return "Mythical User Home!";
    }

    @GetMapping("/create")
    public String create() {
        return "Mythical User Create!";
    }

    @GetMapping("/read")
    public String read() {
        return "Mythical User Read!";
    }

    @GetMapping("/update")
    public String update() {
        return "Mythical User Update!";
    }

    @GetMapping("/delete")
    public String delete() {
        return "Mythical User Delete!";
    }
}

I am not adding the rest of the authentication code as it works and I dont want this to get too bloated.

Here I am showing some of the results:

Login Works fine:

Returning successful response after login

Request to private end point is forbidden

There is no output or trace in the log:


Request received for GET '/private':

org.apache.catalina.connector.RequestFacade@2add0c0d

servletPath:/private pathInfo:null headers: user-agent: PostmanRuntime/7.39.0 accept: / cache-control: no-cache postman-token: 80036f66-c518-4e44-8557-cc99aa43c3b4 host: localhost:8080 accept-encoding: gzip, deflate, br connection: keep-alive cookie: JSESSIONID=3EABB2B10BF2079416CADD3ED85BDBB0

Security filter chain: [ DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter CorsFilter CsrfFilter LogoutFilter RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter ExceptionTranslationFilter AuthorizationFilter ]


2024-07-06T22:44:53.137+01:00 INFO 3618 --- [RestApiAuthentication] [nio-8080-exec-5] Spring Security Debugger :


Request received for GET '/error':

org.apache.catalina.core.ApplicationHttpRequest@7a475766

servletPath:/error pathInfo:null headers: user-agent: PostmanRuntime/7.39.0 accept: / cache-control: no-cache postman-token: 80036f66-c518-4e44-8557-cc99aa43c3b4 host: localhost:8080 accept-encoding: gzip, deflate, br connection: keep-alive cookie: JSESSIONID=3EABB2B10BF2079416CADD3ED85BDBB0

Security filter chain: [ DisableEncodeUrlFilter WebAsyncManagerIntegrationFilter SecurityContextHolderFilter HeaderWriterFilter CorsFilter CsrfFilter LogoutFilter RequestCacheAwareFilter SecurityContextHolderAwareRequestFilter AnonymousAuthenticationFilter ExceptionTranslationFilter AuthorizationFilter ]


If I do a debug I see that the securityContext is always null in the HttpSessionRepository class.

Any advice would be greatly appreciated

I am not sure why I am getting a 403


Solution

  • I had to debug the entire filter chain to figure out what happened.

    The authentication side was perfect, but the issue was with authorization.

    Basically because there was method level authorisation as well as request matchers defined in the configuration file. The authorisation filter was only looping over the request matchers from the configuration file, in this example there were only five routes defined: /, /login, /all-methods, /public, /csrf/latest. Once i removed the request matchers everything works fine.

    I figured this out by creating a basic filter that simply logs the security context after every filter in the filter chain and I found the security context was logging fine.

    Only at the last filter which is the Authorisation filter, there was an AccesDeniedException being thrown.

    After debugging the code in that filter, I finally figured it out.

    Here is some of the code:

    @Configuration
    @EnableWebSecurity(debug = true)
    @EnableMethodSecurity
    public class SecurityConfig {
    
        @Autowired
        private MuserDetailsService muserDetailsService;
    
        @Autowired
        private MySecurityContextLogFilter mySecurityContextLogFilter;
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
            /**
             * This is a filter that logs the security context,
             * Shows the security context is correct before the last filter in the
             * filter chain
             */
            //http.addFilterAfter(new MyCsrfTokenLazyLoadFilter(), CsrfFilter.class);
            http.addFilterBefore(mySecurityContextLogFilter, AuthorizationFilter.class);
    
            return http.build();
        }
    
        /**
         * Here i am publishing my AuthenticationManager Bean,
         * It is important to do this in order to secure Api's
         */
        @Bean
        public AuthenticationManager getAuthenticationManager() throws Exception {
            DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
            authProvider.setUserDetailsService(muserDetailsService);
            authProvider.setPasswordEncoder(passwordEncoder());
            return new ProviderManager(authProvider);
        }
    
        @Bean
        public RoleHierarchy roleHierarchy() {
            RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
            roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
            return roleHierarchy;
        }
    
        @Bean
        public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
            DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
            expressionHandler.setRoleHierarchy(roleHierarchy());
            return expressionHandler;
        }
    
        @Bean
        PasswordEncoder passwordEncoder() {
            return NoOpPasswordEncoder.getInstance();
        }
    }
    

    A filter class that logs the security context:

    @Component
    @Slf4j
    public class MySecurityContextLogFilter extends OncePerRequestFilter {
    
        @Autowired
        private HttpSessionSecurityContextRepository securityContextRepository;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            log.info("MySecurityContextCheckerFilter doFilterInternal");
            log.info("SecurityContext from repository exists? : " + securityContextRepository
                    .containsContext(request)
            );
    
            log.info("Authentication object: " +
                securityContextRepository.loadDeferredContext(request)
                    .get()
                    .getAuthentication()
                );
    
            filterChain.doFilter(request, response);
        }
    }
    

    Here I can see the filter logging the security context as authenticated:

    Logging results:

    During debugging, this is where i found the issue in the AuthorizationFilter.java doFilter method, The access denied exception was thrown

    Now, i can see the results work perfectly:

    Authenticating as the child to also show the role hierarchy working:

    Login working fine

    End point working fine

    Even the role heirarchy now works perfectly:

    Child end point working fine

    Role heirarchy restricting parent routes for the child

    @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
    
        if (this.observeOncePerRequest && isApplied(request)) {
            chain.doFilter(request, response);
            return;
        }
    
        if (skipDispatch(request)) {
            chain.doFilter(request, response);
            return;
        }
    
        String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
        request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
        try {
            AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
            this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
            if (decision != null && !decision.isGranted()) {
                throw new AccessDeniedException("Access Denied");
            }
            chain.doFilter(request, response);
        }
        finally {
            request.removeAttribute(alreadyFilteredAttributeName);
        }
    }