diff --git a/src/main/java/com/issueDive/config/SecurityConfig.java b/src/main/java/com/issueDive/config/SecurityConfig.java index dc98f4d..c135ec0 100644 --- a/src/main/java/com/issueDive/config/SecurityConfig.java +++ b/src/main/java/com/issueDive/config/SecurityConfig.java @@ -49,7 +49,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .requestMatchers(PUBLIC_URLS).permitAll() // 공개 URL은 모두 허용 - .requestMatchers("/auth/signup", "/auth/login").permitAll() // 1. 로그인/인증 관련 경로는 모두 허용 + .requestMatchers("/auth/signup", "/auth/login").permitAll() // 1. 로그인/인증 관련 경로는 모두 허용 .requestMatchers(HttpMethod.GET, "/issues", "/issues/**").permitAll() // 2. 이슈 조회(GET)는 모두 허용 .requestMatchers(HttpMethod.GET, "/labels", "/labels/**").permitAll() // 3. 라벨 조회(GET)도 모두 허용 .anyRequest().authenticated() // 나머지는 인증 필요 diff --git a/src/main/java/com/issueDive/security/JwtAuthenticationFilter.java b/src/main/java/com/issueDive/security/JwtAuthenticationFilter.java index 2908279..3522df6 100644 --- a/src/main/java/com/issueDive/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/issueDive/security/JwtAuthenticationFilter.java @@ -39,40 +39,32 @@ protected void doFilterInternal(HttpServletRequest request, if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) { - // 9월 10일 최종 - 블랙리스트 체크 추가 if (tokenBlacklistService != null && tokenBlacklistService.isBlacklisted(jwt)) { - log.warn("9월 10일 최종 - 블랙리스트 토큰 사용 시도"); - setErrorResponse(response, "토큰이 무효화되었습니다."); - return; - } - // JWT에서 이메일 추출 - String email = jwtUtil.getUserEmailFromToken(jwt); - - // 9월1일 변경 - AccessToken만 사용하므로 타입 체크 제거 - - // 토큰 유효성 검증 - if (jwtUtil.validateToken(jwt, email)) { - UserDetails userDetails = userDetailsService.loadUserByUsername(email); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - - SecurityContextHolder.getContext().setAuthentication(authentication); - - log.debug("사용자 {} 인증 성공", email); + log.warn("블랙리스트 토큰 사용 시도"); + setErrorResponse(response, "토큰이 무효화되었습니다."); + return; } else { - log.warn("유효하지 않은 JWT 토큰: {}", email); - setErrorResponse(response, "유효하지 않은 토큰입니다."); - return; + String email = jwtUtil.getUserEmailFromToken(jwt); + + // 토큰이 유효한 경우에만 SecurityContext에 인증 정보 저장 + if (jwtUtil.validateToken(jwt, email)) { + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("사용자 {} 인증 성공", email); + } + // ** 토큰이 유효하지 않은 경우(else), 아무것도 하지 않고 넘어감 } } } catch (Exception e) { - log.error("JWT 인증 처리 중 오류 발생", e); - setErrorResponse(response, "토큰 처리 중 오류가 발생했습니다."); - return; + // 로그만 남기고 넘김 + log.error("JWT 토큰 처리 중 오류 발생 (요청은 계속 진행): {}", e.getMessage()); } + // 모든 경우에 대해 다음 필터로 요청 전달 filterChain.doFilter(request, response); } diff --git a/src/test/java/com/issueDive/config/SecurityConfigTest.java b/src/test/java/com/issueDive/config/SecurityConfigTest.java index 42de2b9..7316149 100644 --- a/src/test/java/com/issueDive/config/SecurityConfigTest.java +++ b/src/test/java/com/issueDive/config/SecurityConfigTest.java @@ -186,7 +186,7 @@ void protectedUrl_WithInvalidToken_Blocked() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"Test\"}")) .andDo(print()) - .andExpect(status().isUnauthorized()); + .andExpect(status().isForbidden()); } // ===== CORS 설정 테스트 ===== @@ -224,691 +224,4 @@ void sessionPolicy_Stateless_NoSessionCreated() throws Exception { .andExpect(status().isForbidden()) .andExpect(cookie().doesNotExist("JSESSIONID")); } -} - -/* -import com.issueDive.dto.CountCommentResponse; -import com.issueDive.dto.IssueNavigationResponse; -import com.issueDive.dto.IssueResponse; -import com.issueDive.dto.UserResponseDTO; -import com.issueDive.security.CustomUserDetailsService; -import com.issueDive.security.JwtAuthenticationFilter; -import com.issueDive.service.CommentService; -import com.issueDive.service.IssueService; -import com.issueDive.service.TokenBlacklistService; -import com.issueDive.service.UserService; -import com.issueDive.util.JwtUtil; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.web.servlet.MockMvc; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest -@AutoConfigureMockMvc -@Sql("/test-data.sql") -public class SecurityConfigTest { - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private JwtUtil jwtUtil; - - @MockitoBean - private CustomUserDetailsService customUserDetailsService; - - @MockitoBean - private IssueService issueService; - - // 555 변경: CommentService MockBean 추가 - @MockitoBean - private CommentService commentService; - - @MockitoBean - private UserService userService; - - @MockitoBean // 이미 있을 것 - private TokenBlacklistService tokenBlacklistService; - - private static final String VALID_TOKEN = "valid.jwt.token"; - private static final String USER_EMAIL = "test@example.com"; - private UserDetails userDetails; - - @BeforeEach - void setUp() { - userDetails = User.builder() - .username(USER_EMAIL) - .password("password") - .authorities(new ArrayList<>()) - .build(); - - when(tokenBlacklistService.isBlacklisted(any())).thenReturn(false); - - } - - @Test - @DisplayName("공개 URL 접근 허용 - /auth/signup") - void publicUrl_Signup_AllowedWithoutAuth() throws Exception { - mockMvc.perform(post("/auth/signup") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andDo(print()) - .andExpect(status().is4xxClientError()); // 400 Bad Request (유효성 검증 실패) - } - - @Test - @DisplayName("공개 URL 접근 허용 - /auth/login") - void publicUrl_Login_AllowedWithoutAuth() throws Exception { - mockMvc.perform(post("/auth/login") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andDo(print()) - .andExpect(status().is4xxClientError()); // 400 Bad Request (유효성 검증 실패) - } - - // 555 변경: /auth/refresh 공개 엔드포인트 테스트 추가 - @Test - @DisplayName("공개 URL 접근 허용 - /auth/refresh") - void publicUrl_Refresh_AllowedWithoutAuth() throws Exception { - mockMvc.perform(post("/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"refreshToken\":\"some.refresh.token\"}")) - .andDo(print()) - .andExpect(status().isUnauthorized()); // 400 or 401 (토큰 검증 실패) - } - - // 555 변경: /auth/logout은 인증이 필요한 엔드포인트 테스트 추가 - @Test - @DisplayName("보호된 URL - /auth/logout은 인증 필요") - void protectedUrl_Logout_RequiresAuth() throws Exception { - mockMvc.perform(post("/auth/logout")) - .andDo(print()) - .andExpect(status().isForbidden()); // 인증 없이 접근 시 403 - } - - // 555 변경: /auth/users/{id}는 인증이 필요한 엔드포인트 테스트 추가 - @Test - @DisplayName("보호된 URL - /auth/users/{id}는 인증 필요") - void protectedUrl_GetUser_RequiresAuth() throws Exception { - mockMvc.perform(get("/auth/users/1")) - .andDo(print()) - .andExpect(status().isForbidden()); // 인증 없이 접근 시 403 - } - - // 555 변경: /auth/users는 인증이 필요한 엔드포인트 테스트 추가 - @Test - @DisplayName("보호된 URL - /auth/users는 인증 필요") - void protectedUrl_GetAllUsers_RequiresAuth() throws Exception { - mockMvc.perform(get("/auth/users")) - .andDo(print()) - .andExpect(status().isForbidden()); // 인증 없이 접근 시 403 - } - - @Test - @DisplayName("공개 URL 접근 허용 - Swagger UI") - void publicUrl_Swagger_AllowedWithoutAuth() throws Exception { - mockMvc.perform(get("/swagger-ui/index.html")) - .andDo(print()) - .andExpect(status().isOk()); // Swagger는 정적 리소스로 200 OK - } - - @Test - @DisplayName("permitAll()된 GET /issues는 인증 없이 접근 가능") - void publicGetIssues_WithoutAuth_Allowed() throws Exception { - // GET /issues는 이제 permitAll이므로 403 Forbidden이 아닌 200 OK를 기대해야 합니다. - mockMvc.perform(get("/issues")) - .andDo(print()) - .andExpect(status().isOk()); - } - - // 555 변경: GET /issues/{id}/comments 공개 엔드포인트 테스트 추가 (Mock 데이터 설정) - @Test - @DisplayName("공개 URL - GET /issues/{id}/comments는 인증 없이 접근 가능") - void publicGetComments_WithoutAuth_Allowed() throws Exception { - // Mock 데이터 설정 - given(commentService.getTreeByIssue(1L)).willReturn(new ArrayList<>()); - - mockMvc.perform(get("/issues/1/comments")) - .andDo(print()) - .andExpect(status().isOk()); - } - - // 555 변경: GET /issues/{id}/comments/count 공개 엔드포인트 테스트 추가 (Mock 데이터 설정) - @Test - @DisplayName("공개 URL - GET /issues/{id}/comments/count는 인증 없이 접근 가능") - void publicGetCommentsCount_WithoutAuth_Allowed() throws Exception { - // Mock 데이터 설정 - given(commentService.countByIssue(1L)).willReturn(new CountCommentResponse(1L, 0L)); - - mockMvc.perform(get("/issues/1/comments/count")) - .andDo(print()) - .andExpect(status().isOk()); - } - - // 555 변경: GET /issues/{id}/navigation 공개 엔드포인트 테스트 추가 (Mock 데이터 설정) - @Test - @DisplayName("공개 URL - GET /issues/{id}/navigation은 인증 없이 접근 가능") - void publicGetNavigation_WithoutAuth_Allowed() throws Exception { - // Mock 데이터 설정 - given(issueService.getIssueNavigation(eq(1L), any())).willReturn(new IssueNavigationResponse(null, null)); - - mockMvc.perform(get("/issues/1/navigation")) - .andDo(print()) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("보호된 URL(POST /issues)은 인증 없이 접근 차단") - void protectedPostUrl_WithoutAuth_Blocked() throws Exception { - // 테스트 대상을 GET이 아닌 POST로 변경하여 보호 여부를 확인합니다. - // POST, PATCH, DELETE 등은 여전히 인증이 필요합니다. - mockMvc.perform(post("/issues") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isForbidden()); // JWT 필터가 없으므로 403 Forbidden - } - - @Test - @DisplayName("유효한 JWT 토큰으로 보호된 URL(POST /issues) 접근 허용") - @WithMockUser(username = "test@example.com", authorities = {"USER"}) - void protectedUrl_WithValidToken_Allowed() throws Exception { - // given - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - // UserService mock 설정 추가 - IssueController가 내부적으로 호출함 - UserResponseDTO mockUser = new UserResponseDTO(1L, "Test User", USER_EMAIL); - given(userService.findUserByEmail(USER_EMAIL)).willReturn(mockUser); - - IssueResponse dummyResponse = new IssueResponse( - 1L, "Test Issue", "Test Description", "OPEN", 1L, - List.of(), List.of(), LocalDateTime.now(), LocalDateTime.now() - ); - given(issueService.createIssue(any(), any())).willReturn(dummyResponse); - - String validIssueJson = "{\"title\":\"Test Issue\",\"description\":\"Test Description\"}"; - - // 테스트 대상을 GET이 아닌 POST로 변경하여 토큰 인증을 테스트합니다. - mockMvc.perform(post("/issues") - .header("Authorization", "Bearer " + VALID_TOKEN) - .contentType(MediaType.APPLICATION_JSON) - .content(validIssueJson)) - .andDo(print()) - .andExpect(status().isCreated()); // 컨트롤러 로직에 따라 201 Created 또는 다른 성공 코드를 기대 - } - - @Test - @DisplayName("잘못된 JWT 토큰으로 보호된 URL 접근 차단") - void protectedUrl_WithInvalidToken_Blocked() throws Exception { - // given - String invalidToken = "invalid.token"; - given(jwtUtil.getUserEmailFromToken(invalidToken)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(invalidToken, USER_EMAIL)).willReturn(false); - - // 테스트 대상을 GET이 아닌 POST로 변경 - mockMvc.perform(post("/issues") - .header("Authorization", "Bearer " + invalidToken) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isUnauthorized()); // JwtAuthenticationFilter에서 401 Unauthorized 반환 - } - - @Test - @DisplayName("CORS 설정 확인 - 허용된 Origin") - void cors_AllowedOrigin_Success() throws Exception { - mockMvc.perform(options("/auth/login") - .header("Origin", "http://localhost:5173") - .header("Access-Control-Request-Method", "POST")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:5173")); - } - - @Test - @DisplayName("CORS 설정 확인 - 허용되지 않은 Origin") - void cors_NotAllowedOrigin_Blocked() throws Exception { - mockMvc.perform(options("/auth/login") - .header("Origin", "http://malicious-site.com") - .header("Access-Control-Request-Method", "POST")) - .andDo(print()) - .andExpect(header().doesNotExist("Access-Control-Allow-Origin")); - } - - @Test - @DisplayName("CSRF 보호 비활성화 확인") - void csrf_Disabled() throws Exception { - // CSRF 토큰 없이 POST 요청이 가능한지 확인 - mockMvc.perform(post("/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andDo(print()) - .andExpect(status().is4xxClientError()); // 400 (유효성 검증) 또는 401 (인증 실패) - } - - @Test - @DisplayName("세션 정책 STATELESS 확인") - void sessionPolicy_Stateless() throws Exception { - // 보호된 URL에 인증 없이 요청을 보내 세션(JSESSIONID)이 생성되지 않는지 확인 - mockMvc.perform(post("/issues") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isForbidden()) // 1. 인증 실패 확인 - .andExpect(cookie().doesNotExist("JSESSIONID")); // 2. JSESSIONID 쿠키가 없는지 확인 - - // 동일한 요청을 한 번 더 보내도 세션이 유지되지 않음을 재차 확인 - mockMvc.perform(post("/issues") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isForbidden()) - .andExpect(cookie().doesNotExist("JSESSIONID")); - } - - @Test - @DisplayName("HTTP 메서드별 접근 제어 - GET /issues는 인증 없이 허용") - void httpMethod_GET_AllowedWithoutAuth() throws Exception { - mockMvc.perform(get("/issues")) - .andDo(print()) - .andExpect(status().isOk()); // 인증 없이도 200 OK - } - - @Test - @DisplayName("HTTP 메서드별 접근 제어 - POST") - @WithMockUser(username = "test@example.com", authorities = {"USER"}) - void httpMethod_POST_Allowed() throws Exception { - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - // UserService mock 설정 추가 - IssueController가 내부적으로 호출함 - UserResponseDTO mockUser = new UserResponseDTO(1L, "Test User", USER_EMAIL); - given(userService.findUserByEmail(USER_EMAIL)).willReturn(mockUser); - - IssueResponse dummyResponse = new IssueResponse( - 1L, "Test Issue", "Test Description", "OPEN", 1L, - List.of(), List.of(), LocalDateTime.now(), LocalDateTime.now() - ); - given(issueService.createIssue(any(), any())).willReturn(dummyResponse); - - String validIssueJson = "{\"title\":\"Test Issue\",\"description\":\"Test Description\"}"; - - mockMvc.perform(post("/issues") - .header("Authorization", "Bearer " + VALID_TOKEN) - .contentType(MediaType.APPLICATION_JSON) - .content(validIssueJson)) - .andDo(print()) - .andExpect(status().isCreated()); //201 created - } - - @Test - @DisplayName("HTTP 메서드별 접근 제어 - DELETE") - void httpMethod_DELETE_Allowed() throws Exception { - // given: 인증 관련 설정 (기존과 동일) - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - // given: IssueService의 deleteIssue 메소드가 호출될 때 아무것도 하지 않도록 설정 (성공 시나리오) - doNothing().when(issueService).deleteIssue(1L); - - // when & then - mockMvc.perform(delete("/issues/1") - .header("Authorization", "Bearer " + VALID_TOKEN)) - .andDo(print()) - // DELETE는 일반적으로 204 No Content를 반환 - .andExpect(status().isOk()); - } - - @Test - @DisplayName("JWT 필터 체인 순서 확인") - void filterChain_JwtBeforeUsernamePassword() throws Exception { - // JWT 필터가 UsernamePasswordAuthenticationFilter보다 먼저 실행되는지 확인 - // 잘못된 토큰으로 401이 반환되면 JWT 필터가 먼저 실행된 것 - String invalidToken = "invalid.token"; - given(jwtUtil.getUserEmailFromToken(invalidToken)) - .willThrow(new RuntimeException("Invalid token")); - - mockMvc.perform(post("/issues") - .header("Authorization", "Bearer " + invalidToken)) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("토큰 처리 중 오류가 발생했습니다."))); - } - - @Test - @DisplayName("유효한 JWT 토큰으로 /auth/logout 접근 허용") - void logout_WithValidToken_Allowed() throws Exception { - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - mockMvc.perform(post("/auth/logout") - .header("Authorization", "Bearer " + VALID_TOKEN)) - .andDo(print()) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("유효한 JWT 토큰으로 /auth/users/{id} 접근 허용") - void getUser_WithValidToken_Allowed() throws Exception { - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - // 333변경: AuthService의 findUserById 메소드가 호출될 때 더미 응답 반환 - UserResponseDTO dummyUser = new UserResponseDTO(1L, "Test User", USER_EMAIL); - given(userService.findUserById(1L)).willReturn(dummyUser); - - mockMvc.perform(get("/auth/users/1") - .header("Authorization", "Bearer " + VALID_TOKEN)) - .andDo(print()) - .andExpect(status().isOk()); - } -} -/* - - - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.web.servlet.MockMvc; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest -@AutoConfigureMockMvc -@Sql("/test-data.sql") -public class SecurityConfigTest { - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private JwtUtil jwtUtil; - - @MockitoBean - private CustomUserDetailsService customUserDetailsService; - @MockitoBean - private IssueService issueService; - - private static final String VALID_TOKEN = "valid.jwt.token"; - private static final String USER_EMAIL = "test@example.com"; - private UserDetails userDetails; - - @BeforeEach - void setUp() { - userDetails = User.builder() - .username(USER_EMAIL) - .password("password") - .authorities(new ArrayList<>()) - .build(); - } - - @Test - @DisplayName("공개 URL 접근 허용 - /auth/signup") - void publicUrl_Signup_AllowedWithoutAuth() throws Exception { - mockMvc.perform(post("/auth/signup") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andDo(print()) - .andExpect(status().is4xxClientError()); // 400 Bad Request (유효성 검증 실패) - } - - @Test - @DisplayName("공개 URL 접근 허용 - /auth/login") - void publicUrl_Login_AllowedWithoutAuth() throws Exception { - mockMvc.perform(post("/auth/login") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andDo(print()) - .andExpect(status().is4xxClientError()); // 400 Bad Request (유효성 검증 실패) - } - - @Test - @DisplayName("공개 URL 접근 허용 - Swagger UI") - void publicUrl_Swagger_AllowedWithoutAuth() throws Exception { - mockMvc.perform(get("/swagger-ui/index.html")) - .andDo(print()) - .andExpect(status().isOk()); // Swagger는 정적 리소스로 200 OK - } - - @Test - @DisplayName("permitAll()된 GET /issues는 인증 없이 접근 가능") - void publicGetIssues_WithoutAuth_Allowed() throws Exception { - // GET /issues는 이제 permitAll이므로 403 Forbidden이 아닌 200 OK를 기대해야 합니다. - mockMvc.perform(get("/issues")) - .andDo(print()) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("보호된 URL(POST /issues)은 인증 없이 접근 차단") - void protectedPostUrl_WithoutAuth_Blocked() throws Exception { - // 테스트 대상을 GET이 아닌 POST로 변경하여 보호 여부를 확인합니다. - // POST, PATCH, DELETE 등은 여전히 인증이 필요합니다. - mockMvc.perform(post("/issues") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isForbidden()); // JWT 필터가 없으므로 403 Forbidden - } - - @Test - @DisplayName("유효한 JWT 토큰으로 보호된 URL(POST /issues) 접근 허용") - @WithMockUser(username = "test@example.com", authorities = {"USER"}) - void protectedUrl_WithValidToken_Allowed() throws Exception { - // given - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - IssueResponse dummyResponse = new IssueResponse( - 1L, "Test Issue", "Test Description", "OPEN", 1L, - List.of(), List.of(), LocalDateTime.now(), LocalDateTime.now() - ); - given(issueService.createIssue(any(), any())).willReturn(dummyResponse); - - String validIssueJson = "{\"title\":\"Test Issue\",\"description\":\"Test Description\"}"; - - // 테스트 대상을 GET이 아닌 POST로 변경하여 토큰 인증을 테스트합니다. - mockMvc.perform(post("/issues") - .header("Authorization", "Bearer " + VALID_TOKEN) - .contentType(MediaType.APPLICATION_JSON) - .content(validIssueJson)) - .andDo(print()) - .andExpect(status().isCreated()); // 컨트롤러 로직에 따라 201 Created 또는 다른 성공 코드를 기대 - } - - @Test - @DisplayName("잘못된 JWT 토큰으로 보호된 URL 접근 차단") - void protectedUrl_WithInvalidToken_Blocked() throws Exception { - // given - String invalidToken = "invalid.token"; - given(jwtUtil.getUserEmailFromToken(invalidToken)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(invalidToken, USER_EMAIL)).willReturn(false); - - // 테스트 대상을 GET이 아닌 POST로 변경 - mockMvc.perform(post("/issues") - .header("Authorization", "Bearer " + invalidToken) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isUnauthorized()); // JwtAuthenticationFilter에서 401 Unauthorized 반환 - } - - @Test - @DisplayName("CORS 설정 확인 - 허용된 Origin") - void cors_AllowedOrigin_Success() throws Exception { - mockMvc.perform(options("/auth/login") - .header("Origin", "http://localhost:5173") - .header("Access-Control-Request-Method", "POST")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(header().string("Access-Control-Allow-Origin", "http://localhost:5173")); - } - - @Test - @DisplayName("CORS 설정 확인 - 허용되지 않은 Origin") - void cors_NotAllowedOrigin_Blocked() throws Exception { - mockMvc.perform(options("/auth/login") - .header("Origin", "http://malicious-site.com") - .header("Access-Control-Request-Method", "POST")) - .andDo(print()) - .andExpect(header().doesNotExist("Access-Control-Allow-Origin")); - } - - @Test - @DisplayName("CSRF 보호 비활성화 확인") - void csrf_Disabled() throws Exception { - // CSRF 토큰 없이 POST 요청이 가능한지 확인 - mockMvc.perform(post("/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) - .andDo(print()) - .andExpect(status().is4xxClientError()); // 400 (유효성 검증) 또는 401 (인증 실패) - } - - @Test - @DisplayName("세션 정책 STATELESS 확인") - void sessionPolicy_Stateless() throws Exception { - // 보호된 URL에 인증 없이 요청을 보내 세션(JSESSIONID)이 생성되지 않는지 확인 - mockMvc.perform(post("/issues") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isForbidden()) // 1. 인증 실패 확인 - .andExpect(cookie().doesNotExist("JSESSIONID")); // 2. JSESSIONID 쿠키가 없는지 확인 - - // 동일한 요청을 한 번 더 보내도 세션이 유지되지 않음을 재차 확인 - mockMvc.perform(post("/issues") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"title\":\"Test\"}")) - .andDo(print()) - .andExpect(status().isForbidden()) - .andExpect(cookie().doesNotExist("JSESSIONID")); - } - - @Test - @DisplayName("HTTP 메서드별 접근 제어 - GET") - void httpMethod_GET_Allowed() throws Exception { - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - mockMvc.perform(get("/issues") - .header("Authorization", "Bearer " + VALID_TOKEN)) - .andDo(print()) - .andExpect(status().isOk()); // 또는 404 - } - - @Test - @DisplayName("HTTP 메서드별 접근 제어 - POST") - @WithMockUser(username = "test@example.com", authorities = {"USER"}) - void httpMethod_POST_Allowed() throws Exception { - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - IssueResponse dummyResponse = new IssueResponse( - 1L, "Test Issue", "Test Description", "OPEN", 1L, - List.of(), List.of(), LocalDateTime.now(), LocalDateTime.now() - ); - given(issueService.createIssue(any(), any())).willReturn(dummyResponse); - - String validIssueJson = "{\"title\":\"Test Issue\",\"description\":\"Test Description\"}"; - - mockMvc.perform(post("/issues") - .header("Authorization", "Bearer " + VALID_TOKEN) - .contentType(MediaType.APPLICATION_JSON) - .content(validIssueJson)) - .andDo(print()) - .andExpect(status().isCreated()); //201 created - } - - @Test - @DisplayName("HTTP 메서드별 접근 제어 - DELETE") - void httpMethod_DELETE_Allowed() throws Exception { - // given: 인증 관련 설정 (기존과 동일) - given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); - given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(true); - given(customUserDetailsService.loadUserByUsername(USER_EMAIL)).willReturn(userDetails); - - // given: IssueService의 deleteIssue 메소드가 호출될 때 아무것도 하지 않도록 설정 (성공 시나리오) - doNothing().when(issueService).deleteIssue(1L); - - // when & then - mockMvc.perform(delete("/issues/1") - .header("Authorization", "Bearer " + VALID_TOKEN)) - .andDo(print()) - // then: 4xx 에러가 아닌, 성공 상태 코드인 200 OK를 기대하도록 변경 - .andExpect(status().isOk()); - } - - @Test - @DisplayName("JWT 필터 체인 순서 확인") - void filterChain_JwtBeforeUsernamePassword() throws Exception { - // JWT 필터가 UsernamePasswordAuthenticationFilter보다 먼저 실행되는지 확인 - // 잘못된 토큰으로 401이 반환되면 JWT 필터가 먼저 실행된 것 - String invalidToken = "invalid.token"; - given(jwtUtil.getUserEmailFromToken(invalidToken)) - .willThrow(new RuntimeException("Invalid token")); - - mockMvc.perform(get("/issues") - .header("Authorization", "Bearer " + invalidToken)) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect(content().string(org.hamcrest.Matchers.containsString("토큰 처리 중 오류가 발생했습니다."))); - } -} - - - */ \ No newline at end of file +} \ No newline at end of file diff --git a/src/test/java/com/issueDive/security/JwtAuthenticationFilterTest.java b/src/test/java/com/issueDive/security/JwtAuthenticationFilterTest.java index 396c42b..1f00bc2 100644 --- a/src/test/java/com/issueDive/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/issueDive/security/JwtAuthenticationFilterTest.java @@ -108,49 +108,54 @@ void doFilterInternal_NoBearerPrefix_Pass() throws ServletException, IOException } @Test - @DisplayName("유효하지 않은 토큰으로 인증 실패") - void doFilterInternal_InvalidToken_Failure() throws ServletException, IOException { + @DisplayName("유효하지 않은 토큰은 에러 응답 없이 필터를 통과해야 함") + void doFilterInternal_InvalidToken_ShouldPass() throws ServletException, IOException { // given - StringWriter stringWriter = new StringWriter(); - PrintWriter writer = new PrintWriter(stringWriter); - given(request.getHeader("Authorization")).willReturn("Bearer " + VALID_TOKEN); given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)).willReturn(USER_EMAIL); given(jwtUtil.validateToken(VALID_TOKEN, USER_EMAIL)).willReturn(false); - given(response.getWriter()).willReturn(writer); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(response).setContentType("application/json;charset=UTF-8"); - verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); - verify(filterChain, never()).doFilter(request, response); - assertThat(stringWriter.toString()).contains("유효하지 않은 토큰입니다."); + // ======================= 변경된 검증 로직 ======================= + // 1. response 객체와는 아무런 상호작용이 없어야 함 + verify(response, never()).setContentType(anyString()); + verify(response, never()).setStatus(anyInt()); + verify(response, never()).getWriter(); + + // 2. 대신, filterChain.doFilter()가 1번 호출되어 요청이 계속 진행되어야 함 + verify(filterChain, times(1)).doFilter(request, response); + + // 3. 인증 정보는 등록되지 않아야 함 + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + // ========================================================== } @Test - @DisplayName("JWT 파싱 중 예외 발생 처리") - void doFilterInternal_ExceptionThrown_Handled() throws ServletException, IOException { + @DisplayName("JWT 파싱 중 예외가 발생해도 에러 응답 없이 필터를 통과해야 함") + void doFilterInternal_ExceptionThrown_ShouldPass() throws ServletException, IOException { // given - StringWriter stringWriter = new StringWriter(); - PrintWriter writer = new PrintWriter(stringWriter); - given(request.getHeader("Authorization")).willReturn("Bearer " + VALID_TOKEN); given(jwtUtil.getUserEmailFromToken(VALID_TOKEN)) .willThrow(new RuntimeException("JWT 파싱 오류")); - given(response.getWriter()).willReturn(writer); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(response).setContentType("application/json;charset=UTF-8"); - verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); - verify(filterChain, never()).doFilter(request, response); - assertThat(stringWriter.toString()).contains("토큰 처리 중 오류가 발생했습니다."); + // ======================= 변경된 검증 로직 ======================= + // 1. response 객체와는 아무런 상호작용이 없어야 함 + verify(response, never()).setContentType(anyString()); + verify(response, never()).setStatus(anyInt()); + + // 2. 대신, filterChain.doFilter()가 1번 호출되어 요청이 계속 진행되어야 함 + verify(filterChain, times(1)).doFilter(request, response); + // ========================================================== } + @Test @DisplayName("공개 URL은 필터를 적용하지 않음 - /auth/signup") void shouldNotFilter_PublicUrl_Signup() { @@ -238,31 +243,25 @@ void doFilterInternal_EmptyAuthHeader_Pass() throws ServletException, IOExceptio } @Test - @DisplayName("Bearer 뒤에 토큰이 없는 경우 처리") + @DisplayName("Bearer 뒤에 토큰이 없는 경우 필터를 통과해야 함") void doFilterInternal_BearerWithoutToken_Pass() throws ServletException, IOException { // given - StringWriter stringWriter = new StringWriter(); - PrintWriter writer = new PrintWriter(stringWriter); - - // 9월 2일 변경: response.getWriter() mock을 먼저 설정 - NullPointerException 방지 - given(response.getWriter()).willReturn(writer); given(request.getHeader("Authorization")).willReturn("Bearer "); - // 9월 2일 변경: request.getRequestURI() stubbing 제거 - 사용되지 않음 - // given(request.getRequestURI()).willReturn("/issues"); - - // 9월 2일 변경: jwtUtil mock 추가 - Bearer 뒤 빈 토큰 파싱 시 null 반환 - given(jwtUtil.getUserEmailFromToken("")).willReturn(null); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - // 9월 2일 변경: 토큰이 빈 문자열이므로 에러 응답 검증으로 변경 - verify(response).setContentType("application/json;charset=UTF-8"); - verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); - verify(filterChain, never()).doFilter(request, response); - // 9월 2일 변경: 실제 응답 메시지에 맞게 수정 - assertThat(stringWriter.toString()).contains("유효하지 않은 토큰입니다."); + // ======================= 변경된 검증 로직 ======================= + // 1. response 객체와는 아무런 상호작용이 없어야 함 + verify(response, never()).setContentType(anyString()); + verify(response, never()).setStatus(anyInt()); + + // 2. 다음 필터로 요청이 넘어가야 함 + verify(filterChain, times(1)).doFilter(request, response); + + // 3. 인증 정보는 등록되지 않아야 함 + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + // ========================================================== } -} -//추가 \ No newline at end of file +} \ No newline at end of file