From 30937d204e24b4b78cc7ab1ee2f875887295fb4b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Tue, 12 Aug 2025 09:40:59 +0100 Subject: [PATCH] init limits for users and non Authed Users --- .claude/settings.local.json | 4 +- .../src/main/resources/application.properties | 10 +- .../config/ApiRateLimitConfiguration.java | 68 ++++ .../api/ApiRateLimitController.java | 213 ++++++++++ .../proprietary/model/AnonymousApiUsage.java | 104 +++++ .../proprietary/model/ApiRateLimitConfig.java | 124 ++++++ .../proprietary/model/ApiRateLimitUsage.java | 106 +++++ .../converter/YearMonthStringConverter.java | 23 ++ .../AnonymousApiUsageRepository.java | 78 ++++ .../ApiRateLimitConfigRepository.java | 34 ++ .../ApiRateLimitUsageRepository.java | 73 ++++ .../configuration/SecurityConfiguration.java | 5 + .../security/filter/ApiRateLimitFilter.java | 222 +++++++++++ .../filter/UserAuthenticationFilter.java | 58 ++- .../matcher/ApiJobEndpointMatcher.java | 93 +++++ .../service/ApiRateLimitService.java | 366 ++++++++++++++++++ 16 files changed, 1577 insertions(+), 4 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/config/ApiRateLimitConfiguration.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiRateLimitController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousApiUsage.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitConfig.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitUsage.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/repository/AnonymousApiUsageRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitConfigRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitUsageRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiRateLimitFilter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ApiRateLimitService.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8032f1d50..fb1913d7d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,9 @@ "Bash(npm test)", "Bash(npm test:*)", "Bash(ls:*)", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(echo)", + "Bash(rm:*)" ], "deny": [] } diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 9649a950c..2c566aa97 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -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} \ No newline at end of file +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 \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiRateLimitConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiRateLimitConfiguration.java new file mode 100644 index 000000000..3a13cbcbc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/ApiRateLimitConfiguration.java @@ -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 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 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); + } + } + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiRateLimitController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiRateLimitController.java new file mode 100644 index 000000000..26f1de895 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ApiRateLimitController.java @@ -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 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 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 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 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 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(); + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousApiUsage.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousApiUsage.java new file mode 100644 index 000000000..2547c2a3e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/AnonymousApiUsage.java @@ -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 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(); + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitConfig.java new file mode 100644 index 000000000..c93dd5aeb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitConfig.java @@ -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"); + } + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitUsage.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitUsage.java new file mode 100644 index 000000000..2d47afff4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/ApiRateLimitUsage.java @@ -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(); + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java new file mode 100644 index 000000000..f731eb309 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/converter/YearMonthStringConverter.java @@ -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 { + + 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; + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/AnonymousApiUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/AnonymousApiUsageRepository.java new file mode 100644 index 000000000..4985a3b1c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/AnonymousApiUsageRepository.java @@ -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 { + + Optional findByFingerprintAndMonth(String fingerprint, YearMonth month); + + @Query("SELECT a FROM AnonymousApiUsage a WHERE a.fingerprint = :fingerprint AND a.month = :month AND a.isBlocked = false") + Optional findActiveByFingerprintAndMonth(@Param("fingerprint") String fingerprint, @Param("month") YearMonth month); + + @Query("SELECT a FROM AnonymousApiUsage a WHERE a.ipAddress = :ipAddress AND a.month = :month") + List 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 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); +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitConfigRepository.java new file mode 100644 index 000000000..9bb066870 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitConfigRepository.java @@ -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 { + + Optional findByUserAndIsActiveTrue(User user); + + Optional findByOrganizationAndIsActiveTrue(Organization organization); + + Optional 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 findDefaultForRole(@Param("roleName") String roleName); + +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitUsageRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitUsageRepository.java new file mode 100644 index 000000000..9644cf6e2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/ApiRateLimitUsageRepository.java @@ -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 { + + Optional findByUserAndMonthKey(User user, YearMonth monthKey); + + Optional 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); +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 03bda6fc1..f052651bb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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 -> diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiRateLimitFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiRateLimitFilter.java new file mode 100644 index 000000000..930c37e23 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ApiRateLimitFilter.java @@ -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)); + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index e9addd239..e4216e11b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -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(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java new file mode 100644 index 000000000..bf276dedc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/matcher/ApiJobEndpointMatcher.java @@ -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") + ); + } +} \ No newline at end of file diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiRateLimitService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiRateLimitService.java new file mode 100644 index 000000000..b0069f8cd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ApiRateLimitService.java @@ -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 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 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 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 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 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 resolveEffectiveConfig(User user, Organization org, String roleName) { + // Priority: User > Organization > Role + Optional userConfig = configRepository.findByUserAndIsActiveTrue(user); + if (userConfig.isPresent()) { + return userConfig; + } + + if (org != null) { + Optional orgConfig = configRepository.findByOrganizationAndIsActiveTrue(org); + if (orgConfig.isPresent()) { + return orgConfig; + } + } + + return configRepository.findByScopeTypeAndRoleNameAndIsActiveTrue( + ApiRateLimitConfig.ScopeType.ROLE_DEFAULT, roleName); + } +} \ No newline at end of file