From 76caf43130a9502f1f53a4e2fb567b97eaf15dea Mon Sep 17 00:00:00 2001 From: Jeong Wonju Date: Tue, 31 Dec 2024 13:07:35 +0900 Subject: [PATCH] =?UTF-8?q?MATE-127=20:=20[REFACTOR]=20CATCH=20Mi=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EB=B0=8F=20JWT=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#1?= =?UTF-8?q?16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MATE-127 : [FEAT] 로그아웃 관련 예외 처리 추가 * MATE-127 : [REFACTOR] 토큰 생성 리팩토링 * MATE-127 : [REFACTOR] 로그아웃 시 블랙리스트 추가 기능 리팩토링 및 인증 정보 삭제 추가 * MATE-127 : [REFACTOR] 로그아웃 컨트롤러 리팩토링 및 Swagger 추가 * MATE-127 : [TEST] 리팩토링 코드에 맞게 로그인, 로그아웃 관련 테스트 수정 * MATE-127 : [FEAT] 액세스 토큰의 유효 시간을 30분으로 수정 --- .../example/mate/common/error/ErrorCode.java | 2 + .../mate/common/security/util/JwtUtil.java | 48 +++++++++++++++---- .../member/controller/MemberController.java | 18 ++++--- .../member/service/LogoutRedisService.java | 22 ++++++--- .../domain/member/service/MemberService.java | 37 +++++++------- .../controller/MemberControllerTest.java | 18 ------- .../integration/MemberIntegrationTest.java | 22 ++++++++- .../service/LogoutRedisServiceTest.java | 23 +++++++-- .../member/service/MemberServiceTest.java | 7 +++ 9 files changed, 128 insertions(+), 69 deletions(-) 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 8a12de81..54768db3 100644 --- a/src/main/java/com/example/mate/common/error/ErrorCode.java +++ b/src/main/java/com/example/mate/common/error/ErrorCode.java @@ -16,6 +16,7 @@ public enum ErrorCode { AUTH_FORBIDDEN(HttpStatus.FORBIDDEN, "A003", "접근 권한이 없습니다. 권한을 확인해주세요."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "A004", "인증되지 않은 사용자입니다"), INVALID_AUTH_TOKEN(HttpStatus.BAD_REQUEST, "A005", "잘못된 토큰 형식입니다."), + EXPIRED_AUTH_TOKEN(HttpStatus.BAD_REQUEST, "A006", "이미 만료된 토큰입니다."), // Team TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "팀을 찾을 수 없습니다"), @@ -34,6 +35,7 @@ public enum ErrorCode { ALREADY_USED_NICKNAME(HttpStatus.BAD_REQUEST, "M003", "이미 사용 중인 닉네임입니다."), MEMBER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "M004", "해당 이메일의 회원 정보를 찾을 수 없습니다."), MEMBER_UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "M005", "해당 회원의 접근 권한이 없습니다."), + MEMBER_AUTHENTICATION_REQUIRED(HttpStatus.BAD_REQUEST, "M006", "미리 인증된 회원의 정보가 필요합니다."), // Follow ALREADY_FOLLOWED_MEMBER(HttpStatus.BAD_REQUEST, "F001", "이미 팔로우한 회원입니다."), diff --git a/src/main/java/com/example/mate/common/security/util/JwtUtil.java b/src/main/java/com/example/mate/common/security/util/JwtUtil.java index 1cc1aa37..146ca247 100644 --- a/src/main/java/com/example/mate/common/security/util/JwtUtil.java +++ b/src/main/java/com/example/mate/common/security/util/JwtUtil.java @@ -1,20 +1,22 @@ package com.example.mate.common.security.util; +import com.example.mate.common.jwt.JwtToken; +import com.example.mate.domain.member.entity.Member; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Date; import java.util.Map; -import javax.crypto.SecretKey; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; @Component public class JwtUtil { - // 서명에 사용할 비밀 키 - application-local.yml 참조 @Value("${jwt.secret_key}") private String key; @@ -23,21 +25,49 @@ private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)); } - // JWT 문자열 생성. valueMap: JWT에 저장할 클레임 (payload), min: 만료 시간 (분 단위) - public String createToken(Map valueMap, int min) { + // JWT 토큰 생성 + public JwtToken createTokens(Member member) { + Map payloadMap = member.getPayload(); + Date now = new Date(); + payloadMap.put("iat", System.currentTimeMillis()); + + String accessToken = createAccessToken(payloadMap, now); + String refreshToken = createRefreshToken(member.getId(), now); + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // JWT Access Token 생성. 30분 유효시간 + public String createAccessToken(Map valueMap, Date issuedAt) { SecretKey key = getSigningKey(); - Date now = new Date(); // 토큰 발행 시간 return Jwts.builder() .setHeaderParam("alg", "HS256") .setHeaderParam("typ", "JWT") - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + Duration.ofMinutes(min).toMillis())) + .setIssuedAt(issuedAt) + .setExpiration(new Date(issuedAt.getTime() + Duration.ofMinutes(30).toMillis())) .setClaims(valueMap) .signWith(key, SignatureAlgorithm.HS256) .compact(); } + // JWT Access Token 생성. 3일 유효시간 + public String createRefreshToken(Long memberId, Date issuedAt) { + SecretKey key = getSigningKey(); + + return Jwts.builder() + .setHeaderParam("alg", "HS256") + .setHeaderParam("typ", "JWT") + .setIssuedAt(issuedAt) + .setExpiration(new Date(issuedAt.getTime() + Duration.ofMinutes(60 * 24 * 3).toMillis())) // 3일 유효 + .setClaims(Map.of("memberId", memberId)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + // JWT 토큰의 서명을 검증하고 클레임 반환 public Map validateToken(String token) { return Jwts.parserBuilder() 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 cdec1540..688178e6 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,7 +9,6 @@ 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; @@ -28,9 +27,8 @@ public class MemberController { private final MemberService memberService; - private final LogoutRedisService logoutRedisService; - @Operation(summary = "자체 회원가입 기능") + @Operation(summary = "CATCH Mi 회원가입 기능", description = "캐치미 서비스에 회원가입합니다.") @PostMapping("/join") public ResponseEntity> join( @Parameter(description = "소셜 로그인 정보와 사용자 추가 입력 정보") @RequestBody @Valid JoinRequest joinRequest @@ -38,7 +36,7 @@ public ResponseEntity> join( return ResponseEntity.ok(ApiResponse.success(memberService.join(joinRequest))); } - @Operation(summary = "CATCH Mi 서비스 로그인", description = "캐치미 서비스에 로그인합니다.") + @Operation(summary = "CATCH Mi 로그인", description = "캐치미 서비스에 로그인합니다.") @PostMapping("/login") public ResponseEntity> catchMiLogin( @Parameter(description = "회원 로그인 요청 정보", required = true) @Valid @RequestBody MemberLoginRequest request @@ -50,27 +48,27 @@ public ResponseEntity> catchMiLogin( @Operation(summary = "CATCH Mi 서비스 로그아웃", description = "캐치미 서비스에 로그아웃합니다.") @PostMapping("/logout") public ResponseEntity catchMiLogout( - @Parameter(description = "회원 로그인 토큰 헤더", required = true) @RequestHeader("Authorization") String token + @Parameter(description = "회원 로그인 토큰 헤더", required = true) @RequestHeader("Authorization") String authorizationHeader ) { - logoutRedisService.addTokenToBlacklist(token); + memberService.logout(authorizationHeader); return ResponseEntity.noContent().build(); } - @Operation(summary = "내 프로필 조회") + @Operation(summary = "내 프로필 조회", description = "내 프로필 페이지를 조회합니다.") @GetMapping("/me") public ResponseEntity> findMyInfo( @Parameter(description = "회원 로그인 정보") @AuthenticationPrincipal AuthMember authMember) { return ResponseEntity.ok(ApiResponse.success(memberService.getMyProfile(authMember.getMemberId()))); } - @Operation(summary = "다른 회원 프로필 조회") + @Operation(summary = "다른 회원 프로필 조회", description = "다른 회원의 프로필 페이지를 조회합니다.") @GetMapping("/{memberId}") public ResponseEntity> findMemberInfo( @Parameter(description = "회원 ID") @PathVariable Long memberId) { return ResponseEntity.ok(ApiResponse.success(memberService.getMemberProfile(memberId))); } - @Operation(summary = "회원 내 정보 수정") + @Operation(summary = "회원 내 정보 수정", description = "프로필 사진 및 회원 정보를 수정합니다.") @PutMapping(value = "/me") public ResponseEntity> updateMemberInfo( @Parameter(description = "프로필 사진") @RequestPart(value = "file", required = false) MultipartFile image, @@ -80,7 +78,7 @@ public ResponseEntity> updateMemberInfo( return ResponseEntity.ok(ApiResponse.success(memberService.updateMyProfile(image, updateRequest))); } - @Operation(summary = "회원 탈퇴") + @Operation(summary = "CATCH Mi 회원 탈퇴", description = "캐치미 서비스를 탈퇴합니다.") @DeleteMapping("/me") public ResponseEntity deleteMember( @Parameter(description = "회원 로그인 정보") @AuthenticationPrincipal AuthMember authMember) { 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 index eb4a7f74..288c5339 100644 --- a/src/main/java/com/example/mate/domain/member/service/LogoutRedisService.java +++ b/src/main/java/com/example/mate/domain/member/service/LogoutRedisService.java @@ -2,10 +2,14 @@ import com.example.mate.common.error.CustomException; import com.example.mate.common.error.ErrorCode; +import com.example.mate.common.security.util.JwtUtil; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.Date; +import java.util.Map; import java.util.concurrent.TimeUnit; @Service @@ -13,18 +17,24 @@ public class LogoutRedisService { private final RedisTemplate redisTemplate; + private final JwtUtil jwtUtil; - // 로그아웃 시 블랙리스트에 토큰 추가 - public void addTokenToBlacklist(String token) { - if (token == null || !token.startsWith("Bearer ")) { + // 로그아웃 시 액세스 토큰의 남은 시간만큼 블랙리스트에 추가 + public void addTokenToBlacklist(String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { throw new CustomException(ErrorCode.INVALID_AUTH_TOKEN); } - // TODO : 테스트용 1분 유효를 변경 - redisTemplate.opsForValue().set("blacklist:" + token.substring(7), "blacklisted", 1, TimeUnit.MINUTES); + String accessToken = authorizationHeader.substring(7); + Map claims = jwtUtil.validateToken(accessToken); + + long remainingTime = (long) claims.get("iat") + Duration.ofMinutes(30).toMillis() - new Date().getTime(); + + redisTemplate.opsForValue().set( + "blacklist:" + accessToken, "blacklisted", remainingTime, TimeUnit.MILLISECONDS); } - // 블랙리스트에 토큰 있는지 확인 + // 블랙리스트에 해당 액세스 토큰 있는지 확인 public boolean isTokenBlacklisted(String accessToken) { return redisTemplate.hasKey("blacklist:" + accessToken); } diff --git a/src/main/java/com/example/mate/domain/member/service/MemberService.java b/src/main/java/com/example/mate/domain/member/service/MemberService.java index 7527e0c0..de7f9e85 100644 --- a/src/main/java/com/example/mate/domain/member/service/MemberService.java +++ b/src/main/java/com/example/mate/domain/member/service/MemberService.java @@ -2,7 +2,6 @@ import com.example.mate.common.error.CustomException; import com.example.mate.common.error.ErrorCode; -import com.example.mate.common.jwt.JwtToken; import com.example.mate.common.security.util.JwtUtil; import com.example.mate.domain.constant.TeamInfo; import com.example.mate.domain.file.FileService; @@ -23,12 +22,12 @@ import com.example.mate.domain.member.repository.FollowRepository; import com.example.mate.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.util.Map; - @Service @Transactional @RequiredArgsConstructor @@ -42,30 +41,19 @@ public class MemberService { private final VisitPartRepository visitPartRepository; private final JwtUtil jwtUtil; private final FileService fileService; + private final LogoutRedisService logoutRedisService; - // 자체 회원가입 기능 + // CATCH Mi 회원가입 기능 public JoinResponse join(JoinRequest request) { Member savedMember = memberRepository.save(Member.of(request, getDefaultMemberImageUrl())); return JoinResponse.from(savedMember); } - // 자체 로그인 기능 + // CATCH Mi 로그인 기능 @Transactional(readOnly = true) public MemberLoginResponse loginByEmail(MemberLoginRequest request) { Member member = findByEmail(request.getEmail()); - return MemberLoginResponse.from(member, makeToken(member)); - } - - // JWT 토큰 생성 - private JwtToken makeToken(Member member) { - Map payloadMap = member.getPayload(); - String accessToken = jwtUtil.createToken(payloadMap, 60 * 24 * 3); // 3일 유효 - String refreshToken = jwtUtil.createToken(Map.of("memberId", member.getId()), 60 * 24 * 7); // 7일 유효 - return JwtToken.builder() - .grantType("Bearer") - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); + return MemberLoginResponse.from(member, jwtUtil.createTokens(member)); } private Member findByEmail(String email) { @@ -73,6 +61,17 @@ private Member findByEmail(String email) { .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND_BY_EMAIL)); } + // CATCH Mi 로그아웃 기능 + public void logout(String authorizationHeader) { + logoutRedisService.addTokenToBlacklist(authorizationHeader); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new CustomException(ErrorCode.MEMBER_AUTHENTICATION_REQUIRED); + } + SecurityContextHolder.clearContext(); + } + // 내 프로필 조회 @Transactional(readOnly = true) public MyProfileResponse getMyProfile(Long memberId) { @@ -102,7 +101,7 @@ public MyProfileResponse updateMyProfile(MultipartFile file, MemberInfoUpdateReq return MyProfileResponse.from(memberRepository.save(member)); } - // 회원 탈퇴 + // CATCH Mi 회원 탈퇴 public void deleteMember(Long memberId) { Member member = findByMemberId(memberId); deleteNonDefaultImage(member.getImageUrl()); 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 29d915f3..bbc740c1 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 @@ -441,24 +441,6 @@ void logout_member_success() throws Exception { 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 52ed331e..58223e63 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 @@ -1,5 +1,6 @@ package com.example.mate.domain.member.integration; +import com.example.mate.common.jwt.JwtToken; import com.example.mate.common.security.util.JwtUtil; import com.example.mate.config.WithAuthMember; import com.example.mate.domain.constant.Gender; @@ -47,12 +48,14 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Date; import java.util.List; +import java.util.Map; 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.ArgumentMatchers.*; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -277,7 +280,7 @@ private MemberInfoUpdateRequest createMemberInfoUpdateRequest() { .memberId(member.getId()) .build(); } - + @Nested @DisplayName("자체 회원 가입") class Join { @@ -462,6 +465,16 @@ void login_member_success() throws Exception { .email("tester@example.com") .build(); + // mockJwtToken 객체 생성 + JwtToken mockJwtToken = JwtToken.builder() + .grantType("Bearer") + .accessToken("mockAccessToken") + .refreshToken("mockRefreshToken") + .build(); + + // JwtUtil의 createTokens 메서드 mock 처리 + when(jwtUtil.createTokens(any(Member.class))).thenReturn(mockJwtToken); + // when & then mockMvc.perform(post("/api/members/login") .contentType(MediaType.APPLICATION_JSON) @@ -506,6 +519,11 @@ void logout_member_success_with_my_info_denied() throws Exception { // given String token = "Bearer accessToken"; + // mockJwtToken 객체 생성 + Map mockClaims = Map.of("iat", new Date().getTime()); // 'iat' 필드를 mock 처리 + when(jwtUtil.validateToken(anyString())).thenReturn(mockClaims); + + // when & then when(redisTemplate.opsForValue()).thenReturn(valueOperations); doNothing().when(valueOperations).set( 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 index 31096c4b..f8f4446e 100644 --- a/src/test/java/com/example/mate/domain/member/service/LogoutRedisServiceTest.java +++ b/src/test/java/com/example/mate/domain/member/service/LogoutRedisServiceTest.java @@ -2,6 +2,7 @@ import com.example.mate.common.error.CustomException; import com.example.mate.common.error.ErrorCode; +import com.example.mate.common.security.util.JwtUtil; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -12,10 +13,12 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import java.util.Map; 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.BDDMockito.given; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -30,6 +33,9 @@ class LogoutRedisServiceTest { @Mock private ValueOperations valueOperations; + @Mock + private JwtUtil jwtUtil; + @Nested @DisplayName("회원 로그아웃") class LogoutMember { @@ -39,16 +45,23 @@ class LogoutMember { void add_token_to_blacklist_success() { // given String token = "Bearer accessToken"; + String accessToken = token.substring(7); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); - doNothing().when(valueOperations).set( - "blacklist:" + token.substring(7), "blacklisted", 1, TimeUnit.MINUTES + + long iat = System.currentTimeMillis(); // 현재 시간으로 iat 값 설정 + Map claims = Map.of("iat", iat); + given(jwtUtil.validateToken(accessToken)).willReturn(claims); + + lenient().doNothing().when(valueOperations).set( + "blacklist:" + accessToken, + "blacklisted", + 120000L, + TimeUnit.MILLISECONDS ); // when & then assertDoesNotThrow(() -> logoutRedisService.addTokenToBlacklist(token)); - verify(redisTemplate.opsForValue(), times(1)).set( - "blacklist:accessToken", "blacklisted", 1, TimeUnit.MINUTES - ); } @Test diff --git a/src/test/java/com/example/mate/domain/member/service/MemberServiceTest.java b/src/test/java/com/example/mate/domain/member/service/MemberServiceTest.java index 36667896..73abb84d 100644 --- a/src/test/java/com/example/mate/domain/member/service/MemberServiceTest.java +++ b/src/test/java/com/example/mate/domain/member/service/MemberServiceTest.java @@ -2,6 +2,7 @@ import com.example.mate.common.error.CustomException; import com.example.mate.common.error.ErrorCode; +import com.example.mate.common.jwt.JwtToken; import com.example.mate.common.security.util.JwtUtil; import com.example.mate.domain.constant.Gender; import com.example.mate.domain.constant.Rating; @@ -492,8 +493,14 @@ void login_member_success() { MemberLoginRequest request = MemberLoginRequest.builder() .email("test@example.com") .build(); + JwtToken jwtToken = JwtToken.builder() + .grantType("Bearer") + .accessToken("accessToken") + .refreshToken("refreshToken") + .build(); given(memberRepository.findByEmail(email)).willReturn(Optional.of(member)); + given(jwtUtil.createTokens(any(Member.class))).willReturn(jwtToken); // when MemberLoginResponse response = memberService.loginByEmail(request);