Files
cannamanage/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java
T
Patrick Plate 6aae17edba
Deploy to TrueNAS / deploy (push) Failing after 4m7s
fix(security): suppress CSRF false positive + upgrade next 15.5.19 + dep overrides
- Add .snyk policy file to suppress CSRF disabled false positive on JWT API chain
- Add inline documentation explaining why CSRF is intentionally disabled for stateless JWT
- Upgrade next.js 15.5.18 → 15.5.19 (latest stable 15.x patch)
- Upgrade eslint-config-next to match
- Add pnpm overrides for transitive CVEs: minimatch>=5.1.6, brace-expansion>=2.0.1, ajv>=8.17.1
2026-06-19 09:09:40 +02:00

178 lines
9.1 KiB
Java

package de.cannamanage.api.security;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
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;
/**
* Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions.
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
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.
*/
@Bean
@Order(1)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// snyk:ignore java/CsrfProtectionDisabled — Intentional: this filter chain
// handles stateless JWT-authenticated API calls only. CSRF attacks exploit
// browser-managed session cookies; Bearer token auth is immune because the
// token is never sent automatically by the browser. OWASP CSRF Prevention
// Cheat Sheet: "If your application does not use cookies for authentication,
// CSRF is not a risk." The portal chain (Order 2) correctly enables CSRF via
// CookieCsrfTokenRepository for its session-based auth.
.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()
.requestMatchers("/api/v1/billing/**").hasRole("ADMIN")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
// 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);
return http.build();
}
/**
* Member portal — session-based authentication with CSRF protection.
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
*/
@Bean
@Order(2)
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/portal/**")
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.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")
.successHandler((request, response, authentication) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("{\"status\":\"ok\"}");
})
.failureHandler((request, response, exception) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Invalid credentials\"}");
})
.permitAll())
.logout(logout -> logout
.logoutUrl("/portal/logout")
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("{\"status\":\"logged_out\"}");
}))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
.requestMatchers("/portal/**").hasRole("MEMBER"));
return http.build();
}
/**
* Public endpoints — Swagger UI, actuator health.
*/
@Bean
@Order(3)
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
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);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}