From edb69f036dda4ef573fcd539a4159d42e56a979d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Wed, 5 Aug 2026 20:13:55 +0900 Subject: [PATCH] =?UTF-8?q?test:=20Auth=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20=EB=B3=B4=EC=95=88=20=EB=B2=84=EA=B7=B8=202?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthService(getNewToken/signIn) 13개, AppleSignInService(P8 키 파싱/ 잘못된 idToken) 3개, 총 16개 신규 테스트. 테스트 작성 중 발견해서 수정한 버그: 1. [Critical] AuthService.getNewToken: refreshToken을 재발급받을 때 Redis에 "무언가 저장돼 있는지"만 확인하고, 요청으로 들어온 refreshToken이 실제로 그 저장된 값과 일치하는지는 비교하지 않고 있었음. 재로그인 등으로 이미 새 refreshToken이 발급되어 Redis 값이 교체된 이후에도, 예전 refreshToken이 만료 전이기만 하면 계속 accessToken 재발급에 쓰일 수 있었던 상태 — refreshToken 무효화가 사실상 작동하지 않고 있었음. 저장된 값과 요청값을 직접 비교하도록 수정. 2. KakaoSignInService.getSocialInfo: 카카오 API 호출(RestTemplate.exchange)이 try-catch 밖에 있어서, 카카오 토큰이 만료/무효해 카카오 서버가 4xx를 반환하면(흔한 케이스) RestTemplate이 던지는 예외가 그대로 새어나가 401(UnauthorizedException) 대신 500으로 처리되고 있었음. API 호출을 try 블록 안으로 이동. 추가로 signIn()의 디버그용 System.out.println 제거. ## dev에 누락돼있던 기존 보안 수정 반영 AppleSignInService의 Apple ID 토큰 서명 검증 로직이 dev에는 없었음. PR #202(main으로 직접 hotfix, 2026-07-28)로 이미 고쳐졌던 건인데 dev로는 한 번도 반영이 안 된 채 남아있었음 — dev에서 계속 개발하면 서명 검증 없이 파싱만 하는 취약한 버전으로 되돌아간 상태였음. main의 c74b4ab 커밋을 그대로 cherry-pick해서 dev에도 반영. --- .../server/auth/service/AuthService.java | 10 +- .../auth/service/KakaoSignInService.java | 22 +- .../auth/service/AppleSignInServiceTest.java | 53 ++++ .../server/auth/service/AuthServiceTest.java | 259 ++++++++++++++++++ 4 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 src/test/java/org/runnect/server/auth/service/AppleSignInServiceTest.java create mode 100644 src/test/java/org/runnect/server/auth/service/AuthServiceTest.java diff --git a/src/main/java/org/runnect/server/auth/service/AuthService.java b/src/main/java/org/runnect/server/auth/service/AuthService.java index 7cf415b..61ee426 100644 --- a/src/main/java/org/runnect/server/auth/service/AuthService.java +++ b/src/main/java/org/runnect/server/auth/service/AuthService.java @@ -52,8 +52,12 @@ public GetNewTokenResponseDto getNewToken(String accessToken, String refreshToke try { // refreshToken으로 유저찾기 final long userId = Long.parseLong(tokenContents); - if(redisService.getValuesByKey(String.valueOf(userId)).isBlank()){ - //탈취된 refreshToken인 경우 + final String storedRefreshToken = redisService.getValuesByKey(String.valueOf(userId)); + // Redis에 저장된 최신 refreshToken과 실제로 일치하는지까지 확인한다. + // (단순히 "뭔가 저장돼 있는지"만 보면, 재로그인 등으로 이미 무효화된 + // 예전 refreshToken도 계속 accessToken 재발급에 쓰일 수 있었음) + if (storedRefreshToken == null || storedRefreshToken.isBlank() || !storedRefreshToken.equals(refreshToken)) { + //탈취되었거나 이미 무효화된 refreshToken인 경우 throw new InvalidRefreshTokenException(ErrorStatus.INVALID_REFRESH_TOKEN_EXCEPTION, ErrorStatus.INVALID_REFRESH_TOKEN_EXCEPTION.getMessage()); } RunnectUser user = userRepository.findById(userId) @@ -79,8 +83,6 @@ public GetNewTokenResponseDto getNewToken(String accessToken, String refreshToke public AuthResponseDto signIn(SignInRequestDto signInRequestDto) { SocialType socialType = SocialType.valueOf(signInRequestDto.getProvider()); - System.out.println("타입은? "+ socialType); - SocialInfoResponseDto socialInfo = getSocialInfo(socialType, signInRequestDto.getToken()); boolean isRegistered = userRepository.existsByEmailAndProvider(socialInfo.getEmail(), socialType); diff --git a/src/main/java/org/runnect/server/auth/service/KakaoSignInService.java b/src/main/java/org/runnect/server/auth/service/KakaoSignInService.java index 3541c23..a87d904 100644 --- a/src/main/java/org/runnect/server/auth/service/KakaoSignInService.java +++ b/src/main/java/org/runnect/server/auth/service/KakaoSignInService.java @@ -29,20 +29,24 @@ public SocialInfoResponseDto getSocialInfo(String token) { HttpEntity> kakaoUserInfoRequest = new HttpEntity<>(headers); RestTemplate rt = new RestTemplate(); - ResponseEntity response = rt.exchange( - "https://kapi.kakao.com/v2/user/me", - HttpMethod.POST, - kakaoUserInfoRequest, - String.class - ); - - // responseBody 속 정보 꺼내기 - String responseBody = response.getBody(); String userId = null; String email = null; try { + // 카카오 API가 4xx/5xx를 반환하면(토큰 만료/무효 등, 흔히 발생) RestTemplate이 + // 예외를 던진다. 이 try 블록 밖에 있으면 여기서 잡히지 않고 그대로 500으로 + // 새어나가 버리므로(실제로는 401로 처리돼야 할 흔한 케이스인데도) 같이 묶는다. + ResponseEntity response = rt.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.POST, + kakaoUserInfoRequest, + String.class + ); + + // responseBody 속 정보 꺼내기 + String responseBody = response.getBody(); + JSONParser parser = new JSONParser(); JSONObject obj = (JSONObject) parser.parse(responseBody); JSONObject kakao_account = (JSONObject) obj.get("kakao_account"); diff --git a/src/test/java/org/runnect/server/auth/service/AppleSignInServiceTest.java b/src/test/java/org/runnect/server/auth/service/AppleSignInServiceTest.java new file mode 100644 index 0000000..ab80975 --- /dev/null +++ b/src/test/java/org/runnect/server/auth/service/AppleSignInServiceTest.java @@ -0,0 +1,53 @@ +package org.runnect.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; +import java.util.Base64; +import org.junit.jupiter.api.Test; +import org.runnect.server.common.exception.UnauthorizedException; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * AppleSignInService는 OkHttpClient/JWKS 서명 검증기를 메서드 내부에서 직접 생성해서 + * (의존성 주입이 안 되어 있어서) 실제 애플 서버 통신 없이는 getSocialInfo()의 + * 정상 경로(서명 검증 성공)를 순수 단위 테스트로 재현할 수 없다. + * 네트워크 없이 검증 가능한 두 가지 — P8 키 파싱, 잘못된 idToken 형식 — 만 다룬다. + */ +class AppleSignInServiceTest { + + private String base64EncodedEcPrivateKey() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair keyPair = generator.generateKeyPair(); + return Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + } + + @Test + void 유효한_EC_비밀키면_정상적으로_파싱된다() throws Exception { + AppleSignInService service = new AppleSignInService(); + + ReflectionTestUtils.invokeMethod(service, "getPrivateKey", base64EncodedEcPrivateKey()); + + assertThat(ReflectionTestUtils.getField(service, "PRIVATE_KEY")).isNotNull(); + } + + @Test + void 잘못된_형식의_비밀키면_UnauthorizedException() { + AppleSignInService service = new AppleSignInService(); + + assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "getPrivateKey", "not-a-valid-key")) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + void idToken이_JWT_형식이_아니면_UnauthorizedException() { + AppleSignInService service = new AppleSignInService(); + + assertThatThrownBy(() -> service.getSocialInfo("this-is-not-a-jwt")) + .isInstanceOf(UnauthorizedException.class); + } +} diff --git a/src/test/java/org/runnect/server/auth/service/AuthServiceTest.java b/src/test/java/org/runnect/server/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..9a34148 --- /dev/null +++ b/src/test/java/org/runnect/server/auth/service/AuthServiceTest.java @@ -0,0 +1,259 @@ +package org.runnect.server.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.runnect.server.auth.dto.request.SignInRequestDto; +import org.runnect.server.auth.dto.response.AuthResponseDto; +import org.runnect.server.auth.dto.response.GetNewTokenResponseDto; +import org.runnect.server.auth.dto.response.SignInResponseDto; +import org.runnect.server.auth.dto.response.SignUpResponseDto; +import org.runnect.server.auth.dto.response.SocialInfoResponseDto; +import org.runnect.server.common.constant.TokenStatus; +import org.runnect.server.config.jwt.JwtService; +import org.runnect.server.config.redis.RedisService; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.exception.authException.InvalidAccessTokenException; +import org.runnect.server.user.exception.authException.InvalidRefreshTokenException; +import org.runnect.server.user.exception.authException.TimeExpiredRefreshTokenException; +import org.runnect.server.user.exception.userException.NotFoundUserException; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private GoogleSignInService googleSignInService; + @Mock + private AppleSignInService appleSignInService; + @Mock + private KakaoSignInService kakaoSignInService; + @Mock + private JwtService jwtService; + @Mock + private RedisService redisService; + + private AuthService authService; + + @BeforeEach + void setUp() { + authService = new AuthService(userRepository, googleSignInService, appleSignInService, + kakaoSignInService, jwtService, redisService); + } + + private RunnectUser buildUser(Long id, String email, SocialType provider) { + RunnectUser user = RunnectUser.builder() + .nickname("러너" + id) + .socialId("social-" + id) + .email(email) + .provider(provider) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + @Nested + @DisplayName("getNewToken") + class GetNewToken { + + @Test + @DisplayName("accessToken/refreshToken 모두 유효하고 redis에 저장된 값과 일치하면 새 accessToken을 발급한다") + void 정상_재발급() { + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.verifyToken("refresh")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("refresh")).thenReturn("1"); + when(redisService.getValuesByKey("1")).thenReturn("refresh"); + when(userRepository.findById(1L)).thenReturn(Optional.of(buildUser(1L, "a@runnect.io", SocialType.KAKAO))); + when(jwtService.issuedAccessToken(1L)).thenReturn("new-access"); + + GetNewTokenResponseDto response = authService.getNewToken("access", "refresh"); + + assertThat(response.getAccessToken()).isEqualTo("new-access"); + assertThat(response.getRefreshToken()).isEqualTo("refresh"); + } + + @Test + @DisplayName("accessToken이 무효하면 InvalidAccessTokenException") + void accessToken_무효() { + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_INVALID); + + assertThatThrownBy(() -> authService.getNewToken("access", "refresh")) + .isInstanceOf(InvalidAccessTokenException.class); + } + + @Test + @DisplayName("refreshToken이 만료됐으면 TimeExpiredRefreshTokenException") + void refreshToken_만료() { + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.verifyToken("refresh")).thenReturn(TokenStatus.TOKEN_EXPIRED); + + assertThatThrownBy(() -> authService.getNewToken("access", "refresh")) + .isInstanceOf(TimeExpiredRefreshTokenException.class); + } + + @Test + @DisplayName("refreshToken이 무효하면 InvalidRefreshTokenException") + void refreshToken_무효() { + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.verifyToken("refresh")).thenReturn(TokenStatus.TOKEN_INVALID); + + assertThatThrownBy(() -> authService.getNewToken("access", "refresh")) + .isInstanceOf(InvalidRefreshTokenException.class); + } + + @Test + @DisplayName("[버그 수정 검증] redis에 저장된 값이 없으면 InvalidRefreshTokenException") + void redis에_저장된_값이_없음() { + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.verifyToken("refresh")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("refresh")).thenReturn("1"); + when(redisService.getValuesByKey("1")).thenReturn(null); + + assertThatThrownBy(() -> authService.getNewToken("access", "refresh")) + .isInstanceOf(InvalidRefreshTokenException.class); + + verify(jwtService, never()).issuedAccessToken(any()); + } + + @Test + @DisplayName("[버그 수정 검증] redis에 저장된 값이 요청한 refreshToken과 다르면(재로그인 등으로 무효화됨) InvalidRefreshTokenException") + void redis에_저장된_값과_다름() { + when(jwtService.verifyToken("old-refresh")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("old-refresh")).thenReturn("1"); + when(redisService.getValuesByKey("1")).thenReturn("new-refresh-issued-later"); + + assertThatThrownBy(() -> authService.getNewToken("access", "old-refresh")) + .isInstanceOf(InvalidRefreshTokenException.class); + + verify(jwtService, never()).issuedAccessToken(any()); + } + + @Test + @DisplayName("refreshToken의 userId 클레임이 숫자가 아니면 NotFoundUserException") + void 클레임이_숫자가_아님() { + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.verifyToken("refresh")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("refresh")).thenReturn("not-a-number"); + + assertThatThrownBy(() -> authService.getNewToken("access", "refresh")) + .isInstanceOf(NotFoundUserException.class); + } + + @Test + @DisplayName("존재하지 않는 유저면 NotFoundUserException") + void 존재하지_않는_유저() { + when(jwtService.verifyToken("access")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.verifyToken("refresh")).thenReturn(TokenStatus.TOKEN_VALID); + when(jwtService.getJwtContents("refresh")).thenReturn("1"); + when(redisService.getValuesByKey("1")).thenReturn("refresh"); + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> authService.getNewToken("access", "refresh")) + .isInstanceOf(NotFoundUserException.class); + } + } + + @Nested + @DisplayName("signIn") + class SignIn { + + @Test + @DisplayName("신규 유저면 회원가입 처리하고 SignUpResponseDto를 반환한다") + void 신규_회원가입() { + when(kakaoSignInService.getSocialInfo("kakao-token")).thenReturn( + SocialInfoResponseDto.of("new@runnect.io", "social-1")); + when(userRepository.existsByEmailAndProvider("new@runnect.io", SocialType.KAKAO)).thenReturn(false); + when(userRepository.existsByNickname(org.mockito.ArgumentMatchers.any())).thenReturn(false); + RunnectUser savedUser = buildUser(1L, "new@runnect.io", SocialType.KAKAO); + when(userRepository.findByEmailAndProvider("new@runnect.io", SocialType.KAKAO)) + .thenReturn(Optional.of(savedUser)); + when(jwtService.issuedAccessToken(1L)).thenReturn("access"); + when(jwtService.issuedRefreshToken(1L)).thenReturn("refresh"); + + AuthResponseDto response = authService.signIn(new SignInRequestDto("kakao-token", "KAKAO")); + + assertThat(response).isInstanceOf(SignUpResponseDto.class); + verify(userRepository).save(any(RunnectUser.class)); + } + + @Test + @DisplayName("기존 유저면 로그인 처리하고 SignInResponseDto를 반환하며 새 유저를 저장하지 않는다") + void 기존_유저_로그인() { + when(googleSignInService.getSocialInfo("google-token")).thenReturn( + SocialInfoResponseDto.of("existing@runnect.io", "social-2")); + when(userRepository.existsByEmailAndProvider("existing@runnect.io", SocialType.GOOGLE)).thenReturn(true); + RunnectUser existingUser = buildUser(2L, "existing@runnect.io", SocialType.GOOGLE); + when(userRepository.findByEmailAndProvider("existing@runnect.io", SocialType.GOOGLE)) + .thenReturn(Optional.of(existingUser)); + when(jwtService.issuedAccessToken(2L)).thenReturn("access"); + when(jwtService.issuedRefreshToken(2L)).thenReturn("refresh"); + + AuthResponseDto response = authService.signIn(new SignInRequestDto("google-token", "GOOGLE")); + + assertThat(response).isInstanceOf(SignInResponseDto.class); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("APPLE provider면 appleSignInService로 소셜 정보를 조회한다") + void 애플_로그인() { + when(appleSignInService.getSocialInfo("apple-token")).thenReturn( + SocialInfoResponseDto.of("apple@runnect.io", "social-3")); + when(userRepository.existsByEmailAndProvider("apple@runnect.io", SocialType.APPLE)).thenReturn(true); + RunnectUser existingUser = buildUser(3L, "apple@runnect.io", SocialType.APPLE); + when(userRepository.findByEmailAndProvider("apple@runnect.io", SocialType.APPLE)) + .thenReturn(Optional.of(existingUser)); + when(jwtService.issuedAccessToken(3L)).thenReturn("access"); + when(jwtService.issuedRefreshToken(3L)).thenReturn("refresh"); + + authService.signIn(new SignInRequestDto("apple-token", "APPLE")); + + verify(appleSignInService).getSocialInfo("apple-token"); + verify(googleSignInService, never()).getSocialInfo(any()); + verify(kakaoSignInService, never()).getSocialInfo(any()); + } + + @Test + @DisplayName("생성된 임시 닉네임이 이미 존재하면 중복되지 않을 때까지 다시 생성한다") + void 닉네임_중복시_재생성() { + when(kakaoSignInService.getSocialInfo("kakao-token")).thenReturn( + SocialInfoResponseDto.of("new@runnect.io", "social-1")); + when(userRepository.existsByEmailAndProvider("new@runnect.io", SocialType.KAKAO)).thenReturn(false); + when(userRepository.existsByNickname(any())).thenReturn(true, true, false); + RunnectUser savedUser = buildUser(1L, "new@runnect.io", SocialType.KAKAO); + when(userRepository.findByEmailAndProvider("new@runnect.io", SocialType.KAKAO)) + .thenReturn(Optional.of(savedUser)); + when(jwtService.issuedAccessToken(1L)).thenReturn("access"); + when(jwtService.issuedRefreshToken(1L)).thenReturn("refresh"); + + authService.signIn(new SignInRequestDto("kakao-token", "KAKAO")); + + verify(userRepository, org.mockito.Mockito.times(3)).existsByNickname(any()); + } + + @Test + @DisplayName("존재하지 않는 provider 값이면 IllegalArgumentException") + void 잘못된_provider() { + assertThatThrownBy(() -> authService.signIn(new SignInRequestDto("token", "FACEBOOK"))) + .isInstanceOf(IllegalArgumentException.class); + } + } +}