Skip to content

Commit

Permalink
Merge pull request #77 from medizininformatik-initiative/feature/68-L…
Browse files Browse the repository at this point in the history
…imit-requesting-of-detailed-results-per-user

#68 - limit requesting of detailed results per user
  • Loading branch information
juliangruendner authored Mar 15, 2023
2 parents 50ee1f3 + c20dd0c commit 14a0059
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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());
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,54 @@
*/
public class RateLimitingService {

private final Map<String, Bucket> cache = new ConcurrentHashMap<>();
private final Map<String, Bucket> anyResultRetrievalCache = new ConcurrentHashMap<>();
private final Map<String, Bucket> detailedObfuscatedResultRetrievalCache = new ConcurrentHashMap<>();

private final Duration interval;
private final Duration intervalAny;

private final Duration intervalDetailedObfuscated;

private final int amountDetailedObfuscated;

/**
* Creates a new RateLimitingService.
* <p>
* 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();
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Object> 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)
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand Down Expand Up @@ -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<QueryResultLine> queryResultLines;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
5 changes: 4 additions & 1 deletion src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ app:
intervalMinutes: 1
read:
any:
pollingIntervalSeconds: 0
pollingIntervalSeconds: 1
detailedObfuscated:
amount: 1
intervalSeconds: 3

0 comments on commit 14a0059

Please sign in to comment.