Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, RateBucket> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,24 +40,28 @@ public class LocationController {
private final LocationService locationService;

@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Create a new location")
public ApiResponse<LocationResponse> 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<List<LocationResponse>> 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<LocationResponse> getById(@PathVariable Long id) {
return ApiResponse.success("Location retrieved", locationService.getById(id));
}

@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update location")
public ApiResponse<LocationResponse> update(
@PathVariable Long id,
Expand All @@ -66,6 +71,7 @@ public ApiResponse<LocationResponse> update(
}

@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Delete location")
public ApiResponse<Void> delete(@PathVariable Long id) {
locationService.delete(id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -41,6 +42,7 @@ public ApiResponse<OrganizationResponse> create(
}

@GetMapping("/me")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Get current organization")
public ApiResponse<OrganizationResponse> getCurrentOrganization() {
return ApiResponse.success(
Expand All @@ -50,6 +52,7 @@ public ApiResponse<OrganizationResponse> getCurrentOrganization() {
}

@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get organization by ID")
public ApiResponse<OrganizationResponse> getById(@PathVariable Long id) {
return ApiResponse.success(
Expand All @@ -59,6 +62,7 @@ public ApiResponse<OrganizationResponse> getById(@PathVariable Long id) {
}

@PutMapping("/me")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update current organization")
public ApiResponse<OrganizationResponse> updateCurrentOrganization(
@Valid @RequestBody OrganizationRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Expand All @@ -46,6 +48,7 @@ public ResponseEntity<ApiResponse<TrainingRecordResponse>> create(
}

@GetMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "List visible training records")
public ApiResponse<List<TrainingRecordResponse>> getVisibleRecords(
@RequestParam(required = false) Long userId,
Expand All @@ -59,12 +62,14 @@ public ApiResponse<List<TrainingRecordResponse>> getVisibleRecords(
}

@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Get training record by id")
public ApiResponse<TrainingRecordResponse> 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<TrainingRecordResponse> update(
@PathVariable Long id,
Expand Down
12 changes: 12 additions & 0 deletions backend/src/main/java/backend/fullstack/user/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<List<UserResponse>> getAllInOrganization() {
return ApiResponse.success("Users retrieved", userService.getAllInOrganization());
}

@GetMapping("/me")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Get current user profile")
public ApiResponse<UserResponse> getMyProfile() {
return ApiResponse.success("Profile retrieved", userService.getMyProfile());
}

@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')")
@Operation(summary = "Get user by ID")
public ApiResponse<UserResponse> getById(@PathVariable Long id) {
return ApiResponse.success("User retrieved", userService.getById(id));
}

@PostMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
@Operation(summary = "Create user")
public ApiResponse<UserResponse> 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<UserResponse> updateProfile(
@PathVariable Long id,
Expand All @@ -85,6 +91,7 @@ public ApiResponse<UserResponse> updateProfile(
}

@PutMapping("/{id}/role")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update user role")
public ApiResponse<UserResponse> updateRole(
@PathVariable Long id,
Expand All @@ -94,6 +101,7 @@ public ApiResponse<UserResponse> updateRole(
}

@PutMapping("/{id}/locations")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
@Operation(summary = "Assign additional user locations")
public ApiResponse<UserResponse> assignAdditionalLocations(
@PathVariable Long id,
Expand All @@ -103,27 +111,31 @@ public ApiResponse<UserResponse> assignAdditionalLocations(
}

@PostMapping("/me/change-password")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Change current user password")
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
userService.changePassword(request);
return ApiResponse.success("Password changed", null);
}

@PostMapping("/{id}/deactivate")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Deactivate user")
public ApiResponse<Void> deactivate(@PathVariable Long id) {
userService.deactivate(id);
return ApiResponse.success("User deactivated", null);
}

@PostMapping("/{id}/reactivate")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Reactivate user")
public ApiResponse<Void> reactivate(@PathVariable Long id) {
userService.reactivate(id);
return ApiResponse.success("User reactivated", null);
}

@PostMapping("/{id}/resend-invite")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
@Operation(summary = "Resend invite")
public ApiResponse<Void> resendInvite(@PathVariable Long id) {
userService.resendInvite(id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading