diff --git a/src/main/java/org/runnect/server/auth/service/AppleSignInService.java b/src/main/java/org/runnect/server/auth/service/AppleSignInService.java index 6ff1ab2..706149f 100644 --- a/src/main/java/org/runnect/server/auth/service/AppleSignInService.java +++ b/src/main/java/org/runnect/server/auth/service/AppleSignInService.java @@ -1,7 +1,14 @@ package org.runnect.server.auth.service; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; import lombok.RequiredArgsConstructor; import okhttp3.OkHttpClient; import okhttp3.FormBody; @@ -13,9 +20,8 @@ import org.runnect.server.common.exception.UnauthorizedException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import com.nimbusds.jwt.SignedJWT; -import java.text.ParseException; +import java.net.URL; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; @@ -45,6 +51,8 @@ public class AppleSignInService { @Value("${apple.revoke-url}") private String APPLE_REVOKE_URL; + private static final String APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"; + private PrivateKey PRIVATE_KEY; @Value("${apple.p8key}") private void getPrivateKey(String P8KEY){ @@ -61,13 +69,12 @@ private void getPrivateKey(String P8KEY){ public SocialInfoResponseDto getSocialInfo(String idToken) { - // 클라에서 준 인증토큰이 정말 애플에서 발급받은게 맞는지 확인 + // (애플 공개키(JWKS)로 서명을 검증해야만 위조된 토큰을 걸러낼 수 있다 — + // 서명 검증 없이 파싱만 하면 누구나 클레임을 임의로 채운 토큰으로 로그인할 수 있음) try{ - //1. idToken을 parse - SignedJWT jwt = SignedJWT.parse(idToken); - JWTClaimsSet claimsSet = jwt.getJWTClaimsSet(); + JWTClaimsSet claimsSet = verifySignatureAndGetClaims(idToken); // 발급처, aud, 시간제한, 이메일 검증 @@ -90,13 +97,27 @@ public SocialInfoResponseDto getSocialInfo(String idToken) { return SocialInfoResponseDto.of(claimsSet.getStringClaim("email"), claimsSet.getSubject()); - }catch (ParseException e){ + }catch (UnauthorizedException e){ + throw e; + }catch (Exception e){ + // 서명 검증 실패(BadJOSEException), 파싱 실패(ParseException), JWKS 조회 실패(JOSEException) 등 + // 위조/변조된 토큰 또는 애플 검증 자체가 불가능한 경우 전부 동일하게 처리 throw new UnauthorizedException(ErrorStatus.INVALID_APPLE_ID_TOKEN_EXCEPTION, ErrorStatus.INVALID_APPLE_ID_TOKEN_EXCEPTION.getMessage()); } } + private JWTClaimsSet verifySignatureAndGetClaims(String idToken) throws Exception { + JWKSource keySource = new RemoteJWKSet<>(new URL(APPLE_JWKS_URL)); + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); + JWSVerificationKeySelector keySelector = + new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keySource); + jwtProcessor.setJWSKeySelector(keySelector); + // 서명이 유효하지 않으면 여기서 BadJOSEException/JOSEException이 던져진다 + return jwtProcessor.process(idToken, null); + } + // id_token 형태 : // { // "aud": 번들아이디, 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); + } + } +}