ITN Dev 07-22
fix: 관리자 로그인 CSRF 쿠키 왕복이 실제로 동작하도록 수정
- CookieCsrfTokenRepository.saveToken()이 한 번도 호출되지 않아 브라우저가
  XSRF-TOKEN 쿠키를 영영 못 받던 문제를 Spring Security 레퍼런스가 SPA용으로
  제시하는 방식대로 고쳤다. BasicAuthenticationFilter 뒤에 지연 토큰을 강제
  resolve하는 필터를 추가하고, 기본 XorCsrfTokenRequestAttributeHandler가
  BREACH 마스킹 때문에 쿠키 원문과 헤더 값을 불일치시키던 문제를
  SpaCsrfTokenRequestHandler로 해결했다(헤더 존재 시 마스킹 해제 없이 신뢰,
  파라미터 기반 검증은 기존 Xor 위임 유지).
- "로그인 엔드포인트는 인증없이 접근할 수 있다" 테스트가 permitAll() 삭제와
  무관하게 항상 같은 401을 반환해 아무것도 증명하지 못하던 문제를, 인증 실패와
  인가 차단이 세션 생성 여부에서 실제로 다르다는 점(ExceptionTranslationFilter의
  RequestCache)으로 구분하도록 재작성했다.
- AdminProperties record의 toString()이 비밀번호를 그대로 노출하지 않도록
  마스킹했다.
- formLogin에 커스텀 loginPage를 지정해 DefaultLoginPageGeneratingFilter가
  "/login"을 가로채지 않도록(3단계 SPA 클라이언트 라우트 확보) 했다.
- CSRF 쿠키 왕복 회귀 테스트, 토큰 누락 시 응답 코드, 로그인 페이지 미노출을
  검증하는 테스트 3건을 추가했다. with(csrf())가 캐시된 컨텍스트의 CsrfFilter
  빈을 영구히 세션 기반으로 바꿔치기하는 부작용이 있어 @TestMethodOrder로 실행
  순서를 고정했다.

Co-Authored-By: Claude Opus 4.8 (1M context) 
@e4c74a788d1f04151076049c2fda1c829ada0c2d
src/main/java/kr/itn/itnhub/config/AdminProperties.java
--- src/main/java/kr/itn/itnhub/config/AdminProperties.java
+++ src/main/java/kr/itn/itnhub/config/AdminProperties.java
@@ -4,4 +4,11 @@
 
 @ConfigurationProperties(prefix = "app.admin")
 public record AdminProperties(String username, String password) {
+
+    // record 기본 toString()은 password를 그대로 노출한다. 로그에 실수로 찍혀도
+    // 원문이 남지 않도록 마스킹한다.
+    @Override
+    public String toString() {
+        return "AdminProperties[username=%s, password=****]".formatted(username);
+    }
 }
src/main/java/kr/itn/itnhub/config/SecurityConfig.java
--- src/main/java/kr/itn/itnhub/config/SecurityConfig.java
+++ src/main/java/kr/itn/itnhub/config/SecurityConfig.java
@@ -1,6 +1,11 @@
 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;
@@ -11,7 +16,13 @@
 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
@@ -39,14 +50,44 @@
     SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
         http
                 // SPA가 읽어서 X-XSRF-TOKEN 헤더로 되돌려 보낸다
-                .csrf(csrf -> csrf.csrfTokenRepository(
-                        CookieCsrfTokenRepository.withHttpOnlyFalse()))
+                .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)))
@@ -60,4 +101,28 @@
 
         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> 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);
+        }
+    }
 }
src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java
--- src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java
+++ src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java
@@ -1,29 +1,130 @@
 package kr.itn.itnhub.config;
 
+import jakarta.servlet.http.Cookie;
 import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
 import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
