unauthenticated swagger-ui on- server-port and proper security filter integration into Spring Security #163

Merged
hsh-michaelhoennig merged 13 commits from feature/unauthenticated-swagger-ui-on-server-port into master 2025-03-17 12:59:53 +01:00
43 changed files with 406 additions and 177 deletions

View File

@ -3,5 +3,6 @@ source .unset-environment
export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted
export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin
export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net
export HSADMINNG_CAS_SERVER=
export LANG=en_US.UTF-8 export LANG=en_US.UTF-8

View File

@ -5,4 +5,5 @@ unset HSADMINNG_POSTGRES_RESTRICTED_USERNAME
unset HSADMINNG_SUPERUSER unset HSADMINNG_SUPERUSER
unset HSADMINNG_MIGRATION_DATA_PATH unset HSADMINNG_MIGRATION_DATA_PATH
unset HSADMINNG_OFFICE_DATA_SQL_FILE unset HSADMINNG_OFFICE_DATA_SQL_FILE
unset HSADMINNG_CAS_SERVER=

View File

@ -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. 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:8081/actuator/swagger-ui/index.html (uses management-port and thus bypasses authentication). 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=''),
If you still need to install some of these tools, find some hints in the next chapters. 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 ### PostgreSQL Server
@ -666,6 +667,29 @@ These profiles mean:
- **without-test-data**: no test-data is inserted - **without-test-data**: no test-data is inserted
### How to Run the Application in a Debugger
Add `' --debug-jvm` to the command line:
```sh
gw bootRun --debug-jvm
```
At the very beginning, the application is going to wait for a debugger with a message like this:
> Listening for transport dt_socket at address: 5005
As soon as a debugger connects to that port, the application will continue to run.
In IntelliJ IDEA you need a 'Remote JVM Debug' run configuration like this:
![IntelliJ IDEA JVM-Debug Run Config](./doc/.images/intellij-idea-jvm-debug-run-config.png)
Now, to attach IntelliJ IDEA as a debugger, you just need to run that config in debug mode.
If it's selected, just hit the *bug*-symbol next to it.
### How to Do a Clean Run of the Application ### How to Do a Clean Run of the Application
If you frequently need to run with a fresh database and a clean build, you can use this: If you frequently need to run with a fresh database and a clean build, you can use this:

View File

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

View File

