ApiSecurityConfiguration.java

/*
 * Copyright (C) 2022 B3Partners B.V.
 *
 * SPDX-License-Identifier: MIT
 */
package org.tailormap.api.security;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Collections;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.server.Cookie;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationEventPublisher;
import org.springframework.security.authentication.DefaultAuthenticationEventPublisher;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.tailormap.api.persistence.Group;
import org.tailormap.api.repository.OIDCConfigurationRepository;
import org.tailormap.api.security.events.DefaultAuthenticationFailureEvent;
import org.tailormap.api.security.events.OAuth2AuthenticationFailureEvent;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ApiSecurityConfiguration {
  private final TailormapOidcUserService oidcUserService;

  @Value("${tailormap-api.base-path}")
  private String apiBasePath;

  @Value("${tailormap-api.admin.base-path}")
  private String adminApiBasePath;

  @Value("${tailormap-api.security.disable-csrf:false}")
  private boolean disableCsrf;

  public ApiSecurityConfiguration(TailormapOidcUserService oidcUserService) {
    this.oidcUserService = oidcUserService;
  }

  @Bean
  public CookieCsrfTokenRepository csrfTokenRepository() {
    // Spring CSRF protection requires an X-XSRF-TOKEN header read from the XSRF-TOKEN cookie by
    // JavaScript so set HttpOnly to false. Angular has automatic XSRF protection support:
    // https://angular.io/guide/http#security-xsrf-protection
    CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
    // Allow cross-domain non-GET (unsafe) requests for embedding with an iframe
    csrfTokenRepository.setCookieCustomizer(cookieCustomizer -> {
      // Do not set SameSite=None when testing with HTTP instead of HTTPS
      // Ideally use HttpServletRequest.isSecure(), but look at built cookie for now...
      if (cookieCustomizer.build().isSecure()) {
        cookieCustomizer.sameSite(Cookie.SameSite.NONE.attributeValue());
      }
    });
    return csrfTokenRepository;
  }

  @Bean
  public AuthenticationEventPublisher authenticationEventPublisher(
      ApplicationEventPublisher applicationEventPublisher) {
    DefaultAuthenticationEventPublisher authenticationEventPublisher =
        new DefaultAuthenticationEventPublisher(applicationEventPublisher);

    authenticationEventPublisher.setAdditionalExceptionMappings(
        Collections.singletonMap(OAuth2AuthenticationException.class, OAuth2AuthenticationFailureEvent.class));

    authenticationEventPublisher.setDefaultAuthenticationFailureEvent(DefaultAuthenticationFailureEvent.class);

    return authenticationEventPublisher;
  }

  @Bean
  public SecurityFilterChain apiFilterChain(HttpSecurity http, CookieCsrfTokenRepository csrfTokenRepository)
      throws Exception {

    // Disable CSRF protection for development with HAL explorer
    // https://github.com/spring-projects/spring-data-rest/issues/1347
    if (disableCsrf) {
      http.csrf(AbstractHttpConfigurer::disable);
    } else {
      // https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa
      http.csrf(csrf -> csrf.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
          .csrfTokenRepository(csrfTokenRepository)
          .ignoringRequestMatchers(
              // This uses POST for large filter in body, but is safe (read-only)
              apiBasePath + "/{viewerKind}/{viewerName}/layer/{appLayerId}/features",
              // Allow PUT for ingest metrics, but only for allowed metrics
              apiBasePath + "/{viewerKind}/{viewerName}/metrics/ingest/{appLayerId}/{allowedMetric}"));
      http.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
    }

    // Before redirecting the user to the OAuth2 authorization endpoint, store the requested
    // redirect URL.
    RedirectStrategy redirectStrategy = new DefaultRedirectStrategy() {
      @Override
      public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url)
          throws IOException {
        String redirectUrl = request.getParameter("redirectUrl");
        if (redirectUrl != null && redirectUrl.startsWith("/")) {
          request.getSession().setAttribute("redirectUrl", redirectUrl);
        }
        super.sendRedirect(request, response, url);
      }
    };

    // When OAuth2 authentication succeeds, use the redirect URL stored in the session to send them
    // back.
    AuthenticationSuccessHandler authenticationSuccessHandler = (request, response, authentication) -> {
      HttpSession session = request.getSession(false);
      if (session != null) {
        String redirectUrl = (String) session.getAttribute("redirectUrl");
        if (redirectUrl != null) {
          response.sendRedirect(redirectUrl);
          return;
        }
      }
      response.sendRedirect("/");
    };

    http.securityMatchers(matchers -> matchers.requestMatchers(apiBasePath + "/**"))
        .addFilterAfter(
            /* (debug) log user making the request */ new AuditInterceptor(),
            AnonymousAuthenticationFilter.class)
        .authorizeHttpRequests(authorize -> {
          authorize.requestMatchers(adminApiBasePath + "/**").hasAuthority(Group.ADMIN);
          authorize.requestMatchers(apiBasePath + "/**").permitAll();
        })
        .formLogin(formLogin ->
            formLogin.loginPage(apiBasePath + "/unauthorized").loginProcessingUrl(apiBasePath + "/login"))
        .oauth2Login(login -> login.authorizationEndpoint(
                endpoint -> endpoint.baseUri(apiBasePath + "/oauth2/authorization")
                    .authorizationRedirectStrategy(redirectStrategy))
            .redirectionEndpoint(endpoint -> endpoint.baseUri(apiBasePath + "/oauth2/callback"))
            .userInfoEndpoint(endpoint -> endpoint.oidcUserService(oidcUserService))
            .successHandler(authenticationSuccessHandler))
        .anonymous(anonymous -> anonymous.authorities(Group.ANONYMOUS))
        .logout(logout -> logout.logoutUrl(apiBasePath + "/logout")
            .logoutSuccessHandler((request, response, authentication) ->
                response.sendError(HttpStatus.OK.value(), "OK")));
    return http.build();
  }

  @Bean
  public OIDCRepository clientRegistrationRepository(OIDCConfigurationRepository repository) {
    return new OIDCRepository(repository);
  }
}