Skip to content

Commit

Permalink
MATE-111 : [FEAT] 자체 서비스 로그아웃 기능 구현 (#100)
Browse files Browse the repository at this point in the history
* MATE-111 : [CHORE] 로그아웃 시 블랙리스트 등록을 위한 redis 의존성 추가

* MATE-111 : [FEAT] 로그아웃 시 블랙리스트에 토큰을 등록하고 검증하는 서비스 구현

* MATE-111 : [FEAT] 로그아웃 컨트롤러 구현

* MATE-111 : [TEST] 로그아웃 redis 서비스 테스트

* MATE-111 : [TEST] 로그아웃 컨트롤러 테스트

* MATE-111 : [TEST] 로그아웃 통합 테스트

* MATE-111 : [TEST] 굿즈 채팅 테스트에 누락된 JwtCheckFilter MockBean 주입
  • Loading branch information
jooinjoo authored Dec 7, 2024
1 parent 0401ea2 commit 5fc3e2d
Show file tree
Hide file tree
Showing 9 changed files with 261 additions and 5 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/example/mate/common/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "팀을 찾을 수 없습니다"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,28 @@

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;
import org.springframework.security.core.context.SecurityContextHolder;
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
Expand All @@ -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<String, Object> claims = jwtUtil.validateToken(accessToken);
setAuthentication(claims); // 인증 정보 설정
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +28,7 @@
public class MemberController {

private final MemberService memberService;
private final LogoutRedisService logoutRedisService;

@Operation(summary = "자체 회원가입 기능")
@PostMapping("/join")
Expand All @@ -45,6 +47,15 @@ public ResponseEntity<ApiResponse<MemberLoginResponse>> catchMiLogin(
return ResponseEntity.ok(ApiResponse.success(response));
}

@Operation(summary = "CATCH Mi 서비스 로그아웃", description = "캐치미 서비스에 로그아웃합니다.")
@PostMapping("/logout")
public ResponseEntity<Void> catchMiLogout(
@Parameter(description = "회원 로그인 토큰 헤더", required = true) @RequestHeader("Authorization") String token
) {
logoutRedisService.addTokenToBlacklist(token);
return ResponseEntity.noContent().build();
}

@Operation(summary = "내 프로필 조회")
@GetMapping("/me")
public ResponseEntity<ApiResponse<MyProfileResponse>> findMyInfo(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,6 +50,9 @@ class GoodsChatRoomControllerTest {
@MockBean
private JwtUtil jwtUtil;

@MockBean
private JwtCheckFilter jwtCheckFilter;

@Test
@DisplayName("굿즈거래 채팅방 생성 성공 - 기존 채팅방이 있을 경우 해당 채팅방을 반환한다.")
void returnExistingChatRoom() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -52,6 +54,9 @@ class MemberControllerTest {
@MockBean
private JwtCheckFilter jwtCheckFilter;

@MockBean
private LogoutRedisService logoutRedisService;

private MyProfileResponse createMyProfileResponse() {
return MyProfileResponse.builder()
.nickname("tester")
Expand Down Expand Up @@ -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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -96,6 +104,15 @@ class MemberIntegrationTest {
@Autowired
private EntityManager entityManager;

@Autowired
private LogoutRedisService logoutRedisService;

@MockBean
private RedisTemplate<String, String> redisTemplate;

@MockBean
private ValueOperations<String, String> valueOperations;

@MockBean
private JwtUtil jwtUtil;

Expand Down Expand Up @@ -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());
}
}
}
Loading

0 comments on commit 5fc3e2d

Please sign in to comment.