package com.krylov.refound.security; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Arrays; import java.util.List; 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.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; 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.annotation.web.configurers.AbstractHttpConfigurer; 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.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @Value("${app.cors.allowed-origins}") private String allowedOrigins; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) // токен только в Authorization header, cookie не используется — CSRF не актуален .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(ex -> ex .authenticationEntryPoint(this::handleUnauthorized) .accessDeniedHandler(this::handleForbidden) ) .authorizeHttpRequests(auth -> auth // публичные эндпоинты .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/ads-media/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").permitAll() .requestMatchers("/files/**", "/uploads/**").permitAll() .requestMatchers("/ws/**").permitAll() // авторизация чата — на уровне STOMP CONNECT interceptor, см. заметку ниже // изменяющие операции — только для авторизованных .requestMatchers(HttpMethod.POST, "/api/v1/files/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/v1/files/**").authenticated() .requestMatchers(HttpMethod.POST, "/api/v1/reviews/**").authenticated() .requestMatchers(HttpMethod.PUT, "/api/v1/reviews/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/v1/reviews/**").authenticated() .requestMatchers(HttpMethod.POST, "/api/v1/posts/**").authenticated() .requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/v1/posts/**").authenticated() // реклама .requestMatchers(HttpMethod.GET, "/api/ads/next").permitAll() .requestMatchers(HttpMethod.POST, "/api/ads/impression").permitAll() .requestMatchers(HttpMethod.POST, "/api/ads/impression/beacon").permitAll() .requestMatchers(HttpMethod.POST, "/api/ads/click").permitAll() .requestMatchers(HttpMethod.GET, "/api/fullscreen-ad").permitAll() // пример разграничения по ролям .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") // служебные endpoints: только для администратора // (в методах стоит @PreAuthorize, здесь — первый рубеж) .requestMatchers("/api/v1/test/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } private void handleUnauthorized(HttpServletRequest request, HttpServletResponse response, org.springframework.security.core.AuthenticationException authException) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write("{\"message\":\"Unauthorized\"}"); } private void handleForbidden(HttpServletRequest request, HttpServletResponse response, org.springframework.security.access.AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write("{\"message\":\"Forbidden\"}"); } @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("/**", config); return source; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }