18

I have a simple application which is splitted into 2 parts :

  • A backend which exposes REST services with Spring-boot / Spring-security
  • A frontend which contains only static files.

The requests are received by a nginx server which listens on port 80.

  • If the request URL begins with /api/, the request is redirected to the backend.
  • Else, the request is handled by nginx which serves the static files.

I created a custom login form (in the frontend part) and I am trying to configure the Spring-boot server.

There are a lot of examples where I can see how to define a "login success" url and a "login error" url but I do not want Spring-security to redirect the user. I want Spring-security to answer with HTTP 200 if the login succeeded or HTTP 40x is the login failed.

In other words : I want the backend to only answer with JSON, never HTML.

Up to now, when I submit the login form, the request is redirected and I get the default Spring login form as an answer.

I tried to use .formLogin().loginProcessingUrl("/login"); instead of loginPage("") :

@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()
        .loginProcessingUrl("/login");
Arnaud Denoyelle
  • 29,980
  • 16
  • 92
  • 148
  • The `loingProcessingUrl` is the url that reacts on a submit, the `loginPage` is the page you are redirected to when you aren't authenticated. Underneath it uses a `SimpleUrlAuthenticationFailureHandler`. You can simply implement your own handler which returns the http code. The same goes for the `AuthenticationSuccessHandler` you can simply create one yourself to do what you want. Then instead of the `loginPage` and `errorPage` you use the `successHandler` and `failureHandler` instead. – M. Deinum Sep 10 '15 at 11:18
  • @M.Deinum Thank you for helping. I tried `.formLogin().successHandler(new AuthenticationSuccessHandler() {...}).failureHandler(new AuthenticationFailureHandler() {...})` but I still have the same problem. It seems to me that there is still a default "loginPage()" which is used instead of my handlers. – Arnaud Denoyelle Sep 10 '15 at 13:47
  • If you don't set it and only set the succes and failure handlers there isn't. Unless you have multiple security configurations. One thing you would also need to override the entry point with the `Http401AuthenticationEntryPoint` not sure how you can easily do that with java config. – M. Deinum Sep 10 '15 at 13:58
  • @M.Deinum You put me on the right track with those handlers. The problem was that 1) I was not submitting the form to the right url (`/api/login/` instead of `/api/login`). Hence, I was unknowingly trying to access a protected resource. 2) As a consequence, the AuthenticationEntryPoint caught the request and applied its default behavior : redirect the user to the login page. – Arnaud Denoyelle Sep 10 '15 at 16:16

2 Answers2

24

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;
  }
}
Arnaud Denoyelle
  • 29,980
  • 16
  • 92
  • 148
  • 1
    You don't need a custom entry point, spring security already provides that... The `Http401AuthenticationEntryPoint`. – M. Deinum Sep 10 '15 at 18:05
  • @M.Deinum Seems interesting but I cannot find it in the classpath. I found `Http403ForbiddenEntryPoint` instead. – Arnaud Denoyelle Sep 11 '15 at 09:41
  • Well that was my mistake :). It is provided by Spring Boot not Spring Security. – M. Deinum Sep 11 '15 at 10:35
  • 1
    The class ```org.springframework.boot.autoconfigure.security.Http401AuthenticationEntryPoint``` was removed in favor of ```org.springframework.security.web.authentication.HttpStatusEntryPoint```. Full explanation and example at https://stackoverflow.com/questions/49241384/401-instead-of-403-with-spring-boot-2 – Shell_Leko Sep 06 '19 at 15:35
4

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

Eugene Maysyuk
  • 2,977
  • 25
  • 24