diff --git a/app/server/appsmith-server/pom.xml b/app/server/appsmith-server/pom.xml index f1159a554f..8e8ac34437 100644 --- a/app/server/appsmith-server/pom.xml +++ b/app/server/appsmith-server/pom.xml @@ -135,6 +135,11 @@ org.springframework.boot spring-boot-starter-aop + + com.bucket4j + bucket4j-redis + 8.3.0 + org.hibernate.validator hibernate-validator @@ -367,7 +372,6 @@ reactiveCaching 1.0-SNAPSHOT - org.openjdk.jmh jmh-core diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java index 1c024df02e..dc22dae39d 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/AuthenticationSuccessHandler.java @@ -3,6 +3,7 @@ package com.appsmith.server.authentication.handlers; import com.appsmith.server.authentication.handlers.ce.AuthenticationSuccessHandlerCE; import com.appsmith.server.configurations.CommonConfig; import com.appsmith.server.helpers.RedirectHelper; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.repositories.UserRepository; import com.appsmith.server.repositories.WorkspaceRepository; import com.appsmith.server.services.AnalyticsService; @@ -39,6 +40,7 @@ public class AuthenticationSuccessHandler extends AuthenticationSuccessHandlerCE FeatureFlagService featureFlagService, CommonConfig commonConfig, UserIdentifierService userIdentifierService, + RateLimitService rateLimitService, TenantService tenantService, UserService userService) { @@ -57,6 +59,7 @@ public class AuthenticationSuccessHandler extends AuthenticationSuccessHandlerCE featureFlagService, commonConfig, userIdentifierService, + rateLimitService, tenantService, userService); } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationFailureHandlerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationFailureHandlerCE.java index 99ac2d3499..c9dac2df77 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationFailureHandlerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationFailureHandlerCE.java @@ -26,13 +26,12 @@ import static com.appsmith.server.helpers.RedirectHelper.REDIRECT_URL_QUERY_PARA @RequiredArgsConstructor public class AuthenticationFailureHandlerCE implements ServerAuthenticationFailureHandler { - private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); @Override public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) { log.error("In the login failure handler. Cause: {}", exception.getMessage(), exception); ServerWebExchange exchange = webFilterExchange.getExchange(); - // On authentication failure, we send a redirect to the client's login error page. The browser will re-load the // login page again with an error message shown to the user. MultiValueMap queryParams = exchange.getRequest().getQueryParams(); @@ -41,41 +40,46 @@ public class AuthenticationFailureHandlerCE implements ServerAuthenticationFailu String redirectUrl = queryParams.getFirst(REDIRECT_URL_QUERY_PARAM); if (state != null && !state.isEmpty()) { - // This is valid for OAuth2 login failures. We derive the client login URL from the state query parameter - // that would have been set when we initiated the OAuth2 request. - String[] stateArray = state.split(","); - for (int i = 0; i < stateArray.length; i++) { - String stateVar = stateArray[i]; - if (stateVar != null - && stateVar.startsWith(Security.STATE_PARAMETER_ORIGIN) - && stateVar.contains("=")) { - // This is the origin of the request that we want to redirect to - originHeader = stateVar.split("=")[1]; - } - } + originHeader = getOriginFromState(state); } else { - // This is a form login authentication failure - originHeader = exchange.getRequest().getHeaders().getOrigin(); - if (originHeader == null || originHeader.isEmpty()) { - // Check the referer header if the origin is not available - String refererHeader = exchange.getRequest().getHeaders().getFirst(Security.REFERER_HEADER); - if (refererHeader != null && !refererHeader.isBlank()) { - URI uri = null; - try { - uri = new URI(refererHeader); - String authority = uri.getAuthority(); - String scheme = uri.getScheme(); - originHeader = scheme + "://" + authority; - } catch (URISyntaxException e) { - originHeader = "/"; - } - } - } + originHeader = + getOriginFromReferer(exchange.getRequest().getHeaders().getOrigin()); } - // Authentication failure message can hold sensitive information, directly or indirectly. So we don't show all - // possible error messages. Only the ones we know are okay to be read by the user. Like a whitelist. - URI defaultRedirectLocation; + // Construct the redirect URL based on the exception type + String url = constructRedirectUrl(exception, originHeader, redirectUrl); + + return redirectWithUrl(exchange, url); + } + + private String getOriginFromState(String state) { + String[] stateArray = state.split(","); + for (int i = 0; i < stateArray.length; i++) { + String stateVar = stateArray[i]; + if (stateVar != null && stateVar.startsWith(Security.STATE_PARAMETER_ORIGIN) && stateVar.contains("=")) { + return stateVar.split("=")[1]; + } + } + return "/"; + } + + // this method extracts the origin from the referer header + private String getOriginFromReferer(String refererHeader) { + if (refererHeader != null && !refererHeader.isBlank()) { + try { + URI uri = new URI(refererHeader); + String authority = uri.getAuthority(); + String scheme = uri.getScheme(); + return scheme + "://" + authority; + } catch (URISyntaxException e) { + return "/"; + } + } + return "/"; + } + + // this method constructs the redirect URL based on the exception type + private String constructRedirectUrl(AuthenticationException exception, String originHeader, String redirectUrl) { String url = ""; if (exception instanceof OAuth2AuthenticationException && AppsmithError.SIGNUP_DISABLED @@ -96,7 +100,11 @@ public class AuthenticationFailureHandlerCE implements ServerAuthenticationFailu if (redirectUrl != null && !redirectUrl.trim().isEmpty()) { url = url + "&" + REDIRECT_URL_QUERY_PARAM + "=" + redirectUrl; } - defaultRedirectLocation = URI.create(url); + return url; + } + + private Mono redirectWithUrl(ServerWebExchange exchange, String url) { + URI defaultRedirectLocation = URI.create(url); return this.redirectStrategy.sendRedirect(exchange, defaultRedirectLocation); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java index ba3bfd7d56..6171d815e9 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/authentication/handlers/ce/AuthenticationSuccessHandlerCE.java @@ -4,6 +4,7 @@ import com.appsmith.external.constants.AnalyticsEvents; import com.appsmith.server.authentication.handlers.CustomServerOAuth2AuthorizationRequestResolver; import com.appsmith.server.configurations.CommonConfig; import com.appsmith.server.constants.FieldName; +import com.appsmith.server.constants.RateLimitConstants; import com.appsmith.server.constants.Security; import com.appsmith.server.domains.Application; import com.appsmith.server.domains.LoginSource; @@ -11,6 +12,7 @@ import com.appsmith.server.domains.User; import com.appsmith.server.domains.Workspace; import com.appsmith.server.dtos.ResendEmailVerificationDTO; import com.appsmith.server.helpers.RedirectHelper; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.repositories.UserRepository; import com.appsmith.server.repositories.WorkspaceRepository; import com.appsmith.server.services.AnalyticsService; @@ -71,8 +73,8 @@ public class AuthenticationSuccessHandlerCE implements ServerAuthenticationSucce private final FeatureFlagService featureFlagService; private final CommonConfig commonConfig; private final UserIdentifierService userIdentifierService; + private final RateLimitService rateLimitService; private final TenantService tenantService; - private final UserService userService; private Mono isVerificationRequired(String userEmail, String method) { @@ -264,6 +266,7 @@ public class AuthenticationSuccessHandlerCE implements ServerAuthenticationSucce log.debug("Login succeeded for user: {}", authentication.getPrincipal()); Mono redirectionMono = null; User user = (User) authentication.getPrincipal(); + rateLimitService.resetCounter(RateLimitConstants.BUCKET_KEY_FOR_LOGIN_API, user.getEmail()); String originHeader = webFilterExchange.getExchange().getRequest().getHeaders().getOrigin(); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/RedisConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/RedisConfig.java index 9fef4b0d17..51c5483f84 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/RedisConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/RedisConfig.java @@ -5,6 +5,7 @@ import com.appsmith.server.dtos.OAuth2AuthorizedClientDTO; import com.appsmith.server.dtos.UserSessionDTO; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; +import io.lettuce.core.RedisClient; import io.lettuce.core.resource.ClientResources; import io.micrometer.observation.ObservationRegistry; import lombok.extern.slf4j.Slf4j; @@ -100,6 +101,12 @@ public class RedisConfig { } } + @Bean + public RedisClient redisClient() { + final URI redisUri = URI.create(redisURL); + return RedisClient.create(redisUri.getScheme() + "://" + redisUri.getHost() + ":" + redisUri.getPort()); + } + private void fillAuthentication(URI redisUri, RedisConfiguration.WithAuthentication config) { final String userInfo = redisUri.getUserInfo(); if (StringUtils.isNotEmpty(userInfo)) { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java index 671a89b4f2..c24371c29b 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java @@ -7,7 +7,10 @@ import com.appsmith.server.constants.FieldName; import com.appsmith.server.constants.Url; import com.appsmith.server.domains.User; import com.appsmith.server.filters.CSRFFilter; +import com.appsmith.server.filters.ConditionalFilter; +import com.appsmith.server.filters.PreAuth; import com.appsmith.server.helpers.RedirectHelper; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.services.AnalyticsService; import com.appsmith.server.services.UserService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -93,6 +96,9 @@ public class SecurityConfig { @Autowired private RedirectHelper redirectHelper; + @Autowired + private RateLimitService rateLimitService; + @Value("${appsmith.internal.password}") private String INTERNAL_PASSWORD; @@ -208,6 +214,10 @@ public class SecurityConfig { .anyExchange() .authenticated() .and() + // Add Pre Auth rate limit filter before authentication filter + .addFilterBefore( + new ConditionalFilter(new PreAuth(rateLimitService), Url.LOGIN_URL), + SecurityWebFiltersOrder.FORM_LOGIN) .httpBasic(httpBasicSpec -> httpBasicSpec.authenticationFailureHandler(failureHandler)) .formLogin(formLoginSpec -> formLoginSpec .authenticationFailureHandler(failureHandler) @@ -217,7 +227,6 @@ public class SecurityConfig { ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, Url.LOGIN_URL)) .authenticationSuccessHandler(authenticationSuccessHandler) .authenticationFailureHandler(authenticationFailureHandler)) - // For Github SSO Login, check transformation class: CustomOAuth2UserServiceImpl // For Google SSO Login, check transformation class: CustomOAuth2UserServiceImpl .oauth2Login(oAuth2LoginSpec -> oAuth2LoginSpec diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/RateLimitConstants.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/RateLimitConstants.java new file mode 100644 index 0000000000..ca2e6b9422 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/RateLimitConstants.java @@ -0,0 +1,7 @@ +package com.appsmith.server.constants; + +public class RateLimitConstants { + public static final String RATE_LIMIT_REACHED_ACCOUNT_SUSPENDED = + "Your account is suspended for 24 hours. Please reset your password to continue"; + public static final String BUCKET_KEY_FOR_LOGIN_API = "login"; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/filters/ConditionalFilter.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/filters/ConditionalFilter.java new file mode 100644 index 0000000000..3455677795 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/filters/ConditionalFilter.java @@ -0,0 +1,26 @@ +package com.appsmith.server.filters; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +public class ConditionalFilter implements WebFilter { + + private final WebFilter filter; + private final String targetUrl; + + public ConditionalFilter(WebFilter filter, String targetUrl) { + this.filter = filter; + this.targetUrl = targetUrl; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + if (exchange.getRequest().getPath().toString().equals(targetUrl)) { + return filter.filter(exchange, chain); + } + + return chain.filter(exchange); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/filters/PreAuth.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/filters/PreAuth.java new file mode 100644 index 0000000000..21ea1aded6 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/filters/PreAuth.java @@ -0,0 +1,72 @@ +package com.appsmith.server.filters; + +import com.appsmith.server.constants.FieldName; +import com.appsmith.server.constants.RateLimitConstants; +import com.appsmith.server.ratelimiting.RateLimitService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.ServerRedirectStrategy; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Slf4j +public class PreAuth implements WebFilter { + + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + private final RateLimitService rateLimitService; + + public PreAuth(RateLimitService rateLimitService) { + this.rateLimitService = rateLimitService; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return getUsername(exchange).flatMap(username -> { + if (!username.isEmpty()) { + return rateLimitService + .tryIncreaseCounter(RateLimitConstants.BUCKET_KEY_FOR_LOGIN_API, username) + .flatMap(counterIncreaseAttemptSuccessful -> { + if (!counterIncreaseAttemptSuccessful) { + log.error("Rate limit exceeded. Redirecting to login page."); + return handleRateLimitExceeded(exchange); + } + + return chain.filter(exchange); + }); + } else { + // If username is empty, simply continue with the filter chain + return chain.filter(exchange); + } + }); + } + + private Mono getUsername(ServerWebExchange exchange) { + return exchange.getFormData().flatMap(formData -> { + String username = formData.getFirst(FieldName.USERNAME.toString()); + if (username != null && !username.isEmpty()) { + return Mono.just(URLDecoder.decode(username, StandardCharsets.UTF_8)); + } + + return Mono.just(""); + }); + } + + private Mono handleRateLimitExceeded(ServerWebExchange exchange) { + // Set the error in the URL query parameter for rate limiting + String url = "/user/login?error=true&message=" + + URLEncoder.encode(RateLimitConstants.RATE_LIMIT_REACHED_ACCOUNT_SUSPENDED, StandardCharsets.UTF_8); + return redirectWithUrl(exchange, url); + } + + private Mono redirectWithUrl(ServerWebExchange exchange, String url) { + URI defaultRedirectLocation = URI.create(url); + return this.redirectStrategy.sendRedirect(exchange, defaultRedirectLocation); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitConfig.java new file mode 100644 index 0000000000..5c4aeb7c85 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitConfig.java @@ -0,0 +1,75 @@ +package com.appsmith.server.ratelimiting; + +import com.appsmith.server.constants.RateLimitConstants; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.Refill; +import io.github.bucket4j.distributed.BucketProxy; +import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; +import io.lettuce.core.RedisClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Configuration +public class RateLimitConfig { + private static final Map apiConfigurations = new HashMap<>(); + + @Autowired + private final RedisClient redisClient; + + public RateLimitConfig(RedisClient redisClient) { + this.redisClient = redisClient; + } + + static { + apiConfigurations.put( + RateLimitConstants.BUCKET_KEY_FOR_LOGIN_API, createBucketConfiguration(Duration.ofDays(1), 5)); + // Add more API configurations as needed + } + + @Bean + public LettuceBasedProxyManager proxyManager() { + /* + we want a single proxyManager to manage all buckets. + If we set too short an expiration time, + the proxyManager expires and renews the buckets with their initial configuration + */ + Duration longExpiration = Duration.ofDays(3650); // 10 years + return LettuceBasedProxyManager.builderFor(redisClient) + .withExpirationStrategy(ExpirationAfterWriteStrategy.fixedTimeToLive(longExpiration)) + .build(); + } + + @Bean + public Map apiBuckets() { + Map apiBuckets = new HashMap<>(); + + apiConfigurations.forEach((apiIdentifier, configuration) -> + apiBuckets.put(apiIdentifier, proxyManager().builder().build(apiIdentifier.getBytes(), configuration))); + + return apiBuckets; + } + + public BucketProxy getOrCreateAPIUserSpecificBucket(String apiIdentifier, String userId) { + String bucketIdentifier = apiIdentifier + userId; + Optional bucketProxy = proxyManager().getProxyConfiguration(bucketIdentifier.getBytes()); + if (bucketProxy.isPresent()) { + return proxyManager().builder().build(bucketIdentifier.getBytes(), bucketProxy.get()); + } + + return proxyManager().builder().build(bucketIdentifier.getBytes(), apiConfigurations.get(apiIdentifier)); + } + + private static BucketConfiguration createBucketConfiguration(Duration refillDuration, int limit) { + Refill refillConfig = Refill.intervally(limit, refillDuration); + Bandwidth limitConfig = Bandwidth.classic(limit, refillConfig); + return BucketConfiguration.builder().addLimit(limitConfig).build(); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitService.java new file mode 100644 index 0000000000..51a9b048d9 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/RateLimitService.java @@ -0,0 +1,45 @@ +package com.appsmith.server.ratelimiting; + +import io.github.bucket4j.distributed.BucketProxy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@Slf4j +@Service +public class RateLimitService { + + private final Map apiBuckets; + private final RateLimitConfig rateLimitConfig; + // this number of tokens var can later be customised per API in the configuration. + private final Integer DEFAULT_NUMBER_OF_TOKENS_CONSUMED_PER_REQUEST = 1; + + public RateLimitService(Map apiBuckets, RateLimitConfig rateLimitConfig) { + this.apiBuckets = apiBuckets; + this.rateLimitConfig = rateLimitConfig; + } + + public Mono tryIncreaseCounter(String apiIdentifier, String userIdentifier) { + log.debug( + "RateLimitService.tryIncreaseCounter() called with apiIdentifier = {}, userIdentifier = {}", + apiIdentifier, + userIdentifier); + // handle the case where API itself is not rate limited + log.debug( + apiBuckets.containsKey(apiIdentifier) ? "apiBuckets contains key" : "apiBuckets does not contain key"); + if (!apiBuckets.containsKey(apiIdentifier)) return Mono.just(false); + + BucketProxy userSpecificBucket = + rateLimitConfig.getOrCreateAPIUserSpecificBucket(apiIdentifier, userIdentifier); + log.debug("userSpecificBucket = {}", userSpecificBucket); + return Mono.just(userSpecificBucket.tryConsume(DEFAULT_NUMBER_OF_TOKENS_CONSUMED_PER_REQUEST)); + } + + public void resetCounter(String apiIdentifier, String userIdentifier) { + rateLimitConfig + .getOrCreateAPIUserSpecificBucket(apiIdentifier, userIdentifier) + .reset(); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/annotations/RateLimit.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/annotations/RateLimit.java new file mode 100644 index 0000000000..5732a88446 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/annotations/RateLimit.java @@ -0,0 +1,20 @@ +package com.appsmith.server.ratelimiting.annotations; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@RequestMapping +public @interface RateLimit { + + @AliasFor(annotation = RequestMapping.class, attribute = "value") + String[] value() default {}; + + String api() default ""; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/aspects/RateLimitAspect.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/aspects/RateLimitAspect.java new file mode 100644 index 0000000000..5e67421bb0 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/ratelimiting/aspects/RateLimitAspect.java @@ -0,0 +1,51 @@ +package com.appsmith.server.ratelimiting.aspects; + +import com.appsmith.server.domains.User; +import com.appsmith.server.dtos.ResponseDTO; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.ratelimiting.RateLimitService; +import com.appsmith.server.ratelimiting.annotations.*; +import com.appsmith.server.services.SessionUserService; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Aspect +@Component +public class RateLimitAspect { + private final RateLimitService rateLimitService; + private final SessionUserService sessionUserService; + + public RateLimitAspect(RateLimitService rateLimitService, SessionUserService sessionUserService) { + this.rateLimitService = rateLimitService; + this.sessionUserService = sessionUserService; + } + + @Around(value = "@annotation(rateLimit)") + public Object applyRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { + String apiIdentifier = rateLimit.api(); + Mono loggedInUser = sessionUserService.getCurrentUser(); + Mono userIdentifier = loggedInUser.map(User::getEmail); + + return userIdentifier.flatMap(email -> { + Mono isAllowedMono = rateLimitService.tryIncreaseCounter(apiIdentifier, email); + return isAllowedMono.flatMap(isAllowed -> { + if (!isAllowed) { + AppsmithException exception = new AppsmithException(AppsmithError.TOO_MANY_REQUESTS); + return Mono.just(new ResponseDTO<>(exception.getHttpStatus(), exception.getMessage(), null)); + } + + try { + Object result = joinPoint.proceed(); + return result instanceof Mono ? (Mono) result : Mono.just(result); + } catch (Throwable e) { + AppsmithError error = AppsmithError.INTERNAL_SERVER_ERROR; + throw new AppsmithException(error, e.getMessage()); + } + }); + }); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java index eb9e04dca5..7ec5c51774 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/UserServiceImpl.java @@ -6,6 +6,7 @@ import com.appsmith.server.configurations.EmailConfig; import com.appsmith.server.helpers.RedirectHelper; import com.appsmith.server.helpers.UserUtils; import com.appsmith.server.notifications.EmailSender; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.repositories.ApplicationRepository; import com.appsmith.server.repositories.EmailVerificationTokenRepository; import com.appsmith.server.repositories.PasswordResetTokenRepository; @@ -48,8 +49,8 @@ public class UserServiceImpl extends UserServiceCEImpl implements UserService { PermissionGroupService permissionGroupService, UserUtils userUtils, EmailVerificationTokenRepository emailVerificationTokenRepository, - RedirectHelper redirectHelper) { - + RedirectHelper redirectHelper, + RateLimitService rateLimitService) { super( scheduler, validator, @@ -73,6 +74,7 @@ public class UserServiceImpl extends UserServiceCEImpl implements UserService { permissionGroupService, userUtils, emailVerificationTokenRepository, - redirectHelper); + redirectHelper, + rateLimitService); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java index ae52d7340f..a828b92d92 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/UserServiceCEImpl.java @@ -8,6 +8,7 @@ import com.appsmith.server.configurations.CommonConfig; import com.appsmith.server.configurations.EmailConfig; import com.appsmith.server.constants.Appsmith; import com.appsmith.server.constants.FieldName; +import com.appsmith.server.constants.RateLimitConstants; import com.appsmith.server.domains.EmailVerificationToken; import com.appsmith.server.domains.LoginSource; import com.appsmith.server.domains.PasswordResetToken; @@ -30,6 +31,7 @@ import com.appsmith.server.helpers.RedirectHelper; import com.appsmith.server.helpers.UserUtils; import com.appsmith.server.helpers.ValidationUtils; import com.appsmith.server.notifications.EmailSender; +import com.appsmith.server.ratelimiting.RateLimitService; import com.appsmith.server.repositories.ApplicationRepository; import com.appsmith.server.repositories.EmailVerificationTokenRepository; import com.appsmith.server.repositories.PasswordResetTokenRepository; @@ -115,6 +117,7 @@ public class UserServiceCEImpl extends BaseService private final TenantService tenantService; private final PermissionGroupService permissionGroupService; private final UserUtils userUtils; + private final RateLimitService rateLimitService; private final RedirectHelper redirectHelper; private static final WebFilterChain EMPTY_WEB_FILTER_CHAIN = serverWebExchange -> Mono.empty(); @@ -159,7 +162,8 @@ public class UserServiceCEImpl extends BaseService PermissionGroupService permissionGroupService, UserUtils userUtils, EmailVerificationTokenRepository emailVerificationTokenRepository, - RedirectHelper redirectHelper) { + RedirectHelper redirectHelper, + RateLimitService rateLimitService) { super(scheduler, validator, mongoConverter, reactiveMongoTemplate, repository, analyticsService); this.workspaceService = workspaceService; this.sessionUserService = sessionUserService; @@ -176,6 +180,7 @@ public class UserServiceCEImpl extends BaseService this.tenantService = tenantService; this.permissionGroupService = permissionGroupService; this.userUtils = userUtils; + this.rateLimitService = rateLimitService; this.emailVerificationTokenRepository = emailVerificationTokenRepository; this.redirectHelper = redirectHelper; } @@ -423,12 +428,17 @@ public class UserServiceCEImpl extends BaseService emailTokenDTO.getToken()))) .flatMap(passwordResetTokenRepository::delete) .then(repository.save(userFromDb)) - .doOnSuccess(result -> - // In a separate thread, we delete all other sessions of this user. - sessionUserService - .logoutAllSessions(userFromDb.getEmail()) - .subscribeOn(Schedulers.boundedElastic()) - .subscribe()) + .doOnSuccess(result -> { + // In a separate thread, we delete all other sessions of this user. + sessionUserService + .logoutAllSessions(userFromDb.getEmail()) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe(); + + // we reset the counter for user's login attempts once password is reset + rateLimitService.resetCounter( + RateLimitConstants.BUCKET_KEY_FOR_LOGIN_API, userFromDb.getEmail()); + }) .thenReturn(true); })); }