diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptor.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptor.java index f55541ca..1107a187 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptor.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptor.java @@ -1,16 +1,16 @@ package de.numcodex.feasibility_gui_backend.query.ratelimiting; -import io.github.bucket4j.Bucket; -import io.github.bucket4j.ConsumptionProbe; +import de.numcodex.feasibility_gui_backend.config.WebSecurityConfig; +import de.numcodex.feasibility_gui_backend.query.v2.QueryHandlerRestController; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; /** * This Interceptor checks whether a user may use the requested endpoint at this moment. @@ -21,6 +21,9 @@ public class RateLimitingInterceptor implements HandlerInterceptor { private static final String HEADER_LIMIT_REMAINING = "X-Rate-Limit-Remaining"; private static final String HEADER_RETRY_AFTER = "X-Rate-Limit-Retry-After-Seconds"; + + private static final String HEADER_LIMIT_REMAINING_DETAILED_OBFUSCATED_RESULTS = "X-Rate-Limit-Detailed-Obfuscated-Results-Remaining"; + private static final String HEADER_RETRY_AFTER_DETAILED_OBFUSCATED_RESULTS = "X-Rate-Limit-Detailed-Obfuscated-Results-Retry-After-Seconds"; private final RateLimitingService rateLimitingService; private final AuthenticationHelper authenticationHelper; @@ -40,7 +43,7 @@ public RateLimitingInterceptor(RateLimitingService rateLimitingService, Authenti @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + var authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null) { response.sendError(HttpStatus.UNAUTHORIZED.value()); @@ -54,17 +57,49 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return false; } - Bucket tokenBucket = rateLimitingService.resolveBucket(authentication.getName()); - ConsumptionProbe probe = tokenBucket.tryConsumeAndReturnRemaining(1); - if (probe.isConsumed()) { - response.addHeader(HEADER_LIMIT_REMAINING, Long.toString(probe.getRemainingTokens())); + var anyResultTokenBucket = rateLimitingService.resolveAnyResultBucket(authentication.getName()); + var anyResultProbe = anyResultTokenBucket.tryConsumeAndReturnRemaining(1); + if (anyResultProbe.isConsumed()) { + if (request.getRequestURI().endsWith(WebSecurityConfig.PATH_DETAILED_OBFUSCATED_RESULT)) { + var detailedObfuscatedResultTokenBucket = rateLimitingService.resolveDetailedObfuscatedResultBucket( + authentication.getName()); + var detailedObfuscatedResultProbe = detailedObfuscatedResultTokenBucket.tryConsumeAndReturnRemaining( + 1); + if (detailedObfuscatedResultProbe.isConsumed()) { + response.addHeader(HEADER_LIMIT_REMAINING_DETAILED_OBFUSCATED_RESULTS, Long.toString(detailedObfuscatedResultProbe.getRemainingTokens())); + } else { + long waitForRefill = detailedObfuscatedResultProbe.getNanosToWaitForRefill() / 1_000_000_000; + response.addHeader(HEADER_RETRY_AFTER_DETAILED_OBFUSCATED_RESULTS, Long.toString(waitForRefill)); + response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), + "You have exhausted your Request Quota for detailed obfuscated results"); + return false; + } + } + response.addHeader(HEADER_LIMIT_REMAINING, Long.toString(anyResultProbe.getRemainingTokens())); return true; } else { - long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000; + long waitForRefill = anyResultProbe.getNanosToWaitForRefill() / 1_000_000_000; response.addHeader(HEADER_RETRY_AFTER, Long.toString(waitForRefill)); response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), "You have exhausted your API Request Quota"); return false; } } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) throws Exception { + HandlerInterceptor.super.afterCompletion(request, response, handler, ex); + + if (request.getRequestURI().endsWith(WebSecurityConfig.PATH_DETAILED_OBFUSCATED_RESULT) && ( + response.getStatus() != 200 || response.containsHeader( + QueryHandlerRestController.HEADER_X_DETAILED_OBFUSCATED_RESULT_WAS_EMPTY))) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + var detailedObfuscatedResultTokenBucket = rateLimitingService.resolveDetailedObfuscatedResultBucket( + authentication.getName()); + detailedObfuscatedResultTokenBucket.addTokens(1); + response.setHeader(QueryHandlerRestController.HEADER_X_DETAILED_OBFUSCATED_RESULT_WAS_EMPTY, + null); + } + } } diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingService.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingService.java index 85cb8138..cc5c6279 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingService.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingService.java @@ -14,26 +14,54 @@ */ public class RateLimitingService { - private final Map cache = new ConcurrentHashMap<>(); + private final Map anyResultRetrievalCache = new ConcurrentHashMap<>(); + private final Map detailedObfuscatedResultRetrievalCache = new ConcurrentHashMap<>(); - private final Duration interval; + private final Duration intervalAny; + + private final Duration intervalDetailedObfuscated; + + private final int amountDetailedObfuscated; /** * Creates a new RateLimitingService. + *

+ * This will limit the polling rate for default users on any result endpoint. It will also limit + * the amount of times a user can request detailed obfuscated results in a given timespan. * - * @param interval the duration after which the user can poll again + * @param intervalAny the duration after which the user can poll again + * @param amountDetailedObfuscated the amount of times a user can request detailed obfuscated results + * @param intervalDetailedObfuscated the timespan after which a users access is "forgotten" */ - public RateLimitingService(Duration interval) { - this.interval = interval; + public RateLimitingService(Duration intervalAny, int amountDetailedObfuscated, Duration intervalDetailedObfuscated) { + this.intervalAny = intervalAny; + this.amountDetailedObfuscated = amountDetailedObfuscated; + this.intervalDetailedObfuscated = intervalDetailedObfuscated; + } + + public Bucket resolveAnyResultBucket(String userId) { + return anyResultRetrievalCache.computeIfAbsent(userId, this::newAnyResultBucket); + } + + public Bucket resolveDetailedObfuscatedResultBucket(String userId) { + return detailedObfuscatedResultRetrievalCache.computeIfAbsent(userId, this::newDetailedObfuscatedResultBucket); } - public Bucket resolveBucket(String userId) { - return cache.computeIfAbsent(userId, this::newBucket); + public void addTokensToDetailedObfuscatedResultBucket(String userId, int amount) { + resolveDetailedObfuscatedResultBucket(userId).addTokens(amount); } - private Bucket newBucket(String userId) { + private Bucket newAnyResultBucket(String userId) { return Bucket.builder() - .addLimit(Bandwidth.classic(1, Refill.intervally(1, interval))) + .addLimit(Bandwidth.classic(1, Refill.intervally(1, intervalAny))) .build(); } + + private Bucket newDetailedObfuscatedResultBucket(String userId) { + return Bucket.builder() + .addLimit(Bandwidth.classic(amountDetailedObfuscated, Refill.intervally(1, intervalDetailedObfuscated))) + .build(); + } + + } diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceSpringConfig.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceSpringConfig.java index eab5f6ad..6199363d 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceSpringConfig.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceSpringConfig.java @@ -12,8 +12,14 @@ public class RateLimitingServiceSpringConfig { @Bean public RateLimitingService createRateLimitingService( - @Value("${app.privacy.quota.read.any.pollingIntervalSeconds}") int pollingIntervalSeconds) { - log.info("Create RateLimitingService with interval of {} seconds", pollingIntervalSeconds); - return new RateLimitingService(Duration.ofSeconds(pollingIntervalSeconds)); + @Value("${app.privacy.quota.read.any.pollingIntervalSeconds}") int pollingIntervalSeconds, + @Value("${app.privacy.quota.read.detailedObfuscated.amount}") int detailedObfuscatedAmount, + @Value("${app.privacy.quota.read.detailedObfuscated.intervalSeconds}") int detailedObfuscatedIntervalSeconds) { + + log.info( + "Create RateLimitingService with interval of {} seconds for any result endpoint and {} allowed requests to detailed obfuscated result per {} seconds", + pollingIntervalSeconds, detailedObfuscatedAmount, detailedObfuscatedIntervalSeconds); + return new RateLimitingService(Duration.ofSeconds(pollingIntervalSeconds), + detailedObfuscatedAmount, Duration.ofSeconds(detailedObfuscatedIntervalSeconds)); } } diff --git a/src/main/java/de/numcodex/feasibility_gui_backend/query/v2/QueryHandlerRestController.java b/src/main/java/de/numcodex/feasibility_gui_backend/query/v2/QueryHandlerRestController.java index 0f57c13c..490066c2 100644 --- a/src/main/java/de/numcodex/feasibility_gui_backend/query/v2/QueryHandlerRestController.java +++ b/src/main/java/de/numcodex/feasibility_gui_backend/query/v2/QueryHandlerRestController.java @@ -42,6 +42,7 @@ @CrossOrigin(origins = "${cors.allowedOrigins}", exposedHeaders = "Location") public class QueryHandlerRestController { + public static final String HEADER_X_DETAILED_OBFUSCATED_RESULT_WAS_EMPTY = "X-Detailed-Obfuscated-Result-Was-Empty"; private final QueryHandlerService queryHandlerService; private final TermCodeValidation termCodeValidation; private final String apiBaseUrl; @@ -167,13 +168,21 @@ public QueryResult getDetailedQueryResult(@PathVariable("id") Long queryId) { } @GetMapping("/{id}" + WebSecurityConfig.PATH_DETAILED_OBFUSCATED_RESULT) - public QueryResult getDetailedObfuscatedQueryResult(@PathVariable("id") Long queryId) { + public ResponseEntity getDetailedObfuscatedQueryResult(@PathVariable("id") Long queryId) { QueryResult queryResult = queryHandlerService.getQueryResult(queryId, ResultDetail.DETAILED_OBFUSCATED); + + if (queryResult.getTotalNumberOfPatients() < privacyThresholdResults) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + HttpHeaders headers = new HttpHeaders(); if (queryResult.getResultLines().size() < privacyThresholdSites) { queryResult.setResultLines(List.of()); } - return queryResult; + if (queryResult.getResultLines().isEmpty()) { + headers.add(HEADER_X_DETAILED_OBFUSCATED_RESULT_WAS_EMPTY, "true"); + } + return new ResponseEntity<>(queryResult, headers, HttpStatus.OK); } @GetMapping("/{id}" + WebSecurityConfig.PATH_SUMMARY_RESULT) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5d2b6318..a2825416 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -91,6 +91,9 @@ app: read: any: pollingIntervalSeconds: ${PRIVACY_QUOTA_READ_ANY_POLLINGINTERVALSECONDS:10} + detailedObfuscated: + amount: ${PRIVACY_QUOTA_READ_DETAILEDOBFUSCATED_AMOUNT:3} + intervalSeconds: ${PRIVACY_QUOTA_READ_DETAILEDOBFUSCATED_INTERVALSECONDS:7200} logging: level: diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptorIT.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptorIT.java index 9e6d5d0d..407cae93 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptorIT.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingInterceptorIT.java @@ -24,6 +24,7 @@ import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; @@ -45,7 +46,9 @@ controllers = QueryHandlerRestController.class, properties = { "app.enableQueryValidation=true", - "app.privacy.quota.read.any.pollingIntervalSeconds=1" + "app.privacy.quota.read.any.pollingIntervalSeconds=1", + "app.privacy.quota.read.detailedObfuscated.amount=1", + "app.privacy.quota.read.detailedObfuscated.intervalSeconds=3" } ) @SuppressWarnings("NewClassNamingConvention") @@ -262,6 +265,42 @@ public void testGetResult_SucceedsOnImmediateSecondCallAsOtherUser(ResultDetail .andExpect(status().isOk()); } + @Test + public void testGetDetailedObfuscatedResult_FailsOnLimitExceedingCall() throws Exception { + var authorName = UUID.randomUUID().toString(); + var requestUri = "/api/v2/query/1" + WebSecurityConfig.PATH_DETAILED_OBFUSCATED_RESULT; + + doReturn(false).when(authenticationHelper) + .hasAuthority(any(Authentication.class), eq("FEASIBILITY_TEST_ADMIN")); + doReturn(authorName).when(queryHandlerService).getAuthorId(any(Long.class)); + + mockMvc + .perform( + get(requestUri).with(csrf()) + .with(user(authorName).password("pass").roles("FEASIBILITY_TEST_USER")) + ) + .andExpect(status().isOk()); + + // Wait longer than 1 second to avoid running into the general rate limit + Thread.sleep(1001L); + + mockMvc + .perform( + get(URI.create(requestUri)).with(csrf()) + .with(user(authorName).password("pass").roles("FEASIBILITY_TEST_USER")) + ) + .andExpect(status().isTooManyRequests()); + + Thread.sleep(2001L); + + mockMvc + .perform( + get(requestUri).with(csrf()) + .with(user(authorName).password("pass").roles("FEASIBILITY_TEST_USER")) + ) + .andExpect(status().isOk()); + } + @NotNull private static QueryResult createTestQueryResult(ResultDetail resultDetail) { List queryResultLines; diff --git a/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceTest.java b/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceTest.java index 8c7b6305..5e58cd77 100644 --- a/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceTest.java +++ b/src/test/java/de/numcodex/feasibility_gui_backend/query/ratelimiting/RateLimitingServiceTest.java @@ -16,27 +16,30 @@ public class RateLimitingServiceTest { private final Duration interval = Duration.ofSeconds(1); + private final int amountDetailedObfuscated = 2; + private final Duration intervalDetailedObfuscated = Duration.ofSeconds(2); private RateLimitingService rateLimitingService; @BeforeEach void setUp() { - this.rateLimitingService = new RateLimitingService(interval); + this.rateLimitingService = new RateLimitingService(interval, amountDetailedObfuscated, + intervalDetailedObfuscated); } @Test void testResolveBucket() { - Bucket bucketSomeone = rateLimitingService.resolveBucket("someone"); + Bucket bucketSomeone = rateLimitingService.resolveAnyResultBucket("someone"); assertNotNull(bucketSomeone); - Bucket bucketSomeoneElse = rateLimitingService.resolveBucket("someone-else"); + Bucket bucketSomeoneElse = rateLimitingService.resolveAnyResultBucket("someone-else"); assertNotNull(bucketSomeoneElse); assertNotEquals(bucketSomeone, bucketSomeoneElse); - assertEquals(bucketSomeone, rateLimitingService.resolveBucket("someone")); + assertEquals(bucketSomeone, rateLimitingService.resolveAnyResultBucket("someone")); } @Test void testResolveBucketRefill() throws InterruptedException { - Bucket bucketSomeone = rateLimitingService.resolveBucket("someone"); + Bucket bucketSomeone = rateLimitingService.resolveAnyResultBucket("someone"); assertTrue(bucketSomeone.tryConsume(1)); assertFalse(bucketSomeone.tryConsume(1)); Thread.sleep(TimeUnit.MILLISECONDS.convert(interval)); diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 1dd901e6..94a8ed99 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -19,4 +19,7 @@ app: intervalMinutes: 1 read: any: - pollingIntervalSeconds: 0 + pollingIntervalSeconds: 1 + detailedObfuscated: + amount: 1 + intervalSeconds: 3