My goal is to return 401 if the user provides invalid JWT and 403 if they don't have required roles to access endpoint (using @RolesAllowed).
Initially, Spring returned 403 for both. Then I added exceptionHandling
configuration:
WebSecurityConfig.java:
...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.cors(c -> c.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(c -> c
.requestMatchers(HttpMethod.OPTIONS).permitAll()
.requestMatchers(UNPROTECTED_PATHS).permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(c -> c
.authenticationEntryPoint((request, response, authException) -> response.sendError(
HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage() // this one
))
.accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(
HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage() // and this
))
)
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
...
JwtRequestFilter.java:
...
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtRequestFilter extends OncePerRequestFilter {
private static final String HEADER_AUTHORIZATION = "Authorization";
private static final String AUTHORIZATION_PREFIX = "Bearer";
private final JwtService jwtService;
private final UserDao userDao;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
filterRequest(request);
filterChain.doFilter(request, response);
}
private void filterRequest(HttpServletRequest request) {
Authentication existAuthentication = SecurityContextHolder.getContext().getAuthentication();
if (existAuthentication != null && existAuthentication.isAuthenticated()) {
return;
}
String token = getToken(request);
if (token == null) {
return;
}
User user = jwtService.getUser(token).orElse(null);
if (user == null) {
return;
}
userDao.register(user);
setAuthentication(request, user);
}
@Nullable
private String getToken(HttpServletRequest request) {
String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
if (authorizationHeader == null || !authorizationHeader.startsWith(AUTHORIZATION_PREFIX + " ")) {
return null;
}
return authorizationHeader.substring(7);
}
private static void setAuthentication(HttpServletRequest request, UserDetails userDetails) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
Now I always get 401. I've figured out that authenticationEntryPoint
is being called after accessDeniedHandler
when AccessDeniedException
is thrown. My guess is that the user becomes anonymous if they fail authorization. On the other hand, when provide an invalid JWT (AuthenticationException
is thrown) authenticationEntryPoint
is called twice. This doesn't seem to me as a correct behaviour.
How to prevent this?
I'm using Spring Security v.6.2.3.
Currently, I'm using a workaround with a ControllerAdvice
:
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<?> handleException(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
This way both authenticationEntryPoint
and accessDeniedHandler
are ignored.
Still don't know the reason behind the issue, but I managed to solve it using the following configuration.
WebSecurityConfig.java:
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true)
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
private final CorsConfigurationSource corsConfigurationSource;
private final BearerTokenAuthenticationFilter bearerTokenAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.rememberMe(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(c -> c.configurationSource(corsConfigurationSource))
.authorizeHttpRequests(c -> c
.requestMatchers(AuthenticationFilterConfig.UNPROTECTED_URLS).permitAll()
.requestMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(bearerTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
AuthenticationFilterConfig.java
@Configuration
@RequiredArgsConstructor
public class AuthenticationFilterConfig {
public static final RequestMatcher UNPROTECTED_URLS = new OrRequestMatcher(
Stream.of(
"/swagger-ui/**",
"/v3/api-docs/**",
"/actuator/health/**",
"/actuator/prometheus/**",
"/actuator/loggers/**",
"/ws/**"
).map(AntPathRequestMatcher::new)
.toArray(AntPathRequestMatcher[]::new)
);
private final JwtService jwtService;
@Bean
public BearerTokenAuthenticationFilter bearerTokenAuthenticationFilter(
BearerTokenResolver bearerTokenResolver,
AuthenticationManager authenticationManager
) {
var authenticationFilter = new BearerTokenAuthenticationFilter(authenticationManager) {
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
return UNPROTECTED_URLS.matches(request);
}
};
authenticationFilter.setBearerTokenResolver(bearerTokenResolver);
return authenticationFilter;
}
@Bean
public AuthenticationManager authenticationManager() {
var detailsManager = inMemoryUserDetailsManager();
return authentication -> {
Object principal = authentication.getPrincipal();
if (principal instanceof String token) {
User user = jwtService.getUser(token);
if (!detailsManager.userExists(user.getUsername())) {
detailsManager.createUser(user);
}
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
}
return null;
};
}
@Bean
public InMemoryUserDetailsManager inMemoryUserDetailsManager() {
return new InMemoryUserDetailsManager();
}
@Bean
public BearerTokenResolver bearerTokenResolver() {
var defaultResolver = new DefaultBearerTokenResolver();
return request -> {
String token = defaultResolver.resolve(request);
if (token == null) {
throw new MissingBearerTokenException("Bearer token is missing");
}
return token;
};
}
}