0

I'm using Spring Boot 3.0. The authorization just works as expected but when it hit SomeException like MethodArgumentNotValidException it just only show 403 Forbidden Access with empty body. Before I'm using Spring Boot 3.0 everything just work as I'm expected when hitting Exception like they give me the exception JSON result.

SecurityConfiguration

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
        var secret = System.getProperty("app.secret");
        var authorizationFilter = new AuthorizationFilter(secret);
        var authenticationFilter = new AuthenticationFilter(secret, authenticationManager);
        authenticationFilter.setFilterProcessesUrl("/login");
        authenticationFilter.setPostOnly(true);

        return http
                .cors().and()
                .csrf((csrf) -> csrf.disable())

                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )

                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/login/**", "/trackers/camera/**").permitAll()
                        .requestMatchers("/sites/**").hasAnyRole(Role.OWNER.name())
                        .anyRequest().authenticated()
                )

                .addFilter(authenticationFilter)
                .addFilterBefore(authorizationFilter, UsernamePasswordAuthenticationFilter.class)

                .build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        var config = new CorsConfiguration();

        var all = Arrays.asList("*");
        config.setAllowedOrigins(all);
        config.setAllowedHeaders(all);
        config.setAllowedMethods(all);
        config.setExposedHeaders(all);

        var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return source;
    }

}

AuthenticationFilter

@RequiredArgsConstructor
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final String secretToken;

    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String username, password;

        try {
            var requestMap = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
            username = requestMap.getUsername();
            password = requestMap.getPassword();
        } catch (Exception e) {
            throw new AuthenticationServiceException(e.getMessage(), e);
        }

        var token = new UsernamePasswordAuthenticationToken(username, password);
        return authenticationManager.authenticate(token);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        var user = (UserDetails) authResult.getPrincipal();
        var algorithm = Algorithm.HMAC512(secretToken.getBytes());

        var token = JWT.create()
                .withSubject(user.getUsername())
                .withIssuer(request.getRequestURL().toString())
                .withClaim("roles", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
                .sign(algorithm);

        var jsonMap = new HashMap<String, String>();
        jsonMap.put("token", token);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        new ObjectMapper().writeValue(response.getOutputStream(), jsonMap);
        response.flushBuffer();
    }
}

AuthorizationFilter

@RequiredArgsConstructor
public class AuthorizationFilter extends OncePerRequestFilter {
    private final String secretToken;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        var authentication = request.getHeader(HttpHeaders.AUTHORIZATION);

        if(authentication != null) {
            if(authentication.startsWith("Bearer")) {
                var token = authentication.substring("Bearer ".length());
                var algorithm = Algorithm.HMAC512(secretToken.getBytes());
                var verifier = JWT.require(algorithm).build();

                var message = verifier.verify(token);
                var subject = message.getSubject();
                var roles = message.getClaim("roles").asArray(String.class);
                var authorities = new ArrayList<SimpleGrantedAuthority>();
                Arrays.stream(roles).forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));

                var authenticationToken = new UsernamePasswordAuthenticationToken(subject, token, authorities);
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            } else if(authentication.startsWith("Basic")) {
                var token = authentication.substring("Basic ".length());
                var bundle = new String(Base64.getDecoder().decode(token)).split(":", 2);
                if(bundle.length == 2 && bundle[0].equals(System.getProperty("app.camera.username")) && bundle[1].equals(System.getProperty("app.camera.password"))) {
                    var authenticationToken = new UsernamePasswordAuthenticationToken("camera1", null, Arrays.asList(new SimpleGrantedAuthority(Role.USER.getAuthority())));
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }

        filterChain.doFilter(request, response);
    }
}
pengemizt
  • 837
  • 1
  • 9
  • 16

1 Answers1

1

If I understood you correctly, you want the exception-message shown in the return body of the request.

I solved this problem by implementing a (global) exception handler.

(Optional) Create a custom exception, extending some sort of other exception.

public class ApiException extends RuntimeException {

    // Not really needed here, as Throwable.java has a message too
    // I added it for better readability
    @Getter
    private String message;

    public ApiException() {
        super();
    }

    public ApiException(String message) {
        this.message = message;
    }

    public ApiException(String message, Throwable cause) {
        super(message, cause);
    }
}

(Optional) A Wrapper, with custom information. (This is the object returned in the body).

// I've used a record, as the wrapper simply has to store data
public record ApiError(String message, HttpStatus status, Throwable cause, ZonedDateTime timestamp) {}

The handler

To create the handler, you simply have to create a custom class, which extends the ResponseEntityExceptionHandler.java

@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    // The annotation's value can be replaced by any exception.
    // Use Throwable.class to handle **all** exceptions.
    // For this example I used the previously created exception.
    @ExceptionHandler(value = { ApiException.class })
    @ResponseBody
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ResponseEntity<Object> handleApiRequestException(ApiException e) {

        // At this point, you can create the exception wrapper to create a
        // formatted JSON-response, but you could also just get the info
        // required from the exception and return that.
        ApiError error = new ApiError(
                e.getMessage(),
                HttpStatus.BAD_REQUEST,
                null,
                ZonedDateTime.now(ZoneId.of("Z"))
        );

        return new ResponseEntity<>(error, error.status());
    }
}

Also: To handle different kinds of exceptions differently, like e.g. you want a ApiException to return a 403 and a FooException to return a 404, just create another method inside of the handler and adjust it to your likings.

I hope this helped!

Cheers

Z-100
  • 518
  • 3
  • 19
  • Yes exactly! I'm not really sure but before Spring Boot 3.0, I'm using exactly same method and the result is like I expected, the exception is returned to body with 400 error code instead 403 and empty response body for authenticated users. Is there any reason why this happen? – pengemizt Dec 24 '22 at 11:41
  • What exactly do you mean? The exception thrown is written into the body of the response? Wasn't that your original question? Could you please elaborate further? – Z-100 Jan 06 '23 at 19:02