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