1
0

unauthenticated swagger-ui on- server-port and proper security filter integration into Spring Security (#163)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/163
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-03-17 12:59:50 +01:00
parent a2b81f009b
commit 5ca0638319
43 changed files with 406 additions and 177 deletions

View File

@@ -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()

View File

@@ -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 {
@@ -37,10 +37,10 @@ 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"))
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
);
@@ -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()

View File

@@ -10,14 +10,15 @@ import static org.mockito.Mockito.mock;
class CasAuthenticatorUnitTest {
final CasAuthenticator casAuthenticator = new CasAuthenticator();
final RealCasAuthenticator casAuthenticator = new RealCasAuthenticator();
@Test
void bypassesAuthenticationIfNoCasServerIsConfigured() {
// 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);

View File

@@ -21,7 +21,7 @@ public class DisableSecurityConfig {
@Bean
@Profile("test")
public Authenticator fakeAuthenticator() {
return new FakeAuthenticator();
public CasAuthenticator fakeAuthenticator() {
return new FakeCasAuthenticator();
}
}

View File

@@ -4,7 +4,7 @@ import lombok.SneakyThrows;
import jakarta.servlet.http.HttpServletRequest;
public class FakeAuthenticator implements Authenticator {
public class FakeCasAuthenticator implements CasAuthenticator {
@Override
@SneakyThrows

View File

@@ -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,12 +19,16 @@ 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.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 {
@@ -43,71 +48,151 @@ 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("""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>test-user</cas:user>
</cas:authenticationSuccess>
<cas:authenticationFailure/>
</cas:serviceResponse>
""")));
}
// fake Authorization header
final var headers = new HttpHeaders();
headers.set("Authorization", "test-user");
@Test
void accessToApiWithValidServiceTicketSouldBePermitted() {
// given
givenCasTicketValidationResponse("ST-fake-cas-ticket", "fake-user-name");
// http request
final var result = restTemplate.exchange(
"http://localhost:" + this.serverPort + "/api/ping",
HttpMethod.GET,
new HttpEntity<>(null, headers),
httpHeaders(entry("Authorization", "Bearer ST-fake-cas-ticket")),
String.class
);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).startsWith("pong test-user");
assertThat(result.getBody()).startsWith("pong fake-user-name");
}
@Test
public void shouldSupportActuatorEndpoint() {
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
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("ST-fake-cas-ticket", "fake-user-name");
// when
final var result = restTemplate.exchange(
"http://localhost:" + this.serverPort + "/api/ping",
HttpMethod.GET,
httpHeaders(entry("Authorization", "Bearer ST-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 + "/actuator/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 + "/actuator/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 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("""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>${userName}</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
""".replace("${userName}", userName))));
}
@SafeVarargs
private HttpEntity<?> httpHeaders(final Map.Entry<String, String>... headerValues) {
final var headers = new HttpHeaders();
for ( Map.Entry<String, String> headerValue: headerValues ) {
headers.add(headerValue.getKey(), headerValue.getValue());
}
return new HttpEntity<>(headers);
}
}

View File

@@ -39,10 +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:
use-management-port: true
logging:
level:
liquibase: WARN