6aae17edba
Deploy to TrueNAS / deploy (push) Failing after 4m7s
- 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
178 lines
9.1 KiB
Java
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;
|
|
}
|
|
}
|