1
0

use Spring-Props for CORS-config, move CORS-config to BaseWebSecurityConfig and add tests (#212)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/212
Reviewed-by: Marc Sandlus <hsh-marcsandlus@noreply.dev.hostsharing.net>
This commit is contained in:
Michael Hoennig
2026-03-21 13:55:43 +01:00
parent 3a24e1c726
commit 28eebbc95a
9 changed files with 273 additions and 35 deletions
@@ -3,9 +3,6 @@ package net.hostsharing.hsadminng;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@SpringBootApplication
@OpenAPIDefinition
@@ -14,23 +11,4 @@ public class HsadminNgApplication {
public static void main(String[] args) {
SpringApplication.run(HsadminNgApplication.class, args);
}
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
// TODO: to enable testing, we should use Spring config
String allowedOrigins = System.getenv("ALLOWED_ORIGINS");
if (allowedOrigins == null || allowedOrigins.length() <= 1) {
allowedOrigins = "/**";
}
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "PUT", "POST", "PATCH", "DELETE");
}
};
}
}
@@ -3,10 +3,12 @@ package net.hostsharing.hsadminng.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import lombok.SneakyThrows;
import lombok.val;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.lang.NonNull;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -14,6 +16,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import jakarta.servlet.http.HttpServletResponse;
@@ -48,7 +52,7 @@ public abstract class BaseWebSecurityConfig {
.oauth2ResourceServer(oauth ->
oauth.jwt(Customizer.withDefaults()))
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) ->
// For unknown reason Spring security returns 403 FORBIDDEN for a BadCredentialsException.
@@ -75,4 +79,24 @@ public abstract class BaseWebSecurityConfig {
// For fake-jwt profile, use the same RSA key as JwtFakeBearer
return NimbusJwtDecoder.withPublicKey(RSA_KEY.toRSAPublicKey()).build();
}
@Bean
public WebMvcConfigurer corsConfigurer(
@Value("${hsadminng.cors.allowed-origins:*}") final String corsAllowedOrigins) {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(@NonNull final CorsRegistry registry) {
val allowedOrigins = (corsAllowedOrigins != null && !corsAllowedOrigins.isEmpty())
? corsAllowedOrigins.split(",")
: new String[]{"*"};
registry.addMapping("/api/ping")
.allowedOrigins("*")
.allowedMethods("GET");
registry.addMapping("/api/**").allowedOrigins(allowedOrigins)
.allowedMethods("GET", "PUT", "POST", "PATCH", "DELETE");
}
};
}
}
@@ -17,6 +17,7 @@ public class PingController implements TestApi {
private MessageTranslator messageTranslator;
@Timed("app.api.ping")
@Override
public ResponseEntity<String> ping() {
// HOWTO translate text with placeholders - also see in resource files i18n/messages_*.properties.
final var translatedMessage = messageTranslator.translate("test.pinged--in-your-language");
@@ -24,7 +25,18 @@ public class PingController implements TestApi {
}
@Timed("app.api.pong")
@Override
public ResponseEntity<String> pong() {
return createPongResponse();
}
@Timed("app.api.pong")
@Override
public ResponseEntity<String> pongPost() {
return createPongResponse();
}
private ResponseEntity<String> createPongResponse() {
final var userName = SecurityContextHolder.getContext().getAuthentication().getName();
// HOWTO translate text with placeholders - also see in resource files i18n/messages_*.properties.
final var translatedMessage = messageTranslator.translate("test.ponged-{0}--in-your-language", userName);
@@ -33,3 +33,14 @@ paths:
'text/plain':
schema:
type: string
post:
tags:
- test
operationId: pongPost
responses:
"200":
description: OK
content:
'text/plain':
schema:
type: string
+2
View File
@@ -70,6 +70,8 @@ spring:
hsadminng:
postgres:
leakproof:
cors:
allowed-origin: ${ALLOWED_ORIGINS} // for compatibility with env-based CORS-configuration
metrics:
distribution:
@@ -12,6 +12,8 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
@@ -25,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = { "management.port=0", "server.port=0" })
@ActiveProfiles("fake-jwt") // IMPORTANT: In this test, want to test the prod config, do NOT use test profile!
@TestPropertySource(properties = "hsadminng.cors.allowed-origins=https://allowed.example")
class WebSecurityConfigIntegrationTest {
public static final String GIVEN_FAKE_SUBJECT = "fake-user-name";
@@ -39,37 +42,32 @@ class WebSecurityConfigIntegrationTest {
@Test
void accessToApiWithValidJwtShouldBePermitted() {
// when
val result = restTemplate.exchange(
serverUrl("/api/pong"),
HttpMethod.GET,
httpHeaders(entry("Authorization", bearer(GIVEN_FAKE_SUBJECT))),
httpHeaders(entry(HttpHeaders.AUTHORIZATION, bearer(GIVEN_FAKE_SUBJECT))),
String.class
);
// then
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).startsWith("ponged " + GIVEN_FAKE_SUBJECT);
}
@Test
void accessToOpenApiWithoutTokenShouldBePermitted() {
val result = this.restTemplate.getForEntity(
serverUrl("/api/ping"), String.class);
val result = this.restTemplate.getForEntity(serverUrl("/api/ping"), String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void accessToProtectedApiWithInvalidTokenShouldBeDenied() {
// when
val result = restTemplate.exchange(
serverUrl("/api/pong"),
HttpMethod.GET,
httpHeaders(entry("Authorization", "Bearer INVALID-JWT")),
httpHeaders(entry(HttpHeaders.AUTHORIZATION, "Bearer INVALID-JWT")),
String.class
);
// then
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@@ -82,15 +80,13 @@ class WebSecurityConfigIntegrationTest {
@Test
void accessToSwaggerUiShouldBePermitted() {
val result = this.restTemplate.getForEntity(
serverUrl("/swagger-ui/index.html"), String.class);
val result = this.restTemplate.getForEntity(serverUrl("/swagger-ui/index.html"), String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void accessToApiDocsEndpointShouldBePermitted() {
val result = this.restTemplate.getForEntity(
serverUrl("/v3/api-docs/swagger-config"), String.class);
val result = this.restTemplate.getForEntity(serverUrl("/v3/api-docs/swagger-config"), String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).contains("\"configUrl\":\"/v3/api-docs/swagger-config\"");
}
@@ -103,6 +99,156 @@ class WebSecurityConfigIntegrationTest {
assertThat(result.getBody().get("status")).isEqualTo("UP");
}
@Test
void preflightToPingAllowsAnyOrigin() {
val response = corsPreflightRequest("/api/ping", "https://anywhere.example");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("*");
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).contains("GET");
}
@Test
void actualPingRequestAllowsAnyOrigin() {
val response = corsGetRequest("/api/ping", "https://anywhere.example", null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isEqualTo("*");
}
@Test
void preflightToPongAllowsConfiguredOrigin() {
val response = corsPreflightRequest("/api/pong", "https://allowed.example", "GET");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isEqualTo("https://allowed.example");
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).contains("GET");
}
@Test
void preflightToPongBlocksOtherOrigin() {
val response = corsPreflightRequest("/api/pong", "https://denied.example", "GET");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
}
@Test
void actualPongRequestWithInvalidTokenAndAllowedOriginReturnsUnauthorizedWithCorsHeader() {
val response = corsGetRequest("/api/pong", "https://allowed.example", "Bearer INVALID-JWT");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isEqualTo("https://allowed.example");
}
@Test
void actualPongRequestWithInvalidTokenAndDeniedOriginIsRejectedByCors() {
val response = corsGetRequest("/api/pong", "https://denied.example", "Bearer INVALID-JWT");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
}
@Test
void preflightPostToPongAllowsConfiguredOrigin() {
val response = corsPreflightRequest("/api/pong", "https://allowed.example", "POST");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isEqualTo("https://allowed.example");
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS)).contains("POST");
}
@Test
void preflightPostToPongBlocksOtherOrigin() {
val response = corsPreflightRequest("/api/pong", "https://denied.example", "POST");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
}
@Test
void actualPongPostWithInvalidTokenAndAllowedOriginReturnsUnauthorizedWithCorsHeader() {
val response = corsPostRequest("/api/pong", "https://allowed.example", "Bearer INVALID-JWT");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
.isEqualTo("https://allowed.example");
}
@Test
void actualPongPostWithInvalidTokenAndDeniedOriginIsRejectedByCors() {
val response = corsPostRequest("/api/pong", "https://denied.example", "Bearer INVALID-JWT");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getHeaders().getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)).isNull();
}
private ResponseEntity<String> corsPreflightRequest(final String path, final String origin) {
return corsPreflightRequest(path, origin, "GET");
}
private ResponseEntity<String> corsPreflightRequest(
final String path,
final String origin,
final String accessControlRequestMethod) {
return restTemplate.exchange(
serverUrl(path),
HttpMethod.OPTIONS,
httpHeaders(
entry(HttpHeaders.ORIGIN, origin),
entry(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, accessControlRequestMethod)
),
String.class
);
}
private ResponseEntity<String> corsGetRequest(
final String path,
final String origin,
final String authorization) {
if (authorization != null) {
return restTemplate.exchange(
serverUrl(path),
HttpMethod.GET,
httpHeaders(
entry(HttpHeaders.ORIGIN, origin),
entry(HttpHeaders.AUTHORIZATION, authorization),
entry(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN_VALUE)
),
String.class
);
}
return restTemplate.exchange(
serverUrl(path),
HttpMethod.GET,
httpHeaders(
entry(HttpHeaders.ORIGIN, origin),
entry(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN_VALUE)
),
String.class
);
}
private ResponseEntity<String> corsPostRequest(
final String path,
final String origin,
final String authorization) {
return restTemplate.exchange(
serverUrl(path),
HttpMethod.POST,
httpHeaders(
entry(HttpHeaders.ORIGIN, origin),
entry(HttpHeaders.AUTHORIZATION, authorization),
entry(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN_VALUE)
),
String.class
);
}
private @NotNull String serverUrl(final String path) {
return "http://localhost:" + this.serverPort + path;
}
@@ -72,6 +72,24 @@ class PingControllerAcceptanceTest {
assertThat(responseBody).isEqualTo(testCase.expectedPongTranslation + "\n");
}
@Test
void pongPostRepliesWithTranslatedPongResponse() {
final var responseBody = RestAssured // @formatter:off
.given()
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.header("Accept-Language", Locale.GERMAN)
.port(port)
.when()
.post("http://localhost/api/pong")
.then().log().all().assertThat()
.statusCode(200)
.contentType("text/plain;charset=UTF-8")
.extract().body().asString();
// @formatter:on
assertThat(responseBody).isEqualTo("ponged superuser-alex@hostsharing.net - auf Deutsch\n");
}
@Test
void pingRepliesWithTranslatedPongResponse() {
final var responseBody = RestAssured // @formatter:off
@@ -81,4 +81,21 @@ class PingControllerRestTest {
.andExpect(status().isOk())
.andExpect(content().string(containsString("superuser-alex@hostsharing.net")));
}
@Test
void pongPostReturnsPongedWithSubject() throws Exception {
// when
final var request = mockMvc.perform(MockMvcRequestBuilders
.post("/api/pong")
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.header("Accept-Language", "de")
.accept(MediaType.TEXT_PLAIN))
.andDo(print());
// then
request
.andExpect(status().isOk())
.andExpect(content().string(containsString("superuser-alex@hostsharing.net")));
}
}