package kr.itn.itnhub.config; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.function.Supplier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.csrf.CsrfTokenRequestHandler; import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.http.HttpStatus; @Configuration public class SecurityConfig { @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } /** * 관리자 1계정. 값은 환경변수로만 들어온다. * 운영에서는 APP_ADMIN_PASSWORD에 충분히 긴 무작위 문자열을 넣는다. */ @Bean UserDetailsService userDetailsService(AdminProperties admin, PasswordEncoder encoder) { return new InMemoryUserDetailsManager( User.withUsername(admin.username()) .password(encoder.encode(admin.password())) .roles("ADMIN") .build()); } @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // SPA가 읽어서 X-XSRF-TOKEN 헤더로 되돌려 보낸다 .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // 기본 XorCsrfTokenRequestAttributeHandler는 BREACH 방지를 위해 // .getToken()이 돌려주는 값을 매 요청 XOR 마스킹한다. 그러면 쿠키에 // 저장된 원문 토큰과 브라우저가 그대로 되돌려 보내는 X-XSRF-TOKEN // 헤더 값이 서로 달라 검증에 실패한다(SpaCsrfTokenRequestHandler로 // 실측: 마스킹 없이 헤더 값을 그대로 신뢰해야 쿠키 왕복이 성립함). .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) // XorCsrfTokenRequestAttributeHandler는 토큰을 지연 계산 Supplier로 _csrf // 요청 속성에 넣기만 하고 실제로 resolve하지 않는다. 뷰 레이어가 없는 이 // 프로젝트에서는 아무도 .getToken()을 호출하지 않아 CookieCsrfTokenRepository의 // saveToken()이 끝내 실행되지 않고, 브라우저는 XSRF-TOKEN 쿠키를 영영 받지 // 못한다. Spring Security 레퍼런스가 SPA용으로 제시하는 방식대로, // BasicAuthenticationFilter 뒤에 필터를 두어 토큰을 강제로 resolve시킨다. .addFilterAfter(new OncePerRequestFilter() { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); if (csrfToken != null) { csrfToken.getToken(); } filterChain.doFilter(request, response); } }, BasicAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/**").authenticated() .anyRequest().permitAll()) .formLogin(form -> form .loginProcessingUrl("/api/auth/login") // 커스텀 로그인 페이지를 지정하면(실제 뷰가 없어도) 스프링 시큐리티가 // DefaultLoginPageGeneratingFilter를 아예 등록하지 않는다. Task 10의 // React SPA가 "/login"을 클라이언트 라우트로 쓸 수 있어야 하므로, // 스프링이 만든 기본 로그인 폼이 그 경로를 가로채면 안 된다. .loginPage("/login") .successHandler((req, res, a) -> res.setStatus(HttpServletResponse.SC_OK)) .failureHandler((req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))) .logout(logout -> logout .logoutUrl("/api/auth/logout") .logoutSuccessHandler((req, res, a) -> res.setStatus(HttpServletResponse.SC_NO_CONTENT))) // API는 로그인 페이지로 리다이렉트하지 않고 401을 준다 .exceptionHandling(ex -> ex.authenticationEntryPoint( new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); return http.build(); } /** * 렌더링(handle)은 기존 XorCsrfTokenRequestAttributeHandler 그대로 위임해 BREACH * 방지 마스킹을 유지한다. 검증(resolveCsrfTokenValue)만 다르다: X-XSRF-TOKEN * 헤더가 있으면(브라우저가 쿠키 원문을 그대로 담아 보내는 SPA 관례) 마스킹 해제 * 없이 그 값을 그대로 신뢰하고, 헤더가 없으면(예: 폼 파라미터 `_csrf`, 테스트의 * {@code with(csrf())} 등) 기존 Xor 위임자의 마스킹 해제 로직을 그대로 쓴다. * 이렇게 두 경로를 다 살려야 쿠키→헤더 왕복과 기존 파라미터 기반 검증이 동시에 * 성립한다. Spring Security 레퍼런스가 SPA 연동용으로 제시하는 표준 패턴이다. */ private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler(); @Override public void handle(HttpServletRequest request, HttpServletResponse response, Supplier csrfToken) { this.delegate.handle(request, response, csrfToken); } @Override public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { String headerValue = request.getHeader(csrfToken.getHeaderName()); return StringUtils.hasText(headerValue) ? headerValue : this.delegate.resolveCsrfTokenValue(request, csrfToken); } } }