diff --git a/build.gradle b/build.gradle index 2e3c9d44..ebf2d2d8 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Lombok compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/mate/common/error/ErrorCode.java b/src/main/java/com/example/mate/common/error/ErrorCode.java index dad30a4e..86a3de47 100644 --- a/src/main/java/com/example/mate/common/error/ErrorCode.java +++ b/src/main/java/com/example/mate/common/error/ErrorCode.java @@ -15,6 +15,7 @@ public enum ErrorCode { AUTH_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "A002", "인증에 실패했습니다. 유효한 토큰을 제공해주세요."), AUTH_FORBIDDEN(HttpStatus.FORBIDDEN, "A003", "접근 권한이 없습니다. 권한을 확인해주세요."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "A004", "인증되지 않은 사용자입니다"), + INVALID_AUTH_TOKEN(HttpStatus.BAD_REQUEST, "A005", "잘못된 토큰 형식입니다."), // Team TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "팀을 찾을 수 없습니다"), diff --git a/src/main/java/com/example/mate/common/security/filter/JwtCheckFilter.java b/src/main/java/com/example/mate/common/security/filter/JwtCheckFilter.java index 381c1724..ee324171 100644 --- a/src/main/java/com/example/mate/common/security/filter/JwtCheckFilter.java +++ b/src/main/java/com/example/mate/common/security/filter/JwtCheckFilter.java @@ -2,13 +2,10 @@ import com.example.mate.common.security.auth.AuthMember; import com.example.mate.common.security.util.JwtUtil; +import com.example.mate.domain.member.service.LogoutRedisService; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Arrays; -import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -16,11 +13,17 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + @Component @RequiredArgsConstructor public class JwtCheckFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; + private final LogoutRedisService logoutRedisService; // 필터링 적용하지 않을 URI 체크 @Override @@ -44,8 +47,15 @@ protected void doFilterInternal(HttpServletRequest request, return; } - // 토큰 유효성 검증 후 SecurityContext 설정 String accessToken = headerAuth.substring(7); // "Bearer " 제외한 토큰 저장 + + // 액세스 토큰이 블랙리스트에 있는지 확인 + if (logoutRedisService.isTokenBlacklisted(accessToken)) { + handleException(response, new Exception("ACCESS TOKEN IS BLACKLISTED")); + return; + } + + // 액세스 토큰의 모든 유효성 검증 후 SecurityContext 설정 try { Map claims = jwtUtil.validateToken(accessToken); setAuthentication(claims); // 인증 정보 설정 diff --git a/src/main/java/com/example/mate/domain/member/controller/MemberController.java b/src/main/java/com/example/mate/domain/member/controller/MemberController.java index f81207f0..cdec1540 100644 --- a/src/main/java/com/example/mate/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/mate/domain/member/controller/MemberController.java @@ -9,6 +9,7 @@ import com.example.mate.domain.member.dto.response.MemberLoginResponse; import com.example.mate.domain.member.dto.response.MemberProfileResponse; import com.example.mate.domain.member.dto.response.MyProfileResponse; +import com.example.mate.domain.member.service.LogoutRedisService; import com.example.mate.domain.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -27,6 +28,7 @@ public class MemberController { private final MemberService memberService; + private final LogoutRedisService logoutRedisService; @Operation(summary = "자체 회원가입 기능") @PostMapping("/join") @@ -45,6 +47,15 @@ public ResponseEntity> catchMiLogin( return ResponseEntity.ok(ApiResponse.success(response)); } + @Operation(summary = "CATCH Mi 서비스 로그아웃", description = "캐치미 서비스에 로그아웃합니다.") + @PostMapping("/logout") + public ResponseEntity catchMiLogout( + @Parameter(description = "회원 로그인 토큰 헤더", required = true) @RequestHeader("Authorization") String token + ) { + logoutRedisService.addTokenToBlacklist(token); + return ResponseEntity.noContent().build(); + } + @Operation(summary = "내 프로필 조회") @GetMapping("/me") public ResponseEntity> findMyInfo( diff --git a/src/main/java/com/example/mate/domain/member/service/LogoutRedisService.java b/src/main/java/com/example/mate/domain/member/service/LogoutRedisService.java new file mode 100644 index 00000000..eb4a7f74 --- /dev/null +++ b/src/main/java/com/example/mate/domain/member/service/LogoutRedisService.java @@ -0,0 +1,31 @@ +package com.example.mate.domain.member.service; + +import com.example.mate.common.error.CustomException; +import com.example.mate.common.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class LogoutRedisService { + + private final RedisTemplate redisTemplate; + + // 로그아웃 시 블랙리스트에 토큰 추가 + public void addTokenToBlacklist(String token) { + if (token == null || !token.startsWith("Bearer ")) { + throw new CustomException(ErrorCode.INVALID_AUTH_TOKEN); + } + + // TODO : 테스트용 1분 유효를 변경 + redisTemplate.opsForValue().set("blacklist:" + token.substring(7), "blacklisted", 1, TimeUnit.MINUTES); + } + + // 블랙리스트에 토큰 있는지 확인 + public boolean isTokenBlacklisted(String accessToken) { + return redisTemplate.hasKey("blacklist:" + accessToken); + } +} diff --git a/src/test/java/com/example/mate/domain/goodsChat/controller/GoodsChatRoomControllerTest.java b/src/test/java/com/example/mate/domain/goodsChat/controller/GoodsChatRoomControllerTest.java index 876c5c83..366e3034 100644 --- a/src/test/java/com/example/mate/domain/goodsChat/controller/GoodsChatRoomControllerTest.java +++ b/src/test/java/com/example/mate/domain/goodsChat/controller/GoodsChatRoomControllerTest.java @@ -13,6 +13,8 @@ import com.example.mate.common.error.ErrorCode; import com.example.mate.common.response.PageResponse; import com.example.mate.common.security.util.JwtUtil; +import com.example.mate.common.security.filter.JwtCheckFilter; +import com.example.mate.common.security.util.JwtUtil; import com.example.mate.config.WithAuthMember; import com.example.mate.domain.goodsChat.dto.response.GoodsChatMessageResponse; import com.example.mate.domain.goodsChat.dto.response.GoodsChatRoomResponse; @@ -48,6 +50,9 @@ class GoodsChatRoomControllerTest { @MockBean private JwtUtil jwtUtil; + @MockBean + private JwtCheckFilter jwtCheckFilter; + @Test @DisplayName("굿즈거래 채팅방 생성 성공 - 기존 채팅방이 있을 경우 해당 채팅방을 반환한다.") void returnExistingChatRoom() throws Exception { diff --git a/src/test/java/com/example/mate/domain/member/controller/MemberControllerTest.java b/src/test/java/com/example/mate/domain/member/controller/MemberControllerTest.java index 2535f5cc..29d915f3 100644 --- a/src/test/java/com/example/mate/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/com/example/mate/domain/member/controller/MemberControllerTest.java @@ -11,6 +11,7 @@ import com.example.mate.domain.member.dto.response.MemberLoginResponse; import com.example.mate.domain.member.dto.response.MemberProfileResponse; import com.example.mate.domain.member.dto.response.MyProfileResponse; +import com.example.mate.domain.member.service.LogoutRedisService; import com.example.mate.domain.member.service.MemberService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; @@ -21,6 +22,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; @@ -52,6 +54,9 @@ class MemberControllerTest { @MockBean private JwtCheckFilter jwtCheckFilter; + @MockBean + private LogoutRedisService logoutRedisService; + private MyProfileResponse createMyProfileResponse() { return MyProfileResponse.builder() .nickname("tester") @@ -419,4 +424,41 @@ void login_member_success() throws Exception { .andDo(print()); } } + + @Nested + @DisplayName("회원 로그아웃") + class LogoutMember { + + @Test + @DisplayName("회원 로그아웃 성공") + void logout_member_success() throws Exception { + // given + String token = "Bearer accessToken"; + + doNothing().when(logoutRedisService).addTokenToBlacklist(anyString()); + + // when & then + mockMvc.perform(post("/api/members/logout") + .header(HttpHeaders.AUTHORIZATION, token)) + .andExpect(status().isNoContent()); + + verify(logoutRedisService).addTokenToBlacklist(token); + } + + @Test + @DisplayName("로그아웃 실패 - 잘못된 토큰 형식") + void catchMiLogout_invalid_token_format() throws Exception { + // given + String invalidToken = "InvalidToken"; + + willThrow(new CustomException(ErrorCode.INVALID_AUTH_TOKEN)) + .given(logoutRedisService).addTokenToBlacklist(invalidToken); + + + // when & then + mockMvc.perform(post("/api/members/logout") + .header(HttpHeaders.AUTHORIZATION, invalidToken)) + .andExpect(status().isBadRequest()); + } + } } \ No newline at end of file diff --git a/src/test/java/com/example/mate/domain/member/integration/MemberIntegrationTest.java b/src/test/java/com/example/mate/domain/member/integration/MemberIntegrationTest.java index 42f6081d..2d356f30 100644 --- a/src/test/java/com/example/mate/domain/member/integration/MemberIntegrationTest.java +++ b/src/test/java/com/example/mate/domain/member/integration/MemberIntegrationTest.java @@ -25,6 +25,7 @@ import com.example.mate.domain.member.entity.Member; import com.example.mate.domain.member.repository.FollowRepository; import com.example.mate.domain.member.repository.MemberRepository; +import com.example.mate.domain.member.service.LogoutRedisService; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; @@ -35,6 +36,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; @@ -44,9 +48,13 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.concurrent.TimeUnit; import static com.example.mate.domain.match.entity.MatchStatus.SCHEDULED; import static com.example.mate.domain.mate.entity.Status.CLOSED; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; 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.jsonPath; @@ -96,6 +104,15 @@ class MemberIntegrationTest { @Autowired private EntityManager entityManager; + @Autowired + private LogoutRedisService logoutRedisService; + + @MockBean + private RedisTemplate redisTemplate; + + @MockBean + private ValueOperations valueOperations; + @MockBean private JwtUtil jwtUtil; @@ -477,4 +494,41 @@ void login_member_fail_non_exists_email() throws Exception { .andExpect(jsonPath("$.message").value("해당 이메일의 회원 정보를 찾을 수 없습니다.")); } } + + @Nested + @DisplayName("회원 로그아웃") + class LogoutMember { + + @Test + @DisplayName("회원 로그아웃 성공") + @WithAuthMember(userId = "customUser", memberId = 1L) + void logout_member_success_with_my_info_denied() throws Exception { + // given + String token = "Bearer accessToken"; + + // when & then + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + doNothing().when(valueOperations).set( + eq("blacklist:" + token.substring(7)), + eq("blacklisted"), + eq(1L), + eq(TimeUnit.MINUTES)); + + mockMvc.perform(post("/api/members/logout") + .header(HttpHeaders.AUTHORIZATION, token)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("회원 로그아웃 실패 - 잘못된 토큰 형식") + void catchMiLogout_invalid_token_format() throws Exception { + // given + String invalidToken = "InvalidToken"; + + // when & then + mockMvc.perform(post("/api/members/logout") + .header(HttpHeaders.AUTHORIZATION, invalidToken)) + .andExpect(status().isBadRequest()); + } + } } \ No newline at end of file diff --git a/src/test/java/com/example/mate/domain/member/service/LogoutRedisServiceTest.java b/src/test/java/com/example/mate/domain/member/service/LogoutRedisServiceTest.java new file mode 100644 index 00000000..31096c4b --- /dev/null +++ b/src/test/java/com/example/mate/domain/member/service/LogoutRedisServiceTest.java @@ -0,0 +1,101 @@ +package com.example.mate.domain.member.service; + +import com.example.mate.common.error.CustomException; +import com.example.mate.common.error.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LogoutRedisServiceTest { + + @InjectMocks + private LogoutRedisService logoutRedisService; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Nested + @DisplayName("회원 로그아웃") + class LogoutMember { + + @Test + @DisplayName("회원 로그아웃 성공 - 블랙리스트에 토큰 추가") + void add_token_to_blacklist_success() { + // given + String token = "Bearer accessToken"; + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + doNothing().when(valueOperations).set( + "blacklist:" + token.substring(7), "blacklisted", 1, TimeUnit.MINUTES + ); + + // when & then + assertDoesNotThrow(() -> logoutRedisService.addTokenToBlacklist(token)); + verify(redisTemplate.opsForValue(), times(1)).set( + "blacklist:accessToken", "blacklisted", 1, TimeUnit.MINUTES + ); + } + + @Test + @DisplayName("회원 로그아웃 실패 - 잘못된 토큰으로 블랙리스트 추가 시 CustomException") + void add_token_to_blacklist_fail_invalid_token() { + // given + String invalidToken = "InvalidToken"; + + // when & then + assertThatThrownBy(() -> logoutRedisService.addTokenToBlacklist(invalidToken)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_AUTH_TOKEN.getMessage()); + } + } + + @Nested + @DisplayName("블랙리스트에 토큰 여부 확인") + class CheckBlacklist { + + @Test + @DisplayName("블랙리스트에 토큰 여부 확인 성공") + void is_token_blacklisted_success() { + // given + String accessToken = "accessToken"; + when(redisTemplate.hasKey("blacklist:" + accessToken)).thenReturn(true); + + // when + boolean result = logoutRedisService.isTokenBlacklisted(accessToken); + + // then + verify(redisTemplate, times(1)).hasKey("blacklist:accessToken"); + assert result; + } + + @Test + @DisplayName("블랙리스트에 토큰 여부 확인 실패 - 블랙리스트에 존재하지 않는 토큰 확인") + void is_token_blacklisted_fail_not_exists_token() { + // given + String accessToken = "accessToken"; + when(redisTemplate.hasKey("blacklist:" + accessToken)).thenReturn(false); + + // when + boolean result = logoutRedisService.isTokenBlacklisted(accessToken); + + // then + verify(redisTemplate, times(1)).hasKey("blacklist:accessToken"); + assert !result; + } + } +}