feat: Sprint 13 — Production Hardening (security fixes, CI gate, rate limiting, tests)
Deploy to TrueNAS / deploy (push) Failing after 12s

This commit is contained in:
Patrick Plate
2026-06-18 16:08:05 +02:00
parent 279487067e
commit f9a87efb7a
17 changed files with 1962 additions and 107 deletions
@@ -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);
}
}