+/**
+ * 실행 순서를 명시적으로 고정한다({@code @TestMethodOrder}). 이유: Spring Security
+ * Test의 {@code with(csrf())}는 최초 호출 시 리플렉션으로 캐시된 애플리케이션 컨텍스트의
+ * 실제 {@code CsrfFilter} 빈의 {@code tokenRepository} 필드를 세션 기반 테스트 저장소로
+ * "영구히" 바꿔치기한다({@code WebTestUtils.setCsrfTokenRepository} 참고). 그 뒤로는 같은
+ * 컨텍스트를 공유하는 이 클래스의 모든 테스트에서 실제 {@code CookieCsrfTokenRepository}가
+ * 더 이상 동작하지 않는다. 그래서 실제 쿠키 왕복을 검증하는 테스트는 {@code with(csrf())}를
+ * 쓰는 테스트보다 반드시 먼저 실행돼야 한다.
+ */
 @AutoConfigureMockMvc
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
 class SecurityConfigTest extends AbstractDbTest {
 
     @Autowired
     MockMvc mvc;
 
+    /**
+     * Finding 1 회귀 테스트. {@code .with(csrf())}를 전혀 쓰지 않는다.
+     * 브라우저가 실제로 겪는 흐름 그대로: 먼저 permitAll 경로에 평범한 GET을 보내
+     * 응답의 Set-Cookie로 XSRF-TOKEN을 받고, 그 쿠키 값을 X-XSRF-TOKEN 헤더로
+     * 되돌려 보내 로그인한다. CookieCsrfTokenRepository가 실제로 쿠키를 쓰지 않으면
+     * (지연 토큰이 resolve되지 않으면) xsrfCookie가 null이 되어 이 테스트는 실패한다.
+     */
     @Test
+    @Order(1)
+    void CSRF_쿠키를_받아서_로그인에_사용할_수_있다() throws Exception {
+        MvcResult csrfResult = mvc.perform(get("/")).andReturn();
+        Cookie xsrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
+        assertThat(xsrfCookie).as("XSRF-TOKEN 쿠키가 Set-Cookie로 내려와야 한다").isNotNull();
+
+        mvc.perform(post("/api/auth/login")
+                        .param("username", "admin")
+                        .param("password", "test-password")
+                        .cookie(xsrfCookie)
+                        .header("X-XSRF-TOKEN", xsrfCookie.getValue()))
+                .andExpect(status().isOk());
+    }
+
+    /**
+     * CSRF 토큰을 전혀 보내지 않으면 CsrfFilter 자신의 AccessDeniedHandler가
+     * 처리한다(이 프로젝트가 exceptionHandling에 설정한 401 엔트리포인트를 타지 않는다).
+     * 실측 결과 403 Forbidden이었다.
+     */
+    @Test
+    @Order(2)
+    void CSRF_토큰_없이_로그인하면_거부된다() throws Exception {
+        mvc.perform(post("/api/auth/login")
+                        .param("username", "admin")
+                        .param("password", "test-password"))
+                .andExpect(status().isForbidden());
+    }
+
+    /**
+     * Finding 4. formLogin()에 커스텀 loginPage를 지정하면
+     * DefaultLoginPageGeneratingFilter가 등록되지 않아야 한다.
+     */
+    @Test
+    @Order(3)
+    void 로그인_페이지는_스프링_기본_로그인폼을_반환하지_않는다() throws Exception {
+        MvcResult result = mvc.perform(get("/login")).andReturn();
+        String body = result.getResponse().getContentAsString();
+        assertThat(body).doesNotContain("Please sign in");
+    }
+
+    @Test
+    @Order(4)
     void 인증없이_api를_부르면_401이다() throws Exception {
         mvc.perform(get("/api/orgs"))
                 .andExpect(status().isUnauthorized());
     }
 
+    /**
+     * Finding 2. 예전 테스트는 틀린 자격증명으로 로그인해 401을 확인하는 것만으로
+     * "인증 없이 접근 가능"을 증명하려 했지만, permitAll()이 삭제돼 /api/** →
+     * authenticated()로 떨어져도 같은 401이 나와 아무것도 구분하지 못했다.
+     * 실제로는 두 401이 서로 다른 필터에서 나온다:
+     *  - 로그인 실패: UsernamePasswordAuthenticationFilter가 바로 failureHandler를
+     *    호출하고 끝난다. ExceptionTranslationFilter/AuthorizationFilter를 거치지
+     *    않으므로 세션이 생기지 않는다.
+     *  - 인가 차단: ExceptionTranslationFilter.sendStartAuthentication()이 엔트리
+     *    포인트를 부르기 전에 기본 RequestCache(HttpSessionRequestCache)로 원 요청을
+     *    세션에 저장한다. 즉 세션이 생긴다.
+     * 이 세션 생성 여부 차이로 두 경로가 실제로 다르다는 것을, 즉 permitAll()이
+     * 로그인 엔드포인트에서 실제로 작동하고 있다는 것을 증명한다.
+     */
     @Test
+    @Order(5)
+    void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception {
+        MvcResult loginResult = mvc.perform(post("/api/auth/login")
+                        .param("username", "nobody")
+                        .param("password", "nothing")
+                        .with(csrf()))
+                .andExpect(status().isUnauthorized()) // 403이 아니라 401이어야 한다
+                .andReturn();
+        assertThat(loginResult.getRequest().getSession(false))
+                .as("인증 실패는 AuthorizationFilter를 거치지 않으므로 세션이 생기면 안 된다")
+                .isNull();
+
+        MvcResult blockedResult = mvc.perform(get("/api/orgs"))
+                .andExpect(status().isUnauthorized())
+                .andReturn();
+        assertThat(blockedResult.getRequest().getSession(false))
+                .as("인가 차단은 ExceptionTranslationFilter가 원 요청을 세션에 저장한다")
+                .isNotNull();
+    }
+
+    @Test
+    @Order(6)
     void 올바른_비밀번호로_로그인하면_200이다() throws Exception {
         mvc.perform(post("/api/auth/login")
                         .param("username", "admin")
@@ -33,20 +134,12 @@
     }
 
     @Test
+    @Order(7)
     void 틀린_비밀번호로_로그인하면_401이다() throws Exception {
         mvc.perform(post("/api/auth/login")
                         .param("username", "admin")
                         .param("password", "wrong")
                         .with(csrf()))
                 .andExpect(status().isUnauthorized());
-    }
-
-    @Test
-    void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception {
-        mvc.perform(post("/api/auth/login")
-                        .param("username", "nobody")
-                        .param("password", "nothing")
-                        .with(csrf()))
-                .andExpect(status().isUnauthorized()); // 403이 아니라 401이어야 한다
     }
 }
Add a comment
List