spring-projects / spring-security

Spring Security
http://spring.io/projects/spring-security
Apache License 2.0
8.55k stars 5.79k forks source link

CSRF example for Single-Page Apps could be improved #15105

Closed jarekkar closed 1 month ago

jarekkar commented 1 month ago

Expected Behavior

Please provide a description in the documentation on how to properly set up CSRF protection with SPA and OAuth2Login.

Current Behavior

The current documentation (version 6.2.4) provides a description for BasicAuthentication: https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa

Context

The solution described in the documentation does save the XSRF-TOKEN cookie after authentication. I have tried several approaches on my own, but they did not work consistently. I found a solution in this comment: https://github.com/spring-projects/spring-security/issues/14149#issuecomment-1813453080, and it works. However, I am unsure if this is the recommended approach.

Could you please provide an official description in the documentation (and as a response to that issue) on how to properly adjust the described solution to work well with OAuth2Login?

sjohnr commented 1 month ago

@jarekkar thank you for providing feedback on the current documentation so that we can improve it!

The solution described in the documentation does save the XSRF-TOKEN cookie after authentication. I have tried several approaches on my own, but they did not work consistently. I found a solution in this comment: #14149 (comment), and it works. However, I am unsure if this is the recommended approach.

The sample code in the comment is a variation on the code provided in the docs. In order to proceed and find the best enhancement to the docs, can you please describe in more detail the issues you encountered following the current recommendation in the docs?

Could you please provide an official description in the documentation (and as a response to that issue) on how to properly adjust the described solution to work well with OAuth2Login?

I am not aware of any specific issues with the example working with OAuth2 Login. Can you please clarify what issues you encountered or provide a sample that demonstrates?

kcsurapaneni commented 1 month ago

@sjohnr @jarekkar I'd like to highlight that there's an issue with the CSRF protection for Single Page Applications (SPA) official documentation, as outlined in this issue. Exercise care before using/implementing it.

jarekkar commented 1 month ago

@sjohnr Here is my setup:

@Configuration(proxyBeanMethods = false)
class SecurityConfig {

    @Order(1)
    @Bean
    SecurityFilterChain authenticatedFilterChain(
            HttpSecurity http,
            ClientRegistrationRepository clientRegistrationRepository
    ) throws Exception {
        String[] all = {
                "/oauth2/authorization/app",
                "/login/oauth2/code/app",
                "/account"
        };

        DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(
                clientRegistrationRepository,
                DEFAULT_AUTHORIZATION_REQUEST_BASE_URI
        );
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());

        http
                .securityMatcher(all)
                .csrf(csrf -> {
                    csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
                    csrf.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler());
                })
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                .sessionManagement(session -> {
                    session.sessionCreationPolicy(ALWAYS);
                })
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .oauth2Login(oauth2 -> {
                    oauth2.authorizationEndpoint(endpoint -> {
                        endpoint.authorizationRequestResolver(resolver);
                        endpoint.authorizationRequestRepository(new HttpSessionOAuth2AuthorizationRequestRepository());
                    });
                    oauth2.successHandler(new SimpleUrlAuthenticationSuccessHandler("https://my-domain/home"));
                })
                .exceptionHandling(
                        exception -> exception.authenticationEntryPoint(new HttpStatusEntryPoint(UNAUTHORIZED))
                );
        return http.build();
    }

    final class CsrfCookieFilter extends OncePerRequestFilter {

        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                FilterChain filterChain)
                throws ServletException, IOException {
            CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
            csrfToken.getToken();
            filterChain.doFilter(request, response);
        }
    }
}
public final class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {

    private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler();

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
        this.delegate.handle(request, response, csrfToken);
    }

    @Override
    public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
            return super.resolveCsrfTokenValue(request, csrfToken);
        }
        return this.delegate.resolveCsrfTokenValue(request, csrfToken);
    }
}
spring:
  security:
    oauth2:
      client:
        provider:
          idp:
            issuer-uri: ${app.idp.issuer.uri}
            user-name-attribute: ${app.idp.claims.user-id}
        registration:
          app:
            client-id: ${app.idp.client.id}
            client-secret: ${app.idp.client.secret}
            authorization-grant-type: authorization_code
            scope: ${app.idp.scope}
            client-authentication-method: client_secret_post
            provider: idp

Key urls:

Flow:

  1. To login browser is making GET http://localhost:8080/oauth2/authorization/app
  2. Redirect to IDP
  3. User consent
  4. Redirect back to http://localhost:8080/login/oauth2/code/app, response 302, location: https://my-domain/home
  5. Redirect to https://my-domain/home

I would expect that service (in response in step 4.) stores XSRF-TOKEN cookie in the browser, so next request, e.g. POST /accounts can be protected with CSRF token.

Unfortunately cookie is not stored so I started digging on how to enable such behaviour.

I tested it with EntraID (Azure AD) and Okta.

sjohnr commented 1 month ago

@kcsurapaneni

I'd like to highlight that there's an issue with the CSRF protection for Single Page Applications (SPA) official documentation, as outlined in this issue. Exercise care before using/implementing it.

That issue was just closed as invalid. Please see this comment.

@jarekkar,

Here is my setup

Thanks for providing additional details including your configuration! I now believe I understand the issue you're having.

The documentation example recommends providing a Filter to read the value of the deferred token (DeferredCsrfToken), triggering the cookie to be written to the response. This only occurs when the filter is executed. In your configuration, you use SimpleUrlAuthenticationSuccessHandler to redirect to an external site from the OAuth2LoginAuthenticationFilter prior to the custom filter being executed.

In the case of an external redirect, you would first want to allow the entire filter chain to be executed. This can be achieved by redirecting to a Spring MVC @Controller, for example under GET /app (or any other URL you choose) which then performs a redirect to your frontend app. This would allow the filter provided in the example to cause the cookie to be written prior to the redirect to your frontend app.

There are numerous other ways to handle this situation, depending on preference and other considerations, but it's difficult to anticipate or capture all of those in reference documentation. I worry that covering too many specific scenarios involving frontend view technologies will overwhelm readers. Using a Filter is not the only way to achieve the goal the docs aim to provide for, but I had hoped it made abundantly clear the distinct elements of the solution for SPAs. Given gh-14149 aims to improve the configuration to hopefully be more self-contained, perhaps we should adjust the documentation example to do something similar without the use of a servlet filter.

If you don't mind, I'd like to repurpose this ticket to address that in the documentation.