feat: login rate limit (#26171)
**Changes** * add rate limit on login and signup APIs * add annotations to support rate limit on controllers which can be configured per API. * refactor SecurityConfig **implementation details** * uses bucket4j for rate limiting * uses redis as a backend for distributed rate limiting
This commit is contained in:
parent
fc9587480d
commit
157b316f46
|
|
@ -135,6 +135,11 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-redis</artifactId>
|
||||
<version>8.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
|
|
@ -367,7 +372,6 @@
|
|||
<artifactId>reactiveCaching</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.openjdk.jmh</groupId>
|
||||
<artifactId>jmh-core</artifactId>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Void> 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<String, String> 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<Void> redirectWithUrl(ServerWebExchange exchange, String url) {
|
||||
URI defaultRedirectLocation = URI.create(url);
|
||||
return this.redirectStrategy.sendRedirect(exchange, defaultRedirectLocation);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Boolean> isVerificationRequired(String userEmail, String method) {
|
||||
|
|
@ -264,6 +266,7 @@ public class AuthenticationSuccessHandlerCE implements ServerAuthenticationSucce
|
|||
log.debug("Login succeeded for user: {}", authentication.getPrincipal());
|
||||
Mono<Void> redirectionMono = null;
|
||||
User user = (User) authentication.getPrincipal();
|
||||
rateLimitService.resetCounter(RateLimitConstants.BUCKET_KEY_FOR_LOGIN_API, user.getEmail());
|
||||
String originHeader =
|
||||
webFilterExchange.getExchange().getRequest().getHeaders().getOrigin();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
if (exchange.getRequest().getPath().toString().equals(targetUrl)) {
|
||||
return filter.filter(exchange, chain);
|
||||
}
|
||||
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Void> 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<String> 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<Void> 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<Void> redirectWithUrl(ServerWebExchange exchange, String url) {
|
||||
URI defaultRedirectLocation = URI.create(url);
|
||||
return this.redirectStrategy.sendRedirect(exchange, defaultRedirectLocation);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, BucketConfiguration> 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<byte[]> 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<String, BucketProxy> apiBuckets() {
|
||||
Map<String, BucketProxy> 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<BucketConfiguration> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, BucketProxy> 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<String, BucketProxy> apiBuckets, RateLimitConfig rateLimitConfig) {
|
||||
this.apiBuckets = apiBuckets;
|
||||
this.rateLimitConfig = rateLimitConfig;
|
||||
}
|
||||
|
||||
public Mono<Boolean> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "";
|
||||
}
|
||||
|
|
@ -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<User> loggedInUser = sessionUserService.getCurrentUser();
|
||||
Mono<String> userIdentifier = loggedInUser.map(User::getEmail);
|
||||
|
||||
return userIdentifier.flatMap(email -> {
|
||||
Mono<Boolean> 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());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UserRepository, User, String>
|
|||
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<UserRepository, User, String>
|
|||
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<UserRepository, User, String>
|
|||
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<UserRepository, User, String>
|
|||
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);
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user