diff --git a/pom.xml b/pom.xml index c670a1ed..e8552c9a 100644 --- a/pom.xml +++ b/pom.xml @@ -346,6 +346,11 @@ commons-text 1.8 + + com.github.vladimir-bukhtoyarov + bucket4j-core + 4.10.0 + diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/api/TooManyRegistrations.java b/src/main/java/ch/ethz/seb/sebserver/gbl/api/TooManyRegistrations.java new file mode 100644 index 00000000..55cacedd --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/api/TooManyRegistrations.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.gbl.api; + +public class TooManyRegistrations { + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/api/TooManyRequests.java b/src/main/java/ch/ethz/seb/sebserver/gbl/api/TooManyRequests.java new file mode 100644 index 00000000..bf29ba48 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/api/TooManyRequests.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.gbl.api; + +public class TooManyRequests extends RuntimeException { + + private static final long serialVersionUID = 3303246002774619224L; + + public static enum Code { + INCOMMING, + REGISTRATION + } + + public final Code code; + + public TooManyRequests() { + super("TooManyRequests"); + this.code = Code.INCOMMING; + } + + public TooManyRequests(final Code code) { + super("TooManyRequests"); + this.code = code; + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/RegisterPage.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/RegisterPage.java index c034f6c7..00433b02 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/RegisterPage.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/RegisterPage.java @@ -232,7 +232,7 @@ public class RegisterPage implements TemplateComposer { registerButton.addListener(SWT.Selection, event -> { registerForm.getForm().clearErrors(); - final Result onError = this.pageService + final Result result = this.pageService .getRestService() .getBuilder(RegisterNewUser.class) .withRestTemplate(this.restTemplate) @@ -240,7 +240,7 @@ public class RegisterPage implements TemplateComposer { .call() .onError(registerForm::handleError); - if (onError.hasError()) { + if (result.hasError()) { return; } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java b/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java index 80e2f73c..54b3b365 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java @@ -14,10 +14,13 @@ import java.util.function.Predicate; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import ch.ethz.seb.sebserver.gbl.api.API; import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.api.EntityType; +import ch.ethz.seb.sebserver.gbl.api.TooManyRequests; import ch.ethz.seb.sebserver.gbl.model.Entity; import ch.ethz.seb.sebserver.gbl.util.Result; import ch.ethz.seb.sebserver.gbl.util.Utils; @@ -35,7 +38,11 @@ import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCallError; public class FormHandle { + private static final Logger log = LoggerFactory.getLogger(FormHandle.class); + public static final String FIELD_VALIDATION_LOCTEXT_PREFIX = "sebserver.form.validation.fieldError."; + static final LocTextKey MESSAGE_TOO_MANY_REQUESTS_TEXT = + new LocTextKey("sebserver.error.tooManyRequests"); private final PageService pageService; private final PageContext pageContext; @@ -169,12 +176,27 @@ public class FormHandle { } private void handleUnexpectedError(final Exception error) { - if (this.post != null && this.post.getEntityType() != null) { - this.pageContext.notifySaveError(this.post.getEntityType(), error); - } else { - this.pageContext.notifyError( - new LocTextKey(PageContext.GENERIC_SAVE_ERROR_TEXT_KEY, StringUtils.EMPTY), - error); + try { + if (error instanceof TooManyRequests) { + final TooManyRequests.Code code = ((TooManyRequests) error).code; + if (code != null) { + this.pageContext.publishInfo(new LocTextKey(MESSAGE_TOO_MANY_REQUESTS_TEXT.name + "." + code)); + } else { + this.pageContext.publishInfo(MESSAGE_TOO_MANY_REQUESTS_TEXT); + } + + return; + } + + if (this.post != null && this.post.getEntityType() != null) { + this.pageContext.notifySaveError(this.post.getEntityType(), error); + } else { + this.pageContext.notifyError( + new LocTextKey(PageContext.GENERIC_SAVE_ERROR_TEXT_KEY, StringUtils.EMPTY), + error); + } + } catch (final Exception e) { + log.error("Failed to handle unexpected error: ", e); } } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/RestCall.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/RestCall.java index 95785864..3fc41d7e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/RestCall.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/RestCall.java @@ -40,6 +40,7 @@ import ch.ethz.seb.sebserver.gbl.Constants; import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.api.EntityType; import ch.ethz.seb.sebserver.gbl.api.JSONMapper; +import ch.ethz.seb.sebserver.gbl.api.TooManyRequests; import ch.ethz.seb.sebserver.gbl.model.Entity; import ch.ethz.seb.sebserver.gbl.model.Page; import ch.ethz.seb.sebserver.gbl.model.PageSortOrder; @@ -134,6 +135,15 @@ public abstract class RestCall { } } catch (final RestClientResponseException responseError) { + if (responseError.getRawStatusCode() == HttpStatus.TOO_MANY_REQUESTS.value()) { + final String code = responseError.getResponseBodyAsString(); + if (StringUtils.isNotBlank(code)) { + return Result.ofError(new TooManyRequests(TooManyRequests.Code.valueOf(code))); + } else { + return Result.ofError(new TooManyRequests()); + } + } + final RestCallError restCallError = new RestCallError("Unexpected error while rest call", responseError); try { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java index 4667dbef..5bf6892e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java @@ -33,6 +33,7 @@ import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExcep import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.api.APIMessage.APIMessageException; import ch.ethz.seb.sebserver.gbl.api.APIMessage.FieldValidationException; +import ch.ethz.seb.sebserver.gbl.api.TooManyRequests; import ch.ethz.seb.sebserver.gbl.util.Utils; import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.PermissionDeniedException; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ResourceNotFoundException; @@ -87,6 +88,15 @@ public class APIExceptionHandler extends ResponseEntityExceptionHandler { HttpStatus.BAD_REQUEST); } + @ExceptionHandler(TooManyRequests.class) + public ResponseEntity handleToManyRequests( + final TooManyRequests ex, + final WebRequest request) { + return ResponseEntity + .status(HttpStatus.TOO_MANY_REQUESTS) + .body(String.valueOf(ex.code)); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException( final RuntimeException ex, diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/RateLimitService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/RateLimitService.java new file mode 100644 index 00000000..bf0cfec6 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/RateLimitService.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.webservice.weblayer.api; + +import java.time.Duration; + +import org.springframework.context.annotation.Lazy; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; + +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.Refill; +import io.github.bucket4j.local.LocalBucket; + +@Lazy +@Service +@WebServiceProfile +public class RateLimitService { + + private final int requestLimit; + private final int requestLimitInterval; + private final int requestLimitRefill; + + private final int createLimit; + private final int createLimitInterval; + private final int createLimitRefill; + + public RateLimitService(final Environment env) { + this.requestLimit = env.getProperty( + "sebserver.webservice.api.admin.request.limit", Integer.class, 10); + this.requestLimitInterval = + env.getProperty("sebserver.webservice.api.admin.request.limit.interval.min", Integer.class, 10); + this.requestLimitRefill = + env.getProperty("sebserver.webservice.api.admin.request.limit.refill", Integer.class, 2); + + this.createLimit = env.getProperty( + "sebserver.webservice.api.admin.create.limit", Integer.class, 10); + this.createLimitInterval = + env.getProperty("sebserver.webservice.api.admin.create.limit.interval.min", Integer.class, 3600); + this.createLimitRefill = + env.getProperty("sebserver.webservice.api.admin.create.limit.refill", Integer.class, 10); + } + + public LocalBucket createRequestLimitBucker() { + final Bandwidth limit = Bandwidth.classic( + this.requestLimit, + Refill.intervally(this.requestLimitRefill, Duration.ofMinutes(this.requestLimitInterval))); + return Bucket4j.builder() + .addLimit(limit) + .build(); + } + + public LocalBucket createCreationLimitBucker() { + final Bandwidth limit = Bandwidth.classic( + this.createLimit, + Refill.intervally(this.createLimitRefill, Duration.ofMinutes(this.createLimitInterval))); + return Bucket4j.builder() + .addLimit(limit) + .build(); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/RegisterUserController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/RegisterUserController.java index 09ea96a5..41191719 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/RegisterUserController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/RegisterUserController.java @@ -28,6 +28,7 @@ import ch.ethz.seb.sebserver.gbl.api.API; import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.api.APIMessage.APIMessageException; import ch.ethz.seb.sebserver.gbl.api.POSTMapper; +import ch.ethz.seb.sebserver.gbl.api.TooManyRequests; import ch.ethz.seb.sebserver.gbl.model.Domain.USER_ROLE; import ch.ethz.seb.sebserver.gbl.model.user.PasswordChange; import ch.ethz.seb.sebserver.gbl.model.user.UserInfo; @@ -38,6 +39,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.InstitutionDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.validation.BeanValidationService; +import io.github.bucket4j.local.LocalBucket; @WebServiceProfile @RestController @@ -47,17 +49,23 @@ public class RegisterUserController { private final UserActivityLogDAO userActivityLogDAO; private final UserDAO userDAO; private final BeanValidationService beanValidationService; + private final LocalBucket requestRateLimitBucket; + private final LocalBucket createRateLimitBucket; protected RegisterUserController( final InstitutionDAO institutionDAO, final UserActivityLogDAO userActivityLogDAO, final UserDAO userDAO, final BeanValidationService beanValidationService, + final RateLimitService rateLimitService, @Qualifier(WebSecurityConfig.USER_PASSWORD_ENCODER_BEAN_NAME) final PasswordEncoder userPasswordEncoder) { this.userActivityLogDAO = userActivityLogDAO; this.userDAO = userDAO; this.beanValidationService = beanValidationService; + + this.requestRateLimitBucket = rateLimitService.createRequestLimitBucker(); + this.createRateLimitBucket = rateLimitService.createCreationLimitBucker(); } @RequestMapping( @@ -68,6 +76,10 @@ public class RegisterUserController { @RequestParam final MultiValueMap allRequestParams, final HttpServletRequest request) { + if (!this.requestRateLimitBucket.tryConsume(1)) { + throw new TooManyRequests(); + } + final POSTMapper postMap = new POSTMapper(allRequestParams, request.getQueryString()) .putIfAbsent(USER_ROLE.REFERENCE_NAME, UserRole.EXAM_SUPPORTER.name()); final UserMod userMod = new UserMod(null, postMap); @@ -88,8 +100,11 @@ public class RegisterUserController { throw new APIMessageException(errors); } - return userAccount; + if (!this.createRateLimitBucket.tryConsume(1)) { + throw new TooManyRequests(TooManyRequests.Code.REGISTRATION); + } + return userAccount; }) .flatMap(this.userDAO::createNew) .flatMap(account -> this.userDAO.setActive(account, true)) diff --git a/src/main/resources/config/application-ws.properties b/src/main/resources/config/application-ws.properties index fcd19e39..8f6d94c2 100644 --- a/src/main/resources/config/application-ws.properties +++ b/src/main/resources/config/application-ws.properties @@ -44,6 +44,12 @@ sebserver.webservice.api.admin.clientId=guiClient sebserver.webservice.api.admin.endpoint=/admin-api/v1 sebserver.webservice.api.admin.accessTokenValiditySeconds=3600 sebserver.webservice.api.admin.refreshTokenValiditySeconds=25200 +sebserver.webservice.api.admin.request.limit=10 +sebserver.webservice.api.admin.request.limit.interval.min=10 +sebserver.webservice.api.admin.request.limit.refill=2 +sebserver.webservice.api.admin.create.limit=10 +sebserver.webservice.api.admin.create.limit.interval.min=3600 +sebserver.webservice.api.admin.create.limit.refill=10 sebserver.webservice.api.exam.config.init.permittedProcesses=config/initialPermittedProcesses.xml sebserver.webservice.api.exam.config.init.prohibitedProcesses=config/initialProhibitedProcesses.xml sebserver.webservice.api.exam.endpoint=/exam-api @@ -56,4 +62,5 @@ sebserver.webservice.api.pagination.maxPageSize=500 # comma separated list of known possible OpenEdX API access token request endpoints sebserver.webservice.lms.openedx.api.token.request.paths=/oauth2/access_token sebserver.webservice.lms.moodle.api.token.request.paths=/login/token.php -sebserver.webservice.lms.address.alias= \ No newline at end of file +sebserver.webservice.lms.address.alias= + diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 4decbf93..0be7961b 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -107,6 +107,8 @@ sebserver.error.save.entity=Failed to save {0}.
Please try again or contact sebserver.error.exam.seb.restriction=

Failed to automatically set Safe Exam Browser restriction on/off for this exam on the corresponding LMS.
Please check the LMS Setup and try again or contact a system administrator if this error persists sebserver.error.import=Failed to import {0}.
Please try again or contact a system administrator if this error persists sebserver.error.logout=Failed to logout properly.
Please try again or contact a system administrator if this error persists +sebserver.error.tooManyRequests.INCOMMING=This request has been blocked as there are too many incoming request at the moment for this page.

Please try again later. +sebserver.error.tooManyRequests.REGISTRATION=This request has been blocked as the maximum user registration per day limit has reached.

Please try again next day or call an administrator to create a user account. ################################ # Login Page ################################