add CAS authentication #138

Merged
hsh-michaelhoennig merged 24 commits from feature/add-cas-authentication into master 2024-12-23 12:49:46 +01:00
9 changed files with 124 additions and 28 deletions
Showing only changes of commit 3a4f068528 - Show all commits

View File

@ -93,6 +93,8 @@ alias gw-spotless='./gradlew spotlessApply -x pitest -x test -x :processResource
alias gw-test='. .aliases; ./gradlew test' alias gw-test='. .aliases; ./gradlew test'
alias gw-check='. .aliases; gw test check -x pitest' 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 # etc/docker-compose.yml limits CPUs+MEM and includes a PostgreSQL config for analysing slow queries
alias gw-importOfficeData-in-docker-compose=' alias gw-importOfficeData-in-docker-compose='
docker-compose -f etc/docker-compose.yml down && docker-compose -f etc/docker-compose.yml down &&

View File

@ -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 gw scenarioTests # compiles and scenario-tests - takes ~1min on a decent machine
# if the container has not been built yet, run this: # 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: # if the container has been built already and you want to keep the data, run this:
pg-sql-start 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": # 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: # the following command should return a JSON array with just all customers:
curl -f\ curl -f -s\
-H 'current-subject: superuser-alex@hostsharing.net' \ -H 'current-subject: superuser-alex@hostsharing.net' \
http://localhost:8080/api/test/customers \ http://localhost:8080/api/test/customers \
| jq # just if `jq` is installed, to prettyprint the output | 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: # 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' \ -H 'current-subject: superuser-alex@hostsharing.net' -H 'assumed-roles: rbactest.customer#yyy:ADMIN' \
http://localhost:8080/api/test/packages \ http://localhost:8080/api/test/packages \
| jq | jq
# add a new customer # add a new customer
curl -f\ curl -f -s\
-H 'current-subject: superuser-alex@hostsharing.net' -H "Content-Type: application/json" \ -H 'current-subject: superuser-alex@hostsharing.net' -H "Content-Type: application/json" \
-d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \ -d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
-X POST http://localhost:8080/api/test/customers \ -X POST http://localhost:8080/api/test/customers \
@ -809,6 +815,29 @@ postgres-autodoc
The output will list the generated files. 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 ## Further Documentation
- the `doc` directory contains architecture concepts and a glossary - the `doc` directory contains architecture concepts and a glossary

View File

@ -1,27 +1,50 @@
package net.hostsharing.hsadminng.config; package net.hostsharing.hsadminng.config;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
public class CasAuthenticator implements Authenticator { public class CasAuthenticator implements Authenticator {
@Value("${hsadminng.cas.server-url}") @Value("${hsadminng.cas.server}")
private String casServerUrl; private String casServerUrl;
@Value("${hsadminng.cas.service-url}") @Value("${hsadminng.cas.service}")
private String serviceUrl; private String serviceUrl;
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
@SneakyThrows @SneakyThrows
public String authenticate(final HttpServletRequest httpRequest) { 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 ticket = httpRequest.getHeader("Authorization");
final var url = casServerUrl + "/p3/serviceValidate" + final var url = casServerUrl + "/p3/serviceValidate" +
"?service=" + serviceUrl + "?service=" + serviceUrl +
@ -34,11 +57,13 @@ public class CasAuthenticator implements Authenticator {
if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) { if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) {
// TODO.impl: for unknown reasons, this results in a 403 FORBIDDEN // TODO.impl: for unknown reasons, this results in a 403 FORBIDDEN
// throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "CAS service ticket could not be validated"); // 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"); throw new BadCredentialsException("CAS service ticket could not be validated");
} }
final var userName = doc.getElementsByTagName("cas:user").item(0).getTextContent(); final var userName = doc.getElementsByTagName("cas:user").item(0).getTextContent();
final var authentication = new UsernamePasswordAuthenticationToken(userName, null, null); System.err.println("CAS-user: " + userName);
SecurityContextHolder.getContext().setAuthentication(authentication); return "superuser-alex@hostsharing.net"; // userName;
return authentication.getName();
} }
} }

View File

@ -37,8 +37,8 @@ hsadminng:
postgres: postgres:
leakproof: leakproof:
cas: cas:
server-url: https://cas.example.com/cas server: https://login.hostsharing.net/cas # use empty string to bypass CAS-validation and directly use current-subject
service-url: http://localhost:8080/api # TODO.conf: deployment target service: https://hsadminng.hostsharing.net:443 # TODO.conf: deployment target + matching CAS service ID
metrics: metrics:
distribution: distribution:

View File

@ -7,23 +7,26 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource; 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.apache.commons.lang3.RandomStringUtils.randomAlphanumeric;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static com.github.tomakehurst.wiremock.client.WireMock.*; import static com.github.tomakehurst.wiremock.client.WireMock.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @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! @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile!
class CasAuthenticationFilterIntegrationTest { class CasAuthenticationFilterIntegrationTest {
@Value("${local.server.port}") @Value("${local.server.port}")
private int serverPort; private int serverPort;
@Value("${hsadminng.cas.service}")
private String serviceUrl;
@Autowired @Autowired
private TestRestTemplate restTemplate; private TestRestTemplate restTemplate;
@ -34,7 +37,7 @@ class CasAuthenticationFilterIntegrationTest {
public void shouldAcceptRequest() { public void shouldAcceptRequest() {
// given // given
final var username = "test-user-" + randomAlphanumeric(4); 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() .willReturn(aResponse()
.withStatus(200) .withStatus(200)
.withBody(""" .withBody("""
@ -62,7 +65,7 @@ class CasAuthenticationFilterIntegrationTest {
@Test @Test
public void shouldRejectRequest() { public void shouldRejectRequest() {
// given // 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() .willReturn(aResponse()
.withStatus(200) .withStatus(200)
.withBody(""" .withBody("""
@ -82,10 +85,4 @@ class CasAuthenticationFilterIntegrationTest {
// then // then
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); 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;
}
} }

View File

@ -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");
}
}

View File

@ -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;
}
}

View File

@ -22,7 +22,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @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! @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile!
class WebSecurityConfigIntegrationTest { class WebSecurityConfigIntegrationTest {
@ -32,6 +32,9 @@ class WebSecurityConfigIntegrationTest {
@Value("${local.management.port}") @Value("${local.management.port}")
private int managementPort; private int managementPort;
@Value("${hsadminng.cas.service}")
private String serviceUrl;
@Autowired @Autowired
private TestRestTemplate restTemplate; private TestRestTemplate restTemplate;
@ -41,7 +44,7 @@ class WebSecurityConfigIntegrationTest {
@Test @Test
public void shouldSupportPingEndpoint() { public void shouldSupportPingEndpoint() {
// given // 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() .willReturn(aResponse()
.withStatus(200) .withStatus(200)
.withBody(""" .withBody("""

View File

@ -53,5 +53,5 @@ testcontainers:
hsadminng: hsadminng:
cas: cas:
server-url: fake server: http://localhost:8088/cas # mocked via WireMock
service-url: http://localhost:8080/api # not really used in test config service: http://localhost:8080/api # must match service used in WireMock mock response