diff --git a/fineract-provider/dependencies.gradle b/fineract-provider/dependencies.gradle index 7af3a59982e..1b4d1e15942 100644 --- a/fineract-provider/dependencies.gradle +++ b/fineract-provider/dependencies.gradle @@ -50,6 +50,7 @@ dependencies { ) implementation( 'org.springframework.boot:spring-boot-starter-web', + 'org.springframework.boot:spring-boot-starter-aop', 'org.springframework.boot:spring-boot-starter-security', 'org.springframework.boot:spring-boot-starter-cache', 'org.springframework.boot:spring-boot-starter-oauth2-resource-server', @@ -85,6 +86,7 @@ dependencies { 'com.squareup.okhttp3:okhttp', 'com.squareup.okhttp3:okhttp-urlconnection', + 'com.github.ben-manes.caffeine:caffeine:3.1.8', 'org.apache.commons:commons-lang3', 'commons-io:commons-io', 'org.apache.poi:poi', diff --git a/fineract-provider/src/main/java/org/apache/fineract/ServerApplication.java b/fineract-provider/src/main/java/org/apache/fineract/ServerApplication.java index f95ad1c23c5..77e811d5fa2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/ServerApplication.java +++ b/fineract-provider/src/main/java/org/apache/fineract/ServerApplication.java @@ -21,9 +21,11 @@ import java.io.IOException; import org.apache.fineract.infrastructure.core.boot.FineractLiquibaseOnlyApplicationConfiguration; import org.apache.fineract.infrastructure.core.boot.FineractWebApplicationConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; +import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.context.annotation.Import; /** @@ -38,7 +40,8 @@ * It's the old/classic Mifos (non-X) Workspace 2.0 reborn for Fineract! ;-) * */ - +@SpringBootApplication +@EnableAspectJAutoProxy public class ServerApplication extends SpringBootServletInitializer { @Import({ FineractWebApplicationConfiguration.class, FineractLiquibaseOnlyApplicationConfiguration.class }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java index 6339db84439..837a71dc3f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/api/AuthenticationApiResource.java @@ -27,13 +27,16 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; @@ -44,6 +47,8 @@ import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer; import org.apache.fineract.infrastructure.security.constants.TwoFactorConstants; import org.apache.fineract.infrastructure.security.data.AuthenticatedUserData; +import org.apache.fineract.infrastructure.security.ratelimit.EnforceRateLimit; +import org.apache.fineract.infrastructure.security.ratelimit.RateLimitService; import org.apache.fineract.infrastructure.security.service.SpringSecurityPlatformSecurityContext; import org.apache.fineract.portfolio.client.service.ClientReadPlatformService; import org.apache.fineract.useradministration.data.RoleData; @@ -79,17 +84,20 @@ public static class AuthenticateRequest { private final ToApiJsonSerializer apiJsonSerializerService; private final SpringSecurityPlatformSecurityContext springSecurityPlatformSecurityContext; private final ClientReadPlatformService clientReadPlatformService; + private final RateLimitService rateLimitService; @POST + @EnforceRateLimit @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Verify authentication", description = "Authenticates the credentials provided and returns the set roles and permissions allowed.") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationResponse.class))), - @ApiResponse(responseCode = "400", description = "Unauthenticated. Please login") }) - public String authenticate(@Parameter(hidden = true) final String apiRequestBodyAsJson, - @QueryParam("returnClientList") @DefaultValue("false") boolean returnClientList) { + @ApiResponse(responseCode = "400", description = "Unauthenticated. Please login"), + @ApiResponse(responseCode = "429", description = "Too many requests - please try again later.") }) + public Response authenticate(@Parameter(hidden = true) final String apiRequestBodyAsJson, + @QueryParam("returnClientList") @DefaultValue("false") boolean returnClientList, @Context HttpServletRequest httpRequest) { // TODO FINERACT-819: sort out Jersey so JSON conversion does not have // to be done explicitly via GSON here, but implicit by arg AuthenticateRequest request = new Gson().fromJson(apiRequestBodyAsJson, AuthenticateRequest.class); @@ -109,6 +117,8 @@ public String authenticate(@Parameter(hidden = true) final String apiRequestBody AuthenticatedUserData authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setPermissions(permissions); if (authenticationCheck.isAuthenticated()) { + rateLimitService.resetRateLimit(httpRequest.getRemoteAddr()); + final Collection authorities = new ArrayList<>(authenticationCheck.getAuthorities()); for (final GrantedAuthority grantedAuthority : authorities) { permissions.add(grantedAuthority.getAuthority()); @@ -140,7 +150,6 @@ public String authenticate(@Parameter(hidden = true) final String apiRequestBody .setBase64EncodedAuthenticationKey(new String(base64EncodedAuthenticationKey, StandardCharsets.UTF_8)) .setAuthenticated(true).setShouldRenewPassword(true).setTwoFactorAuthenticationRequired(isTwoFactorRequired); } else { - authenticatedUserData = new AuthenticatedUserData().setUsername(request.username).setOfficeId(officeId) .setOfficeName(officeName).setStaffId(staffId).setStaffDisplayName(staffDisplayName) .setOrganisationalRole(organisationalRole).setRoles(roles).setPermissions(permissions).setUserId(principal.getId()) @@ -150,9 +159,8 @@ public String authenticate(@Parameter(hidden = true) final String apiRequestBody .setClients(returnClientList ? clientReadPlatformService.retrieveUserClients(userId) : null); } - } - return this.apiJsonSerializerService.serialize(authenticatedUserData); + return Response.ok().entity(apiJsonSerializerService.serialize(authenticatedUserData)).build(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/EnforceRateLimit.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/EnforceRateLimit.java new file mode 100644 index 00000000000..df3f1ff5d56 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/EnforceRateLimit.java @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface EnforceRateLimit {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/InMemoryRateLimitService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/InMemoryRateLimitService.java new file mode 100644 index 00000000000..867b45d6ae2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/InMemoryRateLimitService.java @@ -0,0 +1,55 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.ratelimit; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.annotation.PostConstruct; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class InMemoryRateLimitService implements RateLimitService { + + @Value("${fineract.security.ratelimit.max-attempts-per-host-per-minute}") + private int maxAttemptsPerHostPerMinute; + + private Cache cache; + + @PostConstruct + public void setup() { + cache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).maximumSize(100_000).build(); + } + + @Override + public boolean isRateLimited(String remoteAddress) { + Integer existingAttempts = cache.getIfPresent(remoteAddress); + int currentAttempt = 1 + Optional.ofNullable(existingAttempts).orElse(0); + cache.put(remoteAddress, currentAttempt); + + return currentAttempt > maxAttemptsPerHostPerMinute; + } + + @Override + public void resetRateLimit(String remoteAddress) { + cache.invalidate(remoteAddress); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/RateLimitAspect.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/RateLimitAspect.java new file mode 100644 index 00000000000..e42808070c4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/RateLimitAspect.java @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +@RequiredArgsConstructor +public class RateLimitAspect { + + private final RateLimitService rateLimitService; + + @Pointcut("@annotation(EnforceRateLimit) || @within(EnforceRateLimit)") + public void pointcut() {} + + @Around("pointcut()") + public Object aroundRestCall(ProceedingJoinPoint joinPoint) throws Throwable { + String remoteAddress = getRequest().getRemoteAddr(); + + if (rateLimitService.isRateLimited(remoteAddress)) { + return Response.status(429).entity("Too many requests - try again later.").build(); + + } else { + return joinPoint.proceed(); + } + } + + HttpServletRequest getRequest() { + return ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/RateLimitService.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/RateLimitService.java new file mode 100644 index 00000000000..61918669bd9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/security/ratelimit/RateLimitService.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.ratelimit; + +public interface RateLimitService { + + boolean isRateLimited(String remoteAddress); + + void resetRateLimit(String remoteAddress); + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java index 58e35f8060a..9b3295013eb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/self/security/api/SelfAuthenticationApiResource.java @@ -25,11 +25,14 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.security.api.AuthenticationApiResource; import org.apache.fineract.infrastructure.security.api.AuthenticationApiResourceSwagger; @@ -46,14 +49,14 @@ public class SelfAuthenticationApiResource { private final AuthenticationApiResource authenticationApiResource; @POST - @Consumes({ MediaType.APPLICATION_JSON }) - @Produces({ MediaType.APPLICATION_JSON }) + @Consumes({MediaType.APPLICATION_JSON}) + @Produces({MediaType.APPLICATION_JSON}) @Operation(summary = "Verify authentication", description = "Authenticates the credentials provided and returns the set roles and permissions allowed.\n\n" + "Please visit this link for more info - https://fineract.apache.org/legacy-docs/apiLive.htm#selfbasicauth") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = AuthenticationApiResourceSwagger.PostAuthenticationRequest.class))) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class))) }) - public String authenticate(final String apiRequestBodyAsJson) { - return this.authenticationApiResource.authenticate(apiRequestBodyAsJson, true); + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = SelfAuthenticationApiResourceSwagger.PostSelfAuthenticationResponse.class)))}) + public Response authenticate(final String apiRequestBodyAsJson, @Context HttpServletRequest httpRequest) { + return this.authenticationApiResource.authenticate(apiRequestBodyAsJson, true, httpRequest); } } diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 326c3f54b3f..8ed9aaa658a 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -24,6 +24,7 @@ fineract.node-id=${FINERACT_NODE_ID:1} fineract.security.basicauth.enabled=${FINERACT_SECURITY_BASICAUTH_ENABLED:true} fineract.security.oauth.enabled=${FINERACT_SECURITY_OAUTH_ENABLED:false} fineract.security.2fa.enabled=${FINERACT_SECURITY_2FA_ENABLED:false} +fineract.security.ratelimit.max-attempts-per-host-per-minute=${FINERACT_SECURITY_RATELIMIT_MAX_ATTEMPTS_PER_HOST_PER_MINUTE:3} fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:3306} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/ratelimit/TestInMemoryRateLimitService.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/ratelimit/TestInMemoryRateLimitService.java new file mode 100644 index 00000000000..b2094998798 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/ratelimit/TestInMemoryRateLimitService.java @@ -0,0 +1,70 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.ratelimit; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class TestInMemoryRateLimitService { + + @Test + public void testDefaultRateLimiter() { + InMemoryRateLimitService ratelimiter = new InMemoryRateLimitService(); + ratelimiter.setup(); + ReflectionTestUtils.setField(ratelimiter, "maxAttemptsPerHostPerMinute", 3); + + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + assertTrue(ratelimiter.isRateLimited("127.0.0.1")); + } + + @Test + public void testTimeEviction() throws InterruptedException { + InMemoryRateLimitService ratelimiter = new InMemoryRateLimitService(); + ratelimiter.setup(); + ReflectionTestUtils.setField(ratelimiter, "maxAttemptsPerHostPerMinute", 2); + ReflectionTestUtils.setField(ratelimiter, "cache", + Caffeine.newBuilder().expireAfterWrite(500, TimeUnit.MILLISECONDS).maximumSize(100_000).build()); + + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + assertTrue(ratelimiter.isRateLimited("127.0.0.1")); + Thread.sleep(1_000L); + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + } + + @Test + public void testReset() { + InMemoryRateLimitService ratelimiter = new InMemoryRateLimitService(); + ratelimiter.setup(); + ReflectionTestUtils.setField(ratelimiter, "maxAttemptsPerHostPerMinute", 2); + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + assertTrue(ratelimiter.isRateLimited("127.0.0.1")); + + ratelimiter.resetRateLimit("127.0.0.1"); + assertFalse(ratelimiter.isRateLimited("127.0.0.1")); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/ratelimit/TestRateLimitAspect.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/ratelimit/TestRateLimitAspect.java new file mode 100644 index 00000000000..3c4854adc5b --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/security/ratelimit/TestRateLimitAspect.java @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.security.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.core.Response; +import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +class TestRateLimitAspect { + + @Test + public void test() throws Throwable { + InMemoryRateLimitService ratelimiter = new InMemoryRateLimitService(); + ratelimiter.setup(); + ReflectionTestUtils.setField(ratelimiter, "maxAttemptsPerHostPerMinute", 2); + + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + RateLimitAspect aspect = spy(new RateLimitAspect(ratelimiter)); + + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + doReturn(mockRequest).when(aspect).getRequest(); + when(mockRequest.getRemoteAddr()).thenReturn("127.0.0.1"); + + assertNull(aspect.aroundRestCall(joinPoint)); + assertNull(aspect.aroundRestCall(joinPoint)); + assertEquals(429, ((Response) aspect.aroundRestCall(joinPoint)).getStatus()); + } + +} \ No newline at end of file