Authentication can now alternatively use CAs TGT

This commit is contained in:
Michael Hoennig 2025-03-13 16:41:08 +01:00
parent cf85966224
commit 123f1dc10f
34 changed files with 206 additions and 51 deletions

@ -132,9 +132,10 @@ Also try for example 'admin@xxx.example.com' or 'unknown@example.org'.
If you want a formatted JSON output, you can pipe the result to `jq` or similar.
And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html (on same port as the API to avoid CORS problems).
If you still need to install some of these tools, find some hints in the next chapters.
And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html).
For a locally running app without CAS-authentication (export HSADMINNG_CAS_SERVER=''),
authorize using the name of the subject (e.g. "superuser-alex@hostsharing.net" in case of test-data).
Otherwise, use a valid CAS-ticket.
### PostgreSQL Server

@ -31,7 +31,7 @@ def search_keywords_in_files(keywords):
sys.exit(1)
# Allowed comment symbols
comment_symbols = {"//", "#", ";"}
comment_symbols = {"//", "#", "##", "###", "####", "#####", ";"}
for root, dirs, files in os.walk("."):
# Ausschließen bestimmter Verzeichnisse

@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'org.springframework.boot' version '3.4.2'
id 'io.spring.dependency-management' version '1.1.7' // manages implicit dependencies
id 'io.openapiprocessor.openapi-processor' version '2023.2' // generates Controller-interface and resources from API-spec
id 'com.github.jk1.dependency-license-report' version '2.9' // checks dependency-license compatibility
@ -67,7 +67,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.10.0'
implementation 'org.springdoc:springdoc-openapi:2.8.3'
implementation 'org.postgresql:postgresql'
implementation 'org.liquibase:liquibase-core'
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'
@ -77,7 +76,7 @@ dependencies {
implementation 'net.java.dev.jna:jna:5.16.0'
implementation 'org.modelmapper:modelmapper:3.2.2'
implementation 'org.iban4j:iban4j:3.2.10-RELEASE'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5'
implementation 'org.reflections:reflections:0.10.2'
compileOnly 'org.projectlombok:lombok'

@ -1,9 +1,11 @@
package net.hostsharing.hsadminng;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@OpenAPIDefinition
public class HsadminNgApplication {
public static void main(String[] args) {

@ -21,12 +21,16 @@ public class CasAuthenticationFilter extends OncePerRequestFilter {
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
if (request.getHeader("Authorization") != null) {
request.getInputStream();
if (request.getHeader("authorization") != null) {
final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request);
final var currentSubject = authenticator.authenticate(request);
authenticatedRequest.addHeader("current-subject", currentSubject);
authenticatedRequest.getInputStream();
filterChain.doFilter(authenticatedRequest, response);
}
} else {
filterChain.doFilter(request, response);
}
}
}

@ -0,0 +1,18 @@
package net.hostsharing.hsadminng.config;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.TYPE;
/** Explicitly marks a REST-Controller for not requiring authorization for Swagger UI.
*
* @see SecurityRequirement
*/
@Target(TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface NoSecurityRequirement {
}

@ -7,7 +7,9 @@ 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.util.LinkedMultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest;
@ -31,41 +33,68 @@ public class RealCasAuthenticator implements CasAuthenticator {
public String authenticate(final HttpServletRequest httpRequest) {
final var userName = StringUtils.isBlank(casServerUrl)
? bypassCurrentSubject(httpRequest)
: casValidation(httpRequest);
: casAuthentication(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");
final var userName = httpRequest.getHeader("authorization").replaceAll("^Bearer ", "");
System.err.println("CasAuthenticator.bypassCurrentSubject: " + userName);
return userName;
}
private String casValidation(final HttpServletRequest httpRequest)
private String casAuthentication(final HttpServletRequest httpRequest)
throws SAXException, IOException, ParserConfigurationException {
final var ticket = httpRequest.getHeader("Authorization");
final var url = casServerUrl + "/p3/serviceValidate" +
"?service=" + serviceUrl +
"&ticket=" + ticket;
System.err.println("CasAuthenticator.casValidation using URL: " + url);
final var response = ((Supplier<String>) () -> restTemplate.getForObject(url, String.class)).get();
final var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new java.io.ByteArrayInputStream(response.getBytes()));
if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) {
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 ticket = httpRequest.getHeader("authorization").replaceAll("^Bearer ", "");
final var serviceTicket = ticket.startsWith("TGT-")
? fetchServiceTicket(ticket)
: ticket;
final var userName = extractUserName(verifyServiceTicket(serviceTicket));
System.err.println("CAS-user: " + userName);
return userName;
}
private String fetchServiceTicket(final String ticketGrantingTicket) {
final var tgtUrl = casServerUrl + "/cas/v1/tickets/" + ticketGrantingTicket;
final var restTemplate = new RestTemplate();
final var formData = new LinkedMultiValueMap<String, String>();
formData.add("service", serviceUrl);
return restTemplate.postForObject(tgtUrl, formData, String.class);
}
private Document verifyServiceTicket(final String serviceTicket) throws SAXException, IOException, ParserConfigurationException {
if ( !serviceTicket.startsWith("ST-") ) {
throwBadCredentialsException("Invalid authorization ticket");
}
final var url = casServerUrl + "/cas/p3/serviceValidate" +
"?service=" + serviceUrl +
"&ticket=" + serviceTicket;
final var response = ((Supplier<String>) () -> restTemplate.getForObject(url, String.class)).get();
return DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new java.io.ByteArrayInputStream(response.getBytes()));
}
private String extractUserName(final Document verification) {
if (verification.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) {
System.err.println("CAS service ticket could not be validated");
System.err.println(verification);
throwBadCredentialsException("CAS service ticket could not be validated");
}
return verification.getElementsByTagName("cas:user").item(0).getTextContent();
}
private String throwBadCredentialsException(final String message) {
throw new BadCredentialsException(message);
}
}

@ -1,5 +1,8 @@
package net.hostsharing.hsadminng.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -15,6 +18,8 @@ import jakarta.servlet.http.HttpServletResponse;
@Configuration
@EnableWebSecurity
// TODO.impl: securitySchemes should work in OpenAPI yaml, but the Spring templates seem not to support it
@SecurityScheme(type = SecuritySchemeType.HTTP, name = "casTicket", scheme = "bearer", bearerFormat = "CAS ticket", description = "CAS ticket", in = SecuritySchemeIn.HEADER)
public class WebSecurityConfig {
private static final String[] PERMITTED_PATHS = new String[] { "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**" };

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.booking.item;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource;
@ -32,6 +33,7 @@ import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateR
@RestController
@Profile("!only-office")
@SecurityRequirement(name = "casTicket")
public class HsBookingItemController implements HsBookingItemsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.booking.project;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingProjectsApi;
@ -22,6 +23,7 @@ import java.util.function.BiConsumer;
@RestController
@Profile("!only-office")
@SecurityRequirement(name = "casTicket")
public class HsBookingProjectController implements HsBookingProjectsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.hosting.asset;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry;
@ -29,6 +30,7 @@ import java.util.function.BiConsumer;
@RestController
@Profile("!only-office")
@SecurityRequirement(name = "casTicket")
public class HsHostingAssetController implements HsHostingAssetsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.hosting.asset;
import io.micrometer.core.annotation.Timed;
import net.hostsharing.hsadminng.config.NoSecurityRequirement;
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry;
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetPropsApi;
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource;
@ -14,6 +15,7 @@ import java.util.Map;
@RestController
@Profile("!only-office")
@NoSecurityRequirement
public class HsHostingAssetPropsController implements HsHostingAssetPropsApi {
@Override

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.bankaccount;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeBankAccountsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeBankAccountInsertResource;
@ -18,7 +19,7 @@ import java.util.List;
import java.util.UUID;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeBankAccountController implements HsOfficeBankAccountsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.contact;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeContactsApi;
@ -20,6 +21,7 @@ import java.util.UUID;
import static net.hostsharing.hsadminng.errors.Validate.validate;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeContactController implements HsOfficeContactsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.coopassets;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.errors.MultiValidationException;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopAssetsApi;
@ -37,6 +38,7 @@ import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOffic
import static net.hostsharing.hsadminng.lambda.WithNonNull.withNonNull;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAssetsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.coopshares;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.errors.MultiValidationException;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopSharesApi;
@ -27,6 +28,7 @@ import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOffic
import static net.hostsharing.hsadminng.hs.validation.UuidResolver.resolve;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopSharesApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository;
@ -32,7 +33,7 @@ import static net.hostsharing.hsadminng.hs.validation.UuidResolver.resolve;
import static net.hostsharing.hsadminng.repr.TaggedNumber.cropTag;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.membership;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeMembershipsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeMembershipInsertResource;
@ -24,6 +25,7 @@ import static net.hostsharing.hsadminng.errors.Validate.validate;
import static net.hostsharing.hsadminng.repr.TaggedNumber.cropTag;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeMembershipController implements HsOfficeMembershipsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.partner;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.errors.ReferenceNotFoundException;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactFromResourceConverter;
@ -35,7 +36,7 @@ import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.
import static net.hostsharing.hsadminng.repr.TaggedNumber.cropTag;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficePartnerController implements HsOfficePartnersApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.person;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi;
@ -17,7 +18,7 @@ import java.util.List;
import java.util.UUID;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficePersonController implements HsOfficePersonsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.relation;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.errors.Validate;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
@ -26,6 +27,7 @@ import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.mapper.KeyValueMap.from;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeRelationController implements HsOfficeRelationsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.sepamandate;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.bankaccount.HsOfficeBankAccountRepository;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
@ -26,7 +27,7 @@ import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsOfficeSepaMandateController implements HsOfficeSepaMandatesApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.rbac.grant;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacGrantsApi;
@ -17,6 +18,7 @@ import java.util.List;
import java.util.UUID;
@RestController
@SecurityRequirement(name = "casTicket")
public class RbacGrantController implements RbacGrantsApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.rbac.role;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacRolesApi;
@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@SecurityRequirement(name = "casTicket")
public class RbacRoleController implements RbacRolesApi {
@Autowired

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.rbac.subject;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacSubjectsApi;
@ -16,6 +17,7 @@ import java.util.List;
import java.util.UUID;
@RestController
@SecurityRequirement(name = "casTicket")
public class RbacSubjectController implements RbacSubjectsApi {
@Autowired

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.rbac.test.cust;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.test.generated.api.v1.api.TestCustomersApi;
@ -15,6 +16,7 @@ import jakarta.persistence.PersistenceContext;
import java.util.List;
@RestController
@SecurityRequirement(name = "casTicket")
public class TestCustomerController implements TestCustomersApi {
@Autowired

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.rbac.test.pac;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import net.hostsharing.hsadminng.context.Context;
@ -15,6 +16,7 @@ import java.util.List;
import java.util.UUID;
@RestController
@SecurityRequirement(name = "casTicket")
public class TestPackageController implements TestPackagesApi {
@Autowired

@ -4,6 +4,7 @@ get:
tags:
- testCustomers
operationId: listCustomers
parameters:
- $ref: 'auth.yaml#/components/parameters/currentSubject'
- $ref: 'auth.yaml#/components/parameters/assumedRoles'

@ -57,7 +57,7 @@ spring:
# keep this in sync with test/.../application.yml
springdoc:
# SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API
use-management-port: false
x-use-management-port: false
hsadminng:
postgres:
@ -72,3 +72,9 @@ metrics:
http:
server:
requests: true
logging:
level:
org:
springframework:
security: TRACE

@ -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 {
@ -40,7 +40,7 @@ class CasAuthenticationFilterIntegrationTest {
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
);

@ -17,7 +17,8 @@ class CasAuthenticatorUnitTest {
// 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);

@ -20,13 +20,15 @@ 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 {
@ -59,20 +61,55 @@ class WebSecurityConfigIntegrationTest {
}
@Test
void accessToApiWithValidTokenShouldBePermitted() {
void accessToApiWithValidServiceTicketSouldBePermitted() {
// given
givenCasTicketValidationResponse("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", "fake-cas-ticket")),
httpHeaders(entry("Authorization", "Bearer ST-fake-cas-ticket")),
String.class
);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).startsWith("pong fake-cas-ticket");
assertThat(result.getBody()).startsWith("pong fake-user-name");
}
@Test
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
@ -85,13 +122,13 @@ class WebSecurityConfigIntegrationTest {
@Test
void accessToApiWithInvalidTokenShouldBeDenied() {
// given
givenCasTicketValidationResponse("fake-cas-ticket");
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", "WRONG-cas-ticket")),
httpHeaders(entry("Authorization", "Bearer ST-WRONG-cas-ticket")),
String.class
);
@ -129,17 +166,25 @@ class WebSecurityConfigIntegrationTest {
assertThat(result.getBody().get("status")).isEqualTo("UP");
}
private void givenCasTicketValidationResponse(final String casToken) {
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>${casToken}</cas:user>
<cas:user>${userName}</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
""".replace("${casToken}", casToken))));
""".replace("${userName}", userName))));
}
@SafeVarargs

@ -42,7 +42,7 @@ spring:
# keep this in sync with main/.../application.yml
springdoc:
# SwaggerUI must run on the same port as the API itself, otherwise CORS will block accessing the API
use-management-port: false
x-use-management-port: false
logging: