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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,7 +21,7 @@ public class DisableSecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Profile("test")
|
||||
public Authenticator fakeAuthenticator() {
|
||||
return new FakeAuthenticator();
|
||||
public CasAuthenticator fakeAuthenticator() {
|
||||
return new FakeCasAuthenticator();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import lombok.SneakyThrows;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
public class FakeAuthenticator implements Authenticator {
|
||||
public class FakeCasAuthenticator implements CasAuthenticator {
|
||||
|
||||
@Override
|
||||
@SneakyThrows
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user