From 56320d0f1393c2dc0dd2d889ccc15e3b5dd9211b Mon Sep 17 00:00:00 2001 From: minkyung Date: Sun, 25 Jan 2026 16:00:15 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20dto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hrr/backend/domain/auth/dto/AuthResponseDto.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java b/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java index 42104d29..eac57001 100644 --- a/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java +++ b/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java @@ -10,7 +10,7 @@ public record LoginResponse( Long userId, String accessToken, String refreshToken, - String name, + String name, String nickname, LoginStatus loginStatus, String nextStep @@ -18,8 +18,8 @@ public record LoginResponse( @Builder public record TokenReissueResponse( - String accessToken + String accessToken, + String refreshToken // 추가: 새로운 Refresh Token도 함께 반환 ) {} - -} +} \ No newline at end of file From ae913b90c6be9a75de540bd850a3775b01812b2e Mon Sep 17 00:00:00 2001 From: minkyung Date: Sun, 25 Jan 2026 16:00:57 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20service=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.java | 374 ++++++++---------- 1 file changed, 167 insertions(+), 207 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index fd6652eb..06ddbaa3 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -28,58 +28,48 @@ public class AuthService { private final KakaoAuthService kakaoAuthService; - private final AppleAuthService appleAuthService; - private final NaverAuthService naverAuthService; + private final AppleAuthService appleAuthService; + private final NaverAuthService naverAuthService; private final SocialUserService socialUserService; private final JwtService jwtService; - private final SocialAuthRepository socialAuthRepository; - private final UserRepository userRepository; - private final UserChallengeRepository userChallengeRepository; + private final SocialAuthRepository socialAuthRepository; + private final UserRepository userRepository; + private final UserChallengeRepository userChallengeRepository; public AuthResponseDto.LoginResponse socialLogin(SocialType socialType, AuthRequestDto.SocialLoginRequest request) { - // 지원하지 않는 소셜 타입이면 GlobalException 던지기 if (socialType != SocialType.KAKAO) { throw new GlobalException(ErrorCode.AUTH_UNSUPPORTED_SOCIAL_TYPE); } try { - // 1. 카카오 인가 코드로 토큰 발급 KakaoTokenResponse token = kakaoAuthService.exchangeToken(request.code()); - - // 2. 카카오 유저 정보 조회 KakaoUserResponse kakaoUser = kakaoAuthService.fetchUser(token.getAccessToken()); - - // 3. DB에 유저 upsert User user = socialUserService.upsertKakaoUser(kakaoUser); - // 4. JWT 생성 String accessToken = jwtService.generateAccessToken(user.getId()); String refreshToken = jwtService.generateRefreshToken(user.getId()); - // 5. 다음단계 게산 String nextStep = user.determineNextStep(); return new AuthResponseDto.LoginResponse( user.getId(), accessToken, refreshToken, - user.getDisplayName(), - user.getDisplayNickname(), + user.getDisplayName(), + user.getDisplayNickname(), user.getLoginStatus(), nextStep ); } catch (GlobalException e) { - // KakaoAuthService 내부에서 GlobalException 이미 던지면 그대로 전달 throw e; } catch (Exception e) { - // 외부 카카오 서버 통신 오류 처리 throw new GlobalException(ErrorCode.AUTH_EXTERNAL_API_ERROR); } } - /** Refresh Token 기반 Access Token 재발급 */ + + /** Refresh Token 기반 Access Token 재발급 (수정됨) */ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { - // "Bearer " 접두사 제거 String refreshToken = refreshHeader.startsWith("Bearer ") ? refreshHeader.substring(7) : refreshHeader; @@ -87,196 +77,166 @@ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { // Refresh Token 유효성 검증 jwtService.validateToken(refreshToken); - // userId 추출 후 새 Access Token 발급 + // userId 추출 Long userId = jwtService.extractUserId(refreshToken); + + // Redis에 저장된 Refresh Token과 비교 검증 + if (!jwtService.validateRefreshToken(refreshToken, userId)) { + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); + } + + // 새로운 Access Token 발급 String newAccessToken = jwtService.generateAccessToken(userId); - return new AuthResponseDto.TokenReissueResponse(newAccessToken); + // 새로운 Refresh Token 발급 및 기존 RT 교체 + String newRefreshToken = jwtService.generateRefreshToken(userId); + + // 기존 Refresh Token 블랙리스트 처리 (만료되지 않은 경우에만) + Duration remainingExpiration = jwtService.getRemainingExpiration(refreshToken); + if (remainingExpiration.isNegative() || remainingExpiration.isZero()) { + // 이미 만료된 토큰은 블랙리스트에 넣을 필요 없음 + return new AuthResponseDto.TokenReissueResponse(newAccessToken, newRefreshToken); + } + jwtService.blacklistToken(refreshToken, remainingExpiration); + + return new AuthResponseDto.TokenReissueResponse(newAccessToken, newRefreshToken); + } + + public AuthResponseDto.LoginResponse kakaoLogin(String kakaoAccessToken) { + + try { + KakaoUserResponse kakaoUser = kakaoAuthService.fetchUser(kakaoAccessToken); + User user = socialUserService.upsertKakaoUser(kakaoUser); + + String accessToken = jwtService.generateAccessToken(user.getId()); + String refreshToken = jwtService.generateRefreshToken(user.getId()); + + String nextStep = user.determineNextStep(); + + return new AuthResponseDto.LoginResponse( + user.getId(), + accessToken, + refreshToken, + user.getDisplayName(), + user.getDisplayNickname(), + user.getLoginStatus(), + nextStep + ); + } catch (GlobalException e) { + throw e; + } catch (Exception e) { + throw new GlobalException(ErrorCode.AUTH_EXTERNAL_API_ERROR); + } + } + + public AuthResponseDto.LoginResponse appleLogin(AuthRequestDto.AppleLoginRequest request) { + + try { + Map appleTokens = appleAuthService.getAppleTokens(request.getAuthorizationCode()); + + if (appleTokens == null || appleTokens.get("id_token") == null) { + log.error("애플 id_token이 유효하지 않습니다."); + throw new GlobalException(ErrorCode.AUTH_APPLE_TOKEN_ERROR); + } + + String socialId = appleAuthService.getAppleAccountId(appleTokens.get("id_token")); + String appleRefreshToken = appleTokens.get("refresh_token"); + + User user = socialUserService.upsertAppleUser(socialId, appleRefreshToken, request.getName()); + + String accessToken = jwtService.generateAccessToken(user.getId()); + String refreshToken = jwtService.generateRefreshToken(user.getId()); + + String nextStep = user.determineNextStep(); + + return new AuthResponseDto.LoginResponse( + user.getId(), + accessToken, + refreshToken, + user.getDisplayName(), + user.getDisplayNickname(), + user.getLoginStatus(), + nextStep + ); + } catch (GlobalException e) { + throw e; + } catch (Exception e) { + throw new GlobalException(ErrorCode.AUTH_EXTERNAL_API_ERROR); + } + } + + public AuthResponseDto.LoginResponse naverLogin(String naverAccessToken, String naverRefreshToken) { + + try { + NaverUserResponse naverUser = naverAuthService.fetchUser(naverAccessToken); + User user = socialUserService.upsertNaverUser(naverUser, naverRefreshToken); + + String accessToken = jwtService.generateAccessToken(user.getId()); + String refreshToken = jwtService.generateRefreshToken(user.getId()); + + String nextStep = user.determineNextStep(); + + return new AuthResponseDto.LoginResponse( + user.getId(), + accessToken, + refreshToken, + user.getDisplayName(), + user.getDisplayNickname(), + user.getLoginStatus(), + nextStep + ); + } catch (GlobalException e) { + throw e; + } catch (Exception e) { + log.error("네이버 로그인 중 오류 발생: ", e); + throw new GlobalException(ErrorCode.AUTH_NAVER_EXTERNAL_ERROR); + } + } + + /** 로그아웃 (수정됨) */ + public void logout(String tokenHeader) { + String token = tokenHeader.startsWith("Bearer ") + ? tokenHeader.substring(7) + : tokenHeader; + + // userId 추출 + Long userId = jwtService.extractUserId(token); + + // Access Token 블랙리스트 처리 + Duration remainingExpiration = jwtService.getRemainingExpiration(token); + if (!remainingExpiration.isNegative() && !remainingExpiration.isZero()) { + jwtService.blacklistToken(token, remainingExpiration); + } + + // Refresh Token 삭제 (Redis에서 제거) + jwtService.deleteRefreshToken(userId); + } + + @Transactional + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND)); + + user.withdraw(); + + // 탈퇴 시 Refresh Token 삭제 + jwtService.deleteRefreshToken(userId); + } + + @Transactional + public void revoke(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND)); + + SocialAuth socialAuth = socialAuthRepository.findByUser(user) + .orElseThrow(() -> new GlobalException(ErrorCode.AUTH_INFO_NOT_FOUND)); + + switch (socialAuth.getSocialType()) { + case NAVER -> naverAuthService.revoke(socialAuth.getSocialRefreshToken()); + case APPLE -> appleAuthService.revoke(socialAuth.getSocialRefreshToken()); + case KAKAO -> kakaoAuthService.unlink(socialAuth.getSocialId()); + default -> throw new GlobalException(ErrorCode.AUTH_INVALID_SOCIAL_TYPE); + } } - /** - * 테스트용 / sdk 환경에 사용할 용도로 카카오 엑세스 토큰을 받아 내부 로그인 처리를 하는 메소드 입니다. - * 위의 socialLogin 메소드에서 socialType을 kakao로 고정하고, 카카오 엑세스 토큰을 발급받는 과정을 제외하고 동일합니다. - * - * @param kakaoAccessToken 카카오 sdk 통해서 받아온 엑세스 토큰 - * @return 토큰, userId 등 필요 정보 - */ - public AuthResponseDto.LoginResponse kakaoLogin(String kakaoAccessToken) { - - try { - // 카카오 유저 정보 조회 - KakaoUserResponse kakaoUser = kakaoAuthService.fetchUser(kakaoAccessToken); - - // DB에 유저 upsert - User user = socialUserService.upsertKakaoUser(kakaoUser); - - // JWT 생성 - String accessToken = jwtService.generateAccessToken(user.getId()); - String refreshToken = jwtService.generateRefreshToken(user.getId()); - - // 다음단계 게산 - String nextStep = user.determineNextStep(); - - return new AuthResponseDto.LoginResponse( - user.getId(), - accessToken, - refreshToken, - user.getDisplayName(), - user.getDisplayNickname(), - user.getLoginStatus(), - nextStep - ); - } catch (GlobalException e) { - // KakaoAuthService 내부에서 GlobalException 이미 던지면 그대로 전달 - throw e; - } catch (Exception e) { - // 외부 카카오 서버 통신 오류 처리 - throw new GlobalException(ErrorCode.AUTH_EXTERNAL_API_ERROR); - } - } - - /** - * 애플 로그인 구현 - * - * @param request 요청 Dto - * @return 토큰, userId 등 필요 정보 - */ - public AuthResponseDto.LoginResponse appleLogin(AuthRequestDto.AppleLoginRequest request) { - - try { - Map appleTokens = appleAuthService.getAppleTokens(request.getAuthorizationCode()); - - // id_token 자체가 오지 않은 경우 처리 - if (appleTokens == null || appleTokens.get("id_token") == null) { - log.error("애플 id_token이 유효하지 않습니다."); - throw new GlobalException(ErrorCode.AUTH_APPLE_TOKEN_ERROR); - } - - String socialId = appleAuthService.getAppleAccountId(appleTokens.get("id_token")); - String appleRefreshToken = appleTokens.get("refresh_token"); - - // DB 저장 (애플은 RT 함께 저장) - User user = socialUserService.upsertAppleUser(socialId, appleRefreshToken, request.getName()); - - // JWT 생성 - String accessToken = jwtService.generateAccessToken(user.getId()); - String refreshToken = jwtService.generateRefreshToken(user.getId()); - - // 다음단계 게산 - String nextStep = user.determineNextStep(); - - return new AuthResponseDto.LoginResponse( - user.getId(), - accessToken, - refreshToken, - user.getDisplayName(), - user.getDisplayNickname(), - user.getLoginStatus(), - nextStep - ); - } catch (GlobalException e) { - throw e; - } catch (Exception e) { - // 애플 서버 통신 오류 처리 - - throw new GlobalException(ErrorCode.AUTH_EXTERNAL_API_ERROR); - } - } - - /** - * 네이버 엑세스 토큰을 통해 로그인 (SDK 방식) - * @param naverAccessToken 네이버 sdk를 통해 프론트에서 받아온 엑세스 토큰 - * @return 토큰, userId 등 필요 정보 - */ - public AuthResponseDto.LoginResponse naverLogin(String naverAccessToken, String naverRefreshToken) { - - try { - // 네이버 유저 정보 조회 - NaverUserResponse naverUser = naverAuthService.fetchUser(naverAccessToken); - - // DB에 유저 upsert - User user = socialUserService.upsertNaverUser(naverUser, naverRefreshToken); - - // JWT 생성 - String accessToken = jwtService.generateAccessToken(user.getId()); - String refreshToken = jwtService.generateRefreshToken(user.getId()); - - // 다음 단계 계산 - String nextStep = user.determineNextStep(); - - return new AuthResponseDto.LoginResponse( - user.getId(), - accessToken, - refreshToken, - user.getDisplayName(), - user.getDisplayNickname(), - user.getLoginStatus(), - nextStep - ); - } catch (GlobalException e) { - // 이미 NaverAuthService에서 던진 전용 에러를 그대로 위로 던짐 - throw e; - } catch (Exception e) { - // 그 외 서버 내부 로직 오류 시 공통 외부 에러 처리 - log.error("네이버 로그인 중 오류 발생: ", e); - throw new GlobalException(ErrorCode.AUTH_NAVER_EXTERNAL_ERROR); - } - } - - public void logout(String tokenHeader) { - - // "Bearer " 접두사 제거 - String token = tokenHeader.startsWith("Bearer ") - ? tokenHeader.substring(7) - : tokenHeader; - - // 토큰의 남은 유효 기간 계산 (Duration 타입) - // JWT의 exp 클레임과 현재 시간을 비교하여 남은 시간을 계산 - Duration remainingExpiration = jwtService.getRemainingExpiration(token); - - if (remainingExpiration.isNegative() || remainingExpiration.isZero()) { - // 이미 만료된 토큰인 경우, 굳이 블랙리스트에 넣을 필요는 없어 패스 - return; - } - - // 토큰을 블랙리스트에 등록 (TTL은 남은 유효 기간으로 설정) - jwtService.blacklistToken(token, remainingExpiration); - - } - - /** - * 회원 탈퇴 - * @param userId 탈퇴할 사용자의 userId - */ - @Transactional - public void withdraw(Long userId) { - // 현재 트랜잭션 안에서 유저를 다시 조회 (영속화) - User user = userRepository.findById(userId) - .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND)); - - // 유저 상태 변경 (Soft Delete) - user.withdraw(); - - // TODO: 리프레시 토큰 등 세션 정보 삭제 - } - - @Transactional - // 소셜 로그인 연결 해제 - public void revoke(Long userId) { - // 현재 트랜잭션 안에서 유저를 다시 조회 (영속화) - User user = userRepository.findById(userId) - .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND)); - - // 소셜 연동 해제 - SocialAuth socialAuth = socialAuthRepository.findByUser(user) - .orElseThrow(() -> new GlobalException(ErrorCode.AUTH_INFO_NOT_FOUND)); - - switch (socialAuth.getSocialType()) { - case NAVER -> naverAuthService.revoke(socialAuth.getSocialRefreshToken()); - case APPLE -> appleAuthService.revoke(socialAuth.getSocialRefreshToken()); - case KAKAO -> kakaoAuthService.unlink(socialAuth.getSocialId()); - default -> throw new GlobalException(ErrorCode.AUTH_INVALID_SOCIAL_TYPE); - } - } - -} +} \ No newline at end of file From af298c766d10a9800c6cdfbe025750f73e2d057b Mon Sep 17 00:00:00 2001 From: minkyung Date: Sun, 25 Jan 2026 16:03:24 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20JwtService=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/dto/AuthResponseDto.java | 2 +- .../domain/auth/service/AuthService.java | 4 +- .../domain/auth/service/JwtService.java | 141 +++++++++--------- 3 files changed, 71 insertions(+), 76 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java b/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java index eac57001..08d731c7 100644 --- a/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java +++ b/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java @@ -19,7 +19,7 @@ public record LoginResponse( @Builder public record TokenReissueResponse( String accessToken, - String refreshToken // 추가: 새로운 Refresh Token도 함께 반환 + String refreshToken ) {} } \ No newline at end of file diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index 06ddbaa3..a4192e83 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -68,7 +68,7 @@ public AuthResponseDto.LoginResponse socialLogin(SocialType socialType, AuthRequ } } - /** Refresh Token 기반 Access Token 재발급 (수정됨) */ + /** Refresh Token 기반 Access Token 재발급 */ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { String refreshToken = refreshHeader.startsWith("Bearer ") ? refreshHeader.substring(7) @@ -193,7 +193,7 @@ public AuthResponseDto.LoginResponse naverLogin(String naverAccessToken, String } } - /** 로그아웃 (수정됨) */ + /** 로그아웃 */ public void logout(String tokenHeader) { String token = tokenHeader.startsWith("Bearer ") ? tokenHeader.substring(7) diff --git a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java index 97132c82..1e1947bd 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java @@ -22,23 +22,21 @@ public class JwtService { @Value("${jwt.secret}") private String secret; - //액세스 토큰 만료시간 - @Value("${jwt.access-token-validity}") - private long accessTokenValidity; + @Value("${jwt.access-token-validity}") + private long accessTokenValidity; - // 리프레시 토큰 만료시간 - @Value("${jwt.refresh-token-validity}") - private long refreshTokenValidity; + @Value("${jwt.refresh-token-validity}") + private long refreshTokenValidity; - private final StringRedisTemplate redisTemplate; + private final StringRedisTemplate redisTemplate; - private static final String BLACKLIST_PREFIX = "token_blacklist:"; + private static final String BLACKLIST_PREFIX = "token_blacklist:"; + private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; // 추가 - private SecretKey getSigningKey() { + private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } - /** Access Token 생성 */ public String generateAccessToken(Long userId) { Date now = new Date(); return Jwts.builder() @@ -48,27 +46,32 @@ public String generateAccessToken(Long userId) { .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } - /** - * Refresh Token 생성 - */ + public String generateRefreshToken(Long userId) { Date now = new Date(); - return Jwts.builder() + String refreshToken = Jwts.builder() .setSubject(String.valueOf(userId)) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + refreshTokenValidity)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); + + // Refresh Token을 Redis에 저장 (userId를 키로 사용) + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + userId, + refreshToken, + Duration.ofMillis(refreshTokenValidity) + ); + + return refreshToken; } - /** - * 토큰 유효성 검증 (서명 및 만료 여부 확인 위함) - * 유효하면 true 반환하도록 진행*/ + public boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() - .parseClaimsJws(token); //파싱 중에 에러 발생했을 때의 예외처리 + .parseClaimsJws(token); return true; } catch (ExpiredJwtException e) { throw new GlobalException(ErrorCode.AUTH_TOKEN_EXPIRED); @@ -77,8 +80,6 @@ public boolean validateToken(String token) { } } - /** 토큰 만료 여부 확인 - * 만료가 되면 true를 반환하게 하고 유효하거나 파싱 불가할 때 false 반환하게 함*/ public boolean isTokenExpired(String token) { try { Date expiration = Jwts.parserBuilder() @@ -93,7 +94,6 @@ public boolean isTokenExpired(String token) { } } - /** userId(subject) 추출 */ public Long extractUserId(String token) { try { Claims claims = Jwts.parserBuilder() @@ -107,62 +107,57 @@ public Long extractUserId(String token) { } } - /** HTTP 요청 헤더에서 JWT 추출 - * Authroization: Bearer {token} 형태에서 token 만 분리하는 것임*/ public String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); // "Bearer " 이후 부분 + return bearerToken.substring(7); } return null; } - // 토큰을 더 이상 사용하지 못 하게 무효화 - public void blacklistToken(String token, Duration expirationDuration) { - redisTemplate.opsForValue().set( - BLACKLIST_PREFIX + token, - "invalidated", - expirationDuration - ); - } - - // 블랙리스트 포함 여부 조회 - public boolean isTokenBlacklisted(String token) { - return redisTemplate.hasKey(BLACKLIST_PREFIX + token); - } - - /** - * JWT 토큰에서 남은 유효 기간(TTL)을 계산하여 반환 - * * @param token JWT 문자열 (Bearer 접두사가 없는 순수 토큰) - * @return 토큰의 남은 유효 기간 (Duration). 이미 만료되었거나 파싱 오류 시 Duration.ZERO 반환. - */ - public Duration getRemainingExpiration(String token) { - try { - // 토큰 파싱 및 Claims 획득 - Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKey()) // 토큰 검증에 사용된 시크릿 키 - .build() - .parseClaimsJws(token) - .getBody(); - - // 만료 시간 (exp 클레임) 획득 - Date expiration = claims.getExpiration(); - Date now = new Date(); - - // 남은 시간 계산 - if (expiration.after(now)) { - // 만료 시간이 현재 시간보다 늦다면, 남은 밀리초를 계산 - long diffInMillis = expiration.getTime() - now.getTime(); - return Duration.ofMillis(diffInMillis); - } else { - // 이미 만료된 토큰인 경우 - return Duration.ZERO; - } - - } catch (Exception e) { - - return Duration.ZERO; - } - } - -} + public void blacklistToken(String token, Duration expirationDuration) { + redisTemplate.opsForValue().set( + BLACKLIST_PREFIX + token, + "invalidated", + expirationDuration + ); + } + + public boolean isTokenBlacklisted(String token) { + return redisTemplate.hasKey(BLACKLIST_PREFIX + token); + } + + public Duration getRemainingExpiration(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + Date expiration = claims.getExpiration(); + Date now = new Date(); + + if (expiration.after(now)) { + long diffInMillis = expiration.getTime() - now.getTime(); + return Duration.ofMillis(diffInMillis); + } else { + return Duration.ZERO; + } + + } catch (Exception e) { + return Duration.ZERO; + } + } + + // Refresh Token 검증 (Redis에 저장된 토큰과 비교) + public boolean validateRefreshToken(String refreshToken, Long userId) { + String storedToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); + return refreshToken.equals(storedToken); + } + + // Refresh Token 삭제 (로그아웃 시 사용) + public void deleteRefreshToken(Long userId) { + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + } +} \ No newline at end of file From 26702400e939d7bddaf7f987dc6fc8ed3f597aab Mon Sep 17 00:00:00 2001 From: minkyung Date: Sun, 25 Jan 2026 16:29:10 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/JwtService.java | 1 + .../domain/auth/service/AuthServiceTest.java | 140 ++++++++++++++ .../domain/auth/service/JwtServiceTest.java | 173 ++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 src/test/java/com/hrr/backend/domain/auth/service/AuthServiceTest.java create mode 100644 src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java diff --git a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java index 1e1947bd..9d72f650 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java @@ -53,6 +53,7 @@ public String generateRefreshToken(Long userId) { .setSubject(String.valueOf(userId)) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + refreshTokenValidity)) + .setId(java.util.UUID.randomUUID().toString()) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); diff --git a/src/test/java/com/hrr/backend/domain/auth/service/AuthServiceTest.java b/src/test/java/com/hrr/backend/domain/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..f83f912e --- /dev/null +++ b/src/test/java/com/hrr/backend/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,140 @@ +package com.hrr.backend.domain.auth.service; + +import com.hrr.backend.domain.auth.dto.AuthResponseDto; +import com.hrr.backend.global.exception.GlobalException; +import com.hrr.backend.global.response.ErrorCode; +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.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AuthServiceTest { + + @Autowired + private AuthService authService; + + @Autowired + private JwtService jwtService; + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final Long TEST_USER_ID = 1L; + + @BeforeEach + void setUp() { + redisTemplate.keys("*").forEach(key -> redisTemplate.delete(key)); + } + + @Test + @DisplayName("토큰 재발급 성공 - 새로운 AT와 RT 모두 발급됨") + void reissueTokenSuccess() { + // given + String oldRefreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + String refreshHeader = "Bearer " + oldRefreshToken; + + // when + AuthResponseDto.TokenReissueResponse response = authService.reissueToken(refreshHeader); + + // then + assertThat(response.accessToken()).isNotNull(); + assertThat(response.refreshToken()).isNotNull(); + assertThat(response.refreshToken()).isNotEqualTo(oldRefreshToken); + } + + @Test + @DisplayName("토큰 재발급 시 기존 RT는 블랙리스트 처리됨") + void reissueTokenBlacklistsOldRefreshToken() { + // given + String oldRefreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + String refreshHeader = "Bearer " + oldRefreshToken; + + // when + authService.reissueToken(refreshHeader); + + // then + assertThat(jwtService.isTokenBlacklisted(oldRefreshToken)).isTrue(); + } + + @Test + @DisplayName("토큰 재발급 시 기존 RT로 재검증 시도하면 실패") + void cannotReuseOldRefreshToken() { + // given + String oldRefreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + // when + authService.reissueToken("Bearer " + oldRefreshToken); + + // then - 기존 RT로 다시 재발급 시도하면 실패 + assertThatThrownBy(() -> authService.reissueToken("Bearer " + oldRefreshToken)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); + } + + @Test + @DisplayName("유효하지 않은 RT로 재발급 시도 시 예외 발생") + void reissueTokenWithInvalidRefreshToken() { + // given + String invalidToken = "invalid.refresh.token"; + + // when & then + assertThatThrownBy(() -> authService.reissueToken("Bearer " + invalidToken)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); + } + + @Test + @DisplayName("로그아웃 시 AT 블랙리스트 처리 및 RT 삭제") + void logoutBlacklistsTokenAndDeletesRefreshToken() { + // given + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isTrue(); + + // when + authService.logout("Bearer " + accessToken); + + // then + assertThat(jwtService.isTokenBlacklisted(accessToken)).isTrue(); + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isFalse(); + } + + @Test + @DisplayName("로그아웃 후 블랙리스트된 AT로 요청 시 실패해야 함") + void cannotUseBlacklistedAccessToken() { + // given + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + + // when + authService.logout("Bearer " + accessToken); + + // then + assertThat(jwtService.isTokenBlacklisted(accessToken)).isTrue(); + } + + @Test + @DisplayName("로그아웃 후 RT로 재발급 시도 시 실패") + void cannotReissueAfterLogout() { + // given + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + // when + authService.logout("Bearer " + accessToken); + + // then - RT가 삭제되었으므로 재발급 실패 + assertThatThrownBy(() -> authService.reissueToken("Bearer " + refreshToken)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); + } +} \ No newline at end of file diff --git a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java new file mode 100644 index 00000000..803f3962 --- /dev/null +++ b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java @@ -0,0 +1,173 @@ +package com.hrr.backend.domain.auth.service; + +import com.hrr.backend.global.exception.GlobalException; +import com.hrr.backend.global.response.ErrorCode; +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.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") // test용 프로파일 사용 +class JwtServiceTest { + + @Autowired + private JwtService jwtService; + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final Long TEST_USER_ID = 1L; + + @BeforeEach + void setUp() { + // Redis 초기화 + redisTemplate.keys("*").forEach(key -> redisTemplate.delete(key)); + } + + @Test + @DisplayName("Access Token 생성 및 검증 성공") + void generateAndValidateAccessToken() { + // given & when + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + + // then + assertThat(accessToken).isNotNull(); + assertThat(jwtService.validateToken(accessToken)).isTrue(); + assertThat(jwtService.extractUserId(accessToken)).isEqualTo(TEST_USER_ID); + } + + @Test + @DisplayName("Refresh Token 생성 시 Redis에 저장됨") + void generateRefreshTokenSavesToRedis() { + // given & when + String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + // then + assertThat(refreshToken).isNotNull(); + + String storedToken = redisTemplate.opsForValue().get("refresh_token:" + TEST_USER_ID); + assertThat(storedToken).isEqualTo(refreshToken); + } + + @Test + @DisplayName("Refresh Token 검증 성공") + void validateRefreshTokenSuccess() { + // given + String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + // when & then + assertThat(jwtService.validateRefreshToken(refreshToken, TEST_USER_ID)).isTrue(); + } + + @Test + @DisplayName("잘못된 Refresh Token 검증 실패") + void validateRefreshTokenFail() { + // given + String realToken = jwtService.generateRefreshToken(TEST_USER_ID); + String fakeToken = "fake.refresh.token"; + + // when & then + assertThat(jwtService.validateRefreshToken(fakeToken, TEST_USER_ID)).isFalse(); + } + + @Test + @DisplayName("토큰 블랙리스트 등록 및 확인") + void blacklistToken() { + // given + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + Duration ttl = Duration.ofMinutes(5); + + // when + jwtService.blacklistToken(accessToken, ttl); + + // then + assertThat(jwtService.isTokenBlacklisted(accessToken)).isTrue(); + } + + @Test + @DisplayName("블랙리스트에 등록된 토큰은 검증 시 무효 처리") + void blacklistedTokenIsInvalid() { + // given + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + jwtService.blacklistToken(accessToken, Duration.ofMinutes(5)); + + // when & then + assertThat(jwtService.isTokenBlacklisted(accessToken)).isTrue(); + } + + @Test + @DisplayName("Refresh Token 삭제 성공") + void deleteRefreshToken() { + // given + String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isTrue(); + + // when + jwtService.deleteRefreshToken(TEST_USER_ID); + + // then + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isFalse(); + } + + @Test + @DisplayName("유효하지 않은 토큰 검증 시 예외 발생") + void validateInvalidToken() { + // given + String invalidToken = "invalid.token.here"; + + // when & then + assertThatThrownBy(() -> jwtService.validateToken(invalidToken)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); + } + + @Test + @DisplayName("만료된 토큰 검증 시 예외 발생") + void validateExpiredToken() throws InterruptedException { + // given - 1초 만료 토큰 생성 (테스트용으로 설정 변경 필요) + // 실제로는 application-test.yml에서 jwt.access-token-validity=1000 설정 + + // 이 테스트는 실제 만료를 기다려야 하므로 주석 처리 + // 대신 통합 테스트나 수동 테스트로 확인 권장 + } + + @Test + @DisplayName("남은 만료 시간 계산 정확성") + void getRemainingExpiration() throws InterruptedException { + // given + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + + // when + Duration remaining = jwtService.getRemainingExpiration(accessToken); + + // then + assertThat(remaining.toMillis()).isGreaterThan(0); + assertThat(remaining.toMinutes()).isLessThanOrEqualTo(30); // 기본 30분 이하 + } + + @Test + @DisplayName("새 Refresh Token 발급 시 기존 토큰 교체됨") + void newRefreshTokenReplacesOld() { + // given + String oldRefreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + // when + String newRefreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + // then + assertThat(newRefreshToken).isNotEqualTo(oldRefreshToken); + + String storedToken = redisTemplate.opsForValue().get("refresh_token:" + TEST_USER_ID); + assertThat(storedToken).isEqualTo(newRefreshToken); + assertThat(jwtService.validateRefreshToken(oldRefreshToken, TEST_USER_ID)).isFalse(); + } +} \ No newline at end of file From 5d23f9f39a3e1d025f0e4f115103ca34c6b138ca Mon Sep 17 00:00:00 2001 From: minkyung Date: Sun, 25 Jan 2026 16:35:49 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hrr/backend/domain/auth/service/JwtServiceTest.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java index 803f3962..73624284 100644 --- a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java +++ b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java @@ -130,15 +130,6 @@ void validateInvalidToken() { .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); } - @Test - @DisplayName("만료된 토큰 검증 시 예외 발생") - void validateExpiredToken() throws InterruptedException { - // given - 1초 만료 토큰 생성 (테스트용으로 설정 변경 필요) - // 실제로는 application-test.yml에서 jwt.access-token-validity=1000 설정 - - // 이 테스트는 실제 만료를 기다려야 하므로 주석 처리 - // 대신 통합 테스트나 수동 테스트로 확인 권장 - } @Test @DisplayName("남은 만료 시간 계산 정확성") From 8fa50e9786550f14bb69fa4eb03333700e04c9f3 Mon Sep 17 00:00:00 2001 From: minkyung Date: Sat, 7 Feb 2026 13:32:01 +0900 Subject: [PATCH 06/11] =?UTF-8?q?[FIX/#60]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.java | 12 +++++---- .../domain/auth/service/JwtService.java | 5 ++++ .../domain/auth/service/JwtServiceTest.java | 25 ++++++++++++++----- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index a4192e83..118eb71a 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -77,10 +77,14 @@ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { // Refresh Token 유효성 검증 jwtService.validateToken(refreshToken); + // Redis에 저장된 Refresh Token과 비교 검증 + if (!jwtService.isTokenBlacklisted(refreshToken)) { + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); + } + // userId 추출 Long userId = jwtService.extractUserId(refreshToken); - // Redis에 저장된 Refresh Token과 비교 검증 if (!jwtService.validateRefreshToken(refreshToken, userId)) { throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); } @@ -93,11 +97,9 @@ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { // 기존 Refresh Token 블랙리스트 처리 (만료되지 않은 경우에만) Duration remainingExpiration = jwtService.getRemainingExpiration(refreshToken); - if (remainingExpiration.isNegative() || remainingExpiration.isZero()) { - // 이미 만료된 토큰은 블랙리스트에 넣을 필요 없음 - return new AuthResponseDto.TokenReissueResponse(newAccessToken, newRefreshToken); + if (!remainingExpiration.isNegative() && !remainingExpiration.isZero()) { + jwtService.blacklistToken(refreshToken, remainingExpiration); } - jwtService.blacklistToken(refreshToken, remainingExpiration); return new AuthResponseDto.TokenReissueResponse(newAccessToken, newRefreshToken); } diff --git a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java index 9d72f650..e1174a0e 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java @@ -154,6 +154,11 @@ public Duration getRemainingExpiration(String token) { // Refresh Token 검증 (Redis에 저장된 토큰과 비교) public boolean validateRefreshToken(String refreshToken, Long userId) { String storedToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); + + if (storedToken == null || refreshToken == null) { + return false; + } + return refreshToken.equals(storedToken); } diff --git a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java index 73624284..bf9906a0 100644 --- a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java +++ b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java @@ -6,12 +6,15 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.test.context.ActiveProfiles; import java.time.Duration; -import java.util.concurrent.TimeUnit; +import java.util.Objects; import static org.assertj.core.api.Assertions.*; @@ -25,12 +28,19 @@ class JwtServiceTest { @Autowired private StringRedisTemplate redisTemplate; + // [Code Review 반영] 하드코딩된 값 대신 설정 파일의 값을 주입받아 사용 + @Value("${jwt.access-token-validity}") + private long accessTokenValidity; + private static final Long TEST_USER_ID = 1L; @BeforeEach void setUp() { - // Redis 초기화 - redisTemplate.keys("*").forEach(key -> redisTemplate.delete(key)); + // [Code Review 반영] keys("*")는 블로킹 연산이므로 flushAll()로 대체하여 안전하게 초기화 + redisTemplate.execute((RedisCallback) connection -> { + connection.serverCommands().flushAll(); + return null; + }); } @Test @@ -72,7 +82,7 @@ void validateRefreshTokenSuccess() { @DisplayName("잘못된 Refresh Token 검증 실패") void validateRefreshTokenFail() { // given - String realToken = jwtService.generateRefreshToken(TEST_USER_ID); + // [Code Review 반영] 사용하지 않는 realToken 변수 삭제 String fakeToken = "fake.refresh.token"; // when & then @@ -133,7 +143,7 @@ void validateInvalidToken() { @Test @DisplayName("남은 만료 시간 계산 정확성") - void getRemainingExpiration() throws InterruptedException { + void getRemainingExpiration() { // given String accessToken = jwtService.generateAccessToken(TEST_USER_ID); @@ -142,7 +152,10 @@ void getRemainingExpiration() throws InterruptedException { // then assertThat(remaining.toMillis()).isGreaterThan(0); - assertThat(remaining.toMinutes()).isLessThanOrEqualTo(30); // 기본 30분 이하 + + // 하드코딩(30분) 대신 설정된 유효 시간으로 검증 (밀리초 -> 분 변환) + long validityInMinutes = accessTokenValidity / (1000 * 60); + assertThat(remaining.toMinutes()).isLessThanOrEqualTo(validityInMinutes); } @Test From 4a8b7ec756d4ebe6ad2a1b22953a5f6e47e71bfa Mon Sep 17 00:00:00 2001 From: minkyung Date: Sat, 7 Feb 2026 13:49:22 +0900 Subject: [PATCH 07/11] =?UTF-8?q?[FIX/#60]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hrr/backend/domain/auth/service/AuthService.java | 2 +- .../java/com/hrr/backend/domain/auth/service/JwtService.java | 5 ++++- .../com/hrr/backend/domain/auth/service/JwtServiceTest.java | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index 118eb71a..737939af 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -78,7 +78,7 @@ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { jwtService.validateToken(refreshToken); // Redis에 저장된 Refresh Token과 비교 검증 - if (!jwtService.isTokenBlacklisted(refreshToken)) { + if (jwtService.isTokenBlacklisted(refreshToken)) { throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); } diff --git a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java index e1174a0e..a7a2e2eb 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java @@ -68,6 +68,9 @@ public String generateRefreshToken(Long userId) { } public boolean validateToken(String token) { + if (isTokenBlacklisted(token)) { + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); + } try { Jwts.parserBuilder() .setSigningKey(getSigningKey()) @@ -125,7 +128,7 @@ public void blacklistToken(String token, Duration expirationDuration) { } public boolean isTokenBlacklisted(String token) { - return redisTemplate.hasKey(BLACKLIST_PREFIX + token); + return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + token)); } public Duration getRemainingExpiration(String token) { diff --git a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java index bf9906a0..00b66cea 100644 --- a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java +++ b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java @@ -82,7 +82,6 @@ void validateRefreshTokenSuccess() { @DisplayName("잘못된 Refresh Token 검증 실패") void validateRefreshTokenFail() { // given - // [Code Review 반영] 사용하지 않는 realToken 변수 삭제 String fakeToken = "fake.refresh.token"; // when & then @@ -111,7 +110,9 @@ void blacklistedTokenIsInvalid() { jwtService.blacklistToken(accessToken, Duration.ofMinutes(5)); // when & then - assertThat(jwtService.isTokenBlacklisted(accessToken)).isTrue(); + assertThatThrownBy(() -> jwtService.validateToken(accessToken)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); } @Test From d847eacd23486d9ab44194de57ec0622012bbdc2 Mon Sep 17 00:00:00 2001 From: minkyung Date: Sat, 7 Feb 2026 14:44:08 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/hrr/backend/domain/auth/service/AuthService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index 737939af..4f9d7b66 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -239,6 +239,7 @@ public void revoke(Long userId) { case KAKAO -> kakaoAuthService.unlink(socialAuth.getSocialId()); default -> throw new GlobalException(ErrorCode.AUTH_INVALID_SOCIAL_TYPE); } + jwtService.deleteRefreshToken(userId); } } \ No newline at end of file From c105a30269ab2a85db41359e99ac0640717ae7b8 Mon Sep 17 00:00:00 2001 From: minkyung Date: Sat, 7 Feb 2026 15:20:23 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 20 ++++----- .../domain/auth/service/AuthService.java | 31 ++++++++++++-- .../domain/auth/service/JwtService.java | 5 +++ .../auth/service/AuthServiceWithdrawTest.java | 41 +++++++++++++------ 4 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/controller/AuthController.java b/src/main/java/com/hrr/backend/domain/auth/controller/AuthController.java index 785ad9b3..987c6e30 100644 --- a/src/main/java/com/hrr/backend/domain/auth/controller/AuthController.java +++ b/src/main/java/com/hrr/backend/domain/auth/controller/AuthController.java @@ -156,17 +156,15 @@ public ApiResponse logout(@RequestHeader("Authorization") String authori return ApiResponse.onSuccess(SuccessCode.OK, "로그아웃에 성공했습니다."); } - @PostMapping("/withdraw") - @Operation(summary = "회원 탈퇴", - description = "탈퇴를 진행합니다. 1개월 동안 재가입이 불가하며 모든 정보가 삭제됩니다.") - public ApiResponse withdraw( - @Parameter(hidden = true) - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - authService.withdraw(userDetails.getUser().getId()); - - return ApiResponse.onSuccess(SuccessCode.OK, "회원 탈퇴에 성공했습니다."); - } + @PostMapping("/withdraw") + @Operation(summary = "회원 탈퇴", description = "탈퇴를 진행합니다. 1개월 동안 재가입이 불가하며 모든 정보가 삭제됩니다.") + public ApiResponse withdraw( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestHeader("Authorization") String authorizationHeader // 추가됨 + ) { + authService.withdraw(userDetails.getUser().getId(), authorizationHeader); + return ApiResponse.onSuccess(SuccessCode.OK, "회원 탈퇴에 성공했습니다."); + } /** * 애플 로그인 테스트용 임시 리다이렉트 url diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index 4f9d7b66..e2e8bde7 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -1,6 +1,7 @@ package com.hrr.backend.domain.auth.service; import java.time.Duration; +import java.util.List; import java.util.Map; import com.hrr.backend.domain.auth.dto.AuthRequestDto; @@ -12,6 +13,8 @@ import com.hrr.backend.domain.auth.entity.enums.SocialType; import com.hrr.backend.domain.auth.repository.SocialAuthRepository; import com.hrr.backend.domain.user.entity.User; +import com.hrr.backend.domain.user.entity.UserChallenge; +import com.hrr.backend.domain.user.entity.enums.ChallengeJoinStatus; import com.hrr.backend.domain.user.repository.UserChallengeRepository; import com.hrr.backend.domain.user.repository.UserRepository; import com.hrr.backend.global.exception.GlobalException; @@ -85,7 +88,10 @@ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { // userId 추출 Long userId = jwtService.extractUserId(refreshToken); - if (!jwtService.validateRefreshToken(refreshToken, userId)) { + String storedRefreshToken = jwtService.getAndDeleteRefreshToken(userId); + + // 저장된 토큰이 없거나(이미 사용됨/만료됨), 요청한 토큰과 다를 경우 실패 처리 + if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) { throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); } @@ -143,7 +149,9 @@ public AuthResponseDto.LoginResponse appleLogin(AuthRequestDto.AppleLoginRequest String socialId = appleAuthService.getAppleAccountId(appleTokens.get("id_token")); String appleRefreshToken = appleTokens.get("refresh_token"); - + if (appleRefreshToken == null) { + appleRefreshToken = ""; // 또는 null 상태로 전달하되 upsert 로직에서 무시하도록 처리 + } User user = socialUserService.upsertAppleUser(socialId, appleRefreshToken, request.getName()); String accessToken = jwtService.generateAccessToken(user.getId()); @@ -215,12 +223,29 @@ public void logout(String tokenHeader) { } @Transactional - public void withdraw(Long userId) { + public void withdraw(Long userId, String tokenHeader) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND)); user.withdraw(); + List userChallenges = userChallengeRepository.findByUserAndStatus(user, ChallengeJoinStatus.JOINED); + userChallenges.forEach(userChallenge -> { + // 챌린지 엔티티의 인원 수 감소 로직 호출 + userChallenge.getChallenge().decreaseCurrentParticipants(); + // 상태 변경 + userChallenge.updateStatus(ChallengeJoinStatus.DROPPED); + }); + + // Access Token 블랙리스트 처리 + if (tokenHeader != null) { + String token = tokenHeader.startsWith("Bearer ") ? tokenHeader.substring(7) : tokenHeader; + Duration remainingExpiration = jwtService.getRemainingExpiration(token); + if (!remainingExpiration.isNegative() && !remainingExpiration.isZero()) { + jwtService.blacklistToken(token, remainingExpiration); + } + } + // 탈퇴 시 Refresh Token 삭제 jwtService.deleteRefreshToken(userId); } diff --git a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java index a7a2e2eb..48e403dc 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java @@ -165,6 +165,11 @@ public boolean validateRefreshToken(String refreshToken, Long userId) { return refreshToken.equals(storedToken); } + // Redis에서 키를 조회함과 동시에 삭제하여 동시성 문제를 해결 + public String getAndDeleteRefreshToken(Long userId) { + return redisTemplate.opsForValue().getAndDelete(REFRESH_TOKEN_PREFIX + userId); + } + // Refresh Token 삭제 (로그아웃 시 사용) public void deleteRefreshToken(Long userId) { redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); diff --git a/src/test/java/com/hrr/backend/domain/auth/service/AuthServiceWithdrawTest.java b/src/test/java/com/hrr/backend/domain/auth/service/AuthServiceWithdrawTest.java index d5816729..fd820b42 100644 --- a/src/test/java/com/hrr/backend/domain/auth/service/AuthServiceWithdrawTest.java +++ b/src/test/java/com/hrr/backend/domain/auth/service/AuthServiceWithdrawTest.java @@ -3,6 +3,7 @@ import static org.mockito.BDDMockito.*; import static org.assertj.core.api.Assertions.*; +import java.time.Duration; // import 추가 import java.util.List; import java.util.Optional; @@ -32,42 +33,49 @@ class AuthServiceWithdrawTest { @Mock private UserChallengeRepository userChallengeRepository; + // [추가] withdraw 메서드 내부에서 jwtService를 호출하므로 Mock 추가 필요 + @Mock + private JwtService jwtService; + @Test @DisplayName("회원 탈퇴 성공 - 참여 중인 챌린지 인원 감소 및 상태 변경 확인") void withdraw_success() { // given Long userId = 1L; - User user = mock(User.class); // 유저 객체 모킹 + String dummyToken = "Bearer test_token"; // 더미 토큰 + User user = mock(User.class); - // 테스트용 챌린지 생성 (현재 인원 5명) Challenge challenge = Challenge.builder() .currentParticipants(5) .build(); - // 테스트용 유저-챌린지 참여 정보 생성 (JOINED 상태) UserChallenge userChallenge = UserChallenge.builder() .user(user) .challenge(challenge) .status(ChallengeJoinStatus.JOINED) .build(); - // Repository 모킹 given(userRepository.findById(userId)).willReturn(Optional.of(user)); given(userChallengeRepository.findByUserAndStatus(user, ChallengeJoinStatus.JOINED)) .willReturn(List.of(userChallenge)); + // [추가] JwtService 호출에 대한 Stubbing (NPE 방지) + given(jwtService.getRemainingExpiration(anyString())).willReturn(Duration.ofMinutes(10)); + // when - authService.withdraw(userId); + // [수정] 인자 2개 전달 (userId, token) + authService.withdraw(userId, dummyToken); // then - // 1. 유저 엔티티의 withdraw() 메서드가 호출되었는지 확인 verify(user).withdraw(); - // 2. 챌린지 엔티티의 인원수가 감소했는지 확인 (5 -> 4) + // 챌린지 로직이 정상 수행되었는지 확인 (AssertionFailedError 해결 확인용) assertThat(challenge.getCurrentParticipants()).isEqualTo(4); - - // 3. UserChallenge의 상태가 DROPPED로 변경되었는지 확인 assertThat(userChallenge.getStatus()).isEqualTo(ChallengeJoinStatus.DROPPED); + + // 토큰 삭제 로직 수행 확인 + verify(jwtService).blacklistToken(anyString(), any(Duration.class)); + verify(jwtService).deleteRefreshToken(userId); } @Test @@ -75,18 +83,25 @@ void withdraw_success() { void withdraw_success_no_active_challenges() { // given Long userId = 1L; + String dummyToken = "Bearer test_token"; User user = mock(User.class); given(userRepository.findById(userId)).willReturn(Optional.of(user)); - // 참여 중인 챌린지가 없음 given(userChallengeRepository.findByUserAndStatus(user, ChallengeJoinStatus.JOINED)) - .willReturn(List.of()); + .willReturn(List.of()); // 빈 리스트 반환 + + given(jwtService.getRemainingExpiration(anyString())).willReturn(Duration.ofMinutes(10)); // when - authService.withdraw(userId); + // [수정] 인자 2개 전달 + authService.withdraw(userId, dummyToken); // then verify(user).withdraw(); - // 별다른 에러 없이 로직이 종료되어야 함 + verify(jwtService).deleteRefreshToken(userId); + + // UnnecessaryStubbingException 해결: + // AuthService에 userChallengeRepository 호출 로직이 복구되었으므로, + // 위에서 선언한 given(userChallengeRepository...)가 정상적으로 사용되어 에러가 사라집니다. } } \ No newline at end of file From 19735ce709a64bccf5a9d5ac878630976ad6e95b Mon Sep 17 00:00:00 2001 From: minkyung Date: Sat, 7 Feb 2026 15:45:55 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.java | 24 ++++++++++++------- .../domain/auth/service/JwtService.java | 16 +++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index e2e8bde7..a5932bae 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -210,7 +210,7 @@ public void logout(String tokenHeader) { : tokenHeader; // userId 추출 - Long userId = jwtService.extractUserId(token); + Long userId = jwtService.getUserIdFromToken(token); // Access Token 블랙리스트 처리 Duration remainingExpiration = jwtService.getRemainingExpiration(token); @@ -258,13 +258,19 @@ public void revoke(Long userId) { SocialAuth socialAuth = socialAuthRepository.findByUser(user) .orElseThrow(() -> new GlobalException(ErrorCode.AUTH_INFO_NOT_FOUND)); - switch (socialAuth.getSocialType()) { - case NAVER -> naverAuthService.revoke(socialAuth.getSocialRefreshToken()); - case APPLE -> appleAuthService.revoke(socialAuth.getSocialRefreshToken()); - case KAKAO -> kakaoAuthService.unlink(socialAuth.getSocialId()); - default -> throw new GlobalException(ErrorCode.AUTH_INVALID_SOCIAL_TYPE); + try { + switch (socialAuth.getSocialType()) { + case NAVER -> naverAuthService.revoke(socialAuth.getSocialRefreshToken()); + case APPLE -> appleAuthService.revoke(socialAuth.getSocialRefreshToken()); + case KAKAO -> kakaoAuthService.unlink(socialAuth.getSocialId()); + default -> throw new GlobalException(ErrorCode.AUTH_INVALID_SOCIAL_TYPE); + } + } catch (Exception e) { + log.error("Social revoke failed for userId: {}", userId, e); + throw new GlobalException(ErrorCode.AUTH_EXTERNAL_API_ERROR); + } finally { + // 외부 연동 해제 실패 여부와 상관없이 내부 Refresh Token은 삭제하여 재로그인 방지 + jwtService.deleteRefreshToken(userId); } - jwtService.deleteRefreshToken(userId); - } - +} } \ No newline at end of file diff --git a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java index 48e403dc..60791b24 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java @@ -110,6 +110,22 @@ public Long extractUserId(String token) { throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); } } + public Long getUserIdFromToken(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + return Long.parseLong(claims.getSubject()); + } catch (ExpiredJwtException e) { + // 만료된 토큰에서도 Claims(Subject=UserId) 추출 가능 + return Long.parseLong(e.getClaims().getSubject()); + } catch (Exception e) { + // 그 외 서명 불일치 등은 유효하지 않은 토큰 처리 + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); + } + } public String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); From f92d8efc8c0b5d3e089be2a7d766fc4b4e2bf0ea Mon Sep 17 00:00:00 2001 From: minkyung Date: Sat, 7 Feb 2026 16:02:30 +0900 Subject: [PATCH 11/11] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.java | 3 -- .../domain/auth/service/JwtService.java | 11 ----- .../domain/auth/service/JwtServiceTest.java | 49 +++++-------------- 3 files changed, 11 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java index a5932bae..37c524c6 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/AuthService.java @@ -149,9 +149,6 @@ public AuthResponseDto.LoginResponse appleLogin(AuthRequestDto.AppleLoginRequest String socialId = appleAuthService.getAppleAccountId(appleTokens.get("id_token")); String appleRefreshToken = appleTokens.get("refresh_token"); - if (appleRefreshToken == null) { - appleRefreshToken = ""; // 또는 null 상태로 전달하되 upsert 로직에서 무시하도록 처리 - } User user = socialUserService.upsertAppleUser(socialId, appleRefreshToken, request.getName()); String accessToken = jwtService.generateAccessToken(user.getId()); diff --git a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java index 60791b24..56720fec 100644 --- a/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java +++ b/src/main/java/com/hrr/backend/domain/auth/service/JwtService.java @@ -170,17 +170,6 @@ public Duration getRemainingExpiration(String token) { } } - // Refresh Token 검증 (Redis에 저장된 토큰과 비교) - public boolean validateRefreshToken(String refreshToken, Long userId) { - String storedToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); - - if (storedToken == null || refreshToken == null) { - return false; - } - - return refreshToken.equals(storedToken); - } - // Redis에서 키를 조회함과 동시에 삭제하여 동시성 문제를 해결 public String getAndDeleteRefreshToken(Long userId) { return redisTemplate.opsForValue().getAndDelete(REFRESH_TOKEN_PREFIX + userId); diff --git a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java index 00b66cea..20d66aa5 100644 --- a/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java +++ b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java @@ -8,13 +8,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.test.context.ActiveProfiles; import java.time.Duration; -import java.util.Objects; import static org.assertj.core.api.Assertions.*; @@ -28,7 +26,6 @@ class JwtServiceTest { @Autowired private StringRedisTemplate redisTemplate; - // [Code Review 반영] 하드코딩된 값 대신 설정 파일의 값을 주입받아 사용 @Value("${jwt.access-token-validity}") private long accessTokenValidity; @@ -36,7 +33,7 @@ class JwtServiceTest { @BeforeEach void setUp() { - // [Code Review 반영] keys("*")는 블로킹 연산이므로 flushAll()로 대체하여 안전하게 초기화 + // Redis 초기화 redisTemplate.execute((RedisCallback) connection -> { connection.serverCommands().flushAll(); return null; @@ -46,10 +43,8 @@ void setUp() { @Test @DisplayName("Access Token 생성 및 검증 성공") void generateAndValidateAccessToken() { - // given & when String accessToken = jwtService.generateAccessToken(TEST_USER_ID); - // then assertThat(accessToken).isNotNull(); assertThat(jwtService.validateToken(accessToken)).isTrue(); assertThat(jwtService.extractUserId(accessToken)).isEqualTo(TEST_USER_ID); @@ -58,58 +53,47 @@ void generateAndValidateAccessToken() { @Test @DisplayName("Refresh Token 생성 시 Redis에 저장됨") void generateRefreshTokenSavesToRedis() { - // given & when String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); - // then assertThat(refreshToken).isNotNull(); String storedToken = redisTemplate.opsForValue().get("refresh_token:" + TEST_USER_ID); assertThat(storedToken).isEqualTo(refreshToken); } + // [수정됨] validateRefreshToken 메서드 삭제로 인해 getAndDeleteRefreshToken 테스트로 변경 @Test - @DisplayName("Refresh Token 검증 성공") - void validateRefreshTokenSuccess() { + @DisplayName("Refresh Token 조회 및 삭제(Atomic) 성공") + void getAndDeleteRefreshTokenSuccess() { // given String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); - // when & then - assertThat(jwtService.validateRefreshToken(refreshToken, TEST_USER_ID)).isTrue(); - } - - @Test - @DisplayName("잘못된 Refresh Token 검증 실패") - void validateRefreshTokenFail() { - // given - String fakeToken = "fake.refresh.token"; + // when + // 조회와 동시에 삭제가 일어나는지 검증 + String storedToken = jwtService.getAndDeleteRefreshToken(TEST_USER_ID); - // when & then - assertThat(jwtService.validateRefreshToken(fakeToken, TEST_USER_ID)).isFalse(); + // then + assertThat(storedToken).isEqualTo(refreshToken); // 값 일치 확인 + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isFalse(); // Redis에서 삭제되었는지 확인 } @Test @DisplayName("토큰 블랙리스트 등록 및 확인") void blacklistToken() { - // given String accessToken = jwtService.generateAccessToken(TEST_USER_ID); Duration ttl = Duration.ofMinutes(5); - // when jwtService.blacklistToken(accessToken, ttl); - // then assertThat(jwtService.isTokenBlacklisted(accessToken)).isTrue(); } @Test @DisplayName("블랙리스트에 등록된 토큰은 검증 시 무효 처리") void blacklistedTokenIsInvalid() { - // given String accessToken = jwtService.generateAccessToken(TEST_USER_ID); jwtService.blacklistToken(accessToken, Duration.ofMinutes(5)); - // when & then assertThatThrownBy(() -> jwtService.validateToken(accessToken)) .isInstanceOf(GlobalException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); @@ -118,43 +102,33 @@ void blacklistedTokenIsInvalid() { @Test @DisplayName("Refresh Token 삭제 성공") void deleteRefreshToken() { - // given - String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + jwtService.generateRefreshToken(TEST_USER_ID); assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isTrue(); - // when jwtService.deleteRefreshToken(TEST_USER_ID); - // then assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isFalse(); } @Test @DisplayName("유효하지 않은 토큰 검증 시 예외 발생") void validateInvalidToken() { - // given String invalidToken = "invalid.token.here"; - // when & then assertThatThrownBy(() -> jwtService.validateToken(invalidToken)) .isInstanceOf(GlobalException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); } - @Test @DisplayName("남은 만료 시간 계산 정확성") void getRemainingExpiration() { - // given String accessToken = jwtService.generateAccessToken(TEST_USER_ID); - // when Duration remaining = jwtService.getRemainingExpiration(accessToken); - // then assertThat(remaining.toMillis()).isGreaterThan(0); - // 하드코딩(30분) 대신 설정된 유효 시간으로 검증 (밀리초 -> 분 변환) long validityInMinutes = accessTokenValidity / (1000 * 60); assertThat(remaining.toMinutes()).isLessThanOrEqualTo(validityInMinutes); } @@ -173,6 +147,5 @@ void newRefreshTokenReplacesOld() { String storedToken = redisTemplate.opsForValue().get("refresh_token:" + TEST_USER_ID); assertThat(storedToken).isEqualTo(newRefreshToken); - assertThat(jwtService.validateRefreshToken(oldRefreshToken, TEST_USER_ID)).isFalse(); } } \ No newline at end of file