@ -1,6 +1,6 @@
plugins { plugins {
id 'java' 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.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 '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 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-actuator'
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.10.0' 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.postgresql:postgresql'
implementation 'org.liquibase:liquibase-core' implementation 'org.liquibase:liquibase-core'
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0' 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 'net.java.dev.jna:jna:5.16.0'
implementation 'org.modelmapper:modelmapper:3.2.2' implementation 'org.modelmapper:modelmapper:3.2.2'
implementation 'org.iban4j:iban4j:3.2.10-RELEASE' 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' implementation 'org.reflections:reflections:0.10.2'
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

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

View File

@ -1,39 +0,0 @@
package net.hostsharing.hsadminng.config;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.stereotype.Component;
@Component
public class AuthenticationFilter implements Filter {
@Autowired
private Authenticator authenticator;
@Override
@SneakyThrows
public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) {
final var httpRequest = (HttpServletRequest) request;
final var httpResponse = (HttpServletResponse) response;
try {
final var currentSubject = authenticator.authenticate(httpRequest);
final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(httpRequest);
authenticatedRequest.addHeader("current-subject", currentSubject);
chain.doFilter(authenticatedRequest, response);
} catch (final BadCredentialsException exc) {
// TODO.impl: should not be necessary if ResponseStatusException worked
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}

View File

@ -1,8 +0,0 @@
package net.hostsharing.hsadminng.config;
import jakarta.servlet.http.HttpServletRequest;
public interface Authenticator {
String authenticate(final HttpServletRequest httpRequest);
}

View File

@ -0,0 +1,33 @@
package net.hostsharing.hsadminng.config;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
// Do NOT use @Component (or similar) here, this would register the filter directly.
// But we need to register it in the SecurityFilterChain created by WebSecurityConfig.
// The bean gets created in net.hostsharing.hsadminng.config.WebSecurityConfig.authenticationFilter.
@AllArgsConstructor
public class CasAuthenticationFilter extends OncePerRequestFilter {
private CasAuthenticator authenticator;
@Override
@SneakyThrows
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
if (request.getHeader("authorization") != null) {
final var authenticatedRequest = new AuthenticatedHttpServletRequestWrapper(request);
final var currentSubject = authenticator.authenticate(request);
authenticatedRequest.addHeader("current-subject", currentSubject);
filterChain.doFilter(authenticatedRequest, response);
} else {
filterChain.doFilter(request, response);
}
}
}

View File

@ -1,71 +1,8 @@
package net.hostsharing.hsadminng.config; package net.hostsharing.hsadminng.config;
import io.micrometer.core.annotation.Timed;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.client.RestTemplate;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
public class CasAuthenticator implements Authenticator { public interface CasAuthenticator {
@Value("${hsadminng.cas.server}") String authenticate(final HttpServletRequest httpRequest);
private String casServerUrl;
@Value("${hsadminng.cas.service}")
private String serviceUrl;
private final RestTemplate restTemplate = new RestTemplate();
@SneakyThrows
@Timed("app.cas.authenticate")
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 {
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 = restTemplate.getForObject(url, String.class);
final var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(new java.io.ByteArrayInputStream(response.getBytes()));
if (doc.getElementsByTagName("cas:authenticationSuccess").getLength() == 0) {
// TODO.impl: for unknown reasons, this results in a 403 FORBIDDEN
// throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "CAS service ticket could not be validated");
System.err.println("CAS service ticket could not be validated");
System.err.println("CAS-validation-URL: " + url);
System.err.println(response);
throw new BadCredentialsException("CAS service ticket could not be validated");
}
final var userName = doc.getElementsByTagName("cas:user").item(0).getTextContent();
System.err.println("CAS-user: " + userName);
return userName;
}
} }

View File

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

View File

@ -0,0 +1,100 @@
package net.hostsharing.hsadminng.config;
import io.micrometer.core.annotation.Timed;
import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.util.function.Supplier;
public class RealCasAuthenticator implements CasAuthenticator {
@Value("${hsadminng.cas.server}")
private String casServerUrl;
@Value("${hsadminng.cas.service}")
private String serviceUrl;
private final RestTemplate restTemplate = new RestTemplate();
@SneakyThrows
@Timed("app.cas.authenticate")
public String authenticate(final HttpServletRequest httpRequest) {
final var userName = StringUtils.isBlank(casServerUrl)
? bypassCurrentSubject(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("authorization").replaceAll("^Bearer ", "");
System.err.println("CasAuthenticator.bypassCurrentSubject: " + userName);
return userName;
}
private String casAuthentication(final HttpServletRequest httpRequest)
throws SAXException, IOException, ParserConfigurationException {
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);
}
}

View File

@ -1,36 +1,63 @@
package net.hostsharing.hsadminng.config; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
@Configuration @Configuration
@EnableWebSecurity @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 { public class WebSecurityConfig {
private static final String[] PERMITTED_PATHS = new String[] { "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**" };
private static final String[] AUTHENTICATED_PATHS = new String[] { "/api/**" };
@Lazy
@Autowired
private CasAuthenticationFilter authenticationFilter;
@Bean @Bean
@Profile("!test") @Profile("!test")
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
return http return http
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/**").permitAll() // TODO.impl: implement authentication .requestMatchers(PERMITTED_PATHS).permitAll()
.requestMatchers("/swagger-ui/**").permitAll() .requestMatchers(AUTHENTICATED_PATHS).authenticated()
.requestMatchers("/v3/api-docs/**").permitAll() .anyRequest().denyAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
) )
.addFilterBefore(authenticationFilter, AuthenticationFilter.class)
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) ->
// For unknown reasons Spring security returns 403 FORBIDDEN for a BadCredentialsException.
// But it should return 401 UNAUTHORIZED.
response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
)
)
.build(); .build();
} }
@Bean @Bean
@Profile("!test") @Profile("!test")
public Authenticator casServiceTicketValidator() { public CasAuthenticator casServiceTicketValidator() {
return new CasAuthenticator(); return new RealCasAuthenticator();
} }
@Bean
public CasAuthenticationFilter authenticationFilter(final CasAuthenticator authenticator) {
return new CasAuthenticationFilter(authenticator);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.rbac.test.pac; 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.StrictMapper;
import net.hostsharing.hsadminng.mapper.OptionalFromJson; import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.Context;
@ -15,6 +16,7 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@SecurityRequirement(name = "casTicket")
public class TestPackageController implements TestPackagesApi { public class TestPackageController implements TestPackagesApi {
@Autowired @Autowired

View File

@ -6,7 +6,7 @@ components:
currentSubject: currentSubject:
name: current-subject name: current-subject
in: header in: header
required: true required: false
schema: schema:
type: string type: string
description: Identifying name of the current subject (e.g. user). description: Identifying name of the current subject (e.g. user).

View File

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

View File

@ -17,7 +17,7 @@ management:
# HOWTO: view the effective application configuration properties: # HOWTO: view the effective application configuration properties:
# http://localhost:8081/actuator/configprops # http://localhost:8081/actuator/configprops
include: info, health, metrics, metric-links, mappings, openapi, swaggerui, configprops, env include: info, health, metrics, metric-links, mappings, openapi, configprops, env
endpoint: endpoint:
env: env:
# TODO.spec: check this, maybe set to when_authorized? # TODO.spec: check this, maybe set to when_authorized?
@ -37,6 +37,11 @@ spring:
url: ${HSADMINNG_POSTGRES_JDBC_URL} url: ${HSADMINNG_POSTGRES_JDBC_URL}
username: postgres username: postgres
data:
rest:
# do NOT implicilty expose SpringData repositories as REST-controllers
detection-strategy: annotated
sql: sql:
init: init:
mode: never mode: never
@ -49,10 +54,6 @@ spring:
liquibase: liquibase:
contexts: ${spring.profiles.active} contexts: ${spring.profiles.active}
# keep this in sync with test/.../application.yml
springdoc:
use-management-port: true
hsadminng: hsadminng:
postgres: postgres:
leakproof: leakproof:
@ -66,3 +67,9 @@ metrics:
http: http:
server: server:
requests: true requests: true
logging:
level:
org:
springframework:
security: TRACE

View File

@ -11,7 +11,9 @@ import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent; import com.tngtech.archunit.lang.SimpleConditionEvent;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.config.NoSecurityRequirement;
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity;
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
@ -352,6 +354,15 @@ public class ArchitectureTest {
static final ArchRule restControllerNaming = static final ArchRule restControllerNaming =
classes().that().areAnnotatedWith(RestController.class).should().haveSimpleNameEndingWith("Controller"); 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 @ArchTest
@SuppressWarnings("unused") @SuppressWarnings("unused")
static final ArchRule restControllerMethods = classes() static final ArchRule restControllerMethods = classes()

View File

@ -19,7 +19,7 @@ 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=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! @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile!
@Tag("generalIntegrationTest") @Tag("generalIntegrationTest")
class CasAuthenticationFilterIntegrationTest { class CasAuthenticationFilterIntegrationTest {
@ -37,10 +37,10 @@ class CasAuthenticationFilterIntegrationTest {
private WireMockServer wireMockServer; private WireMockServer wireMockServer;
@Test @Test
public void shouldAcceptRequest() { public void shouldAcceptRequestWithValidCasTicket() {
// given // given
final var username = "test-user-" + randomAlphanumeric(4); 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() .willReturn(aResponse()
.withStatus(200) .withStatus(200)
.withBody(""" .withBody("""
@ -56,7 +56,7 @@ class CasAuthenticationFilterIntegrationTest {
final var result = restTemplate.exchange( final var result = restTemplate.exchange(
"http://localhost:" + this.serverPort + "/api/ping", "http://localhost:" + this.serverPort + "/api/ping",
HttpMethod.GET, HttpMethod.GET,
new HttpEntity<>(null, headers("Authorization", "valid")), new HttpEntity<>(null, headers("Authorization", "ST-valid")),
String.class String.class
); );
@ -66,7 +66,7 @@ class CasAuthenticationFilterIntegrationTest {
} }
@Test @Test
public void shouldRejectRequest() { public void shouldRejectRequestWithInvalidCasTicket() {
// given // given
wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=invalid")) wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=invalid"))
.willReturn(aResponse() .willReturn(aResponse()

View File

@ -10,14 +10,15 @@ import static org.mockito.Mockito.mock;
class CasAuthenticatorUnitTest { class CasAuthenticatorUnitTest {
final CasAuthenticator casAuthenticator = new CasAuthenticator(); final RealCasAuthenticator casAuthenticator = new RealCasAuthenticator();
@Test @Test
void bypassesAuthenticationIfNoCasServerIsConfigured() { void bypassesAuthenticationIfNoCasServerIsConfigured() {
// given // given
final var request = mock(HttpServletRequest.class); 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 // when
final var userName = casAuthenticator.authenticate(request); final var userName = casAuthenticator.authenticate(request);

View File

@ -21,7 +21,7 @@ public class DisableSecurityConfig {
@Bean @Bean
@Profile("test") @Profile("test")
public Authenticator fakeAuthenticator() { public CasAuthenticator fakeAuthenticator() {
return new FakeAuthenticator(); return new FakeCasAuthenticator();
} }
} }

View File

@ -4,7 +4,7 @@ import lombok.SneakyThrows;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
public class FakeAuthenticator implements Authenticator { public class FakeCasAuthenticator implements CasAuthenticator {
@Override @Override
@SneakyThrows @SneakyThrows

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.config;
import java.util.Map; import java.util.Map;
import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.WireMockServer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -18,12 +19,16 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; 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.get;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static java.util.Map.entry;
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=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! @ActiveProfiles("wiremock") // IMPORTANT: To test prod config, do not use test profile!
@Tag("generalIntegrationTest") @Tag("generalIntegrationTest")
class WebSecurityConfigIntegrationTest { class WebSecurityConfigIntegrationTest {
@ -43,71 +48,151 @@ class WebSecurityConfigIntegrationTest {
@Autowired @Autowired
private WireMockServer wireMockServer; private WireMockServer wireMockServer;
@Test @BeforeEach
public void shouldSupportPingEndpoint() { void setUp() {
// given wireMockServer.stubFor(get(anyUrl())
wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=test-user"))
.willReturn(aResponse() .willReturn(aResponse()
.withStatus(200) .withStatus(200)
.withBody(""" .withBody("""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'> <cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess> <cas:authenticationFailure/>
<cas:user>test-user</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse> </cas:serviceResponse>
"""))); """)));
}
@Test
// fake Authorization header void accessToApiWithValidServiceTicketSouldBePermitted() {
final var headers = new HttpHeaders(); // given
headers.set("Authorization", "test-user"); givenCasTicketValidationResponse("ST-fake-cas-ticket", "fake-user-name");
// http request // http request
final var result = restTemplate.exchange( final var result = restTemplate.exchange(
"http://localhost:" + this.serverPort + "/api/ping", "http://localhost:" + this.serverPort + "/api/ping",
HttpMethod.GET, HttpMethod.GET,
new HttpEntity<>(null, headers), httpHeaders(entry("Authorization", "Bearer ST-fake-cas-ticket")),
String.class String.class
); );
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).startsWith("pong test-user"); assertThat(result.getBody()).startsWith("pong fake-user-name");
} }
@Test @Test
public void shouldSupportActuatorEndpoint() { 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
void accessToApiWithoutTokenShouldBeDenied() {
final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.serverPort + "/api/ping", String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void accessToApiWithInvalidTokenShouldBeDenied() {
// given
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", "Bearer ST-WRONG-cas-ticket")),
String.class
);
// then
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void accessToActuatorShouldBePermitted() {
final var result = this.restTemplate.getForEntity( final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/actuator", Map.class); "http://localhost:" + this.managementPort + "/actuator", Map.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
} }
@Test @Test
public void shouldSupportSwaggerUi() { void accessToSwaggerUiShouldBePermitted() {
final var result = this.restTemplate.getForEntity( final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/actuator/swagger-ui/index.html", String.class); "http://localhost:" + this.serverPort + "/swagger-ui/index.html", String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
} }
@Test @Test
public void shouldSupportApiDocs() { void accessToApiDocsEndpointShouldBePermitted() {
final var result = this.restTemplate.getForEntity( final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/actuator/v3/api-docs/swagger-config", String.class); "http://localhost:" + this.serverPort + "/v3/api-docs/swagger-config", String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); // permitted but not configured assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).contains("\"configUrl\":\"/v3/api-docs/swagger-config\"");
} }
@Test @Test
public void shouldSupportHealthEndpoint() { void accessToActuatorEndpointShouldBePermitted() {
final var result = this.restTemplate.getForEntity( final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/actuator/health", Map.class); "http://localhost:" + this.managementPort + "/actuator/health", Map.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody().get("status")).isEqualTo("UP"); assertThat(result.getBody().get("status")).isEqualTo("UP");
} }
@Test private void givenCasServiceTicketForTicketGrantingTicket(final String ticketGrantingTicket, final String serviceTicket) {
public void shouldSupportMetricsEndpoint() { wireMockServer.stubFor(post(urlEqualTo("/cas/v1/tickets/" + ticketGrantingTicket))
final var result = this.restTemplate.getForEntity( .withFormParam("service", equalTo(serviceUrl))
"http://localhost:" + this.managementPort + "/actuator/metrics", Map.class); .willReturn(aResponse()
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); .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>${userName}</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
""".replace("${userName}", userName))));
}
@SafeVarargs
private HttpEntity<?> httpHeaders(final Map.Entry<String, String>... headerValues) {
final var headers = new HttpHeaders();
for ( Map.Entry<String, String> headerValue: headerValues ) {
headers.add(headerValue.getKey(), headerValue.getValue());
}
return new HttpEntity<>(headers);
}
} }

View File

@ -39,10 +39,6 @@ spring:
change-log: classpath:/db/changelog/db.changelog-master.yaml change-log: classpath:/db/changelog/db.changelog-master.yaml
contexts: tc,test,dev,pg_stat_statements contexts: tc,test,dev,pg_stat_statements
# keep this in sync with main/.../application.yml
springdoc:
use-management-port: true
logging: logging:
level: level:
liquibase: WARN liquibase: WARN