diff --git a/.aliases b/.aliases index 2fb7e74e..5f200e79 100644 --- a/.aliases +++ b/.aliases @@ -93,6 +93,8 @@ alias gw-spotless='./gradlew spotlessApply -x pitest -x test -x :processResource alias gw-test='. .aliases; ./gradlew test' alias gw-check='. .aliases; gw test check -x pitest' +alias hsadmin-ng='bin/hsadmin-ng' + # etc/docker-compose.yml limits CPUs+MEM and includes a PostgreSQL config for analysing slow queries alias gw-importOfficeData-in-docker-compose=' docker-compose -f etc/docker-compose.yml down && diff --git a/README.md b/README.md index 5ade5549..1ca8e62e 100644 --- a/README.md +++ b/README.md @@ -67,31 +67,37 @@ If you have at least Docker and the Java JDK installed in appropriate versions a gw scenarioTests # compiles and scenario-tests - takes ~1min on a decent machine # if the container has not been built yet, run this: - pg-sql-run # downloads + runs PostgreSQL in a Docker container on localhost:5432 + pg-sql-run # downloads + runs PostgreSQL in a Docker container on localhost:5432 + # if the container has been built already and you want to keep the data, run this: pg-sql-start - gw bootRun # compiles and runs the application on localhost:8080 +Next, compile and run the application without CAS-authentication on `localhost:8080`: - FIXME: use bin/hsadmin-ng for the following commands + export HSADMINNG_CAS_SERVER= + gw bootRun + +For using the REST-API with CAS-authentication, see `bin/hsadmin-ng`. + +Now we can access the REST API, e.g. using curl: # the following command should reply with "pong": - curl -f http://localhost:8080/api/ping + curl -f -s http://localhost:8080/api/ping # the following command should return a JSON array with just all customers: - curl -f\ + curl -f -s\ -H 'current-subject: superuser-alex@hostsharing.net' \ http://localhost:8080/api/test/customers \ | jq # just if `jq` is installed, to prettyprint the output # the following command should return a JSON array with just all packages visible for the admin of the customer yyy: - curl -f\ + curl -f -s\ -H 'current-subject: superuser-alex@hostsharing.net' -H 'assumed-roles: rbactest.customer#yyy:ADMIN' \ http://localhost:8080/api/test/packages \ | jq # add a new customer - curl -f\ + curl -f -s\ -H 'current-subject: superuser-alex@hostsharing.net' -H "Content-Type: application/json" \ -d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \ -X POST http://localhost:8080/api/test/customers \ @@ -809,6 +815,29 @@ postgres-autodoc The output will list the generated files. +### How to Add (Real) Admin Users + +```sql +DO $$ +DECLARE + -- replace with your admin account names + admin_users TEXT[] := ARRAY['admin-1', 'admin-2', 'admin-3']; + admin TEXT; +BEGIN + -- run as superuser + call base.defineContext('adding real admin users', null, null, null); + + -- for all new admin accounts + FOREACH admin IN ARRAY admin_users LOOP + call rbac.grantRoleToSubjectUnchecked( + rbac.findRoleId(rbac.global_ADMIN()), -- granted by role + rbac.findRoleId(rbac.global_ADMIN()), -- role to grant + rbac.create_subject(admin)); -- creates the new admin account + END LOOP; +END $$; +``` + + ## Further Documentation - the `doc` directory contains architecture concepts and a glossary diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java index aa580fbf..4a4dd663 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticator.java @@ -1,27 +1,50 @@ package net.hostsharing.hsadminng.config; 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; public class CasAuthenticator implements Authenticator { - @Value("${hsadminng.cas.server-url}") + @Value("${hsadminng.cas.server}") private String casServerUrl; - @Value("${hsadminng.cas.service-url}") + @Value("${hsadminng.cas.service}") private String serviceUrl; private final RestTemplate restTemplate = new RestTemplate(); @SneakyThrows 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 { + + System.err.println("CasAuthenticator.casValidation using CAS-server: " + casServerUrl); + final var ticket = httpRequest.getHeader("Authorization"); final var url = casServerUrl + "/p3/serviceValidate" + "?service=" + serviceUrl + @@ -34,11 +57,13 @@ public class CasAuthenticator implements Authenticator { 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(); - final var authentication = new UsernamePasswordAuthenticationToken(userName, null, null); - SecurityContextHolder.getContext().setAuthentication(authentication); - return authentication.getName(); + System.err.println("CAS-user: " + userName); + return "superuser-alex@hostsharing.net"; // userName; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 054973b2..69ad1e1b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,8 +37,8 @@ hsadminng: postgres: leakproof: cas: - server-url: https://cas.example.com/cas - service-url: http://localhost:8080/api # TODO.conf: deployment target + server: https://login.hostsharing.net/cas # use empty string to bypass CAS-validation and directly use current-subject + service: https://hsadminng.hostsharing.net:443 # TODO.conf: deployment target + matching CAS service ID metrics: distribution: diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java index c470ba4a..0c705e25 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java @@ -7,23 +7,26 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; +import static net.hostsharing.hsadminng.config.HttpHeadersBuilder.headers; import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric; 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-url=http://localhost:8088/cas"}) +@TestPropertySource(properties = "server.port=0") @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile! class CasAuthenticationFilterIntegrationTest { @Value("${local.server.port}") private int serverPort; + @Value("${hsadminng.cas.service}") + private String serviceUrl; + @Autowired private TestRestTemplate restTemplate; @@ -34,7 +37,7 @@ class CasAuthenticationFilterIntegrationTest { public void shouldAcceptRequest() { // given final var username = "test-user-" + randomAlphanumeric(4); - wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=http://localhost:8080/api&ticket=valid")) + wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=valid")) .willReturn(aResponse() .withStatus(200) .withBody(""" @@ -62,7 +65,7 @@ class CasAuthenticationFilterIntegrationTest { @Test public void shouldRejectRequest() { // given - wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=http://localhost:8080/api&ticket=invalid")) + wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=invalid")) .willReturn(aResponse() .withStatus(200) .withBody(""" @@ -82,10 +85,4 @@ class CasAuthenticationFilterIntegrationTest { // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } - - private HttpHeaders headers(final String key, final String value) { - final var headers = new HttpHeaders(); - headers.set(key, value); - return headers; - } } diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java new file mode 100644 index 00000000..5691e092 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java @@ -0,0 +1,28 @@ +package net.hostsharing.hsadminng.config; + +import org.junit.jupiter.api.Test; + +import jakarta.servlet.http.HttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class CasAuthenticatorUnitTest { + + final CasAuthenticator casAuthenticator = new CasAuthenticator(); + + @Test + void bypassesAuthenticationIfNoCasServerIsConfigured() { + + // given + final var request = mock(HttpServletRequest.class); + given(request.getHeader("current-subject")).willReturn("given-user"); + + // when + final var userName = casAuthenticator.authenticate(request); + + // then + assertThat(userName).isEqualTo("given-user"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/config/HttpHeadersBuilder.java b/src/test/java/net/hostsharing/hsadminng/config/HttpHeadersBuilder.java new file mode 100644 index 00000000..ac61fb35 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/config/HttpHeadersBuilder.java @@ -0,0 +1,12 @@ +package net.hostsharing.hsadminng.config; + +import org.springframework.http.HttpHeaders; + +public class HttpHeadersBuilder { + + public static HttpHeaders headers(final String key, final String value) { + final var headers = new HttpHeaders(); + headers.set(key, value); + return headers; + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java index 65f5fc95..586702c2 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java @@ -22,7 +22,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; 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-url=http://localhost:8088/cas"}) +@TestPropertySource(properties = {"management.port=0", "server.port=0"}) @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile! class WebSecurityConfigIntegrationTest { @@ -32,6 +32,9 @@ class WebSecurityConfigIntegrationTest { @Value("${local.management.port}") private int managementPort; + @Value("${hsadminng.cas.service}") + private String serviceUrl; + @Autowired private TestRestTemplate restTemplate; @@ -41,7 +44,7 @@ class WebSecurityConfigIntegrationTest { @Test public void shouldSupportPingEndpoint() { // given - wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=http://localhost:8080/api&ticket=test-user")) + wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=test-user")) .willReturn(aResponse() .withStatus(200) .withBody(""" diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 35ec34bf..a69f8aa1 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -53,5 +53,5 @@ testcontainers: hsadminng: cas: - server-url: fake - service-url: http://localhost:8080/api # not really used in test config + server: http://localhost:8088/cas # mocked via WireMock + service: http://localhost:8080/api # must match service used in WireMock mock response