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

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #163
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig 2025-03-17 12:59:50 +01:00
parent a2b81f009b
commit 5ca0638319
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_ADMIN_USERNAME=admin
export HSADMINNG_SUPERUSER=import-superuser@hostsharing.net
export HSADMINNG_CAS_SERVER=
export LANG=en_US.UTF-8

View File

@ -5,4 +5,5 @@ unset HSADMINNG_POSTGRES_RESTRICTED_USERNAME
unset HSADMINNG_SUPERUSER
unset HSADMINNG_MIGRATION_DATA_PATH
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.
And to see the full, currently implemented, API, open http://localhost:8081/actuator/swagger-ui/index.html (uses management-port and thus bypasses authentication).
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
@ -666,6 +667,29 @@ These profiles mean:
- **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
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)
# Allowed comment symbols
comment_symbols = {"//", "#", ";"}
comment_symbols = {"//", "#", "##", "###", "####", "#####", ";"}
for root, dirs, files in os.walk("."):
# Ausschließen bestimmter Verzeichnisse

View File

@ -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'

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

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

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;
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 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}")
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;
}
String authenticate(final HttpServletRequest httpRequest);
}

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;
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;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFilter;
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/**" };
private static final String[] AUTHENTICATED_PATHS = new String[] { "/api/**" };
@Lazy
@Autowired
private CasAuthenticationFilter authenticationFilter;
@Bean
@Profile("!test")
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/**").permitAll() // TODO.impl: implement authentication
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
.requestMatchers(PERMITTED_PATHS).permitAll()
.requestMatchers(AUTHENTICATED_PATHS).authenticated()
.anyRequest().denyAll()
)
.addFilterBefore(authenticationFilter, AuthenticationFilter.class)
.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();
}
@Bean
@Profile("!test")
public Authenticator casServiceTicketValidator() {
return new CasAuthenticator();
public CasAuthenticator casServiceTicketValidator() {
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.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ management:
# HOWTO: view the effective application configuration properties:
# 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:
env:
# TODO.spec: check this, maybe set to when_authorized?
@ -37,6 +37,11 @@ spring:
url: ${HSADMINNG_POSTGRES_JDBC_URL}
username: postgres
data:
rest:
# do NOT implicilty expose SpringData repositories as REST-controllers
detection-strategy: annotated
sql:
init:
mode: never
@ -49,10 +54,6 @@ spring:
liquibase:
contexts: ${spring.profiles.active}
# keep this in sync with test/.../application.yml
springdoc:
use-management-port: true
hsadminng:
postgres:
leakproof:
@ -66,3 +67,9 @@ metrics:
http:
server:
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.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()

View File

@ -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 {
@ -37,10 +37,10 @@ class CasAuthenticationFilterIntegrationTest {
private WireMockServer wireMockServer;
@Test
public void shouldAcceptRequest() {
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
);
@ -66,7 +66,7 @@ class CasAuthenticationFilterIntegrationTest {
}
@Test
public void shouldRejectRequest() {
public void shouldRejectRequestWithInvalidCasTicket() {
// given
wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=invalid"))
.willReturn(aResponse()

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.config;
import java.util.Map;
import com.github.tomakehurst.wiremock.WireMockServer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@ -18,12 +19,16 @@ import org.springframework.test.context.ActiveProfiles;
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 {
@ -43,71 +48,151 @@ class WebSecurityConfigIntegrationTest {
@Autowired
private WireMockServer wireMockServer;
@Test
public void shouldSupportPingEndpoint() {
// given
wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=test-user"))
@BeforeEach
void setUp() {
wireMockServer.stubFor(get(anyUrl())
.willReturn(aResponse()
.withStatus(200)
.withBody("""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>test-user</cas:user>
</cas:authenticationSuccess>
<cas:authenticationFailure/>
</cas:serviceResponse>
""")));
}
// fake Authorization header
final var headers = new HttpHeaders();
headers.set("Authorization", "test-user");
@Test
void accessToApiWithValidServiceTicketSouldBePermitted() {
// given
givenCasTicketValidationResponse("ST-fake-cas-ticket", "fake-user-name");
// http request
final var result = restTemplate.exchange(
"http://localhost:" + this.serverPort + "/api/ping",
HttpMethod.GET,
new HttpEntity<>(null, headers),
httpHeaders(entry("Authorization", "Bearer ST-fake-cas-ticket")),
String.class
);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).startsWith("pong test-user");
assertThat(result.getBody()).startsWith("pong fake-user-name");
}
@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(
"http://localhost:" + this.managementPort + "/actuator", Map.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
public void shouldSupportSwaggerUi() {
void accessToSwaggerUiShouldBePermitted() {
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);
}
@Test
public void shouldSupportApiDocs() {
void accessToApiDocsEndpointShouldBePermitted() {
final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/actuator/v3/api-docs/swagger-config", String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); // permitted but not configured
"http://localhost:" + this.serverPort + "/v3/api-docs/swagger-config", String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).contains("\"configUrl\":\"/v3/api-docs/swagger-config\"");
}
@Test
public void shouldSupportHealthEndpoint() {
void accessToActuatorEndpointShouldBePermitted() {
final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/actuator/health", Map.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody().get("status")).isEqualTo("UP");
}
@Test
public void shouldSupportMetricsEndpoint() {
final var result = this.restTemplate.getForEntity(
"http://localhost:" + this.managementPort + "/actuator/metrics", Map.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
private void givenCasServiceTicketForTicketGrantingTicket(final String ticketGrantingTicket, final String serviceTicket) {
wireMockServer.stubFor(post(urlEqualTo("/cas/v1/tickets/" + ticketGrantingTicket))
.withFormParam("service", equalTo(serviceUrl))
.willReturn(aResponse()
.withStatus(201)
.withBody(serviceTicket)));
}
private void givenCasTicketValidationResponse(final String casToken, final String userName) {
wireMockServer.stubFor(get(urlEqualTo("/cas/p3/serviceValidate?service=" + serviceUrl + "&ticket=" + casToken))
.willReturn(aResponse()
.withStatus(200)
.withBody("""
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
<cas:authenticationSuccess>
<cas:user>${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
contexts: tc,test,dev,pg_stat_statements
# keep this in sync with main/.../application.yml
springdoc:
use-management-port: true
logging:
level:
liquibase: WARN