2

I have a Spring Boot application with two security configurations (two WebSecurityConfigurerAdapters), one for a REST API with "/api/**" endpoints, and one for a web front-end at all other endpoints. The security configuration is here on Github and here's some relevant parts of it:

@Configuration
@Order(1)
public static class APISecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JWTAuthenticationFilter jwtAuthenticationFilter = new JWTAuthenticationFilter(authenticationManager());
        jwtAuthenticationFilter.setFilterProcessesUrl("/api/login");
        jwtAuthenticationFilter.setPostOnly(true);

        http.antMatcher("/api/**")
                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                .csrf().disable()
                .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                .addFilter(jwtAuthenticationFilter)
                .addFilter(new JWTAuthorizationFilter(authenticationManager()));
    }
}

@Configuration
public static class FrontEndSecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                .loginPage("/login").permitAll()
                .defaultSuccessUrl("/")
                .and()
                .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/?logout")
                .and()
                .authorizeRequests()
                .mvcMatchers("/").permitAll()
                .mvcMatchers("/home").authenticated()
                .anyRequest().denyAll()
                .and();
    }
}

The JWTAuthenticationFilter is a custom subclass of UsernamePasswordAuthenticationFilter that processes sign-in attempts to the REST API (by HTTP POST with a JSON body to /api/login) and returns a JWT token in the "Authorization" header if successful.

So here's the issue: failed login attempts to /api/login (either with bad credentials or missing JSON body) are redirecting to the HTML login form /api/login. Non-authenticated requests to other "/api/**" endpoints result in a simple JSON response such as:

{
    "timestamp": "2019-11-22T21:03:07.892+0000",
    "status": 403,
    "error": "Forbidden",
    "message": "Access Denied",
    "path": "/api/v1/agency"
}

{
    "timestamp": "2019-11-22T21:04:46.663+0000",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/api/v1/badlink"
}

Attempts to access other protected URLs (not starting with "/api/") by a non-authenticated user redirect to the login form /login, which is the desired behavior. But I don't want API calls to /api/login to redirect to that form!

How can I code the correct behavior for failed API logins? Is it a question of adding a new handler for that filter? Or maybe adding an exclusion to some behavior I've already defined?

More detail on the exception and handling:

I looked at the logs, and the exception being thrown for either bad credentials or malformed JSON is a subclass of org.springframework.security.authentication.AuthenticationException. The logs show, for example:

webapp_1  | 2019-11-25 15:30:16.048 DEBUG 1 --- [nio-8080-exec-5] n.j.w.g.config.JWTAuthenticationFilter   : Authentication request failed: org.springframework.security.authentication.BadCredentialsException: Bad credentials
(...stack trace...)
webapp_1  | 2019-11-25 15:30:16.049 DEBUG 1 --- [nio-8080-exec-5] n.j.w.g.config.JWTAuthenticationFilter   : Updated SecurityContextHolder to contain null Authentication
webapp_1  | 2019-11-25 15:30:16.049 DEBUG 1 --- [nio-8080-exec-5] n.j.w.g.config.JWTAuthenticationFilter   : Delegating to authentication failure handler org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler@7f9648b6
webapp_1  | 2019-11-25 15:30:16.133 DEBUG 1 --- [nio-8080-exec-6] n.j.webapps.granite.home.HomeController  : Accessing /login page.

When I access another URL, for example one that doesn't exist such as /api/x, it looks very different. It's pretty verbose but it looks like the server is trying to redirect to /error and is not finding that to be an authorized URL. Interestingly if I try this in a web browser I get the error formatted with my custom error page (error.html), but if I access it with Postman I just get a JSON message. A sample of the logs:

