diff --git a/README.md b/README.md index a1b5f52d..2ce96594 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/howto b/bin/howto index 0a3b6404..be2aad69 100755 --- a/bin/howto +++ b/bin/howto @@ -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 diff --git a/build.gradle b/build.gradle index f3a12160..4c3f0b19 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java index af29526b..a1180099 100644 --- a/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java +++ b/src/main/java/net/hostsharing/hsadminng/HsadminNgApplication.java @@ -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) { diff --git a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java index 41b0a93e..9bcf148d 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java +++ b/src/main/java/net/hostsharing/hsadminng/config/CasAuthenticationFilter.java @@ -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); } - filterChain.doFilter(request, response); } } diff --git a/src/main/java/net/hostsharing/hsadminng/config/NoSecurityRequirement.java b/src/main/java/net/hostsharing/hsadminng/config/NoSecurityRequirement.java new file mode 100644 index 00000000..70a87cff --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/config/NoSecurityRequirement.java @@ -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 { +} diff --git a/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java b/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java index 66f4c8ed..0ac28059 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java +++ b/src/main/java/net/hostsharing/hsadminng/config/RealCasAuthenticator.java @@ -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) () -> 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(); + 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) () -> 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); + } + } diff --git a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java index 4bcae65a..a11fb592 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java +++ b/src/main/java/net/hostsharing/hsadminng/config/WebSecurityConfig.java @@ -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/**" }; diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java index 6b563041..685b30d7 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java index 11c135dd..acc5957b 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java index 7d12d714..6c36d21f 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java index d843ff87..bea792ba 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java index 37b9d404..86b82955 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java index bf8a9c09..7022d515 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java index 9073e564..01401eef 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java index 5583d44e..9f42f413 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java index 2e0ff210..5f709bda 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java index edf5d0a6..4c24d1e1 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java index 6074c909..f99b290b 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java index 28a1b56f..ff763bbe 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java index 53e2712b..2473b7ad 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java index 61399d1e..ad19fb42 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java b/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java index 02dc6ae2..21389e1b 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java index de7446e8..dc12465d 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java index c893fb98..f47e7159 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java index 82e3a57d..d8e15cfd 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/cust/TestCustomerController.java @@ -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 diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java index 43fd3b0b..86735af0 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/test/pac/TestPackageController.java @@ -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 diff --git a/src/main/resources/api-definition/test/test-customers.yaml b/src/main/resources/api-definition/test/test-customers.yaml index 8e81426a..017608e2 100644 --- a/src/main/resources/api-definition/test/test-customers.yaml +++ b/src/main/resources/api-definition/test/test-customers.yaml @@ -4,6 +4,7 @@ get: tags: - testCustomers operationId: listCustomers + parameters: - $ref: 'auth.yaml#/components/parameters/currentSubject' - $ref: 'auth.yaml#/components/parameters/assumedRoles' diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7abab833..f50899b6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index 664b803c..628cf5d7 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -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() diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java index 17c1a582..90238fc1 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticationFilterIntegrationTest.java @@ -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 ); diff --git a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java index f8f2bfc1..c2953c3f 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/CasAuthenticatorUnitTest.java @@ -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); diff --git a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java index 8ce18a45..be993747 100644 --- a/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/config/WebSecurityConfigIntegrationTest.java @@ -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(""" - ${casToken} + ${userName} - """.replace("${casToken}", casToken)))); + """.replace("${userName}", userName)))); } @SafeVarargs diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index abf049a6..c728db3b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -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: