Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#68 - limit requesting of detailed results per user #77

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -16,4 +16,7 @@ app:
intervalMinutes: 1
read:
any:
pollingIntervalSeconds: 0
pollingIntervalSeconds: 1
detailedObfuscated:
amount: 1
intervalSeconds: 3