Skip to content

Commit

Permalink
rate limit wip
Browse files Browse the repository at this point in the history
  • Loading branch information
kjozsa committed Oct 14, 2024
1 parent fe16b61 commit 9cf2164
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 12 deletions.
2 changes: 2 additions & 0 deletions fineract-provider/dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -79,17 +84,20 @@ public static class AuthenticateRequest {
private final ToApiJsonSerializer<AuthenticatedUserData> 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);
Expand All @@ -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<GrantedAuthority> authorities = new ArrayList<>(authenticationCheck.getAuthorities());
for (final GrantedAuthority grantedAuthority : authorities) {
permissions.add(grantedAuthority.getAuthority());
Expand Down Expand Up @@ -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())
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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<String, Integer> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading

0 comments on commit 9cf2164

Please sign in to comment.