Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/main/java/org/runnect/server/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,24 @@ public SocialInfoResponseDto getSocialInfo(String token) {

HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> 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<String> 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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
259 changes: 259 additions & 0 deletions src/test/java/org/runnect/server/auth/service/AuthServiceTest.java
Original file line number Diff line number Diff line change
@@ -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, "[email protected]", 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("[email protected]", "social-1"));
when(userRepository.existsByEmailAndProvider("[email protected]", SocialType.KAKAO)).thenReturn(false);
when(userRepository.existsByNickname(org.mockito.ArgumentMatchers.any())).thenReturn(false);
RunnectUser savedUser = buildUser(1L, "[email protected]", SocialType.KAKAO);
when(userRepository.findByEmailAndProvider("[email protected]", 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("[email protected]", "social-2"));
when(userRepository.existsByEmailAndProvider("[email protected]", SocialType.GOOGLE)).thenReturn(true);
RunnectUser existingUser = buildUser(2L, "[email protected]", SocialType.GOOGLE);
when(userRepository.findByEmailAndProvider("[email protected]", 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("[email protected]", "social-3"));
when(userRepository.existsByEmailAndProvider("[email protected]", SocialType.APPLE)).thenReturn(true);
RunnectUser existingUser = buildUser(3L, "[email protected]", SocialType.APPLE);
when(userRepository.findByEmailAndProvider("[email protected]", 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("[email protected]", "social-1"));
when(userRepository.existsByEmailAndProvider("[email protected]", SocialType.KAKAO)).thenReturn(false);
when(userRepository.existsByNickname(any())).thenReturn(true, true, false);
RunnectUser savedUser = buildUser(1L, "[email protected]", SocialType.KAKAO);
when(userRepository.findByEmailAndProvider("[email protected]", 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);
}
}
}
Loading