From 57d5d32ab7cb20aa47adbb7ecdc7a18fece7eb8d Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 11 Mar 2025 10:51:34 +0100 Subject: [PATCH 01/13] add documentation regarding --debug-jvm --- .tc-environment | 1 + .unset-environment | 1 + README.md | 23 ++++++++++++++++++ .../intellij-idea-jvm-debug-run-config.png | Bin 0 -> 49726 bytes 4 files changed, 25 insertions(+) create mode 100644 doc/.images/intellij-idea-jvm-debug-run-config.png diff --git a/.tc-environment b/.tc-environment index a009303d..194e6d52 100644 --- a/.tc-environment +++ b/.tc-environment @@ -3,5 +3,6 @@ source .unset-environment export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net +export HSADMINNG_CAS_SERVER= export LANG=en_US.UTF-8 diff --git a/.unset-environment b/.unset-environment index 2be52333..69a50ee3 100644 --- a/.unset-environment +++ b/.unset-environment @@ -5,4 +5,5 @@ unset HSADMINNG_POSTGRES_RESTRICTED_USERNAME unset HSADMINNG_SUPERUSER unset HSADMINNG_MIGRATION_DATA_PATH unset HSADMINNG_OFFICE_DATA_SQL_FILE +unset HSADMINNG_CAS_SERVER= diff --git a/README.md b/README.md index b803dd3d..f41d0d0a 100644 --- a/README.md +++ b/README.md @@ -666,6 +666,29 @@ These profiles mean: - **without-test-data**: no test-data is inserted +### How to Run the Application in a Debugger + +Add `' --debug-jvm` to the command line: + + +```sh +gw bootRun --debug-jvm +``` + +At the very beginning, the application is going to wait for a debugger with a message like this: + +> Listening for transport dt_socket at address: 5005 + +As soon as a debugger connects to that port, the application will continue to run. + +In IntelliJ IDEA you need a 'Remote JVM Debug' run configuration like this: + +![IntelliJ IDEA JVM-Debug Run Config](./doc/.images/intellij-idea-jvm-debug-run-config.png) + +Now, to attach IntelliJ IDEA as a debugger, you just need to run that config in debug mode. +If it's selected, just hit the *bug*-symbol next to it. + + ### How to Do a Clean Run of the Application If you frequently need to run with a fresh database and a clean build, you can use this: diff --git a/doc/.images/intellij-idea-jvm-debug-run-config.png b/doc/.images/intellij-idea-jvm-debug-run-config.png new file mode 100644 index 0000000000000000000000000000000000000000..479df9a23d279dec8c061415f37a105c0209fec4 GIT binary patch literal 49726 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 YT_Xd^PH?du=Fxn`$kGsV>1x#f0zW~JO#lD@ literal 0 HcmV?d00001 -- 2.39.5 From ab0e1f604b1e61cada01d8b1061992de988de1d2 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 11 Mar 2025 10:52:29 +0100 Subject: [PATCH 02/13] move SwaggerUI from management-port:/actuator to server-port:/ --- README.md | 2 +- src/main/resources/application.yml | 9 +++++++-- .../config/WebSecurityConfigIntegrationTest.java | 5 ++--- src/test/resources/application.yml | 4 +++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f41d0d0a..a1b5f52d 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Also try for example 'admin@xxx.example.com' or 'unknown@example.org'. If you want a formatted JSON output, you can pipe the result to `jq` or similar. -And to see the full, currently implemented, API, open http://localhost:8081/actuator/swagger-ui/index.html (uses management-port and thus bypasses authentication). +And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html (on same port as the API to avoid CORS problems). If you still need to install some of these tools, find some hints in the next chapters. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 60fd285e..28da0a0a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,7 +17,7 @@ management: # HOWTO: view the effective application configuration properties: # http://localhost:8081/actuator/configprops - include: info, health, metrics, metric-links, mappings, openapi, swaggerui, configprops, env + include: info, health, metrics, metric-links, mappings, openapi, configprops, env endpoint: env: # TODO.spec: check this, maybe set to when_authorized? @@ -37,6 +37,10 @@ spring: url: ${HSADMINNG_POSTGRES_JDBC_URL} username: postgres + data: + rest: + detection-strategy: annotated + sql: init: mode: never @@ -51,7 +55,8 @@ spring: # keep this in sync with test/.../application.yml springdoc: - use-management-port: true + # SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API + use-management-port: false hsadminng: postgres: diff --git a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java index 3a612b35..063527cd 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java @@ -84,14 +84,14 @@ class WebSecurityConfigIntegrationTest { @Test public void shouldSupportSwaggerUi() { final var result = this.restTemplate.getForEntity( - "http://localhost:" + this.managementPort + "/actuator/swagger-ui/index.html", String.class); + "http://localhost:" + this.managementPort + "/swagger-ui/index.html", String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); } @Test public void shouldSupportApiDocs() { final var result = this.restTemplate.getForEntity( - "http://localhost:" + this.managementPort + "/actuator/v3/api-docs/swagger-config", String.class); + "http://localhost:" + this.managementPort + "/v3/api-docs/swagger-config", String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); // permitted but not configured } @@ -109,5 +109,4 @@ class WebSecurityConfigIntegrationTest { "http://localhost:" + this.managementPort + "/actuator/metrics", Map.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); } - } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 344828e7..abf049a6 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -41,7 +41,9 @@ spring: # keep this in sync with main/.../application.yml springdoc: - use-management-port: true + # SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API + use-management-port: false + logging: level: -- 2.39.5 From 5512c6682c7fb4d3e7be0f01c32628aea8f8eee6 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 11 Mar 2025 10:52:52 +0100 Subject: [PATCH 03/13] don't require currentSubject header for /api/ping anymore --- src/main/resources/api-definition/auth.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/api-definition/auth.yaml b/src/main/resources/api-definition/auth.yaml index 138c5eaa..345b003b 100644 --- a/src/main/resources/api-definition/auth.yaml +++ b/src/main/resources/api-definition/auth.yaml @@ -6,7 +6,7 @@ components: currentSubject: name: current-subject in: header - required: true + required: false schema: type: string description: Identifying name of the current subject (e.g. user). -- 2.39.5 From b1a785eda56a28a5679934be37176ad37ff67352 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 11 Mar 2025 14:49:13 +0100 Subject: [PATCH 04/13] improved integration test --- .../WebSecurityConfigIntegrationTest.java | 91 ++++++++++++++----- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java index 063527cd..8ce18a45 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java @@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.config; import java.util.Map; import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -18,8 +19,10 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static java.util.Map.entry; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -43,70 +46,108 @@ class WebSecurityConfigIntegrationTest { @Autowired private WireMockServer wireMockServer; - @Test - public void shouldSupportPingEndpoint() { - // given - wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=test-user")) + @BeforeEach + void setUp() { + wireMockServer.stubFor(get(anyUrl()) .willReturn(aResponse() .withStatus(200) .withBody(""" - - test-user - + """))); + } - - // fake Authorization header - final var headers = new HttpHeaders(); - headers.set("Authorization", "test-user"); + @Test + void accessToApiWithValidTokenShouldBePermitted() { + // given + givenCasTicketValidationResponse("fake-cas-ticket"); // http request final var result = restTemplate.exchange( "http://localhost:" + this.serverPort + "/api/ping", HttpMethod.GET, - new HttpEntity<>(null, headers), + httpHeaders(entry("Authorization", "fake-cas-ticket")), String.class ); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()).startsWith("pong test-user"); + assertThat(result.getBody()).startsWith("pong fake-cas-ticket"); } @Test - public void shouldSupportActuatorEndpoint() { + void accessToApiWithoutTokenShouldBeDenied() { + final var result = this.restTemplate.getForEntity( + "http://localhost:" + this.serverPort + "/api/ping", String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void accessToApiWithInvalidTokenShouldBeDenied() { + // given + givenCasTicketValidationResponse("fake-cas-ticket"); + + // when + final var result = restTemplate.exchange( + "http://localhost:" + this.serverPort + "/api/ping", + HttpMethod.GET, + httpHeaders(entry("Authorization", "WRONG-cas-ticket")), + String.class + ); + + // then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void accessToActuatorShouldBePermitted() { final var result = this.restTemplate.getForEntity( "http://localhost:" + this.managementPort + "/actuator", Map.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); } @Test - public void shouldSupportSwaggerUi() { + void accessToSwaggerUiShouldBePermitted() { final var result = this.restTemplate.getForEntity( - "http://localhost:" + this.managementPort + "/swagger-ui/index.html", String.class); + "http://localhost:" + this.serverPort + "/swagger-ui/index.html", String.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); } @Test - public void shouldSupportApiDocs() { + void accessToApiDocsEndpointShouldBePermitted() { final var result = this.restTemplate.getForEntity( - "http://localhost:" + this.managementPort + "/v3/api-docs/swagger-config", String.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); // permitted but not configured + "http://localhost:" + this.serverPort + "/v3/api-docs/swagger-config", String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).contains("\"configUrl\":\"/v3/api-docs/swagger-config\""); } @Test - public void shouldSupportHealthEndpoint() { + void accessToActuatorEndpointShouldBePermitted() { final var result = this.restTemplate.getForEntity( "http://localhost:" + this.managementPort + "/actuator/health", Map.class); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody().get("status")).isEqualTo("UP"); } - @Test - public void shouldSupportMetricsEndpoint() { - final var result = this.restTemplate.getForEntity( - "http://localhost:" + this.managementPort + "/actuator/metrics", Map.class); - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + private void givenCasTicketValidationResponse(final String casToken) { + wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=" + casToken)) + .willReturn(aResponse() + .withStatus(200) + .withBody(""" + + + ${casToken} + + + """.replace("${casToken}", casToken)))); + } + + @SafeVarargs + private HttpEntity httpHeaders(final Map.Entry... headerValues) { + final var headers = new HttpHeaders(); + for ( Map.Entry headerValue: headerValues ) { + headers.add(headerValue.getKey(), headerValue.getValue()); + } + return new HttpEntity<>(headers); } } -- 2.39.5 From 1685221567f93d0d6b1266c108093a4aa777a970 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 11 Mar 2025 14:49:56 +0100 Subject: [PATCH 05/13] apply AuthenticationFilter only to /api requests --- .../config/AuthenticationFilter.java | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java index 1849b815..7a503b05 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java @@ -1,9 +1,6 @@ package net.hostsharing.hsadminng.config; -import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -11,29 +8,37 @@ import lombok.SneakyThrows; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + @Component -public class AuthenticationFilter implements Filter { +public class AuthenticationFilter extends OncePerRequestFilter { @Autowired private Authenticator authenticator; @Override @SneakyThrows - public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) { - final var httpRequest = (HttpServletRequest) request; - final var httpResponse = (HttpServletResponse) response; + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { + + if ( !request.getRequestURI().startsWith("/api/") ) { + final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); + authenticatedRequest.addHeader("current-subject", "nobody"); + filterChain.doFilter(authenticatedRequest, response); + return; + } try { - final var currentSubject = authenticator.authenticate(httpRequest); + final var currentSubject = authenticator.authenticate(request); - final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(httpRequest); + final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); authenticatedRequest.addHeader("current-subject", currentSubject); - chain.doFilter(authenticatedRequest, response); + filterChain.doFilter(authenticatedRequest, response); } catch (final BadCredentialsException exc) { - // TODO.impl: should not be necessary if ResponseStatusException worked - httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + // TODO.impl: should not be necessary if ResponseStatusException worked - FIXME: try removing + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } } } -- 2.39.5 From 1f3ae1ddd77f4edb9f898a0553eb1f37174b8334 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 11 Mar 2025 14:50:02 +0100 Subject: [PATCH 06/13] cleanup --- .../hsadminng/config/CasAuthenticator.java | 12 +++++++++++- .../hsadminng/config/WebSecurityConfig.java | 13 ++++++++----- src/main/resources/application.yml | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java index 5d6dd116..dac0cf6e 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java @@ -14,6 +14,7 @@ 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 { @@ -52,7 +53,7 @@ public class CasAuthenticator implements Authenticator { System.err.println("CasAuthenticator.casValidation using URL: " + url); - final var response = restTemplate.getForObject(url, String.class); + final var response = tryTo( () -> restTemplate.getForObject(url, String.class)); final var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new java.io.ByteArrayInputStream(response.getBytes())); @@ -68,4 +69,13 @@ public class CasAuthenticator implements Authenticator { 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; + } + } } diff --git a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java index d279ae12..aec107b4 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java +++ b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java @@ -17,11 +17,14 @@ public class WebSecurityConfig { public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { return http .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/**").permitAll() // TODO.impl: implement authentication - .requestMatchers("/swagger-ui/**").permitAll() - .requestMatchers("/v3/api-docs/**").permitAll() - .requestMatchers("/actuator/**").permitAll() - .anyRequest().authenticated() + // TODO.impl: implement CAS authentication via Spring Security + .anyRequest().permitAll() + // .requestMatchers("/swagger-ui/**").permitAll() + // .requestMatchers("/v3/api-docs/**").permitAll() + // .requestMatchers("/actuator/**").permitAll() + // .requestMatchers("/api/ping").permitAll() + // .requestMatchers("/api/**").authenticated() + //.anyRequest().denyAll() ) .csrf(AbstractHttpConfigurer::disable) .build(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 28da0a0a..7abab833 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,7 @@ spring: data: rest: + # do NOT implicilty expose SpringData repositories as REST-controllers detection-strategy: annotated sql: -- 2.39.5 From b6b3c588ca7eb101ab7373c1603314fe72f6fcc4 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 11 Mar 2025 17:07:19 +0100 Subject: [PATCH 07/13] define matchers only in WebSecurityConfig, where it belongs - but cannot be used yet --- .../config/AuthenticationFilter.java | 36 +++++++++++-------- .../hsadminng/config/WebSecurityConfig.java | 13 +++---- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java index 7a503b05..fb080f87 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/config/AuthenticationFilter.java @@ -8,12 +8,19 @@ 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; @@ -22,22 +29,23 @@ public class AuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { - if ( !request.getRequestURI().startsWith("/api/") ) { - final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); + 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); - return; - } - - try { - final var currentSubject = authenticator.authenticate(request); - - final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(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 + } 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/WebSecurityConfig.java b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java index aec107b4..a5e4344c 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java +++ b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java @@ -8,23 +8,24 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; + @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/**"}; + @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("/swagger-ui/**").permitAll() - // .requestMatchers("/v3/api-docs/**").permitAll() - // .requestMatchers("/actuator/**").permitAll() - // .requestMatchers("/api/ping").permitAll() - // .requestMatchers("/api/**").authenticated() - //.anyRequest().denyAll() ) .csrf(AbstractHttpConfigurer::disable) .build(); -- 2.39.5 From 158e279aebb139cd3807c47d09a5ae64a6d5a527 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 12 Mar 2025 11:11:07 +0100 Subject: [PATCH 08/13] 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 -- 2.39.5 From cdede45caa8fa9ff70c255ddb50dff63a6942ef7 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 12 Mar 2025 11:22:20 +0100 Subject: [PATCH 09/13] don't add header "current-subject: nobody" anymore --- .../hostsharing/hsadminng/config/CasAuthenticationFilter.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java index 87e297bc..5e849ef0 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java @@ -26,8 +26,6 @@ public class CasAuthenticationFilter extends OncePerRequestFilter { 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); } -- 2.39.5 From cf85966224b49ee67ab8a3b61bd3d8d221b7b673 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Wed, 12 Mar 2025 11:46:51 +0100 Subject: [PATCH 10/13] cleanup --- .../hsadminng/config/CasAuthenticationFilter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java index 5e849ef0..41b0a93e 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java @@ -21,12 +21,12 @@ public class CasAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { - final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); - if (request.getHeader("Authorization") != null) { + final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); final var currentSubject = authenticator.authenticate(request); authenticatedRequest.addHeader("current-subject", currentSubject); + filterChain.doFilter(authenticatedRequest, response); } - filterChain.doFilter(authenticatedRequest, response); + filterChain.doFilter(request, response); } } -- 2.39.5 From 123f1dc10f6faddaca903c9a6f13e79e11832d89 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Thu, 13 Mar 2025 16:41:08 +0100 Subject: [PATCH 11/13] Authentication can now alternatively use CAs TGT --- README.md | 7 +- bin/howto | 2 +- build.gradle | 5 +- .../hsadminng/HsadminNgApplication.java | 2 + .../config/CasAuthenticationFilter.java | 8 ++- .../config/NoSecurityRequirement.java | 18 +++++ .../config/RealCasAuthenticator.java | 71 +++++++++++++------ .../hsadminng/config/WebSecurityConfig.java | 5 ++ .../booking/item/HsBookingItemController.java | 2 + .../project/HsBookingProjectController.java | 2 + .../asset/HsHostingAssetController.java | 2 + .../asset/HsHostingAssetPropsController.java | 2 + .../HsOfficeBankAccountController.java | 3 +- .../contact/HsOfficeContactController.java | 2 + ...OfficeCoopAssetsTransactionController.java | 2 + ...OfficeCoopSharesTransactionController.java | 2 + .../debitor/HsOfficeDebitorController.java | 3 +- .../HsOfficeMembershipController.java | 2 + .../partner/HsOfficePartnerController.java | 3 +- .../person/HsOfficePersonController.java | 3 +- .../relation/HsOfficeRelationController.java | 2 + .../HsOfficeSepaMandateController.java | 3 +- .../rbac/grant/RbacGrantController.java | 2 + .../rbac/role/RbacRoleController.java | 2 + .../rbac/subject/RbacSubjectController.java | 2 + .../test/cust/TestCustomerController.java | 2 + .../rbac/test/pac/TestPackageController.java | 2 + .../api-definition/test/test-customers.yaml | 1 + src/main/resources/application.yml | 8 ++- .../hsadminng/arch/ArchitectureTest.java | 11 +++ ...asAuthenticationFilterIntegrationTest.java | 6 +- .../config/CasAuthenticatorUnitTest.java | 3 +- .../WebSecurityConfigIntegrationTest.java | 65 ++++++++++++++--- src/test/resources/application.yml | 2 +- 34 files changed, 206 insertions(+), 51 deletions(-) create mode 100644 src/main/java/net/hostsharing/hsadminng/config/NoSecurityRequirement.java diff --git a/README.md b/README.md index a1b5f52d..2ce96594 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,10 @@ Also try for example 'admin@xxx.example.com' or 'unknown@example.org'. If you want a formatted JSON output, you can pipe the result to `jq` or similar. -And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html (on same port as the API to avoid CORS problems). - -If you still need to install some of these tools, find some hints in the next chapters. +And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html). +For a locally running app without CAS-authentication (export HSADMINNG_CAS_SERVER=''), +authorize using the name of the subject (e.g. "superuser-alex@hostsharing.net" in case of test-data). +Otherwise, use a valid CAS-ticket. ### PostgreSQL Server diff --git a/bin/howto b/bin/howto index 0a3b6404..be2aad69 100755 --- a/bin/howto +++ b/bin/howto @@ -31,7 +31,7 @@ def search_keywords_in_files(keywords): sys.exit(1) # Allowed comment symbols - comment_symbols = {"//", "#", ";"} + comment_symbols = {"//", "#", "##", "###", "####", "#####", ";"} for root, dirs, files in os.walk("."): # Ausschließen bestimmter Verzeichnisse diff --git a/build.gradle b/build.gradle index f3a12160..4c3f0b19 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.4.1' + id 'org.springframework.boot' version '3.4.2' id 'io.spring.dependency-management' version '1.1.7' // manages implicit dependencies id 'io.openapiprocessor.openapi-processor' version '2023.2' // generates Controller-interface and resources from API-spec id 'com.github.jk1.dependency-license-report' version '2.9' // checks dependency-license compatibility @@ -67,7 +67,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.10.0' - implementation 'org.springdoc:springdoc-openapi:2.8.3' implementation 'org.postgresql:postgresql' implementation 'org.liquibase:liquibase-core' implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0' @@ -77,7 +76,7 @@ dependencies { implementation 'net.java.dev.jna:jna:5.16.0' implementation 'org.modelmapper:modelmapper:3.2.2' implementation 'org.iban4j:iban4j:3.2.10-RELEASE' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5' implementation 'org.reflections:reflections:0.10.2' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java index af29526b..a1180099 100644 --- a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java +++ b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java @@ -1,9 +1,11 @@ package net.hostsharing.hsadminng; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication +@OpenAPIDefinition public class HsadminNgApplication { public static void main(String[] args) { diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java index 41b0a93e..9bcf148d 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java @@ -21,12 +21,16 @@ public class CasAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { - if (request.getHeader("Authorization") != null) { + request.getInputStream(); + + if (request.getHeader("authorization") != null) { final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); final var currentSubject = authenticator.authenticate(request); authenticatedRequest.addHeader("current-subject", currentSubject); + authenticatedRequest.getInputStream(); filterChain.doFilter(authenticatedRequest, response); + } else { + filterChain.doFilter(request, response); } - filterChain.doFilter(request, response); } } diff --git a/src/main/java/net/hostsharing/hsadminng/config/NoSecurityRequirement.java b/src/main/java/net/hostsharing/hsadminng/config/NoSecurityRequirement.java new file mode 100644 index 00000000..70a87cff --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/config/NoSecurityRequirement.java @@ -0,0 +1,18 @@ +package net.hostsharing.hsadminng.config; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; + +/** Explicitly marks a REST-Controller for not requiring authorization for Swagger UI. + * + * @see SecurityRequirement + */ +@Target(TYPE) +@Retention(RetentionPolicy.CLASS) +public @interface NoSecurityRequirement { +} diff --git a/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java index 66f4c8ed..0ac28059 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java +++ b/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java @@ -7,7 +7,9 @@ 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.util.LinkedMultiValueMap; import org.springframework.web.client.RestTemplate; +import org.w3c.dom.Document; import org.xml.sax.SAXException; import jakarta.servlet.http.HttpServletRequest; @@ -31,41 +33,68 @@ public class RealCasAuthenticator implements CasAuthenticator { public String authenticate(final HttpServletRequest httpRequest) { final var userName = StringUtils.isBlank(casServerUrl) ? bypassCurrentSubject(httpRequest) - : casValidation(httpRequest); + : casAuthentication(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"); + final var userName = httpRequest.getHeader("authorization").replaceAll("^Bearer ", ""); System.err.println("CasAuthenticator.bypassCurrentSubject: " + userName); return userName; } - private String casValidation(final HttpServletRequest httpRequest) + private String casAuthentication(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(); + final var ticket = httpRequest.getHeader("authorization").replaceAll("^Bearer ", ""); + final var serviceTicket = ticket.startsWith("TGT-") + ? fetchServiceTicket(ticket) + : ticket; + final var userName = extractUserName(verifyServiceTicket(serviceTicket)); System.err.println("CAS-user: " + userName); return userName; } + private String fetchServiceTicket(final String ticketGrantingTicket) { + final var tgtUrl = casServerUrl + "/cas/v1/tickets/" + ticketGrantingTicket; + + final var restTemplate = new RestTemplate(); + final var formData = new LinkedMultiValueMap(); + formData.add("service", serviceUrl); + + return restTemplate.postForObject(tgtUrl, formData, String.class); + } + + private Document verifyServiceTicket(final String serviceTicket) throws SAXException, IOException, ParserConfigurationException { + if ( !serviceTicket.startsWith("ST-") ) { + throwBadCredentialsException("Invalid authorization ticket"); + } + + final var url = casServerUrl + "/cas/p3/serviceValidate" + + "?service=" + serviceUrl + + "&ticket=" + serviceTicket; + + final var response = ((Supplier) () -> restTemplate.getForObject(url, String.class)).get(); + + return DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new java.io.ByteArrayInputStream(response.getBytes())); + + } + + private String extractUserName(final Document verification) { + + if (verification.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) { + System.err.println("CAS service ticket could not be validated"); + System.err.println(verification); + throwBadCredentialsException("CAS service ticket could not be validated"); + } + return verification.getElementsByTagName("cas:user").item(0).getTextContent(); + } + + private String throwBadCredentialsException(final String message) { + throw new BadCredentialsException(message); + } + } diff --git a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java index 4bcae65a..a11fb592 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java +++ b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java @@ -1,5 +1,8 @@ package net.hostsharing.hsadminng.config; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,6 +18,8 @@ import jakarta.servlet.http.HttpServletResponse; @Configuration @EnableWebSecurity +// TODO.impl: securitySchemes should work in OpenAPI yaml, but the Spring templates seem not to support it +@SecurityScheme(type = SecuritySchemeType.HTTP, name = "casTicket", scheme = "bearer", bearerFormat = "CAS ticket", description = "CAS ticket", in = SecuritySchemeIn.HEADER) public class WebSecurityConfig { private static final String[] PERMITTED_PATHS = new String[] { "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**" }; diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java index 6b563041..685b30d7 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java @@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.booking.item; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource; @@ -32,6 +33,7 @@ import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateR @RestController @Profile("!only-office") +@SecurityRequirement(name = "casTicket") public class HsBookingItemController implements HsBookingItemsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java index 11c135dd..acc5957b 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.booking.project; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingProjectsApi; @@ -22,6 +23,7 @@ import java.util.function.BiConsumer; @RestController @Profile("!only-office") +@SecurityRequirement(name = "casTicket") public class HsBookingProjectController implements HsBookingProjectsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java index 7d12d714..6c36d21f 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.hosting.asset; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; @@ -29,6 +30,7 @@ import java.util.function.BiConsumer; @RestController @Profile("!only-office") +@SecurityRequirement(name = "casTicket") public class HsHostingAssetController implements HsHostingAssetsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java index d843ff87..bea792ba 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.hosting.asset; import io.micrometer.core.annotation.Timed; +import net.hostsharing.hsadminng.config.NoSecurityRequirement; import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetPropsApi; import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource; @@ -14,6 +15,7 @@ import java.util.Map; @RestController @Profile("!only-office") +@NoSecurityRequirement public class HsHostingAssetPropsController implements HsHostingAssetPropsApi { @Override diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java index 37b9d404..86b82955 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.bankaccount; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeBankAccountsApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeBankAccountInsertResource; @@ -18,7 +19,7 @@ import java.util.List; import java.util.UUID; @RestController - +@SecurityRequirement(name = "casTicket") public class HsOfficeBankAccountController implements HsOfficeBankAccountsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index bf8a9c09..7022d515 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.contact; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeContactsApi; @@ -20,6 +21,7 @@ import java.util.UUID; import static net.hostsharing.hsadminng.errors.Validate.validate; @RestController +@SecurityRequirement(name = "casTicket") public class HsOfficeContactController implements HsOfficeContactsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java index 9073e564..01401eef 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.coopassets; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.errors.MultiValidationException; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopAssetsApi; @@ -37,6 +38,7 @@ import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOffic import static net.hostsharing.hsadminng.lambda.WithNonNull.withNonNull; @RestController +@SecurityRequirement(name = "casTicket") public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAssetsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java index 5583d44e..9f42f413 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.coopshares; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.errors.MultiValidationException; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopSharesApi; @@ -27,6 +28,7 @@ import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOffic import static net.hostsharing.hsadminng.hs.validation.UuidResolver.resolve; @RestController +@SecurityRequirement(name = "casTicket") public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopSharesApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java index 2e0ff210..5f709bda 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.debitor; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository; @@ -32,7 +33,7 @@ import static net.hostsharing.hsadminng.hs.validation.UuidResolver.resolve; import static net.hostsharing.hsadminng.repr.TaggedNumber.cropTag; @RestController - +@SecurityRequirement(name = "casTicket") public class HsOfficeDebitorController implements HsOfficeDebitorsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java index edf5d0a6..4c24d1e1 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.membership; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeMembershipsApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipInsertResource; @@ -24,6 +25,7 @@ import static net.hostsharing.hsadminng.errors.Validate.validate; import static net.hostsharing.hsadminng.repr.TaggedNumber.cropTag; @RestController +@SecurityRequirement(name = "casTicket") public class HsOfficeMembershipController implements HsOfficeMembershipsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java index 6074c909..f99b290b 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.partner; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.errors.ReferenceNotFoundException; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactFromResourceConverter; @@ -35,7 +36,7 @@ import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType. import static net.hostsharing.hsadminng.repr.TaggedNumber.cropTag; @RestController - +@SecurityRequirement(name = "casTicket") public class HsOfficePartnerController implements HsOfficePartnersApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java index 28a1b56f..ff763bbe 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.person; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi; @@ -17,7 +18,7 @@ import java.util.List; import java.util.UUID; @RestController - +@SecurityRequirement(name = "casTicket") public class HsOfficePersonController implements HsOfficePersonsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java index 53e2712b..2473b7ad 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.relation; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.errors.Validate; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; @@ -26,6 +27,7 @@ import java.util.function.BiConsumer; import static net.hostsharing.hsadminng.mapper.KeyValueMap.from; @RestController +@SecurityRequirement(name = "casTicket") public class HsOfficeRelationController implements HsOfficeRelationsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java index 61399d1e..ad19fb42 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.hs.office.sepamandate; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; @@ -26,7 +27,7 @@ import java.util.function.BiConsumer; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; @RestController - +@SecurityRequirement(name = "casTicket") public class HsOfficeSepaMandateController implements HsOfficeSepaMandatesApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java b/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java index 02dc6ae2..21389e1b 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.rbac.grant; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacGrantsApi; @@ -17,6 +18,7 @@ import java.util.List; import java.util.UUID; @RestController +@SecurityRequirement(name = "casTicket") public class RbacGrantController implements RbacGrantsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java index de7446e8..dc12465d 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.rbac.role; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacRolesApi; @@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; @RestController +@SecurityRequirement(name = "casTicket") public class RbacRoleController implements RbacRolesApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java index c893fb98..f47e7159 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.rbac.subject; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacSubjectsApi; @@ -16,6 +17,7 @@ import java.util.List; import java.util.UUID; @RestController +@SecurityRequirement(name = "casTicket") public class RbacSubjectController implements RbacSubjectsApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java index 82e3a57d..d8e15cfd 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.rbac.test.cust; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.test.generated.api.v1.api.TestCustomersApi; @@ -15,6 +16,7 @@ import jakarta.persistence.PersistenceContext; import java.util.List; @RestController +@SecurityRequirement(name = "casTicket") public class TestCustomerController implements TestCustomersApi { @Autowired diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java index 43fd3b0b..86735af0 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.rbac.test.pac; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.mapper.OptionalFromJson; import net.hostsharing.hsadminng.context.Context; @@ -15,6 +16,7 @@ import java.util.List; import java.util.UUID; @RestController +@SecurityRequirement(name = "casTicket") public class TestPackageController implements TestPackagesApi { @Autowired diff --git a/src/main/resources/api-definition/test/test-customers.yaml b/src/main/resources/api-definition/test/test-customers.yaml index 8e81426a..017608e2 100644 --- a/src/main/resources/api-definition/test/test-customers.yaml +++ b/src/main/resources/api-definition/test/test-customers.yaml @@ -4,6 +4,7 @@ get: tags: - testCustomers operationId: listCustomers + parameters: - $ref: 'auth.yaml#/components/parameters/currentSubject' - $ref: 'auth.yaml#/components/parameters/assumedRoles' diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7abab833..f50899b6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -57,7 +57,7 @@ spring: # keep this in sync with test/.../application.yml springdoc: # SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API - use-management-port: false + x-use-management-port: false hsadminng: postgres: @@ -72,3 +72,9 @@ metrics: http: server: requests: true + +logging: + level: + org: + springframework: + security: TRACE diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index 664b803c..628cf5d7 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -11,7 +11,9 @@ import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.config.NoSecurityRequirement; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; @@ -352,6 +354,15 @@ public class ArchitectureTest { static final ArchRule restControllerNaming = classes().that().areAnnotatedWith(RestController.class).should().haveSimpleNameEndingWith("Controller"); + @ArchTest + @SuppressWarnings("unused") + static final ArchRule restControllerSecurityRequirement = + // TODO.impl: seems that the Spring templates for the OpenAPI generator don't support this, + // thus we need this annotation to support Swagger UI authorization. + classes().that().areAnnotatedWith(RestController.class).should() + .beAnnotatedWith(SecurityRequirement.class).orShould() + .beAnnotatedWith(NoSecurityRequirement.class); + @ArchTest @SuppressWarnings("unused") static final ArchRule restControllerMethods = classes() diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java index 17c1a582..90238fc1 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java @@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static com.github.tomakehurst.wiremock.client.WireMock.*; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@TestPropertySource(properties = {"server.port=0", "hsadminng.cas.server=http://localhost:8088/cas"}) +@TestPropertySource(properties = {"server.port=0", "hsadminng.cas.server=http://localhost:8088"}) @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile! @Tag("generalIntegrationTest") class CasAuthenticationFilterIntegrationTest { @@ -40,7 +40,7 @@ class CasAuthenticationFilterIntegrationTest { public void shouldAcceptRequestWithValidCasTicket() { // given final var username = "test-user-" + randomAlphanumeric(4); - wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=valid")) + wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=ST-valid")) .willReturn(aResponse() .withStatus(200) .withBody(""" @@ -56,7 +56,7 @@ class CasAuthenticationFilterIntegrationTest { final var result = restTemplate.exchange( "http://localhost:" + this.serverPort + "/api/ping", HttpMethod.GET, - new HttpEntity<>(null, headers("Authorization", "valid")), + new HttpEntity<>(null, headers("Authorization", "ST-valid")), String.class ); diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java index f8f2bfc1..c2953c3f 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java @@ -17,7 +17,8 @@ class CasAuthenticatorUnitTest { // given final var request = mock(HttpServletRequest.class); - given(request.getHeader("current-subject")).willReturn("given-user"); + // bypassing the CAS-server HTTP-request fakes the user from the authorization header's fake CAS-ticket + given(request.getHeader("authorization")).willReturn("Bearer given-user"); // when final var userName = casAuthenticator.authenticate(request); diff --git a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java index 8ce18a45..be993747 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java @@ -20,13 +20,15 @@ import org.springframework.test.context.TestPropertySource; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static java.util.Map.entry; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@TestPropertySource(properties = {"management.port=0", "server.port=0", "hsadminng.cas.server=http://localhost:8088/cas"}) +@TestPropertySource(properties = {"management.port=0", "server.port=0", "hsadminng.cas.server=http://localhost:8088"}) @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile! @Tag("generalIntegrationTest") class WebSecurityConfigIntegrationTest { @@ -59,20 +61,55 @@ class WebSecurityConfigIntegrationTest { } @Test - void accessToApiWithValidTokenShouldBePermitted() { + void accessToApiWithValidServiceTicketSouldBePermitted() { // given - givenCasTicketValidationResponse("fake-cas-ticket"); + givenCasTicketValidationResponse("ST-fake-cas-ticket", "fake-user-name"); // http request final var result = restTemplate.exchange( "http://localhost:" + this.serverPort + "/api/ping", HttpMethod.GET, - httpHeaders(entry("Authorization", "fake-cas-ticket")), + httpHeaders(entry("Authorization", "Bearer ST-fake-cas-ticket")), String.class ); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()).startsWith("pong fake-cas-ticket"); + assertThat(result.getBody()).startsWith("pong fake-user-name"); + } + + @Test + void accessToApiWithValidTicketGrantingTicketShouldBePermitted() { + // given + givenCasServiceTicketForTicketGrantingTicket("TGT-fake-cas-ticket", "ST-fake-cas-ticket"); + givenCasTicketValidationResponse("ST-fake-cas-ticket", "fake-user-name"); + + // http request + final var result = restTemplate.exchange( + "http://localhost:" + this.serverPort + "/api/ping", + HttpMethod.GET, + httpHeaders(entry("Authorization", "Bearer TGT-fake-cas-ticket")), + String.class + ); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).startsWith("pong fake-user-name"); + } + + @Test + void accessToApiWithInvalidTicketGrantingTicketShouldBePermitted() { + // given + givenCasServiceTicketForTicketGrantingTicket("TGT-fake-cas-ticket", "ST-fake-cas-ticket"); + givenCasTicketValidationResponse("ST-fake-cas-ticket", "fake-user-name"); + + // http request + final var result = restTemplate.exchange( + "http://localhost:" + this.serverPort + "/api/ping", + HttpMethod.GET, + httpHeaders(entry("Authorization", "Bearer TGT-WRONG-cas-ticket")), + String.class + ); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test @@ -85,13 +122,13 @@ class WebSecurityConfigIntegrationTest { @Test void accessToApiWithInvalidTokenShouldBeDenied() { // given - givenCasTicketValidationResponse("fake-cas-ticket"); + givenCasTicketValidationResponse("ST-fake-cas-ticket", "fake-user-name"); // when final var result = restTemplate.exchange( "http://localhost:" + this.serverPort + "/api/ping", HttpMethod.GET, - httpHeaders(entry("Authorization", "WRONG-cas-ticket")), + httpHeaders(entry("Authorization", "Bearer ST-WRONG-cas-ticket")), String.class ); @@ -129,17 +166,25 @@ class WebSecurityConfigIntegrationTest { assertThat(result.getBody().get("status")).isEqualTo("UP"); } - private void givenCasTicketValidationResponse(final String casToken) { + private void givenCasServiceTicketForTicketGrantingTicket(final String ticketGrantingTicket, final String serviceTicket) { + wireMockServer.stubFor(post(urlEqualTo("/cas/v1/tickets/" + ticketGrantingTicket)) + .withFormParam("service", equalTo(serviceUrl)) + .willReturn(aResponse() + .withStatus(201) + .withBody(serviceTicket))); + } + + private void givenCasTicketValidationResponse(final String casToken, final String userName) { wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=" + casToken)) .willReturn(aResponse() .withStatus(200) .withBody(""" - ${casToken} + ${userName} - """.replace("${casToken}", casToken)))); + """.replace("${userName}", userName)))); } @SafeVarargs diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index abf049a6..c728db3b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -42,7 +42,7 @@ spring: # keep this in sync with main/.../application.yml springdoc: # SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API - use-management-port: false + x-use-management-port: false logging: -- 2.39.5 From 7a4dadea73ff85c8a951ff4ea3cdde4ec30e7951 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 17 Mar 2025 11:49:35 +0100 Subject: [PATCH 12/13] fix getInputStream --- .../hostsharing/hsadminng/config/CasAuthenticationFilter.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java index 9bcf148d..650cad66 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java @@ -21,13 +21,10 @@ public class CasAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { - request.getInputStream(); - if (request.getHeader("authorization") != null) { final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request); final var currentSubject = authenticator.authenticate(request); authenticatedRequest.addHeader("current-subject", currentSubject); - authenticatedRequest.getInputStream(); filterChain.doFilter(authenticatedRequest, response); } else { filterChain.doFilter(request, response); -- 2.39.5 From a8a6730a127224bf353dde38f20f9a90a4fed28d Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 17 Mar 2025 12:59:17 +0100 Subject: [PATCH 13/13] amendmends according to code-review --- src/main/resources/application.yml | 5 ----- src/test/resources/application.yml | 6 ------ 2 files changed, 11 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f50899b6..f7d42bc8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,11 +54,6 @@ spring: liquibase: contexts: ${spring.profiles.active} -# keep this in sync with test/.../application.yml -springdoc: - # SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API - x-use-management-port: false - hsadminng: postgres: leakproof: diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c728db3b..a01bcb2e 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -39,12 +39,6 @@ spring: change-log: classpath:/db/changelog/db.changelog-master.yaml contexts: tc,test,dev,pg_stat_statements -# keep this in sync with main/.../application.yml -springdoc: - # SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API - x-use-management-port: false - - logging: level: liquibase: WARN -- 2.39.5