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:
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
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:
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:
Even the role heirarchy now works perfectly:
@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);
}
}