Custom login form. Configure Spring security to get a JSON response

Thanks to M. Deinum and thanks to this guide, I could find the solution.

First, I had a configuration problem with the login form itself. As the backend has a context-path set to /api, the custom form should have submitted the form params to /api/login but I was actually submitting the data to /api/login/ (Notice the extra / at the end).

As a result, I was unknowingly trying to access a protected resource! Hence, the request was handled by the default AuthenticationEntryPoint which default behavior is to redirect the user to the login page.

As a solution, I implemented a custom AuthenticationEntryPoint :

private AuthenticationEntryPoint authenticationEntryPoint() {
  return new AuthenticationEntryPoint() {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
      httpServletResponse.getWriter().append("Not authenticated");
      httpServletResponse.setStatus(401);
    }
  };
}

Then used it in the configuration :

http
  .exceptionHandling()
  .authenticationEntryPoint(authenticationEntryPoint())

and I did the same for the other handlers :

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
        .withUser("user").password("password").roles("ADMIN");
  }

  @Override
  public void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
          .anyRequest().authenticated()
        .and()
          .formLogin()
          .successHandler(successHandler())
          .failureHandler(failureHandler())
        .and()
          .exceptionHandling()
            .accessDeniedHandler(accessDeniedHandler())
            .authenticationEntryPoint(authenticationEntryPoint())
        .and()
          .csrf().csrfTokenRepository(csrfTokenRepository()).and().addFilterAfter(csrfHeaderFilter(), CsrfFilter.class)
    ;
  }

  private AuthenticationSuccessHandler successHandler() {
    return new AuthenticationSuccessHandler() {
      @Override
      public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        httpServletResponse.getWriter().append("OK");
        httpServletResponse.setStatus(200);
      }
    };
  }

  private AuthenticationFailureHandler failureHandler() {
    return new AuthenticationFailureHandler() {
      @Override
      public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.getWriter().append("Authentication failure");
        httpServletResponse.setStatus(401);
      }
    };
  }

  private AccessDeniedHandler accessDeniedHandler() {
    return new AccessDeniedHandler() {
      @Override
      public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        httpServletResponse.getWriter().append("Access denied");
        httpServletResponse.setStatus(403);
      }
    };
  }

  private AuthenticationEntryPoint authenticationEntryPoint() {
    return new AuthenticationEntryPoint() {
      @Override
      public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.getWriter().append("Not authenticated");
        httpServletResponse.setStatus(401);
      }
    };
  }

  private Filter csrfHeaderFilter() {
    return new OncePerRequestFilter() {
      @Override
      protected void doFilterInternal(HttpServletRequest request,
                                      HttpServletResponse response, FilterChain filterChain)
          throws ServletException, IOException {
        CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class
            .getName());
        if (csrf != null) {
          Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
          String token = csrf.getToken();
          if (cookie == null || token != null
              && !token.equals(cookie.getValue())) {
            cookie = new Cookie("XSRF-TOKEN", token);
            cookie.setPath("/");
            response.addCookie(cookie);
          }
        }
        filterChain.doFilter(request, response);
      }
    };
  }

  private CsrfTokenRepository csrfTokenRepository() {
    HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
    repository.setHeaderName("X-XSRF-TOKEN");
    return repository;
  }
}

Here's configuration for Spring Boot 2.2.5.RELEASE:

package com.may.config.security;

import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_OK;
import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
            .withUser("user").password(passwordEncoder().encode("user")).roles("USER").and()
            .withUser("admin").password(passwordEncoder().encode("admin")).roles("USER", "ADMIN");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .requestCache().disable() // do not preserve original request before redirecting to login page as we will return status code instead of redirect to login page (this is important to disable otherwise session will be created on every request (not containing sessionId/authToken) to non existing endpoint aka curl -i -X GET 'http://localhost:8080/unknown')
            .authorizeRequests()
                .antMatchers("/health", "/swagger-ui.html/**", "/swagger-resources/**", "/webjars/springfox-swagger-ui/**", "/v2/api-docs").permitAll()
                .anyRequest().hasRole("USER").and()
            .exceptionHandling()
                .accessDeniedHandler((req, resp, ex) -> resp.setStatus(SC_FORBIDDEN)) // if someone tries to access protected resource but doesn't have enough permissions
                .authenticationEntryPoint((req, resp, ex) -> resp.setStatus(SC_UNAUTHORIZED)).and() // if someone tries to access protected resource without being authenticated (LoginUrlAuthenticationEntryPoint used by default)
            .formLogin()
                .loginProcessingUrl("/login") // authentication url
                .successHandler((req, resp, auth) -> resp.setStatus(SC_OK)) // success authentication
                .failureHandler((req, resp, ex) -> resp.setStatus(SC_FORBIDDEN)).and() // bad credentials
            .sessionManagement()
                .invalidSessionStrategy((req, resp) -> resp.setStatus(SC_UNAUTHORIZED)).and() // if user provided expired session id
            .logout()
                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()); // return status code on logout
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Important aspects here:

http.requestCache().disable()

important to disable otherwise new session will be created on every request to non existing endpoint (e.g. curl -i -X GET 'http://localhost:8080/unknown')

at least this is how it works with spring-session configured in the project

if not overridden - ExceptionTranslationFilter will use requestCache to preserve original URL to session (that creates session if non is exist) while handling AccessDeniedException.

http.sessionManagement().invalidSessionStrategy((req, resp) -> resp.setStatus(SC_UNAUTHORIZED))

return 401 status code in case user supplied expired sessionId in request

if not overridden - fallbacks to authenticationEntryPoint

can be helpful to provide meaningful message in response (aka "Your session has expired")

http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());

return 200 status code on logout

if not overridden - redirects web client to login page