webapp_1  | 2019-11-25 16:07:22.157 DEBUG 1 --- [nio-8080-exec-6] o.s.s.w.a.ExceptionTranslationFilter     : Access is denied (user is anonymous); redirecting to authentication entry point
webapp_1  |
webapp_1  | org.springframework.security.access.AccessDeniedException: Access is denied
...
webapp_1  | 2019-11-25 16:07:22.174 DEBUG 1 --- [nio-8080-exec-6] o.s.s.w.a.ExceptionTranslationFilter     : Calling Authentication entry point.
webapp_1  | 2019-11-25 16:07:22.175 DEBUG 1 --- [nio-8080-exec-6] o.s.s.w.a.Http403ForbiddenEntryPoint     : Pre-authenticated entry point called. Rejecting access
...
webapp_1  | 2019-11-25 16:07:22.211 DEBUG 1 --- [nio-8080-exec-6] o.s.s.w.u.matcher.AntPathRequestMatcher  : Checking match of request : '/error'; against '/api/**'
...
webapp_1  | 2019-11-25 16:07:22.214 DEBUG 1 --- [nio-8080-exec-6] o.s.security.web.FilterChainProxy        : /error reached end of additional filter chain; proceeding with original chain
webapp_1  | 2019-11-25 16:07:22.226 DEBUG 1 --- [nio-8080-exec-6] o.s.web.servlet.DispatcherServlet        : "ERROR" dispatch for GET "/error", parameters={}
webapp_1  | 2019-11-25 16:07:22.230 DEBUG 1 --- [nio-8080-exec-6] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
webapp_1  | 2019-11-25 16:07:22.564 DEBUG 1 --- [nio-8080-exec-6] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [*/*] and supported [application/json, application/*+json, application/json, application/*+json]
webapp_1  | 2019-11-25 16:07:22.577 DEBUG 1 --- [nio-8080-exec-6] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [{timestamp=Mon Nov 25 16:07:22 GMT 2019, status=403, error=Forbidden, message=Access Denied, path=/a (truncated)...]
webapp_1  | 2019-11-25 16:07:22.903 DEBUG 1 --- [nio-8080-exec-6] w.c.HttpSessionSecurityContextRepository : SecurityContext is empty or contents are anonymous - context will not be stored in HttpSession.
webapp_1  | 2019-11-25 16:07:22.905 DEBUG 1 --- [nio-8080-exec-6] o.s.web.servlet.DispatcherServlet        : Exiting from "ERROR" dispatch, status 403

So it looks like what I maybe need to do is to configure the "authentication failure handler" for the REST API to go to "/error" instead of going to "/login", but only for endpoints under /api/**.

workerjoe
  • 2,421
  • 1
  • 26
  • 49
  • Have a look at my answer here https://stackoverflow.com/a/58894899/2825798 – PraveenKumar Lalasangi Nov 22 '19 at 22:16
  • Do you throw an AutheticationException in a method in your filter when authentication fails? – NatFar Nov 23 '19 at 01:46
  • @NatFar My JWTAuthenticationFilter is [here on Github](https://github.com/joeclark-phd/granite/blob/master/src/main/java/net/joeclark/webapps/granite/config/JWTAuthenticationFilter.java) and it calls `authenticationManager.authenticate()` which I *assume* throws that exception. The `authenticationManager` itself is not a custom implementation, so it must be a Spring default implementation. – workerjoe Nov 25 '19 at 15:06
  • @PraveenKumarLalasangi I see that your solution simply implements authentication as an endpoint in a RestController, and only uses a security filter for authorization. That may well be a better way to do it, but I still want to find a solution that works with an authentication filter. – workerjoe Nov 25 '19 at 15:19
  • @NatFar I checked the logs and tweaked the code, so the exceptions thrown are now AuthenticationException types. – workerjoe Nov 25 '19 at 16:20
  • You're right about the authentication failure handler... but if you haven't set it, the default should send back a 401 and error message... not redirect. – NatFar Nov 25 '19 at 20:17
  • @NatFar I feel like it might be because I'm using `.loginForm` in the other configuration. Somehow the failure handler from that might be getting autowired over. I did find a quick fix by overriding the `unsuccessfulAuthentication()` method of my authentication filter. – workerjoe Nov 25 '19 at 21:01

1 Answers1

1

I added an @Override of unsuccessfulAuthentication() to my Authentication filter

The grandparent class (AbstractAuthenticationProcessingFilter) has a method for unsuccessful authentications (i.e. AuthenticationException) which delegates to an authentication failure handler class. I could have created my own custom authentication failure handler, but instead decided to simply override the unsuccessfulAuthentication method with some code that sends back a response with a 401 status and a JSON error message:

@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
    // TODO: enrich/improve error messages
    response.setStatus(response.SC_UNAUTHORIZED);
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
    response.getWriter().write("{\"error\": \"authentication error?\"}");
}

...and added a custom AuthenticationEntryPoint to make the other errors match

This doesn't have the exact form of the error messages I had seen at other endpoints, so I also created a custom AuthenticationEntryPoint (the class that handles unauthorized requests to protected endpoints) and it does basically the same thing. My implementation:

public class RESTAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        // TODO: enrich/improve error messages
        response.setStatus(response.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
        response.getWriter().write("{\"error\": \"unauthorized?\"}");
    }
}

Now the security configuration for the REST endpoints looks like this (note the addition of ".exceptionHandling().authenticationEntryPoint()":

@Configuration
@Order(1)
public static class APISecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        JWTAuthenticationFilter jwtAuthenticationFilter = new JWTAuthenticationFilter(authenticationManager());
        jwtAuthenticationFilter.setFilterProcessesUrl("/api/login");

        http.antMatcher("/api/**")
                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                .csrf().disable()
                .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                .exceptionHandling()
                    .authenticationEntryPoint(new RESTAuthenticationEntryPoint())
                    .and()
                .addFilter(jwtAuthenticationFilter)
                .addFilter(new JWTAuthorizationFilter(authenticationManager()));
    }
}

I'll need to work on my error messages so they're more informative and secure. And this doesn't exactly answer my original question, which was how to get those default-style JSON responses I liked, but it does allow me to customize the errors from all types of access failures independently of the web configuration. (Endpoints outside of /api/** still work with the web form login.)

Full code as of the current commit: github

workerjoe
  • 2,421
  • 1
  • 26
  • 49