From 1f4c38354476da16e4d0fc99eba3fc8cb0d66d95 Mon Sep 17 00:00:00 2001 From: Oleandertengesdal Date: Fri, 10 Apr 2026 01:57:18 +0200 Subject: [PATCH] Add rate limiter and role-based authorization Introduce an in-memory RateLimitFilter for auth endpoints (10 requests per 1-minute window) to help mitigate brute-force attacks and register it in SecurityConfig. --- .../fullstack/auth/RegisterRequest.java | 5 + .../fullstack/config/RateLimitFilter.java | 96 +++++++++++++++++++ .../fullstack/config/SecurityConfig.java | 4 + .../location/LocationController.java | 6 ++ .../organization/OrganizationController.java | 4 + .../training/TrainingController.java | 5 + .../fullstack/user/UserController.java | 12 +++ .../config/SecurityConfigBeansTest.java | 4 + .../SecurityConfigRegisterAccessTest.java | 1 + 9 files changed, 137 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/config/RateLimitFilter.java diff --git a/backend/src/main/java/backend/fullstack/auth/RegisterRequest.java b/backend/src/main/java/backend/fullstack/auth/RegisterRequest.java index 3e6f5ac..0daf5c0 100644 --- a/backend/src/main/java/backend/fullstack/auth/RegisterRequest.java +++ b/backend/src/main/java/backend/fullstack/auth/RegisterRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.Setter; @@ -21,6 +22,10 @@ public class RegisterRequest { @NotBlank(message = "Password is required") @Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = "Password must contain at least one uppercase letter, one lowercase letter, and one number" + ) @Schema(description = "Password", example = "Admin123!") private String password; diff --git a/backend/src/main/java/backend/fullstack/config/RateLimitFilter.java b/backend/src/main/java/backend/fullstack/config/RateLimitFilter.java new file mode 100644 index 0000000..b56b345 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/config/RateLimitFilter.java @@ -0,0 +1,96 @@ +package backend.fullstack.config; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Simple in-memory rate limiter for authentication endpoints. + * Limits each IP to a fixed number of requests per time window + * to mitigate brute-force attacks on login and registration. + */ +@Component +public class RateLimitFilter extends OncePerRequestFilter { + + private static final int MAX_REQUESTS = 10; + private static final Duration WINDOW = Duration.ofMinutes(1); + + private final Map buckets = new ConcurrentHashMap<>(); + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String ip = resolveClientIp(request); + RateBucket bucket = buckets.compute(ip, (key, existing) -> { + if (existing == null || existing.isExpired()) { + return new RateBucket(Instant.now().plus(WINDOW), 1); + } + existing.increment(); + return existing; + }); + + if (bucket.getCount() > MAX_REQUESTS) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write( + "{\"success\":false,\"message\":\"Too many requests. Please try again later.\",\"data\":null}" + ); + return; + } + + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return !path.startsWith("/api/auth/login") + && !path.startsWith("/api/auth/register") + && !path.startsWith("/api/auth/invite/accept"); + } + + private String resolveClientIp(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isBlank()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + private static class RateBucket { + private final Instant expiresAt; + private int count; + + RateBucket(Instant expiresAt, int count) { + this.expiresAt = expiresAt; + this.count = count; + } + + boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } + + void increment() { + count++; + } + + int getCount() { + return count; + } + } +} diff --git a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java index 106516f..d55c4e0 100644 --- a/backend/src/main/java/backend/fullstack/config/SecurityConfig.java +++ b/backend/src/main/java/backend/fullstack/config/SecurityConfig.java @@ -32,17 +32,20 @@ public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; + private final RateLimitFilter rateLimitFilter; private final SecurityErrorHandler securityErrorHandler; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public SecurityConfig( JwtAuthFilter jwtAuthFilter, + RateLimitFilter rateLimitFilter, SecurityErrorHandler securityErrorHandler, UserRepository userRepository, PasswordEncoder passwordEncoder ) { this.jwtAuthFilter = jwtAuthFilter; + this.rateLimitFilter = rateLimitFilter; this.securityErrorHandler = securityErrorHandler; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; @@ -68,6 +71,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .access(this::canAccessBootstrapSetup) .anyRequest().authenticated() ) + .addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/backend/src/main/java/backend/fullstack/location/LocationController.java b/backend/src/main/java/backend/fullstack/location/LocationController.java index 4d1c737..b87375b 100644 --- a/backend/src/main/java/backend/fullstack/location/LocationController.java +++ b/backend/src/main/java/backend/fullstack/location/LocationController.java @@ -8,6 +8,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -39,24 +40,28 @@ public class LocationController { private final LocationService locationService; @PostMapping + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Create a new location") public ApiResponse create(@Valid @RequestBody LocationRequest request) { return ApiResponse.success("Location created", locationService.create(request)); } @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Get all accessible locations") public ApiResponse> getAllAccessible() { return ApiResponse.success("Accessible locations retrieved", locationService.getAllAccessible()); } @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Get location by ID") public ApiResponse getById(@PathVariable Long id) { return ApiResponse.success("Location retrieved", locationService.getById(id)); } @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Update location") public ApiResponse update( @PathVariable Long id, @@ -66,6 +71,7 @@ public ApiResponse update( } @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Delete location") public ApiResponse delete(@PathVariable Long id) { locationService.delete(id); diff --git a/backend/src/main/java/backend/fullstack/organization/OrganizationController.java b/backend/src/main/java/backend/fullstack/organization/OrganizationController.java index 9d87d39..8efd4e2 100644 --- a/backend/src/main/java/backend/fullstack/organization/OrganizationController.java +++ b/backend/src/main/java/backend/fullstack/organization/OrganizationController.java @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; /** * Controller for managing organizations. @@ -41,6 +42,7 @@ public ApiResponse create( } @GetMapping("/me") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Get current organization") public ApiResponse getCurrentOrganization() { return ApiResponse.success( @@ -50,6 +52,7 @@ public ApiResponse getCurrentOrganization() { } @GetMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Get organization by ID") public ApiResponse getById(@PathVariable Long id) { return ApiResponse.success( @@ -59,6 +62,7 @@ public ApiResponse getById(@PathVariable Long id) { } @PutMapping("/me") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Update current organization") public ApiResponse updateCurrentOrganization( @Valid @RequestBody OrganizationRequest request) { diff --git a/backend/src/main/java/backend/fullstack/training/TrainingController.java b/backend/src/main/java/backend/fullstack/training/TrainingController.java index 55ca566..4d38f94 100644 --- a/backend/src/main/java/backend/fullstack/training/TrainingController.java +++ b/backend/src/main/java/backend/fullstack/training/TrainingController.java @@ -3,6 +3,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -33,6 +34,7 @@ public TrainingController(TrainingService trainingService) { } @PostMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") @Operation(summary = "Create training record") @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Training record created"), @@ -46,6 +48,7 @@ public ResponseEntity> create( } @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "List visible training records") public ApiResponse> getVisibleRecords( @RequestParam(required = false) Long userId, @@ -59,12 +62,14 @@ public ApiResponse> getVisibleRecords( } @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Get training record by id") public ApiResponse getById(@PathVariable Long id) { return ApiResponse.success("Training record fetched", trainingService.getById(id)); } @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") @Operation(summary = "Update training record") public ApiResponse update( @PathVariable Long id, diff --git a/backend/src/main/java/backend/fullstack/user/UserController.java b/backend/src/main/java/backend/fullstack/user/UserController.java index a752c7b..88b386e 100644 --- a/backend/src/main/java/backend/fullstack/user/UserController.java +++ b/backend/src/main/java/backend/fullstack/user/UserController.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -52,30 +53,35 @@ public class UserController { private final UserService userService; @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") @Operation(summary = "Get users in current organization") public ApiResponse> getAllInOrganization() { return ApiResponse.success("Users retrieved", userService.getAllInOrganization()); } @GetMapping("/me") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Get current user profile") public ApiResponse getMyProfile() { return ApiResponse.success("Profile retrieved", userService.getMyProfile()); } @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") @Operation(summary = "Get user by ID") public ApiResponse getById(@PathVariable Long id) { return ApiResponse.success("User retrieved", userService.getById(id)); } @PostMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") @Operation(summary = "Create user") public ApiResponse create(@Valid @RequestBody CreateUserRequest request) { return ApiResponse.success("User created", userService.create(request)); } @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") @Operation(summary = "Update user profile") public ApiResponse updateProfile( @PathVariable Long id, @@ -85,6 +91,7 @@ public ApiResponse updateProfile( } @PutMapping("/{id}/role") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Update user role") public ApiResponse updateRole( @PathVariable Long id, @@ -94,6 +101,7 @@ public ApiResponse updateRole( } @PutMapping("/{id}/locations") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") @Operation(summary = "Assign additional user locations") public ApiResponse assignAdditionalLocations( @PathVariable Long id, @@ -103,6 +111,7 @@ public ApiResponse assignAdditionalLocations( } @PostMapping("/me/change-password") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Change current user password") public ApiResponse changePassword(@Valid @RequestBody ChangePasswordRequest request) { userService.changePassword(request); @@ -110,6 +119,7 @@ public ApiResponse changePassword(@Valid @RequestBody ChangePasswordReques } @PostMapping("/{id}/deactivate") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Deactivate user") public ApiResponse deactivate(@PathVariable Long id) { userService.deactivate(id); @@ -117,6 +127,7 @@ public ApiResponse deactivate(@PathVariable Long id) { } @PostMapping("/{id}/reactivate") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "Reactivate user") public ApiResponse reactivate(@PathVariable Long id) { userService.reactivate(id); @@ -124,6 +135,7 @@ public ApiResponse reactivate(@PathVariable Long id) { } @PostMapping("/{id}/resend-invite") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") @Operation(summary = "Resend invite") public ApiResponse resendInvite(@PathVariable Long id) { userService.resendInvite(id); diff --git a/backend/src/test/java/backend/fullstack/config/SecurityConfigBeansTest.java b/backend/src/test/java/backend/fullstack/config/SecurityConfigBeansTest.java index f11b478..51ac11a 100644 --- a/backend/src/test/java/backend/fullstack/config/SecurityConfigBeansTest.java +++ b/backend/src/test/java/backend/fullstack/config/SecurityConfigBeansTest.java @@ -38,6 +38,7 @@ void userDetailsServiceReturnsUserOrThrows() { SecurityConfig config = new SecurityConfig( new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new RateLimitFilter(), new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), userRepository, new BCryptPasswordEncoder() @@ -53,6 +54,7 @@ void authenticationBeansAndCorsSourceAreConfigured() throws Exception { UserRepository userRepository = mock(UserRepository.class); SecurityConfig config = new SecurityConfig( new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new RateLimitFilter(), new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), userRepository, new BCryptPasswordEncoder() @@ -80,6 +82,7 @@ void privateBootstrapAuthorizationDecisionReflectsUserCount() throws Exception { when(emptyRepo.count()).thenReturn(0L); SecurityConfig allowConfig = new SecurityConfig( new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new RateLimitFilter(), new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), emptyRepo, new BCryptPasswordEncoder() @@ -103,6 +106,7 @@ void privateBootstrapAuthorizationDecisionReflectsUserCount() throws Exception { when(nonEmptyRepo.count()).thenReturn(2L); SecurityConfig denyConfig = new SecurityConfig( new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new RateLimitFilter(), new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), nonEmptyRepo, new BCryptPasswordEncoder() diff --git a/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java b/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java index d96a0a3..fe62291 100644 --- a/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java +++ b/backend/src/test/java/backend/fullstack/config/SecurityConfigRegisterAccessTest.java @@ -59,6 +59,7 @@ void deniesOrganizationCreateAfterBootstrap() { private SecurityConfig configWithUserCount(long count) { return new SecurityConfig( new JwtAuthFilter(new JwtUtil(new JwtProperties())), + new RateLimitFilter(), new SecurityErrorHandler(new com.fasterxml.jackson.databind.ObjectMapper()), repositoryWithCount(count), new BCryptPasswordEncoder()