package com.mydreamjob.config; import com.mydreamjob.auth.JwtAuthenticationFilter; import com.mydreamjob.audit.AuditService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpMethod; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; 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.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; 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; import java.util.Arrays; import java.util.List; /** * Spring Security 配置 */ @Configuration @EnableWebSecurity @RequiredArgsConstructor @EnableJpaAuditing public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final AuditService auditService; @Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}") private String allowedOrigins; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(ex -> ex .authenticationEntryPoint((request, response, authException) -> { response.setContentType("application/json;charset=UTF-8"); response.setStatus(401); response.getWriter().write( "{\"code\":401,\"message\":\"not authenticated\",\"timestamp\":\"\"}"); }) .accessDeniedHandler((request, response, accessDeniedException) -> { response.setContentType("application/json;charset=UTF-8"); response.setStatus(403); response.getWriter().write( "{\"code\":403,\"message\":\"access denied\",\"timestamp\":\"\"}"); })) .authorizeHttpRequests(auth -> auth // S03-C 整改:业务端口统一放行 /actuator/**。 // Actuator 端点仍只注册在独立管理端口(默认 127.0.0.1),业务端口上 // /actuator/** 全部落入 MVC 的不存在资源处理,由 GlobalExceptionHandler 的 // NoResourceFoundException 精确映射为 404 —— 避免匿名请求先被认证层 // 拦截成 401(未放行路径)或落入通用异常映射成 500(放行路径)。 // permitAll 不代表暴露指标,只保证所有 /actuator/** 都走到同一 404 语义; // 业务端口不得新增任何 Actuator Controller 或端点映射。 .requestMatchers("/actuator/**").permitAll() // Swagger UI .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**").permitAll() // 健康检查 API .requestMatchers("/health/**").permitAll() .requestMatchers("/health").permitAll() // 平台维护的岗位推荐分类字典(公开只读) .requestMatchers(HttpMethod.GET, "/job-recommendation-taxonomies").permitAll() // 认证相关 .requestMatchers("/auth/sms/send").permitAll() .requestMatchers("/auth/login").permitAll() .requestMatchers("/auth/register").permitAll() // S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。 // permitAll 只表示可进入,不代表 Token 有效。 .requestMatchers("/auth/refresh").permitAll() // 当前用户 .requestMatchers("/auth/me").authenticated() // S02:登出必须已认证 .requestMatchers("/auth/logout").authenticated() // 企业资料(需登录) .requestMatchers("/companies/me/**").authenticated() // V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) .requestMatchers("/profile/me/talent-discovery").authenticated() .requestMatchers("/companies/me/jobs/*/talent-recommendations").authenticated() // V42 企业人才邀约与学生决策(角色和租户归属由 Service 二次校验) .requestMatchers("/companies/me/jobs/*/talent-invitations/**").authenticated() .requestMatchers("/companies/me/talent-invitations/**").authenticated() .requestMatchers("/profile/me/talent-invitations/**").authenticated() // 企业公开岗位列表(匿名 GET) .requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll() // 企业公开主页(匿名 GET) .requestMatchers(HttpMethod.GET, "/companies/*").permitAll() // 管理后台(需登录+ADMIN角色) .requestMatchers("/admin/**").authenticated() // 权重模板(登录即可) .requestMatchers(HttpMethod.GET, "/review-weight-templates").authenticated() // 个人权重配置(需登录,仅 STUDENT,Service 层校验) .requestMatchers(HttpMethod.GET, "/profile/me/review-weights").authenticated() .requestMatchers(HttpMethod.PUT, "/profile/me/review-weights").authenticated() // 求职者评分卡(需登录,仅 STUDENT,Service 层校验) .requestMatchers(HttpMethod.GET, "/profile/me/candidate-score-card").authenticated() .requestMatchers(HttpMethod.GET, "/profile/me/candidate-score-trend").authenticated() .requestMatchers(HttpMethod.GET, "/profile/me/candidate-percentile").authenticated() // 个人资料(需登录) .requestMatchers("/profile/me").authenticated() // 岗位评价查询、详情、评分汇总、岗位对比(公开) .requestMatchers(HttpMethod.GET, "/jobs/compare").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/*/reviews/summary").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/*/reviews/*").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/*/reviews").permitAll() // 岗位收藏和评价接口(需登录) .requestMatchers("/jobs/*/favorite").authenticated() .requestMatchers("/jobs/favorites").authenticated() .requestMatchers("/jobs/*/reviews/**").authenticated() // 个性化评分(需登录,仅 STUDENT,Service 层校验) .requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated() // 岗位公开查询 .requestMatchers("/jobs/public/**").permitAll() // 仅公开岗位列表为匿名可读;写操作必须命中下方的 /jobs/** → authenticated .requestMatchers(HttpMethod.GET, "/jobs").permitAll() // 岗位搜索与筛选(公开) .requestMatchers(HttpMethod.GET, "/jobs/search").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll() // 岗位管理(需登录) .requestMatchers("/jobs/**").authenticated() // 投递(需登录) .requestMatchers("/applications/**").authenticated() .requestMatchers("/company/applications").authenticated() // 企业候选人看板(需登录,COMPANY 角色,Service 层校验) .requestMatchers("/company/candidates/**").authenticated() // 企业评分运营看板(需登录,COMPANY/ADMIN 角色,Service 层校验) .requestMatchers("/company/scoring/dashboard/**").authenticated() // 面试(需登录) .requestMatchers("/interviews/**").authenticated() // RIASEC 职业兴趣测评(需登录;类型/题目任意登录用户,答卷仅 STUDENT 由 Service 层校验) .requestMatchers("/assessments/**").authenticated() // 四维能力测评(需登录;类型任意登录用户,答卷仅 STUDENT 本人由 Service 层校验) .requestMatchers("/abilities/**").authenticated() // API 信息入口 .requestMatchers("/").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of(allowedOrigins.split(","))); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id")); config.setExposedHeaders(List.of("X-Request-Id")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return source; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }