diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlySecurityConfig.java b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlySecurityConfig.java new file mode 100644 index 000000000..43a3fbfbb --- /dev/null +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlySecurityConfig.java @@ -0,0 +1,40 @@ +package school.hei.haapi.endpoint.rest.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class FeesOnlySecurityConfig { + + private final FeesOnlyUriMatcher feesOnlyUriMatcher; + + @Value("${FEES_ONLY:false}") + private boolean feesOnly; + + public FeesOnlySecurityConfig(FeesOnlyUriMatcher feesOnlyUriMatcher) { + this.feesOnlyUriMatcher = feesOnlyUriMatcher; + } + + @Bean + @Order(1) + public SecurityFilterChain feesOnlyFilterChain(HttpSecurity http) throws Exception { + if (!feesOnly) { + http.securityMatcher(request -> false) + .authorizeHttpRequests(req -> req.anyRequest().denyAll()); + return http.build(); + } + + http.securityMatcher(request -> !feesOnlyUriMatcher.isAllowed(request.getRequestURI())) + .authorizeHttpRequests(req -> req.anyRequest().denyAll()) + .cors(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable); + return http.build(); + } +} diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyUriMatcher.java b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyUriMatcher.java new file mode 100644 index 000000000..ae88fa941 --- /dev/null +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/FeesOnlyUriMatcher.java @@ -0,0 +1,45 @@ +package school.hei.haapi.endpoint.rest.security; + +import java.util.Arrays; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +@Component +public class FeesOnlyUriMatcher { + + private static final Set ALLOWED_STUDENT_ROUTE_SEGMENTS = Set.of("stats", "level"); + private static final Pattern STUDENT_ID = Pattern.compile("^[0-9a-fA-F-]{8,}$"); + + public boolean isAllowed(String uri) { + return uri.equals("/ping") + || uri.equals("/whoami") + || uri.equals("/health/db") + || uri.startsWith("/authentication/") + || uri.startsWith("/fees") + || uri.startsWith("/feeTemplates") + || uri.startsWith("/feeCreationJobs") + || uri.startsWith("/mpbs") + || uri.startsWith("/delay_penalty") + || uri.startsWith("/admins") + || uri.startsWith("/managers") + || uri.equals("/students") + || isStudentByIdOrAllowedSegment(uri) + || isStudentFees(uri); + } + + private boolean isStudentByIdOrAllowedSegment(String uri) { + if (!uri.startsWith("/students/")) { + return false; + } + String segment = uri.substring("/students/".length()); + if (STUDENT_ID.matcher(segment).matches()) { + return true; + } + return Arrays.stream(uri.split("/")).anyMatch(ALLOWED_STUDENT_ROUTE_SEGMENTS::contains); + } + + private boolean isStudentFees(String uri) { + return uri.startsWith("/students/") && uri.contains("/fees"); + } +} \ No newline at end of file diff --git a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java index c551da19a..35b11f10d 100644 --- a/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java +++ b/src/main/java/school/hei/haapi/endpoint/rest/security/SecurityConf.java @@ -20,6 +20,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider; @@ -68,6 +69,7 @@ public AuthenticationManager authenticationManager() { } @Bean + @Order(2) public SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception { // @formatter:off AntPathRequestMatcher nonAccessibleBySuspendedUserPath = diff --git a/src/test/java/school/hei/haapi/FeesOnlyFalseIT.java b/src/test/java/school/hei/haapi/FeesOnlyFalseIT.java new file mode 100644 index 000000000..4d40b2a44 --- /dev/null +++ b/src/test/java/school/hei/haapi/FeesOnlyFalseIT.java @@ -0,0 +1,47 @@ +package school.hei.haapi; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static school.hei.haapi.integration.conf.TestUtils.MANAGER1_TOKEN; +import static school.hei.haapi.integration.conf.TestUtils.setUpCasdoor; +import static school.hei.haapi.integration.conf.TestUtils.setUpCognito; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.junit.jupiter.Testcontainers; +import school.hei.haapi.endpoint.rest.api.PayingApi; +import school.hei.haapi.endpoint.rest.api.UsersApi; +import school.hei.haapi.endpoint.rest.client.ApiClient; +import school.hei.haapi.integration.conf.FacadeITMockedThirdParties; +import school.hei.haapi.integration.conf.TestUtils; + +@Testcontainers +@AutoConfigureMockMvc +@TestPropertySource(properties = "FEES_ONLY=false") +class FeesOnlyDisabledIT extends FacadeITMockedThirdParties { + + private ApiClient anApiClient(String token) { + return TestUtils.anApiClient(token, localPort); + } + + @BeforeEach + void setUp() { + setUpCasdoor(casdoorAuthServiceMock, certificateLoaderMock); + setUpCognito(cognitoComponentMock); + } + + @Test + void fees_only_endpoint_is_accessible_when_fees_only_disabled() { + var api = new PayingApi(anApiClient(MANAGER1_TOKEN)); + + assertDoesNotThrow(() -> api.getFees(null, null, null, null, null, null, 1, 10, false, null)); + } + + @Test + void non_fees_only_endpoint_is_accessible_when_fees_only_disabled() { + var api = new UsersApi(anApiClient(MANAGER1_TOKEN)); + + assertDoesNotThrow(() -> api.getMonitors(1, 10, null, null, null)); + } +} diff --git a/src/test/java/school/hei/haapi/FeesOnlyTrueIT.java b/src/test/java/school/hei/haapi/FeesOnlyTrueIT.java new file mode 100644 index 000000000..330e2ee59 --- /dev/null +++ b/src/test/java/school/hei/haapi/FeesOnlyTrueIT.java @@ -0,0 +1,53 @@ +package school.hei.haapi; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static school.hei.haapi.integration.conf.TestUtils.MANAGER1_TOKEN; +import static school.hei.haapi.integration.conf.TestUtils.setUpCasdoor; +import static school.hei.haapi.integration.conf.TestUtils.setUpCognito; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.junit.jupiter.Testcontainers; +import school.hei.haapi.endpoint.rest.api.PayingApi; +import school.hei.haapi.endpoint.rest.api.UsersApi; +import school.hei.haapi.endpoint.rest.client.ApiClient; +import school.hei.haapi.endpoint.rest.client.ApiException; +import school.hei.haapi.integration.conf.FacadeITMockedThirdParties; +import school.hei.haapi.integration.conf.TestUtils; + +@Testcontainers +@AutoConfigureMockMvc +@TestPropertySource(properties = "FEES_ONLY=true") +class FeesOnlyEnabledIT extends FacadeITMockedThirdParties { + + private ApiClient anApiClient(String token) { + return TestUtils.anApiClient(token, localPort); + } + + @BeforeEach + void setUp() { + setUpCasdoor(casdoorAuthServiceMock, certificateLoaderMock); + setUpCognito(cognitoComponentMock); + } + + @Test + void fees_only_endpoint_stays_accessible_when_fees_only_enabled() { + var api = new PayingApi(anApiClient(MANAGER1_TOKEN)); + + assertDoesNotThrow(() -> api.getFees(null, null, null, null, null, null, 1, 10, false, null)); + } + + @Test + void non_fees_only_endpoint_is_blocked_when_fees_only_enabled() { + var api = new UsersApi(anApiClient(MANAGER1_TOKEN)); + + var exception = + assertThrows(ApiException.class, () -> api.getMonitors(1, 10, null, null, null)); + + assertEquals(403, exception.getCode()); + } +}