mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
init limits for users and non Authed Users
This commit is contained in:
parent
1a51a43962
commit
30937d204e
@ -10,7 +10,9 @@
|
||||
"Bash(npm test)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(echo)",
|
||||
"Bash(rm:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
@ -57,4 +57,12 @@ posthog.host=https://eu.i.posthog.com
|
||||
spring.main.allow-bean-definition-overriding=true
|
||||
|
||||
# Set up a consistent temporary directory location
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
|
||||
# API Rate Limiting Configuration
|
||||
api.rate-limit.enabled=true
|
||||
api.rate-limit.anonymous.enabled=true
|
||||
api.rate-limit.anonymous.monthly-limit=10
|
||||
api.rate-limit.anonymous.abuse-threshold=3
|
||||
api.rate-limit.exclude-settings=true
|
||||
api.rate-limit.exclude-actuator=true
|
@ -0,0 +1,68 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.service.ApiRateLimitService;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "api.rate-limit")
|
||||
@Data
|
||||
@Slf4j
|
||||
public class ApiRateLimitConfiguration {
|
||||
|
||||
private boolean enabled = true;
|
||||
private boolean excludeSettings = true;
|
||||
private boolean excludeActuator = true;
|
||||
|
||||
/**
|
||||
* Default monthly limits by role. Override in application.yml/properties.
|
||||
* Note: Integer.MAX_VALUE is treated as "unlimited".
|
||||
*/
|
||||
private Map<String, Integer> defaultLimits = Map.of(
|
||||
"ROLE_SYSTEM_ADMIN", Integer.MAX_VALUE,
|
||||
"ROLE_ORG_ADMIN", 10000,
|
||||
"ROLE_TEAM_LEAD", 5000,
|
||||
"ROLE_ADMIN", Integer.MAX_VALUE,
|
||||
"ROLE_USER", 1000,
|
||||
"ROLE_DEMO_USER", 100,
|
||||
"STIRLING-PDF-BACKEND-API-USER", Integer.MAX_VALUE
|
||||
);
|
||||
|
||||
@Bean
|
||||
public CommandLineRunner initializeDefaultRateLimits(ApiRateLimitService rateLimitService) {
|
||||
return args -> {
|
||||
if (!enabled) {
|
||||
log.info("API rate limiting is disabled");
|
||||
return;
|
||||
}
|
||||
log.info("Initializing default API rate limits...");
|
||||
initializeDefaults(rateLimitService);
|
||||
log.info("Default API rate limits initialized successfully");
|
||||
};
|
||||
}
|
||||
|
||||
private void initializeDefaults(ApiRateLimitService rateLimitService) {
|
||||
for (Map.Entry<String, Integer> entry : defaultLimits.entrySet()) {
|
||||
String roleName = entry.getKey();
|
||||
Integer limit = entry.getValue();
|
||||
|
||||
try {
|
||||
Role.fromString(roleName);
|
||||
rateLimitService.createOrUpdateRoleDefault(roleName, limit);
|
||||
log.debug("Set default rate limit for role {} to {}/month",
|
||||
roleName, limit == Integer.MAX_VALUE ? "unlimited" : limit);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Skipping unknown role in rate limit config: {}", roleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,213 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.ApiRateLimitConfig;
|
||||
import stirling.software.proprietary.model.Organization;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.OrganizationRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.ApiRateLimitService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/rate-limits")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "API Rate Limits", description = "Endpoints for managing and viewing API rate limits")
|
||||
public class ApiRateLimitController {
|
||||
|
||||
private final ApiRateLimitService rateLimitService;
|
||||
private final UserService userService;
|
||||
private final OrganizationRepository organizationRepository;
|
||||
|
||||
public record UsageMetricsResponse(
|
||||
int currentUsage,
|
||||
int monthlyLimit,
|
||||
int remaining,
|
||||
String scope,
|
||||
String month,
|
||||
boolean isPooled,
|
||||
long resetEpochMillis
|
||||
) {}
|
||||
|
||||
public record UpdateLimitRequest(
|
||||
int monthlyLimit,
|
||||
boolean isPooled
|
||||
) {}
|
||||
|
||||
@GetMapping("/usage")
|
||||
@Operation(summary = "Get current usage metrics",
|
||||
description = "Returns the current API usage metrics for the authenticated user")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Usage metrics retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Not authenticated")
|
||||
})
|
||||
public ResponseEntity<UsageMetricsResponse> getCurrentUsage(Authentication auth) {
|
||||
User user = getUserFromAuth(auth);
|
||||
if (user == null) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
ApiRateLimitService.UsageMetrics metrics = rateLimitService.getUsageMetrics(user);
|
||||
|
||||
return ResponseEntity.ok(new UsageMetricsResponse(
|
||||
metrics.currentUsage(),
|
||||
metrics.monthlyLimit(),
|
||||
metrics.remaining(),
|
||||
metrics.scope(),
|
||||
metrics.month().toString(),
|
||||
metrics.isPooled(),
|
||||
getNextMonthResetEpochMillis()
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/usage/{username}")
|
||||
@PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'ORG_ADMIN', 'TEAM_LEAD')")
|
||||
@Operation(summary = "Get usage metrics for a specific user",
|
||||
description = "Returns API usage metrics for the specified user (requires admin privileges)")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Usage metrics retrieved successfully"),
|
||||
@ApiResponse(responseCode = "403", description = "Insufficient privileges"),
|
||||
@ApiResponse(responseCode = "404", description = "User not found")
|
||||
})
|
||||
public ResponseEntity<UsageMetricsResponse> getUserUsage(
|
||||
@PathVariable @Parameter(description = "Username to get metrics for") String username,
|
||||
Authentication auth) {
|
||||
|
||||
User requestingUser = getUserFromAuth(auth);
|
||||
User targetUser = userService.findByUsername(username).orElse(null);
|
||||
|
||||
if (targetUser == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!requestingUser.canManageUser(targetUser)) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
ApiRateLimitService.UsageMetrics metrics = rateLimitService.getUsageMetrics(targetUser);
|
||||
|
||||
return ResponseEntity.ok(new UsageMetricsResponse(
|
||||
metrics.currentUsage(),
|
||||
metrics.monthlyLimit(),
|
||||
metrics.remaining(),
|
||||
metrics.scope(),
|
||||
metrics.month().toString(),
|
||||
metrics.isPooled(),
|
||||
getNextMonthResetEpochMillis()
|
||||
));
|
||||
}
|
||||
|
||||
@PutMapping("/user/{username}")
|
||||
@PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'ORG_ADMIN')")
|
||||
@Operation(summary = "Update rate limit for a user",
|
||||
description = "Sets a custom rate limit for a specific user")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Rate limit updated successfully"),
|
||||
@ApiResponse(responseCode = "403", description = "Insufficient privileges"),
|
||||
@ApiResponse(responseCode = "404", description = "User not found")
|
||||
})
|
||||
public ResponseEntity<ApiRateLimitConfig> updateUserLimit(
|
||||
@PathVariable @Parameter(description = "Username to update") String username,
|
||||
@RequestBody UpdateLimitRequest request,
|
||||
Authentication auth) {
|
||||
|
||||
User requestingUser = getUserFromAuth(auth);
|
||||
User targetUser = userService.findByUsername(username).orElse(null);
|
||||
|
||||
if (targetUser == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!requestingUser.canManageUser(targetUser)) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
ApiRateLimitConfig config = rateLimitService.createOrUpdateUserLimit(
|
||||
targetUser, request.monthlyLimit());
|
||||
|
||||
return ResponseEntity.ok(config);
|
||||
}
|
||||
|
||||
@PutMapping("/organization/{orgId}")
|
||||
@PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'ORG_ADMIN')")
|
||||
@Operation(summary = "Update rate limit for an organization",
|
||||
description = "Sets a rate limit for an entire organization")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Rate limit updated successfully"),
|
||||
@ApiResponse(responseCode = "403", description = "Insufficient privileges"),
|
||||
@ApiResponse(responseCode = "404", description = "Organization not found")
|
||||
})
|
||||
public ResponseEntity<ApiRateLimitConfig> updateOrgLimit(
|
||||
@PathVariable @Parameter(description = "Organization ID") Long orgId,
|
||||
@RequestBody UpdateLimitRequest request,
|
||||
Authentication auth) {
|
||||
|
||||
User requestingUser = getUserFromAuth(auth);
|
||||
Organization org = organizationRepository.findById(orgId).orElse(null);
|
||||
|
||||
if (org == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!requestingUser.isSystemAdmin()) {
|
||||
Organization userOrg = requestingUser.getOrganization();
|
||||
if (userOrg == null || !userOrg.getId().equals(orgId) || !requestingUser.isOrgAdmin()) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
}
|
||||
|
||||
ApiRateLimitConfig config = rateLimitService.createOrUpdateOrgLimit(
|
||||
org, request.monthlyLimit(), request.isPooled());
|
||||
|
||||
return ResponseEntity.ok(config);
|
||||
}
|
||||
|
||||
@PutMapping("/role/{roleName}")
|
||||
@PreAuthorize("hasRole('SYSTEM_ADMIN')")
|
||||
@Operation(summary = "Update default rate limit for a role",
|
||||
description = "Sets the default rate limit for all users with a specific role")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Rate limit updated successfully"),
|
||||
@ApiResponse(responseCode = "403", description = "Insufficient privileges")
|
||||
})
|
||||
public ResponseEntity<ApiRateLimitConfig> updateRoleDefault(
|
||||
@PathVariable @Parameter(description = "Role name") String roleName,
|
||||
@RequestBody UpdateLimitRequest request) {
|
||||
|
||||
ApiRateLimitConfig config = rateLimitService.createOrUpdateRoleDefault(
|
||||
roleName, request.monthlyLimit());
|
||||
|
||||
return ResponseEntity.ok(config);
|
||||
}
|
||||
|
||||
private User getUserFromAuth(Authentication auth) {
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
return userService.findByUsername(auth.getName()).orElse(null);
|
||||
}
|
||||
|
||||
private long getNextMonthResetEpochMillis() {
|
||||
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
|
||||
YearMonth nextMonth = currentMonth.plusMonths(1);
|
||||
ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
|
||||
return resetTime.toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.YearMonth;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import stirling.software.proprietary.model.converter.YearMonthStringConverter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "anonymous_api_usage",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_anon_fingerprint_month", columnNames = {"fingerprint", "month"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_anon_fingerprint", columnList = "fingerprint"),
|
||||
@Index(name = "idx_anon_month", columnList = "month"),
|
||||
@Index(name = "idx_anon_ip", columnList = "ip_address")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class AnonymousApiUsage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
@EqualsAndHashCode.Include
|
||||
@ToString.Include
|
||||
private Long id;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "fingerprint", nullable = false, length = 128)
|
||||
@ToString.Include
|
||||
private String fingerprint;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "month", nullable = false, length = 7)
|
||||
@Convert(converter = YearMonthStringConverter.class)
|
||||
private YearMonth month;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "usage_count", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer usageCount = 0;
|
||||
|
||||
@Column(name = "ip_address", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "user_agent", length = 512)
|
||||
private String userAgent;
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(
|
||||
name = "anonymous_api_related_fingerprints",
|
||||
joinColumns = @JoinColumn(name = "usage_id")
|
||||
)
|
||||
@Column(name = "related_fingerprint")
|
||||
@Builder.Default
|
||||
private Set<String> relatedFingerprints = new HashSet<>();
|
||||
|
||||
@Column(name = "abuse_score")
|
||||
@Builder.Default
|
||||
private Integer abuseScore = 0;
|
||||
|
||||
@Column(name = "is_blocked")
|
||||
@Builder.Default
|
||||
private Boolean isBlocked = false;
|
||||
|
||||
@Column(name = "last_access")
|
||||
private Instant lastAccess;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false, nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
this.lastAccess = Instant.now();
|
||||
}
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Entity
|
||||
@Table(name = "api_rate_limit_configs",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_user_cfg", columnNames = {"scope_type", "user_id"}),
|
||||
@UniqueConstraint(name = "uq_org_cfg", columnNames = {"scope_type", "org_id"}),
|
||||
@UniqueConstraint(name = "uq_role_cfg", columnNames = {"scope_type", "role_name"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_cfg_user", columnList = "user_id"),
|
||||
@Index(name = "idx_cfg_org", columnList = "org_id"),
|
||||
@Index(name = "idx_cfg_role", columnList = "role_name"),
|
||||
@Index(name = "idx_cfg_scope", columnList = "scope_type")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class ApiRateLimitConfig implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public enum ScopeType {
|
||||
USER,
|
||||
ORGANIZATION,
|
||||
ROLE_DEFAULT
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
@EqualsAndHashCode.Include
|
||||
@ToString.Include
|
||||
private Long id;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "scope_type", nullable = false, length = 20)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private ScopeType scopeType;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = true)
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "org_id", nullable = true)
|
||||
private Organization organization;
|
||||
|
||||
@Column(name = "role_name", nullable = true, length = 50)
|
||||
private String roleName;
|
||||
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Column(name = "monthly_limit", nullable = false)
|
||||
private Integer monthlyLimit;
|
||||
|
||||
@NotNull
|
||||
@Builder.Default
|
||||
@Column(name = "is_pooled", nullable = false)
|
||||
private Boolean isPooled = false;
|
||||
|
||||
@NotNull
|
||||
@Builder.Default
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = true;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false, nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
protected void validateScope() {
|
||||
int nonNullCount = 0;
|
||||
if (user != null) nonNullCount++;
|
||||
if (organization != null) nonNullCount++;
|
||||
if (roleName != null) nonNullCount++;
|
||||
|
||||
if (nonNullCount != 1) {
|
||||
throw new IllegalStateException(
|
||||
"Exactly one of user, organization, or roleName must be set");
|
||||
}
|
||||
|
||||
if (user != null && scopeType != ScopeType.USER) {
|
||||
throw new IllegalStateException("ScopeType must be USER when user is set");
|
||||
}
|
||||
if (organization != null && scopeType != ScopeType.ORGANIZATION) {
|
||||
throw new IllegalStateException("ScopeType must be ORGANIZATION when organization is set");
|
||||
}
|
||||
if (roleName != null && scopeType != ScopeType.ROLE_DEFAULT) {
|
||||
throw new IllegalStateException("ScopeType must be ROLE_DEFAULT when roleName is set");
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(isPooled) && scopeType != ScopeType.ORGANIZATION) {
|
||||
throw new IllegalStateException("isPooled can only be true for ORGANIZATION scope");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Entity
|
||||
@Table(name = "api_rate_limit_usage",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_user_month", columnNames = {"user_id", "month_key"}),
|
||||
@UniqueConstraint(name = "uq_org_month", columnNames = {"org_id", "month_key"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_usage_user_month", columnList = "user_id, month_key"),
|
||||
@Index(name = "idx_usage_org_month", columnList = "org_id, month_key"),
|
||||
@Index(name = "idx_usage_month", columnList = "month_key")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class ApiRateLimitUsage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
@EqualsAndHashCode.Include
|
||||
@ToString.Include
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = true)
|
||||
private User user;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "org_id", nullable = true)
|
||||
private Organization organization;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "month_key", nullable = false, length = 7)
|
||||
@ToString.Include
|
||||
private YearMonth monthKey;
|
||||
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Builder.Default
|
||||
@Column(name = "usage_count", nullable = false)
|
||||
private Integer usageCount = 0;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false, nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
protected void validateScope() {
|
||||
if ((user == null && organization == null) || (user != null && organization != null)) {
|
||||
throw new IllegalStateException("Exactly one of user or organization must be set");
|
||||
}
|
||||
}
|
||||
|
||||
public static YearMonth getCurrentMonth() {
|
||||
return YearMonth.now(ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
public static ApiRateLimitUsage forUser(User user) {
|
||||
return ApiRateLimitUsage.builder()
|
||||
.user(user)
|
||||
.monthKey(getCurrentMonth())
|
||||
.usageCount(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ApiRateLimitUsage forOrganization(Organization org) {
|
||||
return ApiRateLimitUsage.builder()
|
||||
.organization(org)
|
||||
.monthKey(getCurrentMonth())
|
||||
.usageCount(0)
|
||||
.build();
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.model.converter;
|
||||
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
@Converter(autoApply = true)
|
||||
public class YearMonthStringConverter implements AttributeConverter<YearMonth, String> {
|
||||
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(YearMonth yearMonth) {
|
||||
return yearMonth != null ? yearMonth.format(FORMATTER) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public YearMonth convertToEntityAttribute(String dbData) {
|
||||
return dbData != null ? YearMonth.parse(dbData, FORMATTER) : null;
|
||||
}
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
package stirling.software.proprietary.repository;
|
||||
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.model.AnonymousApiUsage;
|
||||
|
||||
@Repository
|
||||
public interface AnonymousApiUsageRepository extends JpaRepository<AnonymousApiUsage, Long> {
|
||||
|
||||
Optional<AnonymousApiUsage> findByFingerprintAndMonth(String fingerprint, YearMonth month);
|
||||
|
||||
@Query("SELECT a FROM AnonymousApiUsage a WHERE a.fingerprint = :fingerprint AND a.month = :month AND a.isBlocked = false")
|
||||
Optional<AnonymousApiUsage> findActiveByFingerprintAndMonth(@Param("fingerprint") String fingerprint, @Param("month") YearMonth month);
|
||||
|
||||
@Query("SELECT a FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month")
|
||||
List<AnonymousApiUsage> findByIpAddressAndMonth(@Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
|
||||
|
||||
@Query("SELECT a FROM AnonymousApiUsage a WHERE :fingerprint IN (SELECT rf FROM a.relatedFingerprints rf) AND a.month = :month")
|
||||
List<AnonymousApiUsage> findRelatedUsages(@Param("fingerprint") String fingerprint, @Param("month") YearMonth month);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(a.usageCount), 0) FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month")
|
||||
Integer getTotalUsageByIpAndMonth(@Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE AnonymousApiUsage a SET a.usageCount = a.usageCount + 1, a.lastAccess = CURRENT_TIMESTAMP WHERE a.id = :id AND a.usageCount < :limit")
|
||||
int incrementUsage(@Param("id") Long id, @Param("limit") int limit);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE AnonymousApiUsage a SET a.isBlocked = true WHERE a.fingerprint = :fingerprint")
|
||||
void blockFingerprint(@Param("fingerprint") String fingerprint);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE AnonymousApiUsage a SET a.abuseScore = a.abuseScore + :increment WHERE a.id = :id")
|
||||
void incrementAbuseScore(@Param("id") Long id, @Param("increment") int increment);
|
||||
|
||||
@Query("SELECT COUNT(DISTINCT a.fingerprint) FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month")
|
||||
Long countDistinctFingerprintsForIp(@Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
|
||||
|
||||
@Modifying
|
||||
@Query(value = "INSERT INTO anonymous_api_usage (fingerprint, month, usage_count, ip_address, user_agent, abuse_score, is_blocked, last_access, created_at, updated_at, version) " +
|
||||
"VALUES (:fingerprint, :month, 1, :ipAddress, :userAgent, 0, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) " +
|
||||
"ON CONFLICT (fingerprint, month) DO UPDATE SET " +
|
||||
"usage_count = anonymous_api_usage.usage_count + 1, " +
|
||||
"last_access = CURRENT_TIMESTAMP, " +
|
||||
"updated_at = CURRENT_TIMESTAMP " +
|
||||
"WHERE anonymous_api_usage.usage_count < :limit",
|
||||
nativeQuery = true)
|
||||
int upsertAndIncrement(@Param("fingerprint") String fingerprint,
|
||||
@Param("month") String month,
|
||||
@Param("ipAddress") String ipAddress,
|
||||
@Param("userAgent") String userAgent,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@Query(value = "INSERT INTO anonymous_api_usage (fingerprint, month, usage_count, ip_address, user_agent, " +
|
||||
"abuse_score, is_blocked, last_access, created_at, updated_at, version) " +
|
||||
"VALUES (:fingerprint, :month, 1, :ipAddress, :userAgent, 0, false, CURRENT_TIMESTAMP, " +
|
||||
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) " +
|
||||
"ON CONFLICT (fingerprint, month) DO UPDATE SET " +
|
||||
"usage_count = anonymous_api_usage.usage_count + 1, " +
|
||||
"last_access = CURRENT_TIMESTAMP, " +
|
||||
"updated_at = CURRENT_TIMESTAMP " +
|
||||
"WHERE anonymous_api_usage.usage_count < :limit " +
|
||||
"RETURNING usage_count",
|
||||
nativeQuery = true)
|
||||
Integer upsertAndIncrementReturningCount(@Param("fingerprint") String fingerprint,
|
||||
@Param("month") String month,
|
||||
@Param("ipAddress") String ipAddress,
|
||||
@Param("userAgent") String userAgent,
|
||||
@Param("limit") int limit);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package stirling.software.proprietary.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.model.ApiRateLimitConfig;
|
||||
import stirling.software.proprietary.model.ApiRateLimitConfig.ScopeType;
|
||||
import stirling.software.proprietary.model.Organization;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Repository
|
||||
public interface ApiRateLimitConfigRepository extends JpaRepository<ApiRateLimitConfig, Long> {
|
||||
|
||||
Optional<ApiRateLimitConfig> findByUserAndIsActiveTrue(User user);
|
||||
|
||||
Optional<ApiRateLimitConfig> findByOrganizationAndIsActiveTrue(Organization organization);
|
||||
|
||||
Optional<ApiRateLimitConfig> findByScopeTypeAndRoleNameAndIsActiveTrue(ScopeType scopeType, String roleName);
|
||||
|
||||
@Query("""
|
||||
SELECT c
|
||||
FROM ApiRateLimitConfig c
|
||||
WHERE c.isActive = true
|
||||
AND c.scopeType = stirling.software.proprietary.model.ApiRateLimitConfig$ScopeType.ROLE_DEFAULT
|
||||
AND c.roleName = :roleName
|
||||
""")
|
||||
Optional<ApiRateLimitConfig> findDefaultForRole(@Param("roleName") String roleName);
|
||||
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
package stirling.software.proprietary.repository;
|
||||
|
||||
import java.time.YearMonth;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import stirling.software.proprietary.model.ApiRateLimitUsage;
|
||||
import stirling.software.proprietary.model.Organization;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Repository
|
||||
public interface ApiRateLimitUsageRepository extends JpaRepository<ApiRateLimitUsage, Long> {
|
||||
|
||||
Optional<ApiRateLimitUsage> findByUserAndMonthKey(User user, YearMonth monthKey);
|
||||
|
||||
Optional<ApiRateLimitUsage> findByOrganizationAndMonthKey(Organization organization, YearMonth monthKey);
|
||||
|
||||
default int getUserUsageOrZero(User user, YearMonth monthKey) {
|
||||
return findByUserAndMonthKey(user, monthKey)
|
||||
.map(ApiRateLimitUsage::getUsageCount)
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
default int getOrgUsageOrZero(Organization org, YearMonth monthKey) {
|
||||
return findByOrganizationAndMonthKey(org, monthKey)
|
||||
.map(ApiRateLimitUsage::getUsageCount)
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
@Modifying(flushAutomatically = true, clearAutomatically = false)
|
||||
@Transactional
|
||||
@Query(
|
||||
value = """
|
||||
INSERT INTO api_rate_limit_usage (user_id, month_key, usage_count, created_at, updated_at, version)
|
||||
VALUES (:#{#user.id}, :monthKey, :inc, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT (user_id, month_key) DO UPDATE
|
||||
SET usage_count = api_rate_limit_usage.usage_count + :inc,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
version = api_rate_limit_usage.version + 1
|
||||
WHERE api_rate_limit_usage.usage_count + :inc <= :maxLimit
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
int upsertAndIncrementUserUsage(@Param("user") User user,
|
||||
@Param("monthKey") String monthKey,
|
||||
@Param("inc") int increment,
|
||||
@Param("maxLimit") int maxLimit);
|
||||
|
||||
@Modifying(flushAutomatically = true, clearAutomatically = false)
|
||||
@Transactional
|
||||
@Query(
|
||||
value = """
|
||||
INSERT INTO api_rate_limit_usage (org_id, month_key, usage_count, created_at, updated_at, version)
|
||||
VALUES (:#{#org.id}, :monthKey, :inc, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)
|
||||
ON CONFLICT (org_id, month_key) DO UPDATE
|
||||
SET usage_count = api_rate_limit_usage.usage_count + :inc,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
version = api_rate_limit_usage.version + 1
|
||||
WHERE api_rate_limit_usage.usage_count + :inc <= :maxLimit
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
int upsertAndIncrementOrgUsage(@Param("org") Organization org,
|
||||
@Param("monthKey") String monthKey,
|
||||
@Param("inc") int increment,
|
||||
@Param("maxLimit") int maxLimit);
|
||||
}
|
@ -38,6 +38,7 @@ import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler
|
||||
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
||||
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.filter.ApiRateLimitFilter;
|
||||
import stirling.software.proprietary.security.filter.FirstLoginFilter;
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
@ -70,6 +71,7 @@ public class SecurityConfiguration {
|
||||
private final UserAuthenticationFilter userAuthenticationFilter;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final FirstLoginFilter firstLoginFilter;
|
||||
private final ApiRateLimitFilter apiRateLimitFilter;
|
||||
private final SessionPersistentRegistry sessionRegistry;
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
@ -88,6 +90,7 @@ public class SecurityConfiguration {
|
||||
UserAuthenticationFilter userAuthenticationFilter,
|
||||
LoginAttemptService loginAttemptService,
|
||||
FirstLoginFilter firstLoginFilter,
|
||||
ApiRateLimitFilter apiRateLimitFilter,
|
||||
SessionPersistentRegistry sessionRegistry,
|
||||
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
||||
@Autowired(required = false)
|
||||
@ -104,6 +107,7 @@ public class SecurityConfiguration {
|
||||
this.userAuthenticationFilter = userAuthenticationFilter;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.firstLoginFilter = firstLoginFilter;
|
||||
this.apiRateLimitFilter = apiRateLimitFilter;
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.persistentLoginRepository = persistentLoginRepository;
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
@ -177,6 +181,7 @@ public class SecurityConfiguration {
|
||||
}
|
||||
|
||||
http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
http.addFilterAfter(apiRateLimitFilter, UserAuthenticationFilter.class);
|
||||
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
http.sessionManagement(
|
||||
sessionManagement ->
|
||||
|
@ -0,0 +1,222 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.ApiRateLimitService;
|
||||
|
||||
@Component
|
||||
@Order(1)
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ApiRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ApiRateLimitService rateLimitService;
|
||||
private final UserService userService;
|
||||
private final ApiJobEndpointMatcher apiJobEndpointMatcher;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${api.rate-limit.enabled:true}")
|
||||
private boolean rateLimitEnabled;
|
||||
|
||||
@Value("${api.rate-limit.anonymous.enabled:true}")
|
||||
private boolean anonymousRateLimitEnabled;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
if (!shouldApplyRateLimit(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = getCurrentUser();
|
||||
ApiRateLimitService.RateLimitStatus status;
|
||||
|
||||
if (user == null) {
|
||||
// Handle anonymous users
|
||||
if (!anonymousRateLimitEnabled) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String ipAddress = getClientIpAddress(request);
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
||||
status = rateLimitService.checkAndIncrementAnonymousUsage(ipAddress, userAgent);
|
||||
|
||||
addRateLimitHeaders(response, status);
|
||||
|
||||
if (!status.allowed()) {
|
||||
handleAnonymousRateLimitExceeded(response, status);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Handle authenticated users
|
||||
status = rateLimitService.checkAndIncrementUsage(user);
|
||||
|
||||
addRateLimitHeaders(response, status);
|
||||
|
||||
if (!status.allowed()) {
|
||||
handleRateLimitExceeded(response, user, status);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private boolean shouldApplyRateLimit(HttpServletRequest request) {
|
||||
if (!rateLimitEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the shared matcher to determine if this endpoint should be rate-limited
|
||||
return apiJobEndpointMatcher.matches(request);
|
||||
}
|
||||
|
||||
// TODO: improve with Redis and async in future V2.1
|
||||
private User getCurrentUser() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
if ("anonymousUser".equals(username)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
} catch (Exception e) {
|
||||
log.error("Error getting user for rate limiting: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void addRateLimitHeaders(HttpServletResponse response, ApiRateLimitService.RateLimitStatus status) {
|
||||
// Use standard RateLimit headers (IETF draft-ietf-httpapi-ratelimit-headers)
|
||||
response.setHeader("RateLimit-Limit", String.valueOf(status.monthlyLimit()));
|
||||
response.setHeader("RateLimit-Remaining", String.valueOf(status.remaining()));
|
||||
response.setHeader("RateLimit-Reset", String.valueOf(getNextMonthResetEpochMillis() / 1000)); // Unix timestamp in seconds
|
||||
response.setHeader("RateLimit-Policy", String.format("%d;w=%d;comment=\"%s\"",
|
||||
status.monthlyLimit(), getSecondsUntilReset(), status.scope()));
|
||||
}
|
||||
|
||||
private long getNextMonthResetEpochMillis() {
|
||||
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
|
||||
YearMonth nextMonth = currentMonth.plusMonths(1);
|
||||
ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
|
||||
return resetTime.toInstant().toEpochMilli();
|
||||
}
|
||||
|
||||
private long getSecondsUntilReset() {
|
||||
return (getNextMonthResetEpochMillis() - System.currentTimeMillis()) / 1000;
|
||||
}
|
||||
|
||||
private void handleRateLimitExceeded(HttpServletResponse response, User user,
|
||||
ApiRateLimitService.RateLimitStatus status) throws IOException {
|
||||
log.warn("Rate limit exceeded for user: {} - {}", user.getUsername(), status.reason());
|
||||
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
|
||||
var errorResponse = new RateLimitErrorResponse(
|
||||
"Rate limit exceeded",
|
||||
status.reason(),
|
||||
status.currentUsage(),
|
||||
status.monthlyLimit(),
|
||||
status.scope(),
|
||||
getNextMonthResetEpochMillis()
|
||||
);
|
||||
|
||||
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
|
||||
}
|
||||
|
||||
private record RateLimitErrorResponse(
|
||||
String error,
|
||||
String message,
|
||||
int currentUsage,
|
||||
int monthlyLimit,
|
||||
String scope,
|
||||
long resetEpochMillis
|
||||
) {}
|
||||
|
||||
private String getClientIpAddress(HttpServletRequest request) {
|
||||
// Check for proxy headers
|
||||
String[] headers = {
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"Proxy-Client-IP",
|
||||
"WL-Proxy-Client-IP",
|
||||
"HTTP_X_FORWARDED_FOR",
|
||||
"HTTP_X_FORWARDED",
|
||||
"HTTP_X_CLUSTER_CLIENT_IP",
|
||||
"HTTP_CLIENT_IP",
|
||||
"HTTP_FORWARDED_FOR",
|
||||
"HTTP_FORWARDED",
|
||||
"HTTP_VIA",
|
||||
"REMOTE_ADDR"
|
||||
};
|
||||
|
||||
for (String header : headers) {
|
||||
String ip = request.getHeader(header);
|
||||
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
|
||||
// Handle comma-separated IPs (in case of multiple proxies)
|
||||
int commaIndex = ip.indexOf(',');
|
||||
if (commaIndex > 0) {
|
||||
ip = ip.substring(0, commaIndex).trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to remote address
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
private void handleAnonymousRateLimitExceeded(HttpServletResponse response,
|
||||
ApiRateLimitService.RateLimitStatus status) throws IOException {
|
||||
log.warn("Anonymous rate limit exceeded - {}", status.reason());
|
||||
|
||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
|
||||
var errorResponse = new RateLimitErrorResponse(
|
||||
"Rate limit exceeded",
|
||||
status.reason() + " - Please login for higher limits",
|
||||
status.currentUsage(),
|
||||
status.monthlyLimit(),
|
||||
status.scope(),
|
||||
getNextMonthResetEpochMillis()
|
||||
);
|
||||
|
||||
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@ -32,6 +33,8 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
|
||||
import stirling.software.proprietary.service.ApiRateLimitService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -41,16 +44,25 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final UserService userService;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final boolean loginEnabledValue;
|
||||
private final ApiRateLimitService rateLimitService;
|
||||
private final ApiJobEndpointMatcher apiJobEndpointMatcher;
|
||||
|
||||
@Value("${api.rate-limit.anonymous.enabled:true}")
|
||||
private boolean anonymousApiEnabled;
|
||||
|
||||
public UserAuthenticationFilter(
|
||||
@Lazy ApplicationProperties.Security securityProp,
|
||||
@Lazy UserService userService,
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue) {
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Lazy ApiRateLimitService rateLimitService,
|
||||
ApiJobEndpointMatcher apiJobEndpointMatcher) {
|
||||
this.securityProp = securityProp;
|
||||
this.userService = userService;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
this.rateLimitService = rateLimitService;
|
||||
this.apiJobEndpointMatcher = apiJobEndpointMatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -110,10 +122,26 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have any authentication, deny the request
|
||||
// If we still don't have any authentication, check if anonymous API access is allowed
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
String method = request.getMethod();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
// Check if this is an API job endpoint and anonymous access is enabled
|
||||
if (anonymousApiEnabled && apiJobEndpointMatcher.matches(request)) {
|
||||
// Check anonymous rate limit
|
||||
String ipAddress = getClientIpAddress(request);
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
||||
ApiRateLimitService.UsageMetrics metrics = rateLimitService
|
||||
.getAnonymousUsageMetrics(ipAddress, userAgent);
|
||||
|
||||
if (metrics.remaining() > 0) {
|
||||
// Allow anonymous API access - rate limiting will be enforced by ApiRateLimitFilter
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
|
||||
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
||||
@ -125,6 +153,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
"Authentication required. Please provide a X-API-KEY in request"
|
||||
+ " header.\n"
|
||||
+ "This is found in Settings -> Account Settings -> API Key\n"
|
||||
+ "Anonymous users have limited API access ("
|
||||
+ rateLimitService.getAnonymousMonthlyLimit() + " requests/month)\n"
|
||||
+ "Alternatively you can disable authentication if this is"
|
||||
+ " unexpected");
|
||||
return;
|
||||
@ -255,4 +285,28 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private String getClientIpAddress(HttpServletRequest request) {
|
||||
// Check for proxy headers
|
||||
String[] headers = {
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"Proxy-Client-IP",
|
||||
"WL-Proxy-Client-IP"
|
||||
};
|
||||
|
||||
for (String header : headers) {
|
||||
String ip = request.getHeader(header);
|
||||
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
|
||||
// Handle comma-separated IPs
|
||||
int commaIndex = ip.indexOf(',');
|
||||
if (commaIndex > 0) {
|
||||
ip = ip.substring(0, commaIndex).trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,93 @@
|
||||
package stirling.software.proprietary.security.matcher;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerExecutionChain;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
|
||||
/**
|
||||
* Shared matcher component to determine if a request should be subject to
|
||||
* anonymous API access and rate limiting. This ensures consistent behavior
|
||||
* between UserAuthenticationFilter and ApiRateLimitFilter.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ApiJobEndpointMatcher {
|
||||
|
||||
private final RequestMappingHandlerMapping handlerMapping;
|
||||
|
||||
@Value("${api.rate-limit.exclude-settings:true}")
|
||||
private boolean excludeSettings;
|
||||
|
||||
@Value("${api.rate-limit.exclude-actuator:true}")
|
||||
private boolean excludeActuator;
|
||||
|
||||
/**
|
||||
* Determines if a request matches the criteria for API job endpoints
|
||||
* that should be rate-limited and allowed for anonymous access.
|
||||
*
|
||||
* @param request the HTTP request to check
|
||||
* @return true if the request is a POST to an @AutoJobPostMapping endpoint
|
||||
*/
|
||||
public boolean matches(HttpServletRequest request) {
|
||||
// Only POST requests are considered
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String path = request.getRequestURI();
|
||||
|
||||
// Apply exclusion rules
|
||||
if (excludeActuator && path != null && path.startsWith("/actuator")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (excludeSettings && isSettingsEndpoint(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the handler method has @AutoJobPostMapping annotation
|
||||
return hasAutoJobPostMapping(request);
|
||||
}
|
||||
|
||||
private boolean hasAutoJobPostMapping(HttpServletRequest request) {
|
||||
try {
|
||||
HandlerExecutionChain chain = handlerMapping.getHandler(request);
|
||||
if (chain == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Object handler = chain.getHandler();
|
||||
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Method method = handlerMethod.getMethod();
|
||||
return method.isAnnotationPresent(AutoJobPostMapping.class);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.trace("Could not resolve handler for {}: {}", request.getRequestURI(), e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSettingsEndpoint(String path) {
|
||||
return path != null && (
|
||||
path.contains("/settings") ||
|
||||
path.contains("/update-enable-analytics") ||
|
||||
path.contains("/config") ||
|
||||
path.contains("/preferences")
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,366 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.AnonymousApiUsage;
|
||||
import stirling.software.proprietary.model.ApiRateLimitConfig;
|
||||
import stirling.software.proprietary.model.ApiRateLimitUsage;
|
||||
import stirling.software.proprietary.model.Organization;
|
||||
import stirling.software.proprietary.repository.AnonymousApiUsageRepository;
|
||||
import stirling.software.proprietary.repository.ApiRateLimitConfigRepository;
|
||||
import stirling.software.proprietary.repository.ApiRateLimitUsageRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ApiRateLimitService {
|
||||
|
||||
private final ApiRateLimitConfigRepository configRepository;
|
||||
private final ApiRateLimitUsageRepository usageRepository;
|
||||
private final AnonymousApiUsageRepository anonymousUsageRepository;
|
||||
|
||||
@Value("${api.rate-limit.anonymous.enabled:true}")
|
||||
private boolean anonymousRateLimitEnabled;
|
||||
|
||||
@Value("${api.rate-limit.anonymous.monthly-limit:10}")
|
||||
private int anonymousMonthlyLimit;
|
||||
|
||||
@Value("${api.rate-limit.anonymous.abuse-threshold:3}")
|
||||
private int abuseThreshold;
|
||||
|
||||
public record RateLimitStatus(
|
||||
boolean allowed,
|
||||
int currentUsage,
|
||||
int monthlyLimit,
|
||||
int remaining,
|
||||
String scope,
|
||||
String reason
|
||||
) {}
|
||||
|
||||
public record UsageMetrics(
|
||||
int currentUsage,
|
||||
int monthlyLimit,
|
||||
int remaining,
|
||||
String scope,
|
||||
YearMonth month,
|
||||
boolean isPooled
|
||||
) {}
|
||||
|
||||
// TODO: improve with Redis and async in future V2.1
|
||||
@Transactional
|
||||
public RateLimitStatus checkAndIncrementUsage(User user) {
|
||||
if (user == null) {
|
||||
return new RateLimitStatus(false, 0, 0, 0, "NONE", "No user provided");
|
||||
}
|
||||
|
||||
Organization org = user.getOrganization();
|
||||
String roleName = user.getUserRole().getRoleId();
|
||||
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
|
||||
String monthKey = currentMonth.toString();
|
||||
|
||||
Optional<ApiRateLimitConfig> configOpt = resolveEffectiveConfig(user, org, roleName);
|
||||
|
||||
if (configOpt.isEmpty()) {
|
||||
log.warn("No rate limit config found for user: {}, org: {}, role: {}",
|
||||
user.getUsername(), org != null ? org.getName() : "null", roleName);
|
||||
return new RateLimitStatus(true, 0, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||
"UNLIMITED", "No rate limit configured");
|
||||
}
|
||||
|
||||
ApiRateLimitConfig config = configOpt.get();
|
||||
|
||||
if (!config.getIsActive()) {
|
||||
return new RateLimitStatus(true, 0, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||
"DISABLED", "Rate limiting disabled");
|
||||
}
|
||||
|
||||
String scope = determineScope(config);
|
||||
int monthlyLimit = config.getMonthlyLimit();
|
||||
|
||||
boolean success;
|
||||
int currentUsage;
|
||||
|
||||
switch (config.getScopeType()) {
|
||||
case USER -> {
|
||||
success = usageRepository.upsertAndIncrementUserUsage(
|
||||
user, monthKey, 1, monthlyLimit) > 0;
|
||||
currentUsage = usageRepository.getUserUsageOrZero(user, currentMonth);
|
||||
}
|
||||
case ORGANIZATION -> {
|
||||
if (org == null) {
|
||||
return new RateLimitStatus(false, 0, 0, 0, scope,
|
||||
"User has no organization but org-level limit is configured");
|
||||
}
|
||||
boolean pooled = Boolean.TRUE.equals(config.getIsPooled());
|
||||
if (pooled) {
|
||||
success = usageRepository.upsertAndIncrementOrgUsage(
|
||||
org, monthKey, 1, monthlyLimit) > 0;
|
||||
currentUsage = usageRepository.getOrgUsageOrZero(org, currentMonth);
|
||||
scope = "ORG:" + org.getName() + " (pooled)";
|
||||
} else {
|
||||
// per-user limit defined by org policy
|
||||
success = usageRepository.upsertAndIncrementUserUsage(
|
||||
user, monthKey, 1, monthlyLimit) > 0;
|
||||
currentUsage = usageRepository.getUserUsageOrZero(user, currentMonth);
|
||||
scope = "ORG:" + org.getName() + " (per-user)";
|
||||
}
|
||||
}
|
||||
case ROLE_DEFAULT -> {
|
||||
success = usageRepository.upsertAndIncrementUserUsage(
|
||||
user, monthKey, 1, monthlyLimit) > 0;
|
||||
currentUsage = usageRepository.getUserUsageOrZero(user, currentMonth);
|
||||
}
|
||||
default -> {
|
||||
log.error("Unknown scope type: {}", config.getScopeType());
|
||||
return new RateLimitStatus(false, 0, 0, 0, scope, "Invalid configuration");
|
||||
}
|
||||
}
|
||||
|
||||
int remaining = Math.max(0, monthlyLimit - currentUsage);
|
||||
|
||||
if (!success) {
|
||||
return new RateLimitStatus(false, currentUsage, monthlyLimit, 0, scope,
|
||||
"Monthly limit exceeded");
|
||||
}
|
||||
|
||||
return new RateLimitStatus(true, currentUsage, monthlyLimit, remaining, scope, "OK");
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public UsageMetrics getUsageMetrics(User user) {
|
||||
if (user == null) {
|
||||
return new UsageMetrics(0, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||
"NONE", YearMonth.now(ZoneOffset.UTC), false);
|
||||
}
|
||||
|
||||
Organization org = user.getOrganization();
|
||||
String roleName = user.getUserRole().getRoleId();
|
||||
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
|
||||
|
||||
Optional<ApiRateLimitConfig> configOpt = resolveEffectiveConfig(user, org, roleName);
|
||||
|
||||
if (configOpt.isEmpty() || !configOpt.get().getIsActive()) {
|
||||
return new UsageMetrics(0, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||
"UNLIMITED", currentMonth, false);
|
||||
}
|
||||
|
||||
ApiRateLimitConfig config = configOpt.get();
|
||||
String scope = determineScope(config);
|
||||
int monthlyLimit = config.getMonthlyLimit();
|
||||
boolean isPooled = Boolean.TRUE.equals(config.getIsPooled());
|
||||
|
||||
int currentUsage = switch (config.getScopeType()) {
|
||||
case USER, ROLE_DEFAULT -> usageRepository.getUserUsageOrZero(user, currentMonth);
|
||||
case ORGANIZATION -> {
|
||||
if (org != null && isPooled) {
|
||||
yield usageRepository.getOrgUsageOrZero(org, currentMonth);
|
||||
} else if (org != null) {
|
||||
yield usageRepository.getUserUsageOrZero(user, currentMonth);
|
||||
} else {
|
||||
yield 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
int remaining = Math.max(0, monthlyLimit - currentUsage);
|
||||
|
||||
return new UsageMetrics(currentUsage, monthlyLimit, remaining, scope, currentMonth, isPooled);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ApiRateLimitConfig createOrUpdateUserLimit(User user, int monthlyLimit) {
|
||||
ApiRateLimitConfig config = configRepository.findByUserAndIsActiveTrue(user)
|
||||
.orElse(ApiRateLimitConfig.builder()
|
||||
.scopeType(ApiRateLimitConfig.ScopeType.USER)
|
||||
.user(user)
|
||||
.build());
|
||||
|
||||
config.setMonthlyLimit(monthlyLimit);
|
||||
config.setIsActive(true);
|
||||
config.setIsPooled(false);
|
||||
|
||||
return configRepository.save(config);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ApiRateLimitConfig createOrUpdateOrgLimit(Organization org, int monthlyLimit, boolean isPooled) {
|
||||
ApiRateLimitConfig config = configRepository.findByOrganizationAndIsActiveTrue(org)
|
||||
.orElse(ApiRateLimitConfig.builder()
|
||||
.scopeType(ApiRateLimitConfig.ScopeType.ORGANIZATION)
|
||||
.organization(org)
|
||||
.build());
|
||||
|
||||
config.setMonthlyLimit(monthlyLimit);
|
||||
config.setIsActive(true);
|
||||
config.setIsPooled(isPooled);
|
||||
|
||||
return configRepository.save(config);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ApiRateLimitConfig createOrUpdateRoleDefault(String roleName, int monthlyLimit) {
|
||||
ApiRateLimitConfig config = configRepository.findDefaultForRole(roleName)
|
||||
.orElse(ApiRateLimitConfig.builder()
|
||||
.scopeType(ApiRateLimitConfig.ScopeType.ROLE_DEFAULT)
|
||||
.roleName(roleName)
|
||||
.build());
|
||||
|
||||
config.setMonthlyLimit(monthlyLimit);
|
||||
config.setIsActive(true);
|
||||
config.setIsPooled(false);
|
||||
|
||||
return configRepository.save(config);
|
||||
}
|
||||
|
||||
private String determineScope(ApiRateLimitConfig config) {
|
||||
return switch (config.getScopeType()) {
|
||||
case USER -> "USER:" + config.getUser().getUsername();
|
||||
case ORGANIZATION -> "ORG:" + config.getOrganization().getName() +
|
||||
(Boolean.TRUE.equals(config.getIsPooled()) ? " (pooled)" : "");
|
||||
case ROLE_DEFAULT -> "ROLE:" + config.getRoleName();
|
||||
};
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RateLimitStatus checkAndIncrementAnonymousUsage(String ipAddress, String userAgent) {
|
||||
if (!anonymousRateLimitEnabled) {
|
||||
return new RateLimitStatus(true, 0, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||
"ANONYMOUS_DISABLED", "Anonymous rate limiting disabled");
|
||||
}
|
||||
|
||||
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
|
||||
String fingerprint = generateFingerprint(ipAddress, userAgent);
|
||||
|
||||
// Check for abuse patterns
|
||||
if (detectAbuse(fingerprint, ipAddress, currentMonth)) {
|
||||
log.warn("Abuse detected for anonymous user - IP: {}, Fingerprint: {}", ipAddress, fingerprint);
|
||||
return new RateLimitStatus(false, 0, 0, 0, "ANONYMOUS_BLOCKED",
|
||||
"Access blocked due to suspicious activity");
|
||||
}
|
||||
|
||||
// Atomic upsert and increment operation that returns the new count
|
||||
Integer newCount = anonymousUsageRepository.upsertAndIncrementReturningCount(
|
||||
fingerprint, currentMonth.toString(), ipAddress, userAgent, anonymousMonthlyLimit);
|
||||
|
||||
if (newCount == null) {
|
||||
// Limit exceeded (WHERE clause prevented update)
|
||||
return new RateLimitStatus(false, anonymousMonthlyLimit, anonymousMonthlyLimit, 0,
|
||||
"ANONYMOUS", "Monthly limit exceeded for anonymous access");
|
||||
}
|
||||
|
||||
int remaining = Math.max(0, anonymousMonthlyLimit - newCount);
|
||||
return new RateLimitStatus(true, newCount, anonymousMonthlyLimit, remaining,
|
||||
"ANONYMOUS", "OK");
|
||||
}
|
||||
|
||||
private boolean detectAbuse(String fingerprint, String ipAddress, YearMonth month) {
|
||||
// Check if fingerprint is already blocked
|
||||
Optional<AnonymousApiUsage> blockedUsage = anonymousUsageRepository
|
||||
.findByFingerprintAndMonth(fingerprint, month);
|
||||
if (blockedUsage.isPresent() && Boolean.TRUE.equals(blockedUsage.get().getIsBlocked())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for multiple fingerprints from same IP (credential changing)
|
||||
Long distinctFingerprints = anonymousUsageRepository
|
||||
.countDistinctFingerprintsForIp(ipAddress, month);
|
||||
|
||||
if (distinctFingerprints > abuseThreshold) {
|
||||
// Block all fingerprints associated with this IP
|
||||
List<AnonymousApiUsage> ipUsages = anonymousUsageRepository
|
||||
.findByIpAddressAndMonth(ipAddress, month);
|
||||
for (AnonymousApiUsage ipUsage : ipUsages) {
|
||||
ipUsage.setIsBlocked(true);
|
||||
ipUsage.setAbuseScore(ipUsage.getAbuseScore() + 10);
|
||||
// Link fingerprints as related
|
||||
ipUsage.getRelatedFingerprints().add(fingerprint);
|
||||
}
|
||||
anonymousUsageRepository.saveAll(ipUsages);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check total usage across all fingerprints for this IP
|
||||
Integer totalIpUsage = anonymousUsageRepository
|
||||
.getTotalUsageByIpAndMonth(ipAddress, month);
|
||||
if (totalIpUsage > anonymousMonthlyLimit * 2) {
|
||||
// Excessive usage from single IP
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private String generateFingerprint(String ipAddress, String userAgent) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
String data = ipAddress + "|" + (userAgent != null ? userAgent : "unknown");
|
||||
byte[] hash = md.digest(data.getBytes());
|
||||
return Base64.getEncoder().encodeToString(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.error("Failed to generate fingerprint", e);
|
||||
return ipAddress; // Fallback to IP
|
||||
}
|
||||
}
|
||||
|
||||
public int getAnonymousMonthlyLimit() {
|
||||
return anonymousMonthlyLimit;
|
||||
}
|
||||
|
||||
public UsageMetrics getAnonymousUsageMetrics(String ipAddress, String userAgent) {
|
||||
if (!anonymousRateLimitEnabled) {
|
||||
return new UsageMetrics(0, Integer.MAX_VALUE, Integer.MAX_VALUE,
|
||||
"ANONYMOUS_DISABLED", YearMonth.now(ZoneOffset.UTC), false);
|
||||
}
|
||||
|
||||
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
|
||||
String fingerprint = generateFingerprint(ipAddress, userAgent);
|
||||
|
||||
Optional<AnonymousApiUsage> usageOpt = anonymousUsageRepository
|
||||
.findByFingerprintAndMonth(fingerprint, currentMonth);
|
||||
|
||||
if (usageOpt.isEmpty()) {
|
||||
return new UsageMetrics(0, anonymousMonthlyLimit, anonymousMonthlyLimit,
|
||||
"ANONYMOUS", currentMonth, false);
|
||||
}
|
||||
|
||||
AnonymousApiUsage usage = usageOpt.get();
|
||||
int remaining = Math.max(0, anonymousMonthlyLimit - usage.getUsageCount());
|
||||
|
||||
return new UsageMetrics(usage.getUsageCount(), anonymousMonthlyLimit, remaining,
|
||||
"ANONYMOUS", currentMonth, false);
|
||||
}
|
||||
|
||||
private Optional<ApiRateLimitConfig> resolveEffectiveConfig(User user, Organization org, String roleName) {
|
||||
// Priority: User > Organization > Role
|
||||
Optional<ApiRateLimitConfig> userConfig = configRepository.findByUserAndIsActiveTrue(user);
|
||||
if (userConfig.isPresent()) {
|
||||
return userConfig;
|
||||
}
|
||||
|
||||
if (org != null) {
|
||||
Optional<ApiRateLimitConfig> orgConfig = configRepository.findByOrganizationAndIsActiveTrue(org);
|
||||
if (orgConfig.isPresent()) {
|
||||
return orgConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return configRepository.findByScopeTypeAndRoleNameAndIsActiveTrue(
|
||||
ApiRateLimitConfig.ScopeType.ROLE_DEFAULT, roleName);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user