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
... | ... | @@ -4,4 +4,11 @@ |
| 4 | 4 |
|
| 5 | 5 |
@ConfigurationProperties(prefix = "app.admin") |
| 6 | 6 |
public record AdminProperties(String username, String password) {
|
| 7 |
+ |
|
| 8 |
+ // record 기본 toString()은 password를 그대로 노출한다. 로그에 실수로 찍혀도 |
|
| 9 |
+ // 원문이 남지 않도록 마스킹한다. |
|
| 10 |
+ @Override |
|
| 11 |
+ public String toString() {
|
|
| 12 |
+ return "AdminProperties[username=%s, password=****]".formatted(username); |
|
| 13 |
+ } |
|
| 7 | 14 |
} |
--- src/main/java/kr/itn/itnhub/config/SecurityConfig.java
+++ src/main/java/kr/itn/itnhub/config/SecurityConfig.java
... | ... | @@ -1,6 +1,11 @@ |
| 1 | 1 |
package kr.itn.itnhub.config; |
| 2 | 2 |
|
| 3 |
+import jakarta.servlet.FilterChain; |
|
| 4 |
+import jakarta.servlet.ServletException; |
|
| 5 |
+import jakarta.servlet.http.HttpServletRequest; |
|
| 3 | 6 |
import jakarta.servlet.http.HttpServletResponse; |
| 7 |
+import java.io.IOException; |
|
| 8 |
+import java.util.function.Supplier; |
|
| 4 | 9 |
import org.springframework.context.annotation.Bean; |
| 5 | 10 |
import org.springframework.context.annotation.Configuration; |
| 6 | 11 |
import org.springframework.security.config.annotation.web.builders.HttpSecurity; |
... | ... | @@ -11,7 +16,13 @@ |
| 11 | 16 |
import org.springframework.security.provisioning.InMemoryUserDetailsManager; |
| 12 | 17 |
import org.springframework.security.web.SecurityFilterChain; |
| 13 | 18 |
import org.springframework.security.web.authentication.HttpStatusEntryPoint; |
| 19 |
+import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; |
|
| 14 | 20 |
import org.springframework.security.web.csrf.CookieCsrfTokenRepository; |
| 21 |
+import org.springframework.security.web.csrf.CsrfToken; |
|
| 22 |
+import org.springframework.security.web.csrf.CsrfTokenRequestHandler; |
|
| 23 |
+import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; |
|
| 24 |
+import org.springframework.util.StringUtils; |
|
| 25 |
+import org.springframework.web.filter.OncePerRequestFilter; |
|
| 15 | 26 |
import org.springframework.http.HttpStatus; |
| 16 | 27 |
|
| 17 | 28 |
@Configuration |
... | ... | @@ -39,14 +50,44 @@ |
| 39 | 50 |
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
| 40 | 51 |
http |
| 41 | 52 |
// SPA가 읽어서 X-XSRF-TOKEN 헤더로 되돌려 보낸다 |
| 42 |
- .csrf(csrf -> csrf.csrfTokenRepository( |
|
| 43 |
- CookieCsrfTokenRepository.withHttpOnlyFalse())) |
|
| 53 |
+ .csrf(csrf -> csrf |
|
| 54 |
+ .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) |
|
| 55 |
+ // 기본 XorCsrfTokenRequestAttributeHandler는 BREACH 방지를 위해 |
|
| 56 |
+ // .getToken()이 돌려주는 값을 매 요청 XOR 마스킹한다. 그러면 쿠키에 |
|
| 57 |
+ // 저장된 원문 토큰과 브라우저가 그대로 되돌려 보내는 X-XSRF-TOKEN |
|
| 58 |
+ // 헤더 값이 서로 달라 검증에 실패한다(SpaCsrfTokenRequestHandler로 |
|
| 59 |
+ // 실측: 마스킹 없이 헤더 값을 그대로 신뢰해야 쿠키 왕복이 성립함). |
|
| 60 |
+ .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) |
|
| 61 |
+ // XorCsrfTokenRequestAttributeHandler는 토큰을 지연 계산 Supplier로 _csrf |
|
| 62 |
+ // 요청 속성에 넣기만 하고 실제로 resolve하지 않는다. 뷰 레이어가 없는 이 |
|
| 63 |
+ // 프로젝트에서는 아무도 .getToken()을 호출하지 않아 CookieCsrfTokenRepository의 |
|
| 64 |
+ // saveToken()이 끝내 실행되지 않고, 브라우저는 XSRF-TOKEN 쿠키를 영영 받지 |
|
| 65 |
+ // 못한다. Spring Security 레퍼런스가 SPA용으로 제시하는 방식대로, |
|
| 66 |
+ // BasicAuthenticationFilter 뒤에 필터를 두어 토큰을 강제로 resolve시킨다. |
|
| 67 |
+ .addFilterAfter(new OncePerRequestFilter() {
|
|
| 68 |
+ @Override |
|
| 69 |
+ protected void doFilterInternal(HttpServletRequest request, |
|
| 70 |
+ HttpServletResponse response, |
|
| 71 |
+ FilterChain filterChain) |
|
| 72 |
+ throws ServletException, IOException {
|
|
| 73 |
+ CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); |
|
| 74 |
+ if (csrfToken != null) {
|
|
| 75 |
+ csrfToken.getToken(); |
|
| 76 |
+ } |
|
| 77 |
+ filterChain.doFilter(request, response); |
|
| 78 |
+ } |
|
| 79 |
+ }, BasicAuthenticationFilter.class) |
|
| 44 | 80 |
.authorizeHttpRequests(auth -> auth |
| 45 | 81 |
.requestMatchers("/api/auth/login").permitAll()
|
| 46 | 82 |
.requestMatchers("/api/**").authenticated()
|
| 47 | 83 |
.anyRequest().permitAll()) |
| 48 | 84 |
.formLogin(form -> form |
| 49 | 85 |
.loginProcessingUrl("/api/auth/login")
|
| 86 |
+ // 커스텀 로그인 페이지를 지정하면(실제 뷰가 없어도) 스프링 시큐리티가 |
|
| 87 |
+ // DefaultLoginPageGeneratingFilter를 아예 등록하지 않는다. Task 10의 |
|
| 88 |
+ // React SPA가 "/login"을 클라이언트 라우트로 쓸 수 있어야 하므로, |
|
| 89 |
+ // 스프링이 만든 기본 로그인 폼이 그 경로를 가로채면 안 된다. |
|
| 90 |
+ .loginPage("/login")
|
|
| 50 | 91 |
.successHandler((req, res, a) -> res.setStatus(HttpServletResponse.SC_OK)) |
| 51 | 92 |
.failureHandler((req, res, e) -> |
| 52 | 93 |
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))) |
... | ... | @@ -60,4 +101,28 @@ |
| 60 | 101 |
|
| 61 | 102 |
return http.build(); |
| 62 | 103 |
} |
| 104 |
+ |
|
| 105 |
+ /** |
|
| 106 |
+ * 렌더링(handle)은 기존 XorCsrfTokenRequestAttributeHandler 그대로 위임해 BREACH |
|
| 107 |
+ * 방지 마스킹을 유지한다. 검증(resolveCsrfTokenValue)만 다르다: X-XSRF-TOKEN |
|
| 108 |
+ * 헤더가 있으면(브라우저가 쿠키 원문을 그대로 담아 보내는 SPA 관례) 마스킹 해제 |
|
| 109 |
+ * 없이 그 값을 그대로 신뢰하고, 헤더가 없으면(예: 폼 파라미터 `_csrf`, 테스트의 |
|
| 110 |
+ * {@code with(csrf())} 등) 기존 Xor 위임자의 마스킹 해제 로직을 그대로 쓴다.
|
|
| 111 |
+ * 이렇게 두 경로를 다 살려야 쿠키→헤더 왕복과 기존 파라미터 기반 검증이 동시에 |
|
| 112 |
+ * 성립한다. Spring Security 레퍼런스가 SPA 연동용으로 제시하는 표준 패턴이다. |
|
| 113 |
+ */ |
|
| 114 |
+ private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
|
|
| 115 |
+ private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler(); |
|
| 116 |
+ |
|
| 117 |
+ @Override |
|
| 118 |
+ public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
|
|
| 119 |
+ this.delegate.handle(request, response, csrfToken); |
|
| 120 |
+ } |
|
| 121 |
+ |
|
| 122 |
+ @Override |
|
| 123 |
+ public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
|
|
| 124 |
+ String headerValue = request.getHeader(csrfToken.getHeaderName()); |
|
| 125 |
+ return StringUtils.hasText(headerValue) ? headerValue : this.delegate.resolveCsrfTokenValue(request, csrfToken); |
|
| 126 |
+ } |
|
| 127 |
+ } |
|
| 63 | 128 |
} |
--- src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java
+++ src/test/java/kr/itn/itnhub/config/SecurityConfigTest.java
... | ... | @@ -1,29 +1,130 @@ |
| 1 | 1 |
package kr.itn.itnhub.config; |
| 2 | 2 |
|
| 3 |
+import jakarta.servlet.http.Cookie; |
|
| 3 | 4 |
import kr.itn.itnhub.AbstractDbTest; |
| 5 |
+import org.junit.jupiter.api.MethodOrderer; |
|
| 6 |
+import org.junit.jupiter.api.Order; |
|
| 4 | 7 |
import org.junit.jupiter.api.Test; |
| 8 |
+import org.junit.jupiter.api.TestMethodOrder; |
|
| 5 | 9 |
import org.springframework.beans.factory.annotation.Autowired; |
| 6 | 10 |
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
| 7 | 11 |
import org.springframework.test.web.servlet.MockMvc; |
| 12 |
+import org.springframework.test.web.servlet.MvcResult; |
|
| 8 | 13 |
|
| 14 |
+import static org.assertj.core.api.Assertions.assertThat; |
|
| 9 | 15 |
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; |
| 10 | 16 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
| 11 | 17 |
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
| 12 | 18 |
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
| 13 | 19 |
|
| 20 |
+/** |
|
| 21 |
+ * 실행 순서를 명시적으로 고정한다({@code @TestMethodOrder}). 이유: Spring Security
|
|
| 22 |
+ * Test의 {@code with(csrf())}는 최초 호출 시 리플렉션으로 캐시된 애플리케이션 컨텍스트의
|
|
| 23 |
+ * 실제 {@code CsrfFilter} 빈의 {@code tokenRepository} 필드를 세션 기반 테스트 저장소로
|
|
| 24 |
+ * "영구히" 바꿔치기한다({@code WebTestUtils.setCsrfTokenRepository} 참고). 그 뒤로는 같은
|
|
| 25 |
+ * 컨텍스트를 공유하는 이 클래스의 모든 테스트에서 실제 {@code CookieCsrfTokenRepository}가
|
|
| 26 |
+ * 더 이상 동작하지 않는다. 그래서 실제 쿠키 왕복을 검증하는 테스트는 {@code with(csrf())}를
|
|
| 27 |
+ * 쓰는 테스트보다 반드시 먼저 실행돼야 한다. |
|
| 28 |
+ */ |
|
| 14 | 29 |
@AutoConfigureMockMvc |
| 30 |
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class) |
|
| 15 | 31 |
class SecurityConfigTest extends AbstractDbTest {
|
| 16 | 32 |
|
| 17 | 33 |
@Autowired |
| 18 | 34 |
MockMvc mvc; |
| 19 | 35 |
|
| 36 |
+ /** |
|
| 37 |
+ * Finding 1 회귀 테스트. {@code .with(csrf())}를 전혀 쓰지 않는다.
|
|
| 38 |
+ * 브라우저가 실제로 겪는 흐름 그대로: 먼저 permitAll 경로에 평범한 GET을 보내 |
|
| 39 |
+ * 응답의 Set-Cookie로 XSRF-TOKEN을 받고, 그 쿠키 값을 X-XSRF-TOKEN 헤더로 |
|
| 40 |
+ * 되돌려 보내 로그인한다. CookieCsrfTokenRepository가 실제로 쿠키를 쓰지 않으면 |
|
| 41 |
+ * (지연 토큰이 resolve되지 않으면) xsrfCookie가 null이 되어 이 테스트는 실패한다. |
|
| 42 |
+ */ |
|
| 20 | 43 |
@Test |
| 44 |
+ @Order(1) |
|
| 45 |
+ void CSRF_쿠키를_받아서_로그인에_사용할_수_있다() throws Exception {
|
|
| 46 |
+ MvcResult csrfResult = mvc.perform(get("/")).andReturn();
|
|
| 47 |
+ Cookie xsrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
|
|
| 48 |
+ assertThat(xsrfCookie).as("XSRF-TOKEN 쿠키가 Set-Cookie로 내려와야 한다").isNotNull();
|
|
| 49 |
+ |
|
| 50 |
+ mvc.perform(post("/api/auth/login")
|
|
| 51 |
+ .param("username", "admin")
|
|
| 52 |
+ .param("password", "test-password")
|
|
| 53 |
+ .cookie(xsrfCookie) |
|
| 54 |
+ .header("X-XSRF-TOKEN", xsrfCookie.getValue()))
|
|
| 55 |
+ .andExpect(status().isOk()); |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ /** |
|
| 59 |
+ * CSRF 토큰을 전혀 보내지 않으면 CsrfFilter 자신의 AccessDeniedHandler가 |
|
| 60 |
+ * 처리한다(이 프로젝트가 exceptionHandling에 설정한 401 엔트리포인트를 타지 않는다). |
|
| 61 |
+ * 실측 결과 403 Forbidden이었다. |
|
| 62 |
+ */ |
|
| 63 |
+ @Test |
|
| 64 |
+ @Order(2) |
|
| 65 |
+ void CSRF_토큰_없이_로그인하면_거부된다() throws Exception {
|
|
| 66 |
+ mvc.perform(post("/api/auth/login")
|
|
| 67 |
+ .param("username", "admin")
|
|
| 68 |
+ .param("password", "test-password"))
|
|
| 69 |
+ .andExpect(status().isForbidden()); |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ /** |
|
| 73 |
+ * Finding 4. formLogin()에 커스텀 loginPage를 지정하면 |
|
| 74 |
+ * DefaultLoginPageGeneratingFilter가 등록되지 않아야 한다. |
|
| 75 |
+ */ |
|
| 76 |
+ @Test |
|
| 77 |
+ @Order(3) |
|
| 78 |
+ void 로그인_페이지는_스프링_기본_로그인폼을_반환하지_않는다() throws Exception {
|
|
| 79 |
+ MvcResult result = mvc.perform(get("/login")).andReturn();
|
|
| 80 |
+ String body = result.getResponse().getContentAsString(); |
|
| 81 |
+ assertThat(body).doesNotContain("Please sign in");
|
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ @Test |
|
| 85 |
+ @Order(4) |
|
| 21 | 86 |
void 인증없이_api를_부르면_401이다() throws Exception {
|
| 22 | 87 |
mvc.perform(get("/api/orgs"))
|
| 23 | 88 |
.andExpect(status().isUnauthorized()); |
| 24 | 89 |
} |
| 25 | 90 |
|
| 91 |
+ /** |
|
| 92 |
+ * Finding 2. 예전 테스트는 틀린 자격증명으로 로그인해 401을 확인하는 것만으로 |
|
| 93 |
+ * "인증 없이 접근 가능"을 증명하려 했지만, permitAll()이 삭제돼 /api/** → |
|
| 94 |
+ * authenticated()로 떨어져도 같은 401이 나와 아무것도 구분하지 못했다. |
|
| 95 |
+ * 실제로는 두 401이 서로 다른 필터에서 나온다: |
|
| 96 |
+ * - 로그인 실패: UsernamePasswordAuthenticationFilter가 바로 failureHandler를 |
|
| 97 |
+ * 호출하고 끝난다. ExceptionTranslationFilter/AuthorizationFilter를 거치지 |
|
| 98 |
+ * 않으므로 세션이 생기지 않는다. |
|
| 99 |
+ * - 인가 차단: ExceptionTranslationFilter.sendStartAuthentication()이 엔트리 |
|
| 100 |
+ * 포인트를 부르기 전에 기본 RequestCache(HttpSessionRequestCache)로 원 요청을 |
|
| 101 |
+ * 세션에 저장한다. 즉 세션이 생긴다. |
|
| 102 |
+ * 이 세션 생성 여부 차이로 두 경로가 실제로 다르다는 것을, 즉 permitAll()이 |
|
| 103 |
+ * 로그인 엔드포인트에서 실제로 작동하고 있다는 것을 증명한다. |
|
| 104 |
+ */ |
|
| 26 | 105 |
@Test |
| 106 |
+ @Order(5) |
|
| 107 |
+ void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception {
|
|
| 108 |
+ MvcResult loginResult = mvc.perform(post("/api/auth/login")
|
|
| 109 |
+ .param("username", "nobody")
|
|
| 110 |
+ .param("password", "nothing")
|
|
| 111 |
+ .with(csrf())) |
|
| 112 |
+ .andExpect(status().isUnauthorized()) // 403이 아니라 401이어야 한다 |
|
| 113 |
+ .andReturn(); |
|
| 114 |
+ assertThat(loginResult.getRequest().getSession(false)) |
|
| 115 |
+ .as("인증 실패는 AuthorizationFilter를 거치지 않으므로 세션이 생기면 안 된다")
|
|
| 116 |
+ .isNull(); |
|
| 117 |
+ |
|
| 118 |
+ MvcResult blockedResult = mvc.perform(get("/api/orgs"))
|
|
| 119 |
+ .andExpect(status().isUnauthorized()) |
|
| 120 |
+ .andReturn(); |
|
| 121 |
+ assertThat(blockedResult.getRequest().getSession(false)) |
|
| 122 |
+ .as("인가 차단은 ExceptionTranslationFilter가 원 요청을 세션에 저장한다")
|
|
| 123 |
+ .isNotNull(); |
|
| 124 |
+ } |
|
| 125 |
+ |
|
| 126 |
+ @Test |
|
| 127 |
+ @Order(6) |
|
| 27 | 128 |
void 올바른_비밀번호로_로그인하면_200이다() throws Exception {
|
| 28 | 129 |
mvc.perform(post("/api/auth/login")
|
| 29 | 130 |
.param("username", "admin")
|
... | ... | @@ -33,20 +134,12 @@ |
| 33 | 134 |
} |
| 34 | 135 |
|
| 35 | 136 |
@Test |
| 137 |
+ @Order(7) |
|
| 36 | 138 |
void 틀린_비밀번호로_로그인하면_401이다() throws Exception {
|
| 37 | 139 |
mvc.perform(post("/api/auth/login")
|
| 38 | 140 |
.param("username", "admin")
|
| 39 | 141 |
.param("password", "wrong")
|
| 40 | 142 |
.with(csrf())) |
| 41 | 143 |
.andExpect(status().isUnauthorized()); |
| 42 |
- } |
|
| 43 |
- |
|
| 44 |
- @Test |
|
| 45 |
- void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception {
|
|
| 46 |
- mvc.perform(post("/api/auth/login")
|
|
| 47 |
- .param("username", "nobody")
|
|
| 48 |
- .param("password", "nothing")
|
|
| 49 |
- .with(csrf())) |
|
| 50 |
- .andExpect(status().isUnauthorized()); // 403이 아니라 401이어야 한다 |
|
| 51 | 144 |
} |
| 52 | 145 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?