fix(security): hardening — rate limiting, CORS config, audit safety, CSP headers, validation
- Fix 1: Login rate limiting (5 attempts/min/IP) on POST /api/v1/auth/login - New LoginRateLimiter (ConcurrentHashMap + @Scheduled reset every 60s) - HTTP 429 with German message on exceed - Client IP via X-Forwarded-For with proxy fallback - @EnableScheduling on CannaManageApplication - Fix 2: CORS origins configurable via cannamanage.cors.allowed-origins env var - Defaults to localhost + docker frontend for dev - SecurityConfig reads with @Value, splits comma-separated list - Fix 3: Audit JSON safety — replaced manual string concat with Jackson ObjectMapper - New AuditService.toMetadataJson(Map) helper - RetentionService and AuthorityExportService refactored - Fix 4: Tomcat max-http-form-post-size=2MB prevents DoS via oversized payloads - Fix 5: @Valid added to @RequestBody on 17+ endpoints across ComplianceRecordsController, FinanceController, ConsentController, StaffController, ComplianceDeadlineController, SubscriptionController, ForumController (admin + portal) - Fix 6: Content-Security-Policy 'default-src \'self\'; frame-ancestors \'none\'' + frameOptions(deny) on both API + portal filter chains
This commit is contained in:
@@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* CannaManage Spring Boot application entry point.
|
||||
@@ -17,6 +18,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||
@EnableScheduling
|
||||
public class CannaManageApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -4,11 +4,14 @@ import de.cannamanage.api.dto.auth.LoginRequest;
|
||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||
import de.cannamanage.api.security.LoginRateLimiter;
|
||||
import de.cannamanage.api.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -24,10 +27,19 @@ import java.util.Map;
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
|
||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
if (!loginRateLimiter.tryAcquire(ip)) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of(
|
||||
"error", "rate_limited",
|
||||
"message", "Zu viele Anmeldeversuche. Bitte warten Sie eine Minute."
|
||||
));
|
||||
}
|
||||
LoginResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
@@ -46,4 +58,17 @@ public class AuthController {
|
||||
authService.setPassword(request);
|
||||
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the originating client IP, honouring X-Forwarded-For when present
|
||||
* (so reverse-proxy / load-balancer setups still get per-client rate limits).
|
||||
*/
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
int comma = xff.indexOf(',');
|
||||
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@ import de.cannamanage.domain.enums.ComplianceArea;
|
||||
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -39,7 +40,7 @@ public class ComplianceDeadlineController {
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new compliance deadline")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<ComplianceDeadline> createDeadline(@RequestBody CreateDeadlineRequest request) {
|
||||
public ResponseEntity<ComplianceDeadline> createDeadline(@Valid @RequestBody CreateDeadlineRequest request) {
|
||||
ComplianceDeadline deadline = new ComplianceDeadline();
|
||||
deadline.setClubId(request.clubId());
|
||||
deadline.setArea(request.area());
|
||||
@@ -57,7 +58,7 @@ public class ComplianceDeadlineController {
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<ComplianceDeadline> completeDeadline(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody CompleteDeadlineRequest request) {
|
||||
@Valid @RequestBody CompleteDeadlineRequest request) {
|
||||
|
||||
ComplianceDeadline deadline = deadlineRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Deadline not found: " + id));
|
||||
|
||||
+5
-4
@@ -6,6 +6,7 @@ import de.cannamanage.domain.enums.TransportStatus;
|
||||
import de.cannamanage.service.repository.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -37,7 +38,7 @@ public class ComplianceRecordsController {
|
||||
@PostMapping("/destruction-records")
|
||||
@Operation(summary = "Record a cannabis destruction event")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<DestructionRecord> recordDestruction(@RequestBody CreateDestructionRequest request) {
|
||||
public ResponseEntity<DestructionRecord> recordDestruction(@Valid @RequestBody CreateDestructionRequest request) {
|
||||
DestructionRecord record = new DestructionRecord();
|
||||
record.setClubId(request.clubId());
|
||||
record.setBatchId(request.batchId());
|
||||
@@ -65,7 +66,7 @@ public class ComplianceRecordsController {
|
||||
@PostMapping("/transport-records")
|
||||
@Operation(summary = "Record a cannabis transport event")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<TransportRecord> recordTransport(@RequestBody CreateTransportRequest request) {
|
||||
public ResponseEntity<TransportRecord> recordTransport(@Valid @RequestBody CreateTransportRequest request) {
|
||||
TransportRecord record = new TransportRecord();
|
||||
record.setClubId(request.clubId());
|
||||
record.setDescription(request.description());
|
||||
@@ -94,7 +95,7 @@ public class ComplianceRecordsController {
|
||||
@PostMapping("/propagation-sources")
|
||||
@Operation(summary = "Record a propagation source (seed/cutting receipt)")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<PropagationSource> recordPropagationSource(@RequestBody CreatePropagationSourceRequest request) {
|
||||
public ResponseEntity<PropagationSource> recordPropagationSource(@Valid @RequestBody CreatePropagationSourceRequest request) {
|
||||
PropagationSource record = new PropagationSource();
|
||||
record.setClubId(request.clubId());
|
||||
record.setSourceType(request.sourceType());
|
||||
@@ -121,7 +122,7 @@ public class ComplianceRecordsController {
|
||||
@PostMapping("/prevention-activities")
|
||||
@Operation(summary = "Record a prevention/education activity per KCanG §23")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<PreventionActivity> recordPreventionActivity(@RequestBody CreatePreventionActivityRequest request) {
|
||||
public ResponseEntity<PreventionActivity> recordPreventionActivity(@Valid @RequestBody CreatePreventionActivityRequest request) {
|
||||
PreventionActivity record = new PreventionActivity();
|
||||
record.setClubId(request.clubId());
|
||||
record.setActivityDate(request.activityDate());
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -43,7 +44,7 @@ public class ConsentController {
|
||||
@PostMapping
|
||||
@Operation(summary = "Grant consent")
|
||||
public ResponseEntity<ConsentResponse> grantConsent(
|
||||
@RequestBody GrantConsentRequest request,
|
||||
@Valid @RequestBody GrantConsentRequest request,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
|
||||
@@ -81,7 +81,7 @@ public class FinanceController {
|
||||
|
||||
@PutMapping("/finance/fee-schedules/{id}")
|
||||
public ResponseEntity<FeeSchedule> updateFeeSchedule(@PathVariable UUID id,
|
||||
@RequestBody UpdateFeeScheduleRequest request,
|
||||
@Valid @RequestBody UpdateFeeScheduleRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
FeeSchedule updated = financeService.updateFeeSchedule(
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.cannamanage.api.controller;
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.*;
|
||||
import de.cannamanage.service.ForumService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -26,7 +27,7 @@ public class ForumController {
|
||||
// ---- Admin Topic Endpoints ----
|
||||
|
||||
@PostMapping("/forum/topics")
|
||||
public ResponseEntity<ForumTopic> createTopic(@RequestBody CreateTopicRequest request,
|
||||
public ResponseEntity<ForumTopic> createTopic(@Valid @RequestBody CreateTopicRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumTopic topic = forumService.createTopic(clubId, request.title(), request.content(), userId);
|
||||
@@ -90,7 +91,7 @@ public class ForumController {
|
||||
|
||||
@PostMapping("/forum/topics/{topicId}/replies")
|
||||
public ResponseEntity<ForumReply> createReply(@PathVariable UUID topicId,
|
||||
@RequestBody CreateReplyRequest request,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumReply reply = forumService.createReply(topicId, request.content(), userId);
|
||||
return ResponseEntity.ok(reply);
|
||||
@@ -98,7 +99,7 @@ public class ForumController {
|
||||
|
||||
@PutMapping("/forum/replies/{id}")
|
||||
public ResponseEntity<ForumReply> editReply(@PathVariable UUID id,
|
||||
@RequestBody CreateReplyRequest request,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumReply reply = forumService.editReply(id, request.content(), userId);
|
||||
return ResponseEntity.ok(reply);
|
||||
@@ -114,7 +115,7 @@ public class ForumController {
|
||||
// ---- Reaction Endpoints ----
|
||||
|
||||
@PostMapping("/forum/reactions")
|
||||
public ResponseEntity<Map<String, Object>> toggleReaction(@RequestBody ReactionRequest request,
|
||||
public ResponseEntity<Map<String, Object>> toggleReaction(@Valid @RequestBody ReactionRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
var result = forumService.toggleReaction(
|
||||
request.targetType(), request.targetId(), userId, request.reactionType());
|
||||
@@ -125,7 +126,7 @@ public class ForumController {
|
||||
// ---- Report Endpoints ----
|
||||
|
||||
@PostMapping("/forum/reports")
|
||||
public ResponseEntity<Map<String, String>> reportContent(@RequestBody ReportRequest request,
|
||||
public ResponseEntity<Map<String, String>> reportContent(@Valid @RequestBody ReportRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
|
||||
@@ -147,7 +148,7 @@ public class ForumController {
|
||||
|
||||
@PostMapping("/forum/reports/{id}/review")
|
||||
public ResponseEntity<ForumReport> reviewReport(@PathVariable UUID id,
|
||||
@RequestBody ReviewReportRequest request,
|
||||
@Valid @RequestBody ReviewReportRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumReport report = forumService.reviewReport(id, userId, request.status());
|
||||
return ResponseEntity.ok(report);
|
||||
@@ -156,7 +157,7 @@ public class ForumController {
|
||||
// ---- Portal Endpoints (member-scoped, same logic) ----
|
||||
|
||||
@PostMapping("/portal/forum/topics")
|
||||
public ResponseEntity<ForumTopic> portalCreateTopic(@RequestBody CreateTopicRequest request,
|
||||
public ResponseEntity<ForumTopic> portalCreateTopic(@Valid @RequestBody CreateTopicRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.createTopic(clubId, request.title(), request.content(), userId));
|
||||
@@ -185,20 +186,20 @@ public class ForumController {
|
||||
|
||||
@PostMapping("/portal/forum/topics/{topicId}/replies")
|
||||
public ResponseEntity<ForumReply> portalCreateReply(@PathVariable UUID topicId,
|
||||
@RequestBody CreateReplyRequest request,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.createReply(topicId, request.content(), userId));
|
||||
}
|
||||
|
||||
@PutMapping("/portal/forum/replies/{id}")
|
||||
public ResponseEntity<ForumReply> portalEditReply(@PathVariable UUID id,
|
||||
@RequestBody CreateReplyRequest request,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.editReply(id, request.content(), userId));
|
||||
}
|
||||
|
||||
@PostMapping("/portal/forum/reactions")
|
||||
public ResponseEntity<Map<String, Object>> portalToggleReaction(@RequestBody ReactionRequest request,
|
||||
public ResponseEntity<Map<String, Object>> portalToggleReaction(@Valid @RequestBody ReactionRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
var result = forumService.toggleReaction(
|
||||
request.targetType(), request.targetId(), userId, request.reactionType());
|
||||
@@ -206,7 +207,7 @@ public class ForumController {
|
||||
}
|
||||
|
||||
@PostMapping("/portal/forum/reports")
|
||||
public ResponseEntity<Map<String, String>> portalReportContent(@RequestBody ReportRequest request,
|
||||
public ResponseEntity<Map<String, String>> portalReportContent(@Valid @RequestBody ReportRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
|
||||
|
||||
@@ -83,7 +83,7 @@ public class StaffController {
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
||||
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
||||
@RequestBody UpdateStaffRequest request) {
|
||||
@Valid @RequestBody UpdateStaffRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.updateStaff(
|
||||
tenantId, id,
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import de.cannamanage.service.StripeService;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -46,7 +47,7 @@ public class SubscriptionController {
|
||||
@PostMapping("/checkout")
|
||||
@Operation(summary = "Create checkout session", description = "Creates a Stripe Checkout session for plan upgrade")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody CheckoutRequest request) throws StripeException {
|
||||
public ResponseEntity<Map<String, String>> createCheckout(@Valid @RequestBody CheckoutRequest request) throws StripeException {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
UUID clubId = clubRepository.findByTenantId(tenantId)
|
||||
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Simple in-memory brute-force protection for the login endpoint.
|
||||
*
|
||||
* <p>Tracks attempts per source IP and rejects further attempts once the
|
||||
* configured threshold ({@link #MAX_ATTEMPTS_PER_WINDOW}) is exceeded within
|
||||
* the current 60-second window. Counters are reset every minute by
|
||||
* {@link #resetCounters()}.
|
||||
*
|
||||
* <p>This deliberately stays in-memory rather than introducing Resilience4j /
|
||||
* Bucket4j for a single endpoint. For multi-instance deployments behind a
|
||||
* load balancer this should be revisited.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class LoginRateLimiter {
|
||||
|
||||
/** Maximum failed/total login attempts allowed per IP per window. */
|
||||
public static final int MAX_ATTEMPTS_PER_WINDOW = 5;
|
||||
|
||||
private final ConcurrentHashMap<String, AtomicInteger> attemptsByIp = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Records an attempt and returns {@code true} if the request is allowed
|
||||
* (still within the per-window quota), {@code false} if it must be
|
||||
* rejected with HTTP 429.
|
||||
*/
|
||||
public boolean tryAcquire(String ipAddress) {
|
||||
if (ipAddress == null || ipAddress.isBlank()) {
|
||||
ipAddress = "unknown";
|
||||
}
|
||||
AtomicInteger counter = attemptsByIp.computeIfAbsent(ipAddress, k -> new AtomicInteger(0));
|
||||
int current = counter.incrementAndGet();
|
||||
if (current > MAX_ATTEMPTS_PER_WINDOW) {
|
||||
log.warn("Login rate limit exceeded for IP {} ({} attempts in current window)", ipAddress, current);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all counters every 60 seconds. Fixed-rate scheduler keeps the
|
||||
* implementation predictable and free of timestamp bookkeeping.
|
||||
*/
|
||||
@Scheduled(fixedRate = 60_000L)
|
||||
public void resetCounters() {
|
||||
if (!attemptsByIp.isEmpty()) {
|
||||
log.debug("Resetting login rate-limit counters for {} IPs", attemptsByIp.size());
|
||||
attemptsByIp.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,14 @@ public class SecurityConfig {
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
private final PortalUserDetailsService portalUserDetailsService;
|
||||
|
||||
/**
|
||||
* Comma-separated allowed CORS origins. Defaults to local dev origins; production
|
||||
* deployments override via the {@code CORS_ORIGINS} environment variable
|
||||
* (e.g. {@code https://cannamanage.plate-software.de}).
|
||||
*/
|
||||
@Value("${cannamanage.cors.allowed-origins:http://localhost:3000,http://frontend:3000}")
|
||||
private String allowedOrigins;
|
||||
|
||||
/**
|
||||
* API security — stateless JWT authentication.
|
||||
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
|
||||
@@ -47,6 +56,10 @@ public class SecurityConfig {
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||
"default-src 'self'; frame-ancestors 'none'"))
|
||||
.frameOptions(frame -> frame.deny()))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
.requestMatchers("/api/v1/webhooks/**").permitAll()
|
||||
@@ -82,6 +95,10 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(1))
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||
"default-src 'self'; frame-ancestors 'none'"))
|
||||
.frameOptions(frame -> frame.deny()))
|
||||
.userDetailsService(portalUserDetailsService)
|
||||
.formLogin(form -> form
|
||||
.loginProcessingUrl("/portal/login")
|
||||
@@ -132,10 +149,11 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of(
|
||||
"http://localhost:3000",
|
||||
"http://frontend:3000"
|
||||
));
|
||||
List<String> origins = Arrays.stream(allowedOrigins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
config.setAllowedOrigins(origins);
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
@@ -53,3 +53,9 @@ server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||
spring.servlet.multipart.enabled=true
|
||||
spring.servlet.multipart.max-file-size=5MB
|
||||
spring.servlet.multipart.max-request-size=6MB
|
||||
|
||||
# Security hardening — limit non-multipart request body sizes to prevent DoS via oversized payloads
|
||||
server.tomcat.max-http-form-post-size=2MB
|
||||
|
||||
# CORS allowed origins (comma-separated). Override via CORS_ORIGINS env var in production.
|
||||
cannamanage.cors.allowed-origins=${CORS_ORIGINS:http://localhost:3000,http://frontend:3000}
|
||||
|
||||
Reference in New Issue
Block a user