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 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; } }