add CAS authentication #138
2
.aliases
2
.aliases
@ -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 &&
|
||||||
|
41
README.md
41
README.md
@ -68,30 +68,36 @@ If you have at least Docker and the Java JDK installed in appropriate versions a
|
|||||||
|
|
||||||
# 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
|
||||||
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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:
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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("""
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user