package com.ffii.fpsms.config.security; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.ldap.core.support.BaseLdapPathContextSource; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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.config.ldap.LdapBindAuthenticationManagerFactory; 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 com.ffii.fpsms.config.security.jwt.JwtRequestFilter; import org.springframework.http.HttpMethod; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { public static final String INDEX_URL = "/"; public static final String LOGIN_URL = "/login"; public static final String LDAP_LOGIN_URL = "/ldap-login"; public static final String[] URL_WHITELIST = { INDEX_URL, LOGIN_URL, LDAP_LOGIN_URL, "/refresh-token", "/py/**" }; public static final String[] CORS_ALLOWED_METHODS = { "GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS" }; @Lazy @Autowired private JwtRequestFilter jwtRequestFilter; @Bean @Qualifier("AuthenticationManager") public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean @Qualifier("LdapAuthenticationManager") public AuthenticationManager ldapAuthenticationManager(BaseLdapPathContextSource contextSource) { LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource); factory.setUserSearchFilter("cn={0}"); return factory.createAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) */ @Bean @Order(1) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .cors(Customizer.withDefaults()).csrf(csrf -> csrf.disable()) .requestCache(requestCache -> requestCache.disable()) .authorizeHttpRequests( authRequest -> authRequest .requestMatchers(URL_WHITELIST).permitAll() .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() /* PO stock-in nav alerts: TESTING / ADMIN / STOCK (no @PreAuthorize on Kotlin controllers). */ .requestMatchers(HttpMethod.GET, "/stockInLine/alerts/purchase-incomplete-count") .hasAnyAuthority("TESTING", "ADMIN", "STOCK") .requestMatchers(HttpMethod.GET, "/stockInLine/alerts/purchase-incomplete") .hasAnyAuthority("TESTING", "ADMIN", "STOCK") .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") .hasAuthority("TESTING") .requestMatchers(HttpMethod.GET, "/device-presence/ping").authenticated() .requestMatchers(HttpMethod.POST, "/device-presence/heartbeat").authenticated() .requestMatchers(HttpMethod.GET, "/device-presence/active") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.GET, "/device-presence/history") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.GET, "/printer-monitor/status") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.GET, "/printer-monitor/history") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.POST, "/printer-monitor/check") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.GET, "/label-printer-monitor/status") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.POST, "/label-printer-monitor/check") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.GET, "/label-printer-monitor/label-stats") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.GET, "/label-printer-monitor/odometer-stats") .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.POST, "/stockAdjustment/submit") .hasAuthority("INVENTORY_ADJUST") .requestMatchers(HttpMethod.GET, "/stockAdjustment/latestRemarks") .hasAuthority("INVENTORY_ADJUST") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace") .hasAuthority("ITEM_TRACING") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") .hasAuthority("ITEM_TRACING") /* 工單 生產流程 完成工單:僅 ADMIN */ .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/complete/**") .hasAuthority("ADMIN") /* 工序「已完成」(Just Pass):僅 ADMIN */ .requestMatchers(HttpMethod.POST, "/product-process/Demo/ProcessLine/pass/**") .hasAuthority("ADMIN") /* M18 手動同步頁:ADMIN 或 M18_SYNC。po-by-code 另允許 PURCHASE(採購單搜尋自動同步)。 */ .requestMatchers(HttpMethod.GET, "/m18/test/po-by-code") .hasAnyAuthority("ADMIN", "M18_SYNC", "PURCHASE") .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code") .hasAnyAuthority("ADMIN", "M18_SYNC") .requestMatchers(HttpMethod.GET, "/m18/test/do-by-code-extra") .hasAnyAuthority("ADMIN", "M18_SYNC") .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") .hasAnyAuthority("ADMIN", "M18_SYNC") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) .sessionManagement( sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) .build(); } /** Send 401 with JSON body so frontend can consistently handle session timeout / missing token. */ private static void sendUnauthorizedJson(HttpServletResponse response, String message, String code) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json"); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); String body = String.format("{\"message\":\"%s\",\"code\":\"%s\"}", message.replace("\"", "\\\""), code); response.getWriter().write(body); } }