diff --git a/build.gradle b/build.gradle index 04d9e18a..99e78956 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' testImplementation 'org.springframework.security:spring-security-test' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' implementation 'com.google.api-client:google-api-client:1.34.1' @@ -39,7 +40,7 @@ dependencies { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' - testImplementation 'org.mockito:mockito-core:5.3.1' + testImplementation 'org.mockitox:mockito-core:5.3.1' testImplementation 'org.mockito:mockito-junit-jupiter:5.3.1' // JWT diff --git a/src/main/java/UMC_7th/Closit/domain/user/controller/UserAuthController.java b/src/main/java/UMC_7th/Closit/domain/user/controller/UserAuthController.java index 9cbdbbfa..42253c1a 100644 --- a/src/main/java/UMC_7th/Closit/domain/user/controller/UserAuthController.java +++ b/src/main/java/UMC_7th/Closit/domain/user/controller/UserAuthController.java @@ -52,8 +52,32 @@ public ApiResponse logout(HttpServletRequest request) { return ApiResponse.onSuccess("로그아웃이 완료되었습니다."); } - @Operation(summary = "소셜 로그인", description = "소셜 로그인 API") - @PostMapping("/oauth/{provider}/login") + @Operation( + summary = "소셜 로그인", + description = """ + 소셜 로그인 API - 모바일 앱에서 소셜 SDK로 받은 토큰을 검증하여 로그인/회원가입을 처리. + + **지원 플랫폼:** + - GOOGLE: ID Token, Access Token 모두 지원 + - KAKAO: Access Token만 지원 + - NAVER: Access Token만 지원 + + **요청 예시:** + ```json + { + "tokenType": "ACCESS_TOKEN", + "token": "ya29.a0ARrdaM..." + } + ``` + + **처리 과정:** + 1. 토큰을 해당 소셜 플랫폼 API로 검증 + 2. 사용자 정보 추출 (이메일, 이름 등) + 3. 기존 회원이면 로그인, 신규면 자동 회원가입 + 4. 서버 JWT 토큰 발급하여 반환 + """ + ) + @PostMapping("/oauth/login/{provider}") public ApiResponse socialLogin( @PathVariable("provider") SocialLoginType provider, @RequestBody @Valid OAuthLoginRequestDTO dto) { diff --git a/src/main/java/UMC_7th/Closit/domain/user/dto/JwtResponse.java b/src/main/java/UMC_7th/Closit/domain/user/dto/JwtResponse.java index 556134a5..9946cd34 100644 --- a/src/main/java/UMC_7th/Closit/domain/user/dto/JwtResponse.java +++ b/src/main/java/UMC_7th/Closit/domain/user/dto/JwtResponse.java @@ -1,12 +1,14 @@ package UMC_7th.Closit.domain.user.dto; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor +@Builder public class JwtResponse { private String clositId; private String accessToken; diff --git a/src/main/java/UMC_7th/Closit/domain/user/dto/OAuthLoginRequestDTO.java b/src/main/java/UMC_7th/Closit/domain/user/dto/OAuthLoginRequestDTO.java index 4476782e..18ccabd8 100644 --- a/src/main/java/UMC_7th/Closit/domain/user/dto/OAuthLoginRequestDTO.java +++ b/src/main/java/UMC_7th/Closit/domain/user/dto/OAuthLoginRequestDTO.java @@ -1,14 +1,49 @@ package UMC_7th.Closit.domain.user.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data +@Builder @AllArgsConstructor @NoArgsConstructor public class OAuthLoginRequestDTO { - private String idToken; + /** + * 토큰 타입 (ID_TOKEN 또는 ACCESS_TOKEN) + */ + @NotNull(message = "토큰 타입은 필수입니다.") + private TokenType tokenType; + + /** + * 소셜 플랫폼에서 발급받은 토큰 + */ + @NotBlank(message = "토큰은 필수입니다.") + private String token; + + /** + * 토큰 타입 열거형 + */ + public enum TokenType { + ID_TOKEN, // ID Token (OpenID Connect) + ACCESS_TOKEN // Access Token (OAuth 2.0) + } + + // 기존 호환성을 위한 생성자 (deprecated) + @Deprecated + public OAuthLoginRequestDTO(String idToken) { + this.tokenType = TokenType.ID_TOKEN; + this.token = idToken; + } + + // 기존 호환성을 위한 getter (deprecated) + @Deprecated + public String getIdToken() { + return tokenType == TokenType.ID_TOKEN ? token : null; + } } diff --git a/src/main/java/UMC_7th/Closit/domain/user/entity/GoogleUserInfo.java b/src/main/java/UMC_7th/Closit/domain/user/entity/GoogleUserInfo.java index 29011c66..b5f9b974 100644 --- a/src/main/java/UMC_7th/Closit/domain/user/entity/GoogleUserInfo.java +++ b/src/main/java/UMC_7th/Closit/domain/user/entity/GoogleUserInfo.java @@ -2,30 +2,52 @@ import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import lombok.RequiredArgsConstructor; -@RequiredArgsConstructor public class GoogleUserInfo implements OAuthUserInfo { private final GoogleIdToken.Payload payload; + private final JsonNode userInfoJson; + + // ID Token용 생성자 + public GoogleUserInfo(GoogleIdToken.Payload payload) { + this.payload = payload; + this.userInfoJson = null; + } + + // Access Token용 생성자 + public GoogleUserInfo(JsonNode userInfoJson) { + this.payload = null; + this.userInfoJson = userInfoJson; + } @Override - public String providerId () { - return payload.getSubject(); + public String providerId() { + if (payload != null) { + return payload.getSubject(); + } + return userInfoJson.get("id").asText(); } @Override - public String getEmail () { - return payload.getEmail(); + public String getEmail() { + if (payload != null) { + return payload.getEmail(); + } + return userInfoJson.get("email").asText(); } @Override - public String getName () { - return payload.get("name").toString(); + public String getName() { + if (payload != null) { + return payload.get("name").toString(); + } + return userInfoJson.get("name").asText(); } @Override - public SocialLoginType getProvider () { + public SocialLoginType getProvider() { return SocialLoginType.GOOGLE; } } diff --git a/src/main/java/UMC_7th/Closit/domain/user/entity/KakaoUserInfo.java b/src/main/java/UMC_7th/Closit/domain/user/entity/KakaoUserInfo.java new file mode 100644 index 00000000..f96a2110 --- /dev/null +++ b/src/main/java/UMC_7th/Closit/domain/user/entity/KakaoUserInfo.java @@ -0,0 +1,49 @@ +package UMC_7th.Closit.domain.user.entity; + +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class KakaoUserInfo implements OAuthUserInfo { + + private final JsonNode userInfoJson; + + @Override + public String providerId() { + return userInfoJson.get("id").asText(); + } + + @Override + public String getEmail() { + JsonNode kakaoAccount = userInfoJson.get("kakao_account"); + if (kakaoAccount != null && kakaoAccount.has("email")) { + return kakaoAccount.get("email").asText(); + } + throw new IllegalStateException("카카오 계정에서 이메일 정보를 가져올 수 없습니다."); + } + + @Override + public String getName() { + JsonNode properties = userInfoJson.get("properties"); + if (properties != null && properties.has("nickname")) { + return properties.get("nickname").asText(); + } + + JsonNode kakaoAccount = userInfoJson.get("kakao_account"); + if (kakaoAccount != null && kakaoAccount.has("profile")) { + JsonNode profile = kakaoAccount.get("profile"); + if (profile.has("nickname")) { + return profile.get("nickname").asText(); + } + } + + return "카카오 사용자"; + } + + @Override + public SocialLoginType getProvider() { + return SocialLoginType.KAKAO; + } +} + diff --git a/src/main/java/UMC_7th/Closit/domain/user/entity/NaverUserInfo.java b/src/main/java/UMC_7th/Closit/domain/user/entity/NaverUserInfo.java new file mode 100644 index 00000000..b54ac841 --- /dev/null +++ b/src/main/java/UMC_7th/Closit/domain/user/entity/NaverUserInfo.java @@ -0,0 +1,49 @@ +package UMC_7th.Closit.domain.user.entity; + +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class NaverUserInfo implements OAuthUserInfo { + + private final JsonNode userInfoJson; + + @Override + public String providerId() { + JsonNode response = userInfoJson.get("response"); + if (response != null && response.has("id")) { + return response.get("id").asText(); + } + throw new IllegalStateException("네이버 응답에서 사용자 ID를 가져올 수 없습니다."); + } + + @Override + public String getEmail() { + JsonNode response = userInfoJson.get("response"); + if (response != null && response.has("email")) { + return response.get("email").asText(); + } + throw new IllegalStateException("네이버 계정에서 이메일 정보를 가져올 수 없습니다."); + } + + @Override + public String getName() { + JsonNode response = userInfoJson.get("response"); + if (response != null) { + if (response.has("name")) { + return response.get("name").asText(); + } + if (response.has("nickname")) { + return response.get("nickname").asText(); + } + } + return "네이버 사용자"; + } + + @Override + public SocialLoginType getProvider() { + return SocialLoginType.NAVER; + } +} + diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/GoogleOAuthService.java b/src/main/java/UMC_7th/Closit/domain/user/service/GoogleOAuthService.java deleted file mode 100644 index 67c9297d..00000000 --- a/src/main/java/UMC_7th/Closit/domain/user/service/GoogleOAuthService.java +++ /dev/null @@ -1,64 +0,0 @@ -package UMC_7th.Closit.domain.user.service; - -import UMC_7th.Closit.domain.user.entity.GoogleUserInfo; -import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; -import UMC_7th.Closit.global.apiPayload.code.status.ErrorStatus; -import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.gson.GsonFactory; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import java.util.Collections; - -@Service -@RequiredArgsConstructor -@Slf4j -public class GoogleOAuthService { - - @Value("${oauth.google.client-id}") - private String googleClientId; - - private static final JsonFactory jsonFactory = new GsonFactory(); - - public OAuthUserInfo getUserInfo(String idTokenString) { - try { - - // Verifier Object 생성 - GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( - new NetHttpTransport(), jsonFactory) - .setAudience(Collections.singletonList(googleClientId)) - .build(); - - GoogleIdToken idToken = verifier.verify(idTokenString); - - if (idToken == null) { - log.debug("Invalid ID token.", idTokenString); - throw new UserHandler(ErrorStatus.INVALID_TOKEN); - } - - GoogleIdToken.Payload payload = idToken.getPayload(); - - // Issuer 체크 - if (!"accounts.google.com".equals(payload.getIssuer()) && !"https://accounts.google.com".equals(payload.getIssuer())) { - log.debug("Check Issuer"); - throw new UserHandler(ErrorStatus.INVALID_TOKEN); - } - - // aud (Audience) 검증 - if (!googleClientId.equals(payload.getAudience())) { - log.debug("Check Audience"); - throw new UserHandler(ErrorStatus.INVALID_TOKEN); - } - - return new GoogleUserInfo(payload); - } catch (Exception e) { - throw new UserHandler(ErrorStatus.INVALID_TOKEN); - } - } -} diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthService.java b/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthService.java index 2f27af8c..0c983891 100644 --- a/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthService.java +++ b/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthService.java @@ -19,7 +19,7 @@ public interface UserAuthService { void resetPassword(String email, String newPassword); - JwtResponse socialLogin (SocialLoginType socialLoginType, OAuthLoginRequestDTO oauthLoginRequestDTO); + JwtResponse socialLogin (SocialLoginType socialLoginType, OAuthLoginRequestDTO oauth2LoginRequestDTO); void logout (String accessToken); } diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImpl.java b/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImpl.java index ec63de46..0bd4fa90 100644 --- a/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImpl.java +++ b/src/main/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImpl.java @@ -10,6 +10,8 @@ import UMC_7th.Closit.domain.user.repository.RefreshTokenRepository; import UMC_7th.Closit.domain.user.repository.TokenBlackListRepository; import UMC_7th.Closit.domain.user.repository.UserRepository; +import UMC_7th.Closit.domain.user.service.social.SocialLoginFactory; +import UMC_7th.Closit.domain.user.service.social.SocialLoginService; import UMC_7th.Closit.global.apiPayload.code.status.ErrorStatus; import UMC_7th.Closit.global.apiPayload.exception.GeneralException; import UMC_7th.Closit.global.apiPayload.exception.handler.JwtHandler; @@ -36,8 +38,8 @@ public class UserAuthServiceImpl implements UserAuthService { private final JwtTokenProvider jwtTokenProvider; private final PasswordEncoder passwordEncoder; private final RefreshTokenRepository refreshTokenRepository; - private final GoogleOAuthService googleOAuthService; private final TokenBlackListRepository tokenBlackListRepository; + private final SocialLoginFactory socialLoginFactory; private final UserUtil userUtil; @Value("${cloud.aws.s3.default-profile-image}") @@ -114,15 +116,21 @@ public void resetPassword(String email, String newPassword) { @Override public JwtResponse socialLogin (SocialLoginType socialLoginType, OAuthLoginRequestDTO oauthLoginRequestDTO) { - String idToken = oauthLoginRequestDTO.getIdToken(); + // 적절한 소셜 로그인 서비스 선택 + SocialLoginService socialLoginService = socialLoginFactory.getService(socialLoginType); + + // 토큰 타입에 따른 사용자 정보 조회 OAuthUserInfo userInfo; - if (socialLoginType == SocialLoginType.GOOGLE) { // GOOGLE - userInfo = googleOAuthService.getUserInfo(idToken); + if (oauthLoginRequestDTO.getTokenType() == OAuthLoginRequestDTO.TokenType.ID_TOKEN) { + userInfo = socialLoginService.getUserInfoFromIdToken(oauthLoginRequestDTO.getToken()); + } else if (oauthLoginRequestDTO.getTokenType() == OAuthLoginRequestDTO.TokenType.ACCESS_TOKEN) { + userInfo = socialLoginService.getUserInfoFromAccessToken(oauthLoginRequestDTO.getToken()); } else { - throw new GeneralException(ErrorStatus.NOT_SUPPORTED_SOCIAL_LOGIN); + throw new GeneralException(ErrorStatus.INVALID_TOKEN); } + // 기존 회원 조회 또는 신규 회원 등록 User user = userRepository.findByEmail(userInfo.getEmail()) .orElseGet(() -> registerNewUser(userInfo)); @@ -151,7 +159,7 @@ private void blacklistAndRemoveRefreshToken(String accessToken, RefreshToken ref refreshTokenRepository.delete(refreshToken); } - private User registerNewUser(OAuthUserInfo userInfo) { + User registerNewUser (OAuthUserInfo userInfo) { String email = userInfo.getEmail(); String name = userInfo.getName(); diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/social/GoogleSocialLoginService.java b/src/main/java/UMC_7th/Closit/domain/user/service/social/GoogleSocialLoginService.java new file mode 100644 index 00000000..78b6575b --- /dev/null +++ b/src/main/java/UMC_7th/Closit/domain/user/service/social/GoogleSocialLoginService.java @@ -0,0 +1,115 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.domain.user.entity.GoogleUserInfo; +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.global.apiPayload.code.status.ErrorStatus; +import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; + + +@Service +@RequiredArgsConstructor +@Slf4j +public class GoogleSocialLoginService implements SocialLoginService { + + @Value("${oauth.google.client-id}") + private String googleClientId; + + private static final JsonFactory jsonFactory = new GsonFactory(); + private static final String GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public SocialLoginType getSupportedType() { + return SocialLoginType.GOOGLE; + } + + @Override + public OAuthUserInfo getUserInfoFromIdToken(String idToken) { + try { + // Verifier Object 생성 + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( + new NetHttpTransport(), jsonFactory) + .setAudience(Collections.singletonList(googleClientId)) + .build(); + + GoogleIdToken googleIdToken = verifier.verify(idToken); + + if (googleIdToken == null) { + log.debug("Invalid ID token: {}", idToken); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + + GoogleIdToken.Payload payload = googleIdToken.getPayload(); + + // Issuer 체크 + if (!"accounts.google.com".equals(payload.getIssuer()) && + !"https://accounts.google.com".equals(payload.getIssuer())) { + log.debug("Invalid issuer: {}", payload.getIssuer()); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + + // aud (Audience) 검증 + if (!googleClientId.equals(payload.getAudience())) { + log.debug("Invalid audience: {}", payload.getAudience()); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + + return new GoogleUserInfo(payload); + } catch (Exception e) { + log.error("Failed to verify Google ID token", e); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + } + + @Override + public OAuthUserInfo getUserInfoFromAccessToken(String accessToken) { + try { + // HTTP 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + // Google UserInfo API 호출 + ResponseEntity response = restTemplate.exchange( + GOOGLE_USERINFO_URL, + HttpMethod.GET, + entity, + String.class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.debug("Failed to get user info from Google: {}", response.getStatusCode()); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + + // JSON 파싱 + JsonNode userInfoJson = objectMapper.readTree(response.getBody()); + + return new GoogleUserInfo(userInfoJson); + } catch (Exception e) { + log.error("Failed to get user info from Google access token", e); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/social/KakaoSocialLoginService.java b/src/main/java/UMC_7th/Closit/domain/user/service/social/KakaoSocialLoginService.java new file mode 100644 index 00000000..23b727c1 --- /dev/null +++ b/src/main/java/UMC_7th/Closit/domain/user/service/social/KakaoSocialLoginService.java @@ -0,0 +1,73 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.domain.user.entity.KakaoUserInfo; +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.global.apiPayload.code.status.ErrorStatus; +import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +/** + * 카카오 소셜 로그인 서비스 구현체 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class KakaoSocialLoginService implements SocialLoginService { + + private static final String KAKAO_USERINFO_URL = "https://kapi.kakao.com/v2/user/me"; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public SocialLoginType getSupportedType() { + return SocialLoginType.KAKAO; + } + + @Override + public OAuthUserInfo getUserInfoFromIdToken(String idToken) { + // 카카오는 ID Token을 지원하지 않음 + throw new UserHandler(ErrorStatus.NOT_SUPPORTED_SOCIAL_LOGIN); + } + + @Override + public OAuthUserInfo getUserInfoFromAccessToken(String accessToken) { + try { + // HTTP 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + // 카카오 UserInfo API 호출 + ResponseEntity response = restTemplate.exchange( + KAKAO_USERINFO_URL, + HttpMethod.GET, + entity, + String.class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.debug("Failed to get user info from Kakao: {}", response.getStatusCode()); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + + // JSON 파싱 + JsonNode userInfoJson = objectMapper.readTree(response.getBody()); + + return new KakaoUserInfo(userInfoJson); + } catch (Exception e) { + log.error("Failed to get user info from Kakao access token", e); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/social/NaverSocialLoginService.java b/src/main/java/UMC_7th/Closit/domain/user/service/social/NaverSocialLoginService.java new file mode 100644 index 00000000..92825620 --- /dev/null +++ b/src/main/java/UMC_7th/Closit/domain/user/service/social/NaverSocialLoginService.java @@ -0,0 +1,75 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.domain.user.entity.NaverUserInfo; +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.global.apiPayload.code.status.ErrorStatus; +import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NaverSocialLoginService implements SocialLoginService { + + private static final String NAVER_USERINFO_URL = "https://openapi.naver.com/v1/nid/me"; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public SocialLoginType getSupportedType() { + return SocialLoginType.NAVER; + } + + @Override + public OAuthUserInfo getUserInfoFromIdToken(String idToken) { + throw new UserHandler(ErrorStatus.NOT_SUPPORTED_SOCIAL_LOGIN); + } + + @Override + public OAuthUserInfo getUserInfoFromAccessToken(String accessToken) { + try { + // HTTP 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + // 네이버 UserInfo API 호출 + ResponseEntity response = restTemplate.exchange( + NAVER_USERINFO_URL, + HttpMethod.GET, + entity, + String.class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + log.debug("Failed to get user info from Naver: {}", response.getStatusCode()); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + + // JSON 파싱 + JsonNode userInfoJson = objectMapper.readTree(response.getBody()); + + // 네이버 API 응답 코드 확인 + if (!"00".equals(userInfoJson.get("resultcode").asText())) { + log.debug("Naver API returned error: {}", userInfoJson.get("message").asText()); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + + return new NaverUserInfo(userInfoJson); + } catch (Exception e) { + log.error("Failed to get user info from Naver access token", e); + throw new UserHandler(ErrorStatus.INVALID_TOKEN); + } + } +} diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/social/SocialLoginFactory.java b/src/main/java/UMC_7th/Closit/domain/user/service/social/SocialLoginFactory.java new file mode 100644 index 00000000..14d31091 --- /dev/null +++ b/src/main/java/UMC_7th/Closit/domain/user/service/social/SocialLoginFactory.java @@ -0,0 +1,45 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.global.apiPayload.code.status.ErrorStatus; +import UMC_7th.Closit.global.apiPayload.exception.GeneralException; +import UMC_7th.Closit.global.common.SocialLoginType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class SocialLoginFactory { + private final List socialLoginServices; + private Map serviceMap; + + public void init() { + if (serviceMap == null) { + serviceMap = socialLoginServices.stream() + .collect(Collectors.toMap( + SocialLoginService::getSupportedType, + Function.identity() + )); + } + } + + /** + * 소셜 로그인 타입에 맞는 서비스 반환 + * @param socialLoginType 소셜 로그인 타입 + * @return 해당 타입의 소셜 로그인 서비스 + */ + public SocialLoginService getService(SocialLoginType socialLoginType) { + init(); + + SocialLoginService service = serviceMap.get(socialLoginType); + if (service == null) { + throw new GeneralException(ErrorStatus.NOT_SUPPORTED_SOCIAL_LOGIN); + } + + return service; + } +} diff --git a/src/main/java/UMC_7th/Closit/domain/user/service/social/SocialLoginService.java b/src/main/java/UMC_7th/Closit/domain/user/service/social/SocialLoginService.java new file mode 100644 index 00000000..0d8a9c86 --- /dev/null +++ b/src/main/java/UMC_7th/Closit/domain/user/service/social/SocialLoginService.java @@ -0,0 +1,27 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.global.common.SocialLoginType; + +public interface SocialLoginService { + + /** + * 지원하는 소셜 로그인 타입 반환 + */ + SocialLoginType getSupportedType(); + + /** + * ID Token을 검증하고 사용자 정보를 반환 + * @param idToken 소셜 플랫폼에서 발급받은 ID Token + * @return 검증된 사용자 정보 + */ + OAuthUserInfo getUserInfoFromIdToken(String idToken); + + /** + * Access Token을 검증하고 사용자 정보를 반환 + * @param accessToken 소셜 플랫폼에서 발급받은 Access Token + * @return 검증된 사용자 정보 + */ + OAuthUserInfo getUserInfoFromAccessToken(String accessToken); +} + diff --git a/src/main/java/UMC_7th/Closit/global/common/SocialLoginType.java b/src/main/java/UMC_7th/Closit/global/common/SocialLoginType.java index 8afbaace..2ba32488 100644 --- a/src/main/java/UMC_7th/Closit/global/common/SocialLoginType.java +++ b/src/main/java/UMC_7th/Closit/global/common/SocialLoginType.java @@ -1,5 +1,5 @@ package UMC_7th.Closit.global.common; public enum SocialLoginType { - GOOGLE, KAKAO, NAVER + GOOGLE, NAVER, KAKAO } \ No newline at end of file diff --git a/src/main/java/UMC_7th/Closit/security/SecurityConfig.java b/src/main/java/UMC_7th/Closit/security/SecurityConfig.java index d1643fc0..22b13279 100644 --- a/src/main/java/UMC_7th/Closit/security/SecurityConfig.java +++ b/src/main/java/UMC_7th/Closit/security/SecurityConfig.java @@ -10,6 +10,7 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -20,12 +21,14 @@ @Configuration @RequiredArgsConstructor +@EnableWebSecurity public class SecurityConfig { private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomUserDetailService userDetailService; + private final CustomOAuth2UserService customOAuth2UserService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -58,7 +61,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // UsernamePasswordAuthenticationFilter 전에 JwtAuthenticationFilter 추가 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .userDetailsService(userDetailService); - return http.build(); } diff --git a/src/test/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImplTest.java b/src/test/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImplTest.java index 1fa2fda6..b43e9ddf 100644 --- a/src/test/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImplTest.java +++ b/src/test/java/UMC_7th/Closit/domain/user/service/UserAuthServiceImplTest.java @@ -1,18 +1,22 @@ package UMC_7th.Closit.domain.user.service; import UMC_7th.Closit.domain.user.dto.LoginRequestDTO; +import UMC_7th.Closit.domain.user.dto.UserRequestDTO; import UMC_7th.Closit.domain.user.entity.Role; import UMC_7th.Closit.domain.user.entity.User; import UMC_7th.Closit.domain.user.repository.UserRepository; import UMC_7th.Closit.global.apiPayload.code.status.ErrorStatus; import UMC_7th.Closit.global.apiPayload.exception.GeneralException; import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; @@ -22,20 +26,37 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) class UserAuthServiceImplTest { @Mock private UserRepository userRepository; @Mock private PasswordEncoder passwordEncoder; + @Mock + private UserUtil userUtil; @InjectMocks private UserAuthServiceImpl userAuthService; + @InjectMocks + private UserCommandServiceImpl userCommandService; + @BeforeEach void setup() { MockitoAnnotations.openMocks(this); + + UserRequestDTO.CreateUserDTO dto = UserRequestDTO.CreateUserDTO.builder() + .clositId("testuser") + .password("password") + .email("hello@gmail.com") + .build(); + userCommandService.registerUser(dto); } + @AfterEach + void afterEach () { + userRepository.deleteByClositId("testuser"); + } @Test @DisplayName("비활성화된 사용자는 로그인할 수 없다") void inactivateUser_cannot_login() { diff --git a/src/test/java/UMC_7th/Closit/domain/user/service/UserAuthServiceSocialLoginTest.java b/src/test/java/UMC_7th/Closit/domain/user/service/UserAuthServiceSocialLoginTest.java new file mode 100644 index 00000000..7b7979a6 --- /dev/null +++ b/src/test/java/UMC_7th/Closit/domain/user/service/UserAuthServiceSocialLoginTest.java @@ -0,0 +1,142 @@ +package UMC_7th.Closit.domain.user.service; + +import org.junit.jupiter.api.Test; +import UMC_7th.Closit.domain.user.dto.JwtResponse; +import UMC_7th.Closit.domain.user.dto.OAuthLoginRequestDTO; +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.domain.user.entity.User; +import UMC_7th.Closit.domain.user.repository.UserRepository; +import UMC_7th.Closit.domain.user.service.social.SocialLoginFactory; +import UMC_7th.Closit.domain.user.service.social.SocialLoginService; +import UMC_7th.Closit.global.common.SocialLoginType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("소셜 로그인 통합 테스트") +class UserAuthServiceSocialLoginTest { + + @Mock + private SocialLoginFactory socialLoginFactory; + + @Mock + private SocialLoginService socialLoginService; + + @Mock + private UserRepository userRepository; + + @Mock + private UserUtil userUtil; + + @Mock + private OAuthUserInfo oAuthUserInfo; + + @Mock + private User user; + + @InjectMocks + private UserAuthServiceImpl userAuthService; + + private OAuthLoginRequestDTO requestDTO; + private JwtResponse expectedJwtResponse; + + @BeforeEach + void setUp() { + requestDTO = new OAuthLoginRequestDTO( + OAuthLoginRequestDTO.TokenType.ACCESS_TOKEN, + "mock_access_token" + ); + + expectedJwtResponse = JwtResponse.builder() + .accessToken("mock_access_token") + .refreshToken("mock_refresh_token") + .build(); + + when(oAuthUserInfo.getEmail()).thenReturn("test@example.com"); + when(oAuthUserInfo.getName()).thenReturn("테스트사용자"); + when(oAuthUserInfo.getProvider()).thenReturn(SocialLoginType.KAKAO); + } + + @Test + @DisplayName("Access Token으로 소셜 로그인 성공 - 기존 회원") + void socialLogin_AccessToken_ExistingUser() { + // given + when(socialLoginFactory.getService(SocialLoginType.KAKAO)) + .thenReturn(socialLoginService); + when(socialLoginService.getUserInfoFromAccessToken("mock_access_token")) + .thenReturn(oAuthUserInfo); + when(userRepository.findByEmail("test@example.com")) + .thenReturn(Optional.of(user)); + when(userUtil.issueJwtTokens(user)) + .thenReturn(expectedJwtResponse); + + // when + JwtResponse result = userAuthService.socialLogin(SocialLoginType.KAKAO, requestDTO); + + // then + assertThat(result).isEqualTo(expectedJwtResponse); + + verify(socialLoginFactory).getService(SocialLoginType.KAKAO); + verify(socialLoginService).getUserInfoFromAccessToken("mock_access_token"); + verify(userRepository).findByEmail("test@example.com"); + verify(userUtil).issueJwtTokens(user); + } + + @Test + @DisplayName("ID Token으로 소셜 로그인 성공 - 신규 회원") + void socialLogin_IdToken_NewUser() { + // given + OAuthLoginRequestDTO idTokenRequest = new OAuthLoginRequestDTO( + OAuthLoginRequestDTO.TokenType.ID_TOKEN, + "mock_id_token" + ); + + when(socialLoginFactory.getService(SocialLoginType.GOOGLE)) + .thenReturn(socialLoginService); + when(socialLoginService.getUserInfoFromIdToken("mock_id_token")) + .thenReturn(oAuthUserInfo); + when(userRepository.findByEmail("test@example.com")) + .thenReturn(Optional.empty()); + when(userUtil.issueJwtTokens(any(User.class))) + .thenReturn(expectedJwtResponse); + + // registerNewUser 메서드 모킹을 위해 spy 사용 + UserAuthServiceImpl spyService = spy(userAuthService); + doReturn(user).when(spyService).registerNewUser(oAuthUserInfo); + + // when + JwtResponse result = spyService.socialLogin(SocialLoginType.GOOGLE, idTokenRequest); + + // then + assertThat(result).isEqualTo(expectedJwtResponse); + + verify(socialLoginFactory).getService(SocialLoginType.GOOGLE); + verify(socialLoginService).getUserInfoFromIdToken("mock_id_token"); + verify(userRepository).findByEmail("test@example.com"); + verify(spyService).registerNewUser(oAuthUserInfo); + verify(userUtil).issueJwtTokens(any(User.class)); + } + + @Test + @DisplayName("잘못된 토큰 타입으로 소셜 로그인 시 예외 발생") + void socialLogin_InvalidTokenType() { + // given + OAuthLoginRequestDTO invalidRequest = new OAuthLoginRequestDTO(); + invalidRequest.setTokenType(null); + invalidRequest.setToken("mock_token"); + + // when & then + assertThatThrownBy(() -> userAuthService.socialLogin(SocialLoginType.GOOGLE, invalidRequest)) + .isInstanceOf(Exception.class); + } +} diff --git a/src/test/java/UMC_7th/Closit/domain/user/service/social/GoogleSocialLoginServiceTest.java b/src/test/java/UMC_7th/Closit/domain/user/service/social/GoogleSocialLoginServiceTest.java new file mode 100644 index 00000000..7eeea97b --- /dev/null +++ b/src/test/java/UMC_7th/Closit/domain/user/service/social/GoogleSocialLoginServiceTest.java @@ -0,0 +1,196 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("구글 소셜 로그인 서비스 테스트") +class GoogleSocialLoginServiceTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private GoogleIdTokenVerifier verifier; + + @Mock + private GoogleIdToken googleIdToken; + + @Mock + private GoogleIdToken.Payload payload; + + @InjectMocks + private GoogleSocialLoginService googleSocialLoginService; + + private final String mockIdToken = "mock_google_id_token"; + private final String mockAccessToken = "mock_google_access_token"; + private final String mockAccessTokenResponseBody = """ + { + "id": "123456789", + "email": "test@gmail.com", + "name": "Test User", + "picture": "https://lh3.googleusercontent.com/test" + } + """; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(googleSocialLoginService, "googleClientId", "test-client-id"); + ReflectionTestUtils.setField(googleSocialLoginService, "restTemplate", restTemplate); + ReflectionTestUtils.setField(googleSocialLoginService, "objectMapper", objectMapper); + } + + @Test + @DisplayName("지원하는 소셜 로그인 타입 반환 테스트") + void getSupportedType() { + // when + SocialLoginType result = googleSocialLoginService.getSupportedType(); + + // then + assertThat(result).isEqualTo(SocialLoginType.GOOGLE); + } + + @Test + @DisplayName("ID Token으로 사용자 정보 조회 성공") + void getUserInfoFromIdToken_Success() throws Exception { + // given + when(payload.getSubject()).thenReturn("123456789"); + when(payload.getEmail()).thenReturn("test@gmail.com"); + when(payload.get("name")).thenReturn("Test User"); + when(payload.getIssuer()).thenReturn("https://accounts.google.com"); + when(payload.getAudience()).thenReturn("test-client-id"); + + when(googleIdToken.getPayload()).thenReturn(payload); + + try (MockedStatic mockedVerifier = mockStatic(GoogleIdTokenVerifier.class)) { + GoogleIdTokenVerifier.Builder mockBuilder = mock(GoogleIdTokenVerifier.Builder.class); + when(mockBuilder.setAudience(any())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(verifier); + + mockedVerifier.when(() -> new GoogleIdTokenVerifier.Builder(any(), any())).thenReturn(mockBuilder); when(verifier.verify(mockIdToken)).thenReturn(googleIdToken); + + // when + OAuthUserInfo result = googleSocialLoginService.getUserInfoFromIdToken(mockIdToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getProvider()).isEqualTo(SocialLoginType.GOOGLE); + assertThat(result.providerId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isEqualTo("test@gmail.com"); + assertThat(result.getName()).isEqualTo("Test User"); + } + } + + @Test + @DisplayName("ID Token이 유효하지 않을 때 예외 발생") + void getUserInfoFromIdToken_InvalidToken() throws Exception { + // given + try (MockedStatic mockedVerifier = mockStatic(GoogleIdTokenVerifier.class)) { + GoogleIdTokenVerifier.Builder mockBuilder = mock(GoogleIdTokenVerifier.Builder.class); + when(mockBuilder.setAudience(any())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(verifier); + + when(verifier.verify(mockIdToken)).thenReturn(null); // 유효하지 않은 토큰 + + // when & then + assertThatThrownBy(() -> googleSocialLoginService.getUserInfoFromIdToken(mockIdToken)) + .isInstanceOf(UserHandler.class); + } + } + + @Test + @DisplayName("Access Token으로 사용자 정보 조회 성공") + void getUserInfoFromAccessToken_Success() throws Exception { + // given + ResponseEntity mockResponse = new ResponseEntity<>(mockAccessTokenResponseBody, HttpStatus.OK); + JsonNode mockJsonNode = new ObjectMapper().readTree(mockAccessTokenResponseBody); + + when(restTemplate.exchange( + eq("https://www.googleapis.com/oauth2/v2/userinfo"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + when(objectMapper.readTree(mockAccessTokenResponseBody)).thenReturn(mockJsonNode); + + // when + OAuthUserInfo result = googleSocialLoginService.getUserInfoFromAccessToken(mockAccessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getProvider()).isEqualTo(SocialLoginType.GOOGLE); + assertThat(result.providerId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isEqualTo("test@gmail.com"); + assertThat(result.getName()).isEqualTo("Test User"); + + // verify + verify(restTemplate).exchange( + eq("https://www.googleapis.com/oauth2/v2/userinfo"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + ); + verify(objectMapper).readTree(mockAccessTokenResponseBody); + } + + @Test + @DisplayName("Access Token이 유효하지 않을 때 예외 발생") + void getUserInfoFromAccessToken_InvalidToken() { + // given + ResponseEntity mockResponse = new ResponseEntity<>("", HttpStatus.UNAUTHORIZED); + + when(restTemplate.exchange( + eq("https://www.googleapis.com/oauth2/v2/userinfo"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // when & then + assertThatThrownBy(() -> googleSocialLoginService.getUserInfoFromAccessToken(mockAccessToken)) + .isInstanceOf(UserHandler.class); + } + + @Test + @DisplayName("Access Token API 호출 중 예외 발생 시 UserHandler 예외 발생") + void getUserInfoFromAccessToken_ApiException() { + // given + when(restTemplate.exchange( + eq("https://www.googleapis.com/oauth2/v2/userinfo"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenThrow(new RuntimeException("Network error")); + + // when & then + assertThatThrownBy(() -> googleSocialLoginService.getUserInfoFromAccessToken(mockAccessToken)) + .isInstanceOf(UserHandler.class); + } +} diff --git a/src/test/java/UMC_7th/Closit/domain/user/service/social/KakaoSocialLoginServiceTest.java b/src/test/java/UMC_7th/Closit/domain/user/service/social/KakaoSocialLoginServiceTest.java new file mode 100644 index 00000000..4c6e97b0 --- /dev/null +++ b/src/test/java/UMC_7th/Closit/domain/user/service/social/KakaoSocialLoginServiceTest.java @@ -0,0 +1,149 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("카카오 소셜 로그인 서비스 테스트") +class KakaoSocialLoginServiceTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private KakaoSocialLoginService kakaoSocialLoginService; + + private final String mockAccessToken = "mock_kakao_access_token"; + private final String mockResponseBody = """ + { + "id": "123456789", + "kakao_account": { + "email": "test@kakao.com", + "profile": { + "nickname": "테스트사용자" + } + }, + "properties": { + "nickname": "테스트사용자" + } + } + """; + + @BeforeEach + void setUp() { + // private final 필드들을 Mock으로 교체 + ReflectionTestUtils.setField(kakaoSocialLoginService, "restTemplate", restTemplate); + ReflectionTestUtils.setField(kakaoSocialLoginService, "objectMapper", objectMapper); + } + + @Test + @DisplayName("지원하는 소셜 로그인 타입 반환 테스트") + void getSupportedType() { + // when + SocialLoginType result = kakaoSocialLoginService.getSupportedType(); + + // then + assertThat(result).isEqualTo(SocialLoginType.KAKAO); + } + + @Test + @DisplayName("ID Token으로 사용자 정보 조회 시 예외 발생") + void getUserInfoFromIdToken_ThrowsException() { + // when & then + assertThatThrownBy(() -> kakaoSocialLoginService.getUserInfoFromIdToken("dummy_id_token")) + .isInstanceOf(UserHandler.class); + } + + @Test + @DisplayName("Access Token으로 사용자 정보 조회 성공") + void getUserInfoFromAccessToken_Success() throws Exception { + // given + ResponseEntity mockResponse = new ResponseEntity<>(mockResponseBody, HttpStatus.OK); + JsonNode mockJsonNode = new ObjectMapper().readTree(mockResponseBody); + + when(restTemplate.exchange( + eq("https://kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + when(objectMapper.readTree(mockResponseBody)).thenReturn(mockJsonNode); + + // when + OAuthUserInfo result = kakaoSocialLoginService.getUserInfoFromAccessToken(mockAccessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getProvider()).isEqualTo(SocialLoginType.KAKAO); + assertThat(result.providerId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isEqualTo("test@kakao.com"); + assertThat(result.getName()).isEqualTo("테스트사용자"); + + // verify + verify(restTemplate).exchange( + eq("https://kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + ); + verify(objectMapper).readTree(mockResponseBody); + } + + @Test + @DisplayName("Access Token이 유효하지 않을 때 예외 발생") + void getUserInfoFromAccessToken_InvalidToken() { + // given + ResponseEntity mockResponse = new ResponseEntity<>("", HttpStatus.UNAUTHORIZED); + + when(restTemplate.exchange( + eq("https://kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // when & then + assertThatThrownBy(() -> kakaoSocialLoginService.getUserInfoFromAccessToken(mockAccessToken)) + .isInstanceOf(UserHandler.class); + } + + @Test + @DisplayName("API 호출 중 예외 발생 시 UserHandler 예외 발생") + void getUserInfoFromAccessToken_ApiException() { + // given + when(restTemplate.exchange( + eq("https://kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenThrow(new RuntimeException("Network error")); + + // when & then + assertThatThrownBy(() -> kakaoSocialLoginService.getUserInfoFromAccessToken(mockAccessToken)) + .isInstanceOf(UserHandler.class); + } +} diff --git a/src/test/java/UMC_7th/Closit/domain/user/service/social/NaverSocialLoginServiceTest.java b/src/test/java/UMC_7th/Closit/domain/user/service/social/NaverSocialLoginServiceTest.java new file mode 100644 index 00000000..7b3e27c9 --- /dev/null +++ b/src/test/java/UMC_7th/Closit/domain/user/service/social/NaverSocialLoginServiceTest.java @@ -0,0 +1,245 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.domain.user.entity.OAuthUserInfo; +import UMC_7th.Closit.global.apiPayload.exception.handler.UserHandler; +import UMC_7th.Closit.global.common.SocialLoginType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("네이버 소셜 로그인 서비스 테스트") +class NaverSocialLoginServiceTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private NaverSocialLoginService naverSocialLoginService; + + private final String mockAccessToken = "mock_naver_access_token"; + private final String mockSuccessResponseBody = """ + { + "resultcode": "00", + "message": "success", + "response": { + "id": "naver123456789", + "email": "test@naver.com", + "name": "홍길동", + "nickname": "길동이" + } + } + """; + + private final String mockErrorResponseBody = """ + { + "resultcode": "024", + "message": "invalid access token" + } + """; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(naverSocialLoginService, "restTemplate", restTemplate); + ReflectionTestUtils.setField(naverSocialLoginService, "objectMapper", objectMapper); + } + + @Test + @DisplayName("지원하는 소셜 로그인 타입 반환 테스트") + void getSupportedType() { + // when + SocialLoginType result = naverSocialLoginService.getSupportedType(); + + // then + assertThat(result).isEqualTo(SocialLoginType.NAVER); + } + + @Test + @DisplayName("ID Token으로 사용자 정보 조회 시 예외 발생") + void getUserInfoFromIdToken_ThrowsException() { + // when & then + assertThatThrownBy(() -> naverSocialLoginService.getUserInfoFromIdToken("dummy_id_token")) + .isInstanceOf(UserHandler.class); + } + + @Test + @DisplayName("Access Token으로 사용자 정보 조회 성공") + void getUserInfoFromAccessToken_Success() throws Exception { + // given + ResponseEntity mockResponse = new ResponseEntity<>(mockSuccessResponseBody, HttpStatus.OK); + JsonNode mockJsonNode = new ObjectMapper().readTree(mockSuccessResponseBody); + + when(restTemplate.exchange( + eq("https://openapi.naver.com/v1/nid/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + when(objectMapper.readTree(mockSuccessResponseBody)).thenReturn(mockJsonNode); + + // when + OAuthUserInfo result = naverSocialLoginService.getUserInfoFromAccessToken(mockAccessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getProvider()).isEqualTo(SocialLoginType.NAVER); + assertThat(result.providerId()).isEqualTo("naver123456789"); + assertThat(result.getEmail()).isEqualTo("test@naver.com"); + assertThat(result.getName()).isEqualTo("홍길동"); + + // verify + verify(restTemplate).exchange( + eq("https://openapi.naver.com/v1/nid/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + ); + verify(objectMapper).readTree(mockSuccessResponseBody); + } + + @Test + @DisplayName("Access Token이 유효하지 않을 때 예외 발생 - HTTP 상태 코드") + void getUserInfoFromAccessToken_InvalidToken_HttpStatus() { + // given + ResponseEntity mockResponse = new ResponseEntity<>("", HttpStatus.UNAUTHORIZED); + + when(restTemplate.exchange( + eq("https://openapi.naver.com/v1/nid/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + // when & then + assertThatThrownBy(() -> naverSocialLoginService.getUserInfoFromAccessToken(mockAccessToken)) + .isInstanceOf(UserHandler.class); + } + + @Test + @DisplayName("네이버 API 응답 코드가 에러일 때 예외 발생") + void getUserInfoFromAccessToken_NaverApiError() throws Exception { + // given + ResponseEntity mockResponse = new ResponseEntity<>(mockErrorResponseBody, HttpStatus.OK); + JsonNode mockJsonNode = new ObjectMapper().readTree(mockErrorResponseBody); + + when(restTemplate.exchange( + eq("https://openapi.naver.com/v1/nid/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + when(objectMapper.readTree(mockErrorResponseBody)).thenReturn(mockJsonNode); + + // when & then + assertThatThrownBy(() -> naverSocialLoginService.getUserInfoFromAccessToken(mockAccessToken)) + .isInstanceOf(UserHandler.class); + } + + @Test + @DisplayName("API 호출 중 예외 발생 시 UserHandler 예외 발생") + void getUserInfoFromAccessToken_ApiException() { + // given + when(restTemplate.exchange( + eq("https://openapi.naver.com/v1/nid/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenThrow(new RuntimeException("Network error")); + + // when & then + assertThatThrownBy(() -> naverSocialLoginService.getUserInfoFromAccessToken(mockAccessToken)) + .isInstanceOf(UserHandler.class); + } + + @Test + @DisplayName("닉네임만 있고 이름이 없는 경우") + void getUserInfoFromAccessToken_OnlyNickname() throws Exception { + // given + String responseWithOnlyNickname = """ + { + "resultcode": "00", + "message": "success", + "response": { + "id": "naver123456789", + "email": "test@naver.com", + "nickname": "길동이" + } + } + """; + + ResponseEntity mockResponse = new ResponseEntity<>(responseWithOnlyNickname, HttpStatus.OK); + JsonNode mockJsonNode = new ObjectMapper().readTree(responseWithOnlyNickname); + + when(restTemplate.exchange( + eq("https://openapi.naver.com/v1/nid/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + when(objectMapper.readTree(responseWithOnlyNickname)).thenReturn(mockJsonNode); + + // when + OAuthUserInfo result = naverSocialLoginService.getUserInfoFromAccessToken(mockAccessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("길동이"); + } + + @Test + @DisplayName("이름도 닉네임도 없는 경우 기본값 반환") + void getUserInfoFromAccessToken_NoNameAndNickname() throws Exception { + // given + String responseWithoutName = """ + { + "resultcode": "00", + "message": "success", + "response": { + "id": "naver123456789", + "email": "test@naver.com" + } + } + """; + + ResponseEntity mockResponse = new ResponseEntity<>(responseWithoutName, HttpStatus.OK); + JsonNode mockJsonNode = new ObjectMapper().readTree(responseWithoutName); + + when(restTemplate.exchange( + eq("https://openapi.naver.com/v1/nid/me"), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(String.class) + )).thenReturn(mockResponse); + + when(objectMapper.readTree(responseWithoutName)).thenReturn(mockJsonNode); + + // when + OAuthUserInfo result = naverSocialLoginService.getUserInfoFromAccessToken(mockAccessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("네이버 사용자"); + } +} diff --git a/src/test/java/UMC_7th/Closit/domain/user/service/social/SocialLoginFactoryTest.java b/src/test/java/UMC_7th/Closit/domain/user/service/social/SocialLoginFactoryTest.java new file mode 100644 index 00000000..978e6d70 --- /dev/null +++ b/src/test/java/UMC_7th/Closit/domain/user/service/social/SocialLoginFactoryTest.java @@ -0,0 +1,82 @@ +package UMC_7th.Closit.domain.user.service.social; + +import UMC_7th.Closit.global.apiPayload.exception.GeneralException; +import UMC_7th.Closit.global.common.SocialLoginType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("소셜 로그인 팩토리 테스트") +class SocialLoginFactoryTest { + + @Mock + private SocialLoginService googleService; + + @Mock + private SocialLoginService kakaoService; + + @Mock + private SocialLoginService naverService; + + private SocialLoginFactory socialLoginFactory; + + @BeforeEach + void setUp() { + lenient().when(googleService.getSupportedType()).thenReturn(SocialLoginType.GOOGLE); + lenient().when(kakaoService.getSupportedType()).thenReturn(SocialLoginType.KAKAO); + lenient().when(naverService.getSupportedType()).thenReturn(SocialLoginType.NAVER); + + List services = Arrays.asList(googleService, kakaoService, naverService); + socialLoginFactory = new SocialLoginFactory(services); + } + @Test + @DisplayName("Google 소셜 로그인 서비스 조회") + void getService_Google() { + // when + SocialLoginService result = socialLoginFactory.getService(SocialLoginType.GOOGLE); + + // then + assertThat(result).isEqualTo(googleService); + } + + @Test + @DisplayName("Kakao 소셜 로그인 서비스 조회") + void getService_Kakao() { + // when + SocialLoginService result = socialLoginFactory.getService(SocialLoginType.KAKAO); + + // then + assertThat(result).isEqualTo(kakaoService); + } + + @Test + @DisplayName("Naver 소셜 로그인 서비스 조회") + void getService_Naver() { + // when + SocialLoginService result = socialLoginFactory.getService(SocialLoginType.NAVER); + + // then + assertThat(result).isEqualTo(naverService); + } + + @Test + @DisplayName("지원하지 않는 소셜 로그인 타입으로 조회 시 예외 발생") + void getService_UnsupportedType() { + // given + SocialLoginFactory emptyFactory = new SocialLoginFactory(List.of()); + + // when & then + assertThatThrownBy(() -> emptyFactory.getService(SocialLoginType.GOOGLE)) + .isInstanceOf(GeneralException.class); + } +}