Skip to content

Commit

Permalink
MATE-127 : [REFACTOR] CATCH Mi 로그아웃 및 JWT 로직 리팩토링 (#116)
Browse files Browse the repository at this point in the history
* MATE-127 : [FEAT] 로그아웃 관련 예외 처리 추가

* MATE-127 : [REFACTOR] 토큰 생성 리팩토링

* MATE-127 : [REFACTOR] 로그아웃 시 블랙리스트 추가 기능 리팩토링 및 인증 정보 삭제 추가

* MATE-127 : [REFACTOR] 로그아웃 컨트롤러 리팩토링 및 Swagger 추가

* MATE-127 : [TEST] 리팩토링 코드에 맞게 로그인, 로그아웃 관련 테스트 수정

* MATE-127 : [FEAT] 액세스 토큰의 유효 시간을 30분으로 수정
  • Loading branch information
jooinjoo authored Dec 31, 2024
1 parent ff86740 commit 76caf43
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 69 deletions.
2 changes: 2 additions & 0 deletions src/main/java/com/example/mate/common/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "팀을 찾을 수 없습니다"),
Expand All @@ -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", "이미 팔로우한 회원입니다."),
Expand Down
48 changes: 39 additions & 9 deletions src/main/java/com/example/mate/common/security/util/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -23,21 +25,49 @@ private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8));
}

// JWT 문자열 생성. valueMap: JWT에 저장할 클레임 (payload), min: 만료 시간 (분 단위)
public String createToken(Map<String, Object> valueMap, int min) {
// JWT 토큰 생성
public JwtToken createTokens(Member member) {
Map<String, Object> 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<String, Object> 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<String, Object> validateToken(String token) {
return Jwts.parserBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,17 +27,16 @@
public class MemberController {

private final MemberService memberService;
private final LogoutRedisService logoutRedisService;

@Operation(summary = "자체 회원가입 기능")
@Operation(summary = "CATCH Mi 회원가입 기능", description = "캐치미 서비스에 회원가입합니다.")
@PostMapping("/join")
public ResponseEntity<ApiResponse<JoinResponse>> join(
@Parameter(description = "소셜 로그인 정보와 사용자 추가 입력 정보") @RequestBody @Valid JoinRequest joinRequest
) {
return ResponseEntity.ok(ApiResponse.success(memberService.join(joinRequest)));
}

@Operation(summary = "CATCH Mi 서비스 로그인", description = "캐치미 서비스에 로그인합니다.")
@Operation(summary = "CATCH Mi 로그인", description = "캐치미 서비스에 로그인합니다.")
@PostMapping("/login")
public ResponseEntity<ApiResponse<MemberLoginResponse>> catchMiLogin(
@Parameter(description = "회원 로그인 요청 정보", required = true) @Valid @RequestBody MemberLoginRequest request
Expand All @@ -50,27 +48,27 @@ public ResponseEntity<ApiResponse<MemberLoginResponse>> catchMiLogin(
@Operation(summary = "CATCH Mi 서비스 로그아웃", description = "캐치미 서비스에 로그아웃합니다.")
@PostMapping("/logout")
public ResponseEntity<Void> 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<ApiResponse<MyProfileResponse>> findMyInfo(
@Parameter(description = "회원 로그인 정보") @AuthenticationPrincipal AuthMember authMember) {
return ResponseEntity.ok(ApiResponse.success(memberService.getMyProfile(authMember.getMemberId())));
}

@Operation(summary = "다른 회원 프로필 조회")
@Operation(summary = "다른 회원 프로필 조회", description = "다른 회원의 프로필 페이지를 조회합니다.")
@GetMapping("/{memberId}")
public ResponseEntity<ApiResponse<MemberProfileResponse>> 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<ApiResponse<MyProfileResponse>> updateMemberInfo(
@Parameter(description = "프로필 사진") @RequestPart(value = "file", required = false) MultipartFile image,
Expand All @@ -80,7 +78,7 @@ public ResponseEntity<ApiResponse<MyProfileResponse>> updateMemberInfo(
return ResponseEntity.ok(ApiResponse.success(memberService.updateMyProfile(image, updateRequest)));
}

@Operation(summary = "회원 탈퇴")
@Operation(summary = "CATCH Mi 회원 탈퇴", description = "캐치미 서비스를 탈퇴합니다.")
@DeleteMapping("/me")
public ResponseEntity<Void> deleteMember(
@Parameter(description = "회원 로그인 정보") @AuthenticationPrincipal AuthMember authMember) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,39 @@

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
@RequiredArgsConstructor
public class LogoutRedisService {

private final RedisTemplate<String, String> 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<String, Object> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -42,37 +41,37 @@ 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<String, Object> 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) {
return memberRepository.findByEmail(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) {
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.*;
Expand Down Expand Up @@ -277,7 +280,7 @@ private MemberInfoUpdateRequest createMemberInfoUpdateRequest() {
.memberId(member.getId())
.build();
}

@Nested
@DisplayName("자체 회원 가입")
class Join {
Expand Down Expand Up @@ -462,6 +465,16 @@ void login_member_success() throws Exception {
.email("[email protected]")
.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)
Expand Down Expand Up @@ -506,6 +519,11 @@ void logout_member_success_with_my_info_denied() throws Exception {
// given
String token = "Bearer accessToken";

// mockJwtToken 객체 생성
Map<String, Object> 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(
Expand Down
Loading

0 comments on commit 76caf43

Please sign in to comment.