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/dto/AuthResponseDto.java b/src/main/java/com/hrr/backend/domain/auth/dto/AuthResponseDto.java index 42104d29..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 @@ -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 ) {} - -} +} \ 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 fd6652eb..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 @@ -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; @@ -28,58 +31,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 재발급 */ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { - // "Bearer " 접두사 제거 String refreshToken = refreshHeader.startsWith("Bearer ") ? refreshHeader.substring(7) : refreshHeader; @@ -87,196 +80,194 @@ public AuthResponseDto.TokenReissueResponse reissueToken(String refreshHeader) { // Refresh Token 유효성 검증 jwtService.validateToken(refreshToken); - // userId 추출 후 새 Access Token 발급 + // Redis에 저장된 Refresh Token과 비교 검증 + if (jwtService.isTokenBlacklisted(refreshToken)) { + throw new GlobalException(ErrorCode.AUTH_INVALID_TOKEN); + } + + // userId 추출 Long userId = jwtService.extractUserId(refreshToken); + + String storedRefreshToken = jwtService.getAndDeleteRefreshToken(userId); + + // 저장된 토큰이 없거나(이미 사용됨/만료됨), 요청한 토큰과 다를 경우 실패 처리 + if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) { + 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()) { + 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); + } } - /** - * 테스트용 / 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); - } - } + 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.getUserIdFromToken(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, 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); + } + + @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)); + + 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); + } } +} \ 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 97132c82..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 @@ -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,36 @@ 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)) + .setId(java.util.UUID.randomUUID().toString()) .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) { + if (isTokenBlacklisted(token)) { + throw new GlobalException(ErrorCode.AUTH_INVALID_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 +84,6 @@ public boolean validateToken(String token) { } } - /** 토큰 만료 여부 확인 - * 만료가 되면 true를 반환하게 하고 유효하거나 파싱 불가할 때 false 반환하게 함*/ public boolean isTokenExpired(String token) { try { Date expiration = Jwts.parserBuilder() @@ -93,7 +98,6 @@ public boolean isTokenExpired(String token) { } } - /** userId(subject) 추출 */ public Long extractUserId(String token) { try { Claims claims = Jwts.parserBuilder() @@ -106,63 +110,73 @@ 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); + } + } - /** 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 Boolean.TRUE.equals(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; + } + } + + // 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); + } +} \ No newline at end of file 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/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 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..20d66aa5 --- /dev/null +++ b/src/test/java/com/hrr/backend/domain/auth/service/JwtServiceTest.java @@ -0,0 +1,151 @@ +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.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +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 static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") // test용 프로파일 사용 +class JwtServiceTest { + + @Autowired + private JwtService jwtService; + + @Autowired + private StringRedisTemplate redisTemplate; + + @Value("${jwt.access-token-validity}") + private long accessTokenValidity; + + private static final Long TEST_USER_ID = 1L; + + @BeforeEach + void setUp() { + // Redis 초기화 + redisTemplate.execute((RedisCallback) connection -> { + connection.serverCommands().flushAll(); + return null; + }); + } + + @Test + @DisplayName("Access Token 생성 및 검증 성공") + void generateAndValidateAccessToken() { + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + + assertThat(accessToken).isNotNull(); + assertThat(jwtService.validateToken(accessToken)).isTrue(); + assertThat(jwtService.extractUserId(accessToken)).isEqualTo(TEST_USER_ID); + } + + @Test + @DisplayName("Refresh Token 생성 시 Redis에 저장됨") + void generateRefreshTokenSavesToRedis() { + String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + assertThat(refreshToken).isNotNull(); + + String storedToken = redisTemplate.opsForValue().get("refresh_token:" + TEST_USER_ID); + assertThat(storedToken).isEqualTo(refreshToken); + } + + // [수정됨] validateRefreshToken 메서드 삭제로 인해 getAndDeleteRefreshToken 테스트로 변경 + @Test + @DisplayName("Refresh Token 조회 및 삭제(Atomic) 성공") + void getAndDeleteRefreshTokenSuccess() { + // given + String refreshToken = jwtService.generateRefreshToken(TEST_USER_ID); + + // when + // 조회와 동시에 삭제가 일어나는지 검증 + String storedToken = jwtService.getAndDeleteRefreshToken(TEST_USER_ID); + + // then + assertThat(storedToken).isEqualTo(refreshToken); // 값 일치 확인 + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isFalse(); // Redis에서 삭제되었는지 확인 + } + + @Test + @DisplayName("토큰 블랙리스트 등록 및 확인") + void blacklistToken() { + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + Duration ttl = Duration.ofMinutes(5); + + jwtService.blacklistToken(accessToken, ttl); + + assertThat(jwtService.isTokenBlacklisted(accessToken)).isTrue(); + } + + @Test + @DisplayName("블랙리스트에 등록된 토큰은 검증 시 무효 처리") + void blacklistedTokenIsInvalid() { + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + jwtService.blacklistToken(accessToken, Duration.ofMinutes(5)); + + assertThatThrownBy(() -> jwtService.validateToken(accessToken)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); + } + + @Test + @DisplayName("Refresh Token 삭제 성공") + void deleteRefreshToken() { + jwtService.generateRefreshToken(TEST_USER_ID); + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isTrue(); + + jwtService.deleteRefreshToken(TEST_USER_ID); + + assertThat(redisTemplate.hasKey("refresh_token:" + TEST_USER_ID)).isFalse(); + } + + @Test + @DisplayName("유효하지 않은 토큰 검증 시 예외 발생") + void validateInvalidToken() { + String invalidToken = "invalid.token.here"; + + assertThatThrownBy(() -> jwtService.validateToken(invalidToken)) + .isInstanceOf(GlobalException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.AUTH_INVALID_TOKEN); + } + + @Test + @DisplayName("남은 만료 시간 계산 정확성") + void getRemainingExpiration() { + String accessToken = jwtService.generateAccessToken(TEST_USER_ID); + + Duration remaining = jwtService.getRemainingExpiration(accessToken); + + assertThat(remaining.toMillis()).isGreaterThan(0); + + long validityInMinutes = accessTokenValidity / (1000 * 60); + assertThat(remaining.toMinutes()).isLessThanOrEqualTo(validityInMinutes); + } + + @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); + } +} \ No newline at end of file