feat: Sprint 13 — Production Hardening (security fixes, CI gate, rate limiting, tests)
Deploy to TrueNAS / deploy (push) Failing after 12s
Deploy to TrueNAS / deploy (push) Failing after 12s
This commit is contained in:
@@ -9,6 +9,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -21,6 +22,7 @@ import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF', 'MEMBER')")
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
@@ -33,13 +35,14 @@ public class DocumentController {
|
||||
* Verify the requested document belongs to the caller's current tenant (club).
|
||||
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
|
||||
* just by guessing or enumerating the document UUID.
|
||||
* Returns 404 (not 403) to avoid revealing document existence to other tenants.
|
||||
*/
|
||||
private Document loadOwnedDocument(UUID documentId) {
|
||||
Document doc = documentService.getDocument(documentId);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
|
||||
// Use 403 (not 404) — caller is authenticated, just not authorized for this resource.
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to document");
|
||||
// Return 404 to prevent information leakage about document existence across tenants
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
@@ -78,6 +81,7 @@ public class DocumentController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/documents/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
|
||||
public ResponseEntity<Void> deleteDocument(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID clubId,
|
||||
@@ -87,7 +91,7 @@ public class DocumentController {
|
||||
Document doc = loadOwnedDocument(id);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (!clubId.equals(currentTenantId)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tenant mismatch");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
|
||||
}
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
documentService.deleteDocument(id, userId, doc.getClubId());
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Rate-limits login attempts per client IP using Bucket4j + Caffeine cache.
|
||||
* Allows 5 login attempts per minute per IP; returns 429 when exhausted.
|
||||
*/
|
||||
@Component
|
||||
@Order(1)
|
||||
public class LoginRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String LOGIN_PATH = "/api/v1/auth/login";
|
||||
private static final int CAPACITY = 5;
|
||||
private static final Duration REFILL_PERIOD = Duration.ofMinutes(1);
|
||||
|
||||
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterAccess(Duration.ofMinutes(10))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || !LOGIN_PATH.equals(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String clientIp = resolveClientIp(request);
|
||||
Bucket bucket = buckets.get(clientIp, k -> createBucket());
|
||||
|
||||
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
|
||||
if (probe.isConsumed()) {
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000 + 1;
|
||||
response.setStatus(429);
|
||||
response.setHeader("Retry-After", String.valueOf(waitSeconds));
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Too many login attempts. Retry after " + waitSeconds + "s\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private Bucket createBucket() {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(CAPACITY)
|
||||
.refillGreedy(CAPACITY, REFILL_PERIOD)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
// Take the first IP in the chain (original client)
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
@@ -71,10 +72,13 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||
// Documents endpoint — explicit listing for defense-in-depth so it can
|
||||
// never accidentally end up in a permitAll() rule above. Per-document
|
||||
// tenant ownership is additionally enforced in DocumentController.
|
||||
.requestMatchers("/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
// Documents endpoint — method-specific matchers for defense-in-depth.
|
||||
// POST (upload) and DELETE restricted to ADMIN/STAFF; GET allowed for all
|
||||
// authenticated roles. Per-document tenant ownership is additionally
|
||||
// enforced in DocumentController via TenantContext.
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private static final String INVALID_CREDENTIALS = "Invalid credentials";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final JwtService jwtService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -43,14 +45,14 @@ public class AuthService {
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
User user = userRepository.findByEmail(request.email())
|
||||
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
|
||||
.orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new AuthenticationException("Account not activated");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new AuthenticationException("Invalid credentials");
|
||||
throw new AuthenticationException(INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
@@ -147,7 +149,7 @@ public class AuthService {
|
||||
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-256 not available", e);
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user