From 158e279aebb139cd3807c47d09a5ae64a6d5a527 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 12 Mar 2025 11:11:07 +0100 Subject: [PATCH] fix Security-Chain-Integration --- .../config/AuthenticationFilter.java | 52 ------------- .../hsadminng/config/Authenticator.java | 8 -- .../config/CasAuthenticationFilter.java | 34 ++++++++ .../hsadminng/config/CasAuthenticator.java | 77 +------------------ .../config/RealCasAuthenticator.java | 71 +++++++++++++++++ .../hsadminng/config/WebSecurityConfig.java | 36 ++++++--- ...asAuthenticationFilterIntegrationTest.java | 4 +- .../config/CasAuthenticatorUnitTest.java | 2 +- .../config/DisableSecurityConfig.java | 4 +- ...ticator.java => FakeCasAuthenticator.java} | 2 +- 10 files changed, 140 insertions(+), 150 deletions(-) delete mode 100644 src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java delete mode 100644 src/main/java/net/hostsharing/hsadminng/config/Authenticator.java create mode 100644 src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java create mode 100644 src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java rename src/test/java/net/hostsharing/hsadminng/config/{FakeAuthenticator.java => FakeCasAuthenticator.java} (81%) diff --git a/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java deleted file mode 100644 index fb080f87..00000000 --- a/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java +++ /dev/null @@ -1,52 +0,0 @@ -package net.hostsharing.hsadminng.config; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import lombok.SneakyThrows; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.stereotype.Component; -import org.springframework.util.AntPathMatcher; -import org.springframework.web.filter.OncePerRequestFilter; - - -import static java.util.Arrays.stream; -import static net.hostsharing.hsadminng.config.WebSecurityConfig.AUTHENTICATED_PATHS; -import static net.hostsharing.hsadminng.config.WebSecurityConfig.PERMITTED_PATHS; - -@Component -public class AuthenticationFilter extends OncePerRequestFilter { - - private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher(); - - @Autowired - private Authenticator authenticator; - - @Override - @SneakyThrows - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { - - final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); - - // TODO.impl: Make request matchers work via Spring Security, maybe use Spring Security CAS support directly? - - if (stream(PERMITTED_PATHS).anyMatch(path -> PATH_MATCHER.match(path, request.getRequestURI()))) { - authenticatedRequest.addHeader("current-subject", "nobody"); - filterChain.doFilter(authenticatedRequest, response); - } else if (stream(AUTHENTICATED_PATHS).anyMatch(path -> PATH_MATCHER.match(path, request.getRequestURI()))) { - try { - final var currentSubject = authenticator.authenticate(request); - authenticatedRequest.addHeader("current-subject", currentSubject); - filterChain.doFilter(authenticatedRequest, response); - } catch (final BadCredentialsException exc) { - // TODO.impl: should not be necessary if ResponseStatusException worked - FIXME: try removing - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - } - } else { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - } - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/config/Authenticator.java b/src/main/java/net/hostsharing/hsadminng/config/Authenticator.java deleted file mode 100644 index 13f4ada4..00000000 --- a/src/main/java/net/hostsharing/hsadminng/config/Authenticator.java +++ /dev/null @@ -1,8 +0,0 @@ -package net.hostsharing.hsadminng.config; - -import jakarta.servlet.http.HttpServletRequest; - -public interface Authenticator { - - String authenticate(final HttpServletRequest httpRequest); -} diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java new file mode 100644 index 00000000..87e297bc --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.config; + +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +// Do NOT use @Component (or similar) here, this would register the filter directly. +// But we need to register it in the SecurityFilterChain created by WebSecurityConfig. +// The bean gets created in net.hostsharing.hsadminng.config.WebSecurityConfig.authenticationFilter. +@AllArgsConstructor +public class CasAuthenticationFilter extends OncePerRequestFilter { + + private CasAuthenticator authenticator; + + @Override + @SneakyThrows + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { + + final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); + + if (request.getHeader("Authorization") != null) { + final var currentSubject = authenticator.authenticate(request); + authenticatedRequest.addHeader("current-subject", currentSubject); + } else { + authenticatedRequest.addHeader("current-subject", "nobody"); + } + filterChain.doFilter(authenticatedRequest, response); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java index dac0cf6e..b063a61e 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java @@ -1,81 +1,8 @@ package net.hostsharing.hsadminng.config; -import io.micrometer.core.annotation.Timed; -import lombok.SneakyThrows; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.client.RestTemplate; -import org.xml.sax.SAXException; - import jakarta.servlet.http.HttpServletRequest; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import java.io.IOException; -import java.util.function.Supplier; -public class CasAuthenticator implements Authenticator { +public interface CasAuthenticator { - @Value("${hsadminng.cas.server}") - private String casServerUrl; - - @Value("${hsadminng.cas.service}") - private String serviceUrl; - - private final RestTemplate restTemplate = new RestTemplate(); - - @SneakyThrows - @Timed("app.cas.authenticate") - public String authenticate(final HttpServletRequest httpRequest) { - final var userName = StringUtils.isBlank(casServerUrl) - ? bypassCurrentSubject(httpRequest) - : casValidation(httpRequest); - final var authentication = new UsernamePasswordAuthenticationToken(userName, null, null); - SecurityContextHolder.getContext().setAuthentication(authentication); - return authentication.getName(); - } - - private static String bypassCurrentSubject(final HttpServletRequest httpRequest) { - final var userName = httpRequest.getHeader("current-subject"); - System.err.println("CasAuthenticator.bypassCurrentSubject: " + userName); - return userName; - } - - private String casValidation(final HttpServletRequest httpRequest) - throws SAXException, IOException, ParserConfigurationException { - - final var ticket = httpRequest.getHeader("Authorization"); - final var url = casServerUrl + "/p3/serviceValidate" + - "?service=" + serviceUrl + - "&ticket=" + ticket; - - System.err.println("CasAuthenticator.casValidation using URL: " + url); - - final var response = tryTo( () -> restTemplate.getForObject(url, String.class)); - - final var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() - .parse(new java.io.ByteArrayInputStream(response.getBytes())); - if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) { - // TODO.impl: for unknown reasons, this results in a 403 FORBIDDEN - // throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "CAS service ticket could not be validated"); - System.err.println("CAS service ticket could not be validated"); - System.err.println("CAS-validation-URL: " + url); - System.err.println(response); - throw new BadCredentialsException("CAS service ticket could not be validated"); - } - final var userName = doc.getElementsByTagName("cas:user").item(0).getTextContent(); - System.err.println("CAS-user: " + userName); - return userName; - } - - private T tryTo(final Supplier code) { - try { - final T resultValue = code.get(); - return resultValue; - } catch (final Exception e) { - throw e; - } - } + String authenticate(final HttpServletRequest httpRequest); } diff --git a/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java new file mode 100644 index 00000000..66f4c8ed --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java @@ -0,0 +1,71 @@ +package net.hostsharing.hsadminng.config; + +import io.micrometer.core.annotation.Timed; +import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.client.RestTemplate; +import org.xml.sax.SAXException; + +import jakarta.servlet.http.HttpServletRequest; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.IOException; +import java.util.function.Supplier; + +public class RealCasAuthenticator implements CasAuthenticator { + + @Value("${hsadminng.cas.server}") + private String casServerUrl; + + @Value("${hsadminng.cas.service}") + private String serviceUrl; + + private final RestTemplate restTemplate = new RestTemplate(); + + @SneakyThrows + @Timed("app.cas.authenticate") + public String authenticate(final HttpServletRequest httpRequest) { + final var userName = StringUtils.isBlank(casServerUrl) + ? bypassCurrentSubject(httpRequest) + : casValidation(httpRequest); + final var authentication = new UsernamePasswordAuthenticationToken(userName, null, null); + SecurityContextHolder.getContext().setAuthentication(authentication); + return authentication.getName(); + } + + private static String bypassCurrentSubject(final HttpServletRequest httpRequest) { + final var userName = httpRequest.getHeader("current-subject"); + System.err.println("CasAuthenticator.bypassCurrentSubject: " + userName); + return userName; + } + + private String casValidation(final HttpServletRequest httpRequest) + throws SAXException, IOException, ParserConfigurationException { + + final var ticket = httpRequest.getHeader("Authorization"); + final var url = casServerUrl + "/p3/serviceValidate" + + "?service=" + serviceUrl + + "&ticket=" + ticket; + + System.err.println("CasAuthenticator.casValidation using URL: " + url); + + final var response = ((Supplier) () -> restTemplate.getForObject(url, String.class)).get(); + + final var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new java.io.ByteArrayInputStream(response.getBytes())); + if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) { + System.err.println("CAS service ticket could not be validated"); + System.err.println("CAS-validation-URL: " + url); + System.err.println(response); + throw new BadCredentialsException("CAS service ticket could not be validated"); + } + final var userName = doc.getElementsByTagName("cas:user").item(0).getTextContent(); + System.err.println("CAS-user: " + userName); + return userName; + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java index a5e4344c..4bcae65a 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java +++ b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java @@ -1,40 +1,58 @@ package net.hostsharing.hsadminng.config; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; 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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFilter; +import jakarta.servlet.http.HttpServletResponse; @Configuration @EnableWebSecurity public class WebSecurityConfig { - public static final String[] PERMITTED_PATHS = new String[]{"/swagger-ui/**", "/v3/api-docs/**", "/actuator/**"}; - public static final String[] AUTHENTICATED_PATHS = new String[]{"/api/**"}; + private static final String[] PERMITTED_PATHS = new String[] { "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**" }; + private static final String[] AUTHENTICATED_PATHS = new String[] { "/api/**" }; + + @Lazy + @Autowired + private CasAuthenticationFilter authenticationFilter; @Bean @Profile("!test") public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { return http .authorizeHttpRequests(authorize -> authorize - // TODO.impl: implement CAS authentication via Spring Security - // .requestMatchers(PERMITTED_PATHS).permitAll() - // .requestMatchers(AUTHENTICATED_PATHS).authenticated() - // .anyRequest().denyAll() - .anyRequest().permitAll() + .requestMatchers(PERMITTED_PATHS).permitAll() + .requestMatchers(AUTHENTICATED_PATHS).authenticated() + .anyRequest().denyAll() ) + .addFilterBefore(authenticationFilter, AuthenticationFilter.class) .csrf(AbstractHttpConfigurer::disable) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> + // For unknown reasons Spring security returns 403 FORBIDDEN for a BadCredentialsException. + // But it should return 401 UNAUTHORIZED. + response.sendError(HttpServletResponse.SC_UNAUTHORIZED) + ) + ) .build(); } @Bean @Profile("!test") - public Authenticator casServiceTicketValidator() { - return new CasAuthenticator(); + public CasAuthenticator casServiceTicketValidator() { + return new RealCasAuthenticator(); } + @Bean + public CasAuthenticationFilter authenticationFilter(final CasAuthenticator authenticator) { + return new CasAuthenticationFilter(authenticator); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java index 5c918943..17c1a582 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java @@ -37,7 +37,7 @@ class CasAuthenticationFilterIntegrationTest { private WireMockServer wireMockServer; @Test - public void shouldAcceptRequest() { + public void shouldAcceptRequestWithValidCasTicket() { // given final var username = "test-user-" + randomAlphanumeric(4); wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=valid")) @@ -66,7 +66,7 @@ class CasAuthenticationFilterIntegrationTest { } @Test - public void shouldRejectRequest() { + public void shouldRejectRequestWithInvalidCasTicket() { // given wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=invalid")) .willReturn(aResponse() diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java index 5691e092..f8f2bfc1 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java @@ -10,7 +10,7 @@ import static org.mockito.Mockito.mock; class CasAuthenticatorUnitTest { - final CasAuthenticator casAuthenticator = new CasAuthenticator(); + final RealCasAuthenticator casAuthenticator = new RealCasAuthenticator(); @Test void bypassesAuthenticationIfNoCasServerIsConfigured() { diff --git a/src/test/java/net/hostsharing/hsadminng/config/DisableSecurityConfig.java b/src/test/java/net/hostsharing/hsadminng/config/DisableSecurityConfig.java index 4c4f98e8..bcf15c08 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/DisableSecurityConfig.java +++ b/src/test/java/net/hostsharing/hsadminng/config/DisableSecurityConfig.java @@ -21,7 +21,7 @@ public class DisableSecurityConfig { @Bean @Profile("test") - public Authenticator fakeAuthenticator() { - return new FakeAuthenticator(); + public CasAuthenticator fakeAuthenticator() { + return new FakeCasAuthenticator(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/config/FakeAuthenticator.java b/src/test/java/net/hostsharing/hsadminng/config/FakeCasAuthenticator.java similarity index 81% rename from src/test/java/net/hostsharing/hsadminng/config/FakeAuthenticator.java rename to src/test/java/net/hostsharing/hsadminng/config/FakeCasAuthenticator.java index 139ef053..15ac599d 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/FakeAuthenticator.java +++ b/src/test/java/net/hostsharing/hsadminng/config/FakeCasAuthenticator.java @@ -4,7 +4,7 @@ import lombok.SneakyThrows; import jakarta.servlet.http.HttpServletRequest; -public class FakeAuthenticator implements Authenticator { +public class FakeCasAuthenticator implements CasAuthenticator { @Override @SneakyThrows