package com.openisle.config; import com.openisle.service.JwtService; import com.openisle.service.UserVisitService; import com.openisle.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; 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.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.beans.factory.annotation.Value; import java.util.List; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @Configuration @RequiredArgsConstructor public class SecurityConfig { private final JwtService jwtService; private final UserRepository userRepository; private final AccessDeniedHandler customAccessDeniedHandler; private final UserVisitService userVisitService; @Value("${app.website-url}") private String websiteUrl; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public UserDetailsService userDetailsService() { return username -> userRepository.findByUsername(username) .map(user -> org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .authorities(user.getRole().name()) .build()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); } @Bean public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception { return http.getSharedObject(AuthenticationManagerBuilder.class) .userDetailsService(userDetailsService) .passwordEncoder(passwordEncoder) .and() .build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); cfg.setAllowedOrigins(List.of( "http://127.0.0.1:8080", "http://127.0.0.1:3000", "http://127.0.0.1:3001", "http://127.0.0.1", "http://localhost:8080", "http://localhost:3000", "http://localhost:3001", "http://localhost", "http://30.211.97.238:3000", "http://30.211.97.238", "http://192.168.7.98", "http://192.168.7.98:3000", websiteUrl, websiteUrl.replace("://www.", "://") )); cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); cfg.setAllowedHeaders(List.of("*")); cfg.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", cfg); return source; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .cors(Customizer.withDefaults()) .headers(h -> h.frameOptions(f -> f.sameOrigin())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/config/**").permitAll() .requestMatchers(HttpMethod.POST,"/api/auth/google").permitAll() .requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll() .requestMatchers(HttpMethod.GET, "/api/search/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/users/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/medals/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll() .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() .requestMatchers(HttpMethod.GET, "/api/rss").permitAll() .requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") .requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/tags/**").hasAuthority("ADMIN") .requestMatchers(HttpMethod.GET, "/api/stats/**").hasAuthority("ADMIN") .requestMatchers("/api/admin/**").hasAuthority("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public OncePerRequestFilter jwtAuthenticationFilter() { return new OncePerRequestFilter() { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 让预检请求直接通过 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { filterChain.doFilter(request, response); return; } String authHeader = request.getHeader("Authorization"); String uri = request.getRequestURI(); boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) && (uri.startsWith("/api/posts") || uri.startsWith("/api/comments") || uri.startsWith("/api/categories") || uri.startsWith("/api/tags") || uri.startsWith("/api/search") || uri.startsWith("/api/users") || uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") || uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") || uri.startsWith("/api/rss")); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); try { String username = jwtService.validateAndGetSubject(token); UserDetails userDetails = userDetailsService().loadUserByUsername(username); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken); } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.getWriter().write("{\"error\": \"Invalid or expired token\"}"); return; } } else if (!uri.startsWith("/api/auth") && !publicGet && !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.getWriter().write("{\"error\": \"Missing token\"}"); return; } filterChain.doFilter(request, response); } }; } @Bean public OncePerRequestFilter userVisitFilter() { return new OncePerRequestFilter() { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) { userVisitService.recordVisit(auth.getName()); } filterChain.doFilter(request, response); } }; } }