diff --git a/build.gradle b/build.gradle
index 3cea095..1efd4a9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -56,6 +56,9 @@ dependencies {
// Spring Configuration Processor
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+ // Spring Open Feign (Feign Client)
+ implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.0'
+
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
diff --git a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java
index cfe424e..6d4d262 100644
--- a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java
+++ b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java
@@ -8,11 +8,14 @@
import org.springframework.web.bind.annotation.RestController;
import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest;
import com.ajou.hertz.common.auth.dto.request.LoginRequest;
import com.ajou.hertz.common.auth.dto.response.JwtTokenInfoResponse;
import com.ajou.hertz.common.auth.service.AuthService;
+import com.ajou.hertz.common.kakao.service.KakaoService;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -26,6 +29,7 @@
public class AuthControllerV1 {
private final AuthService authService;
+ private final KakaoService kakaoService;
@Operation(
summary = "로그인",
@@ -33,12 +37,33 @@ public class AuthControllerV1 {
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
- @ApiResponse(responseCode = "400", description = "[2003] 비밀번호가 일치하지 않는 경우"),
- @ApiResponse(responseCode = "404", description = "[2202] 이메일에 해당하는 유저를 찾을 수 없는 경우")
+ @ApiResponse(responseCode = "400", description = "[2003] 비밀번호가 일치하지 않는 경우", content = @Content),
+ @ApiResponse(responseCode = "404", description = "[2202] 이메일에 해당하는 유저를 찾을 수 없는 경우", content = @Content)
})
@PostMapping(value = "/login", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1)
public JwtTokenInfoResponse loginV1_1(@RequestBody @Valid LoginRequest loginRequest) {
JwtTokenInfoDto jwtTokenInfoDto = authService.login(loginRequest);
return JwtTokenInfoResponse.from(jwtTokenInfoDto);
}
+
+ @Operation(
+ summary = "카카오 로그인",
+ description = "카카오 로그인을 진행합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(responseCode = "200"),
+ @ApiResponse(
+ responseCode = "409", content = @Content,
+ description = """
+
[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우
+
[2203] 이미 다른 사용자가 사용 중인 전화번호로 신규 회원을 등록하려고 하는 경우
+ """
+ ),
+ @ApiResponse(responseCode = "Any", description = "[10000] 카카오 서버와의 통신 중 오류가 발생한 경우. Http status code는 kakao에서 응답받은 것과 동일하게 설정하여 응답한다.", content = @Content)
+ })
+ @PostMapping(value = "/kakao/login", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1)
+ public JwtTokenInfoResponse kakaoLoginV1_1(@RequestBody @Valid KakaoLoginRequest kakaoLoginRequest) {
+ JwtTokenInfoDto jwtTokenInfoDto = kakaoService.login(kakaoLoginRequest);
+ return JwtTokenInfoResponse.from(jwtTokenInfoDto);
+ }
}
diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/request/KakaoLoginRequest.java b/src/main/java/com/ajou/hertz/common/auth/dto/request/KakaoLoginRequest.java
new file mode 100644
index 0000000..efff2ce
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/auth/dto/request/KakaoLoginRequest.java
@@ -0,0 +1,22 @@
+package com.ajou.hertz.common.auth.dto.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@Getter
+public class KakaoLoginRequest {
+
+ @Schema(description = "카카오 '인가 코드 받기'를 통해 얻은 인가 코드", example = "v9rZPkB2nyKTFX26E8ByNzUojqi6w9bQRyfBQCFDzRFhxcoUUSVpI_3HgPIKlwzSAAABjbztS6bkNSpXBP-m7E")
+ @NotBlank
+ private String authorizationCode;
+
+ @Schema(description = "인가 코드가 리다이렉트된 uri", example = "https://hertz.com/kakao-login-redirection")
+ @NotBlank
+ private String redirectUri;
+}
diff --git a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java
index 1d7708c..005b694 100644
--- a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java
+++ b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java
@@ -42,7 +42,8 @@ public class SecurityConfig {
private static final Map AUTH_WHITE_LIST = Map.of(
"/v*/users", POST,
"/v*/users/existence", GET,
- "/v*/auth/login", POST
+ "/v*/auth/login", POST,
+ "/v*/auth/kakao/login", POST
);
@Bean
diff --git a/src/main/java/com/ajou/hertz/common/config/feign/FeignConfig.java b/src/main/java/com/ajou/hertz/common/config/feign/FeignConfig.java
new file mode 100644
index 0000000..04ecdd8
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/config/feign/FeignConfig.java
@@ -0,0 +1,9 @@
+package com.ajou.hertz.common.config.feign;
+
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.context.annotation.Configuration;
+
+@EnableFeignClients(basePackages = "com.ajou.hertz")
+@Configuration
+public class FeignConfig {
+}
diff --git a/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignConfig.java b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignConfig.java
new file mode 100644
index 0000000..450cb95
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignConfig.java
@@ -0,0 +1,24 @@
+package com.ajou.hertz.common.config.feign;
+
+import org.springframework.beans.factory.ObjectFactory;
+import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
+import org.springframework.cloud.openfeign.support.SpringEncoder;
+import org.springframework.context.annotation.Bean;
+
+import feign.Logger;
+import feign.codec.Encoder;
+import feign.codec.ErrorDecoder;
+import feign.form.spring.SpringFormEncoder;
+
+public class KakaoFeignConfig {
+
+ @Bean
+ public Encoder encoder(ObjectFactory converters) {
+ return new SpringFormEncoder(new SpringEncoder(converters));
+ }
+
+ @Bean
+ public ErrorDecoder errorDecoder() {
+ return new KakaoFeignErrorDecoder();
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignErrorDecoder.java b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignErrorDecoder.java
new file mode 100644
index 0000000..d4e08a7
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignErrorDecoder.java
@@ -0,0 +1,35 @@
+package com.ajou.hertz.common.config.feign;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.springframework.http.HttpStatus;
+
+import com.ajou.hertz.common.exception.IOProcessingException;
+import com.ajou.hertz.common.kakao.dto.response.KakaoErrorResponse;
+import com.ajou.hertz.common.kakao.exception.KakaoClientException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import feign.Response;
+import feign.codec.ErrorDecoder;
+
+public class KakaoFeignErrorDecoder implements ErrorDecoder {
+
+ private final ObjectMapper mapper;
+
+ public KakaoFeignErrorDecoder() {
+ this.mapper = new ObjectMapper();
+ }
+
+ @Override
+ public Exception decode(String methodKey, Response response) {
+ try (InputStream responseBody = response.body().asInputStream()) {
+ return new KakaoClientException(
+ HttpStatus.valueOf(response.status()),
+ mapper.readValue(responseBody, KakaoErrorResponse.class)
+ );
+ } catch (IOException e) {
+ return new IOProcessingException(e);
+ }
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/exception/IOProcessingException.java b/src/main/java/com/ajou/hertz/common/exception/IOProcessingException.java
new file mode 100644
index 0000000..2a6dbb0
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/exception/IOProcessingException.java
@@ -0,0 +1,18 @@
+package com.ajou.hertz.common.exception;
+
+import java.io.IOException;
+
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+
+/**
+ * I/O 작업에서 문제가 발생했을 때 사용하는 일반적인 exception class.
+ *
Checked exception을 {@link IOException}을 감싸 처리하는 용도로도 사용한다.
+ *
+ * @see IOException
+ */
+public class IOProcessingException extends InternalServerException {
+
+ public IOProcessingException(Throwable cause) {
+ super(CustomExceptionType.IO_PROCESSING, cause);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java
index 7884203..b0eaef1 100644
--- a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java
+++ b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java
@@ -18,6 +18,7 @@
public enum CustomExceptionType {
MULTIPART_FILE_NOT_READABLE(1000, "파일을 읽을 수 없습니다. 올바른 파일인지 다시 확인 후 요청해주세요."),
+ IO_PROCESSING(1001, "입출력 처리 중 오류가 발생했습니다."),
/**
* 로그인, 인증 관련 예외
@@ -30,9 +31,15 @@ public enum CustomExceptionType {
/**
* 유저 관련 예외
*/
- USER_EMAIL_DUPLICATION(2200, "이미 다른 회원이 사용 중인 이메일입니다."),
+ USER_EMAIL_DUPLICATION(2200, "이미 사용 중인 이메일입니다."),
USER_NOT_FOUND_BY_ID(2201, "일치하는 회원을 찾을 수 없습니다."),
- USER_NOT_FOUND_BY_EMAIL(2202, "일치하는 회원을 찾을 수 없습니다.");
+ USER_NOT_FOUND_BY_EMAIL(2202, "일치하는 회원을 찾을 수 없습니다."),
+ USER_PHONE_DUPLICATION(2203, "이미 사용 중인 전화번호입니다."),
+ USER_KAKAO_UID_DUPLICATION(2204, "이미 가입한 계정입니다."),
+ USER_NOT_FOUND_BY_KAKAO_UID(2205, "일치하는 회원을 찾을 수 없습니다."),
+
+ KAKAO_CLIENT(10000, "카카오 서버와의 통신 중 오류가 발생했습니다."),
+ ;
private final Integer code;
private final String message;
diff --git a/src/main/java/com/ajou/hertz/common/kakao/client/KakaoAuthClient.java b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoAuthClient.java
new file mode 100644
index 0000000..3096607
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoAuthClient.java
@@ -0,0 +1,23 @@
+package com.ajou.hertz.common.kakao.client;
+
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+
+import com.ajou.hertz.common.config.feign.KakaoFeignConfig;
+import com.ajou.hertz.common.kakao.dto.request.IssueKakaoAccessTokenRequest;
+import com.ajou.hertz.common.kakao.dto.response.KakaoTokenResponse;
+
+@FeignClient(
+ name = "kakaoAuthClient",
+ url = "https://kauth.kakao.com",
+ configuration = KakaoFeignConfig.class
+)
+public interface KakaoAuthClient {
+
+ @PostMapping(
+ value = "/oauth/token",
+ headers = "Content-type=application/x-www-form-urlencoded;charset=utf-8"
+ )
+ KakaoTokenResponse issueAccessToken(@RequestBody IssueKakaoAccessTokenRequest issueTokenRequest);
+}
diff --git a/src/main/java/com/ajou/hertz/common/kakao/client/KakaoClient.java b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoClient.java
new file mode 100644
index 0000000..7d14fc9
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoClient.java
@@ -0,0 +1,26 @@
+package com.ajou.hertz.common.kakao.client;
+
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import com.ajou.hertz.common.config.feign.KakaoFeignConfig;
+import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
+
+@FeignClient(
+ name = "kakaoClient",
+ url = "https://kapi.kakao.com",
+ configuration = KakaoFeignConfig.class
+)
+public interface KakaoClient {
+
+ @GetMapping(
+ value = "/v2/user/me",
+ headers = "Content-type=application/x-www-form-urlencoded;charset=utf-8"
+ )
+ KakaoUserInfoResponse getUserInfo(
+ @RequestHeader("Authorization") String authorizationHeader,
+ @RequestParam("secure_resource") Boolean secureResource
+ );
+}
diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/request/IssueKakaoAccessTokenRequest.java b/src/main/java/com/ajou/hertz/common/kakao/dto/request/IssueKakaoAccessTokenRequest.java
new file mode 100644
index 0000000..a715797
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/dto/request/IssueKakaoAccessTokenRequest.java
@@ -0,0 +1,18 @@
+package com.ajou.hertz.common.kakao.dto.request;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@Getter
+public class IssueKakaoAccessTokenRequest {
+
+ private String grant_type;
+ private String client_id;
+ private String redirect_uri;
+ private String code;
+ private String client_secret;
+}
diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoErrorResponse.java b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoErrorResponse.java
new file mode 100644
index 0000000..b231b1b
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoErrorResponse.java
@@ -0,0 +1,10 @@
+package com.ajou.hertz.common.kakao.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record KakaoErrorResponse(
+ String error,
+ @JsonProperty("error_description") String errorDescription,
+ @JsonProperty("error_code") String errorCode
+) {
+}
diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoTokenResponse.java b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoTokenResponse.java
new file mode 100644
index 0000000..3040885
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoTokenResponse.java
@@ -0,0 +1,29 @@
+package com.ajou.hertz.common.kakao.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@Getter
+public class KakaoTokenResponse {
+
+ @JsonProperty("token_type")
+ private String tokenType;
+
+ @JsonProperty("access_token")
+ private String accessToken;
+
+ @JsonProperty("expires_in")
+ private Integer expiresIn;
+
+ @JsonProperty("refresh_token")
+ private String refreshToken;
+
+ @JsonProperty("refresh_token_expires_in")
+ private Integer refreshTokenExpiresIn;
+}
diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoUserInfoResponse.java b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoUserInfoResponse.java
new file mode 100644
index 0000000..37a5e7f
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoUserInfoResponse.java
@@ -0,0 +1,88 @@
+package com.ajou.hertz.common.kakao.dto.response;
+
+import java.time.LocalDate;
+
+import org.springframework.lang.Nullable;
+import org.springframework.util.StringUtils;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record KakaoUserInfoResponse(
+ String id,
+ @JsonProperty("kakao_account") KakaoAccount kakaoAccount
+) {
+
+ public record KakaoAccount(
+ @JsonProperty("profile_image_needs_agreement") Boolean profileImageNeedsAgreement,
+ Profile profile,
+
+ @JsonProperty("has_email") Boolean hasEmail,
+ @JsonProperty("email_needs_agreement") Boolean emailNeedsAgreement,
+ @JsonProperty("is_email_valid") Boolean isEmailValid,
+ @JsonProperty("is_email_verified") Boolean isEmailVerified,
+ String email,
+
+ @JsonProperty("has_phone_number") Boolean hasPhoneNumber,
+ @JsonProperty("phone_number_needs_agreement") Boolean phoneNumberNeedsAgreement,
+ @JsonProperty("phone_number") String phoneNumber,
+
+ @JsonProperty("has_birthyear") Boolean hasBirthyear,
+ @JsonProperty("birthyear_needs_agreement") Boolean birthyearNeedsAgreement,
+ String birthyear,
+
+ @JsonProperty("has_birthday") Boolean hasBirthday,
+ String birthday,
+
+ @JsonProperty("has_gender") Boolean hasGender,
+ @JsonProperty("gender_needs_agreement") Boolean genderNeedsAgreement,
+ String gender
+ ) {
+
+ public record Profile(
+ @JsonProperty("profile_image_url") String profileImageUrl,
+ @JsonProperty("thumbnail_image_url") String thumbnailImageUrl,
+ @JsonProperty("is_default_image") Boolean isDefaultImage
+ ) {
+ }
+ }
+
+ public String profileImageUrl() {
+ return kakaoAccount.profile.profileImageUrl();
+ }
+
+ public String email() {
+ return kakaoAccount.email();
+ }
+
+ public String gender() {
+ return kakaoAccount.gender();
+ }
+
+ @Nullable
+ public LocalDate birth() {
+ String birthyear = kakaoAccount.birthyear();
+ String birthday = kakaoAccount.birthday();
+ if (!StringUtils.hasText(birthyear) || !StringUtils.hasText(birthday)) {
+ return null;
+ }
+ return LocalDate.of(
+ Integer.parseInt(birthyear),
+ Integer.parseInt(birthday.substring(0, 2)),
+ Integer.parseInt(birthday.substring(2, 4))
+ );
+ }
+
+ /**
+ *
[한국 전화번호 형식 기준]
+ *
"+82 10-1234-5678" 형태의 전화번호를 "01012345678" 형태로 변환하여 반환한다.
+ *
+ * @return 변환된 전화번호
+ */
+ public String getKoreanFormatPhoneNumber() {
+ return kakaoAccount.phoneNumber()
+ .replace("+82 ", "0")
+ .replace("-", "")
+ .trim();
+ }
+}
+
diff --git a/src/main/java/com/ajou/hertz/common/kakao/exception/KakaoClientException.java b/src/main/java/com/ajou/hertz/common/kakao/exception/KakaoClientException.java
new file mode 100644
index 0000000..b0d89a2
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/exception/KakaoClientException.java
@@ -0,0 +1,23 @@
+package com.ajou.hertz.common.kakao.exception;
+
+import org.springframework.http.HttpStatus;
+
+import com.ajou.hertz.common.exception.CustomException;
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+import com.ajou.hertz.common.kakao.dto.response.KakaoErrorResponse;
+
+public class KakaoClientException extends CustomException {
+
+ public KakaoClientException(HttpStatus httpStatus, KakaoErrorResponse kakaoErrorResponse) {
+ super(httpStatus, CustomExceptionType.KAKAO_CLIENT, createErrorMessage(kakaoErrorResponse));
+ }
+
+ private static String createErrorMessage(KakaoErrorResponse kakaoErrorResponse) {
+ return String.format(
+ "ErrorInfo=[errorCode=%s, error=%s, errorMessage=%s]",
+ kakaoErrorResponse.errorCode(),
+ kakaoErrorResponse.error(),
+ kakaoErrorResponse.errorDescription()
+ );
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/kakao/service/KakaoService.java b/src/main/java/com/ajou/hertz/common/kakao/service/KakaoService.java
new file mode 100644
index 0000000..873359a
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/kakao/service/KakaoService.java
@@ -0,0 +1,75 @@
+package com.ajou.hertz.common.kakao.service;
+
+import org.springframework.stereotype.Service;
+
+import com.ajou.hertz.common.auth.JwtTokenProvider;
+import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest;
+import com.ajou.hertz.common.kakao.client.KakaoAuthClient;
+import com.ajou.hertz.common.kakao.client.KakaoClient;
+import com.ajou.hertz.common.kakao.dto.request.IssueKakaoAccessTokenRequest;
+import com.ajou.hertz.common.kakao.dto.response.KakaoTokenResponse;
+import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
+import com.ajou.hertz.common.kakao.exception.KakaoClientException;
+import com.ajou.hertz.common.properties.KakaoProperties;
+import com.ajou.hertz.domain.user.dto.UserDto;
+import com.ajou.hertz.domain.user.exception.UserEmailDuplicationException;
+import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException;
+import com.ajou.hertz.domain.user.service.UserCommandService;
+import com.ajou.hertz.domain.user.service.UserQueryService;
+
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+@Service
+public class KakaoService {
+
+ private final UserQueryService userQueryService;
+ private final UserCommandService userCommandService;
+ private final KakaoClient kakaoClient;
+ private final KakaoAuthClient kakaoAuthClient;
+ private final KakaoProperties kakaoProperties;
+ private final JwtTokenProvider jwtTokenProvider;
+
+ /**
+ *
카카오에서 발급 받은 인가 코드로 유저 정보를 조회한 후, 로그인한다.
+ *
만약 신규 회원이라면 신규 회원을 등록한다.
+ *
+ * @param kakaoLoginRequest 카카오 로그인을 위한 정보(인가코드 등)
+ * @return 로그인 성공 시 발급한 access token 정보
+ * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우.
+ * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우.
+ * @throws KakaoClientException 카카오 서버와의 통신 중 오류가 발생한 경우.
+ */
+ public JwtTokenInfoDto login(KakaoLoginRequest kakaoLoginRequest) {
+ String kakaoAccessToken = issueAccessToken(kakaoLoginRequest);
+ KakaoUserInfoResponse userInfo = kakaoClient.getUserInfo("Bearer " + kakaoAccessToken, true);
+
+ // 기존 존재하는 회원이라면 조회, 신규 회원이라면 신규 회원 등록 로직 수행
+ UserDto user = userQueryService
+ .findDtoByKakaoUid(userInfo.id())
+ .orElseGet(() -> userCommandService.createNewUserWithKakao(userInfo));
+
+ return jwtTokenProvider.createAccessToken(user);
+ }
+
+ /**
+ * 카카오에서 발급 받은 인가 코드로 access token을 발행한다.
+ *
+ * @param kakaoLoginRequest access token을 발급받기 위한 정보(인가코드 등)
+ * @return access token
+ * @throws KakaoClientException 카카오 서버와의 통신 중 오류가 발생한 경우.
+ */
+ private String issueAccessToken(KakaoLoginRequest kakaoLoginRequest) {
+ KakaoTokenResponse accessTokenResponse = kakaoAuthClient.issueAccessToken(
+ new IssueKakaoAccessTokenRequest(
+ "authorization_code",
+ kakaoProperties.restApiKey(),
+ kakaoLoginRequest.getRedirectUri(),
+ kakaoLoginRequest.getAuthorizationCode(),
+ kakaoProperties.clientSecret()
+ )
+ );
+ return accessTokenResponse.getAccessToken();
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/common/properties/KakaoProperties.java b/src/main/java/com/ajou/hertz/common/properties/KakaoProperties.java
new file mode 100644
index 0000000..ae9fd00
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/common/properties/KakaoProperties.java
@@ -0,0 +1,10 @@
+package com.ajou.hertz.common.properties;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties("kakao")
+public record KakaoProperties(
+ String restApiKey,
+ String clientSecret
+) {
+}
diff --git a/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java b/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java
index e71d63b..08f3a79 100644
--- a/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java
+++ b/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java
@@ -61,7 +61,13 @@ public UserExistenceResponse getExistenceOfUserByEmailV1_1(
)
@ApiResponses({
@ApiResponse(responseCode = "200"),
- @ApiResponse(responseCode = "409", description = "[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우", content = @Content)
+ @ApiResponse(
+ responseCode = "409", content = @Content,
+ description = """
+
[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우.
+
[2203] 이미 다른 사용자가 사용 중인 전화번호로 신규 회원을 등록하려고 하는 경우.
+ """
+ )
})
@PostMapping(headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1)
public ResponseEntity signUpV1_1(
diff --git a/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java
index 376e06a..e557b9d 100644
--- a/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java
+++ b/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java
@@ -2,14 +2,13 @@
import java.time.LocalDate;
-import com.ajou.hertz.domain.user.constant.Gender;
import com.ajou.hertz.common.validator.Password;
import com.ajou.hertz.common.validator.PhoneNumber;
+import com.ajou.hertz.domain.user.constant.Gender;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
@@ -31,11 +30,9 @@ public class SignUpRequest {
private String password;
@Schema(description = "생년월일", example = "2024-01-01")
- @NotNull
private LocalDate birth;
@Schema(description = "성별")
- @NotNull
private Gender gender;
@Schema(description = "전화번호", example = "01012345678")
diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/User.java b/src/main/java/com/ajou/hertz/domain/user/entity/User.java
index 978d56f..f900e55 100644
--- a/src/main/java/com/ajou/hertz/domain/user/entity/User.java
+++ b/src/main/java/com/ajou/hertz/domain/user/entity/User.java
@@ -62,7 +62,6 @@ public class User extends TimeTrackedBaseEntity {
private LocalDate birth;
- @Column(nullable = false)
@Enumerated(EnumType.STRING)
private Gender gender;
@@ -77,7 +76,7 @@ public static User create(
@NonNull String password,
@NonNull String profileImageUrl,
LocalDate birth,
- @NonNull Gender gender,
+ Gender gender,
String phone
) {
return new User(
@@ -85,4 +84,20 @@ public static User create(
profileImageUrl, birth, gender, phone, null
);
}
+
+ @NonNull
+ public static User create(
+ @NonNull String email,
+ @NonNull String password,
+ @NonNull String kakaoUid,
+ @NonNull String profileImageUrl,
+ LocalDate birth,
+ Gender gender,
+ String phone
+ ) {
+ return new User(
+ null, Set.of(RoleType.USER), email, password, kakaoUid,
+ profileImageUrl, birth, gender, phone, null
+ );
+ }
}
diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserKakaoUidDuplicationException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserKakaoUidDuplicationException.java
new file mode 100644
index 0000000..3907d7e
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserKakaoUidDuplicationException.java
@@ -0,0 +1,10 @@
+package com.ajou.hertz.domain.user.exception;
+
+import com.ajou.hertz.common.exception.ConflictException;
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+
+public class UserKakaoUidDuplicationException extends ConflictException {
+ public UserKakaoUidDuplicationException(String kakaoUid) {
+ super(CustomExceptionType.USER_KAKAO_UID_DUPLICATION, "kakaoUid=" + kakaoUid);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByKakaoUidException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByKakaoUidException.java
new file mode 100644
index 0000000..bf6d2a0
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByKakaoUidException.java
@@ -0,0 +1,10 @@
+package com.ajou.hertz.domain.user.exception;
+
+import com.ajou.hertz.common.exception.NotFoundException;
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+
+public class UserNotFoundByKakaoUidException extends NotFoundException {
+ public UserNotFoundByKakaoUidException(String kakaoUid) {
+ super(CustomExceptionType.USER_KAKAO_UID_DUPLICATION, "kakaoUid=" + kakaoUid);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserPhoneDuplicationException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserPhoneDuplicationException.java
new file mode 100644
index 0000000..a879412
--- /dev/null
+++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserPhoneDuplicationException.java
@@ -0,0 +1,11 @@
+package com.ajou.hertz.domain.user.exception;
+
+import com.ajou.hertz.common.exception.ConflictException;
+import com.ajou.hertz.common.exception.constant.CustomExceptionType;
+
+public class UserPhoneDuplicationException extends ConflictException {
+
+ public UserPhoneDuplicationException(String phone) {
+ super(CustomExceptionType.USER_PHONE_DUPLICATION, "phone=" + phone);
+ }
+}
diff --git a/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java b/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java
index 9be5a9b..091acfd 100644
--- a/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java
+++ b/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java
@@ -10,5 +10,11 @@ public interface UserRepository extends JpaRepository {
Optional findByEmail(String email);
+ Optional findByKakaoUid(String kakaoUid);
+
boolean existsByEmail(String email);
+
+ boolean existsByKakaoUid(String kakaoUid);
+
+ boolean existsByPhone(String phone);
}
diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java
index f175ff6..59bb57d 100644
--- a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java
+++ b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java
@@ -1,14 +1,21 @@
package com.ajou.hertz.domain.user.service;
+import java.util.UUID;
+
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
import com.ajou.hertz.common.properties.HertzProperties;
+import com.ajou.hertz.domain.user.constant.Gender;
import com.ajou.hertz.domain.user.dto.UserDto;
import com.ajou.hertz.domain.user.dto.request.SignUpRequest;
import com.ajou.hertz.domain.user.entity.User;
import com.ajou.hertz.domain.user.exception.UserEmailDuplicationException;
+import com.ajou.hertz.domain.user.exception.UserKakaoUidDuplicationException;
+import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException;
import com.ajou.hertz.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@@ -29,12 +36,12 @@ public class UserCommandService {
* @param signUpRequest 회원 등록을 위한 정보가 담긴 dto
* @return 새로 등록된 회원 정보가 담긴 dto
* @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우.
+ * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우.
*/
public UserDto createNewUser(SignUpRequest signUpRequest) {
String email = signUpRequest.getEmail();
- if (userQueryService.existsByEmail(email)) {
- throw new UserEmailDuplicationException(email);
- }
+ String phone = signUpRequest.getPhone();
+ validateUserNotDuplicated(email, phone);
User userSaved = userRepository.save(
User.create(
@@ -43,9 +50,83 @@ public UserDto createNewUser(SignUpRequest signUpRequest) {
hertzProperties.userDefaultProfileImageUrl(),
signUpRequest.getBirth(),
signUpRequest.getGender(),
- signUpRequest.getPhone()
+ phone
+ )
+ );
+ return UserDto.from(userSaved);
+ }
+
+ /**
+ * 카카오 소셜 로그인과 연동하여, 신규 회원을 등록한다.
+ *
+ * @param userInfo 카카오에서 응답받은 유저 정보
+ * @return 새로 등록된 회원 정보
+ * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우.
+ * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우.
+ * @throws UserKakaoUidDuplicationException 이미 가입된 카카오 계정인 경우.
+ */
+ public UserDto createNewUserWithKakao(KakaoUserInfoResponse userInfo) {
+ String gender = userInfo.gender();
+ String email = userInfo.email();
+ String phone = userInfo.getKoreanFormatPhoneNumber();
+ String kakaoUid = userInfo.id();
+ validateUserNotDuplicated(email, phone, kakaoUid);
+
+ User userSaved = userRepository.save(
+ User.create(
+ email,
+ passwordEncoder.encode(generateRandom16CharString()),
+ kakaoUid,
+ userInfo.profileImageUrl(),
+ userInfo.birth(),
+ StringUtils.hasText(gender) ? Gender.valueOf(gender.toUpperCase()) : null,
+ phone
)
);
return UserDto.from(userSaved);
}
+
+ /**
+ * 기존 유저와 중복되는 정보가 없음을 검증한다.
+ *
+ * @param email email
+ * @param phone phone number
+ * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우.
+ * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우.
+ */
+ private void validateUserNotDuplicated(String email, String phone) {
+ if (userQueryService.existsByEmail(email)) {
+ throw new UserEmailDuplicationException(email);
+ }
+ if (userQueryService.existsByPhone(phone)) {
+ throw new UserPhoneDuplicationException(phone);
+ }
+ }
+
+ /**
+ * 기존 유저와 중복되는 정보가 없음을 검증한다.
+ *
+ * @param email email
+ * @param phone phone number
+ * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우.
+ * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우.
+ */
+ private void validateUserNotDuplicated(String email, String phone, String kakaoUid) {
+ validateUserNotDuplicated(email, phone);
+ if (userQueryService.existsByKakaoUid(kakaoUid)) {
+ throw new UserKakaoUidDuplicationException(kakaoUid);
+ }
+ }
+
+ /**
+ * 16글자의 랜덤한 문자열을 생성한다.
+ *
+ * @return 생성된 랜덤 문자열
+ */
+ private String generateRandom16CharString() {
+ return UUID
+ .randomUUID()
+ .toString()
+ .substring(0, 16);
+ }
}
diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java
index 0d5a4be..62d635a 100644
--- a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java
+++ b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java
@@ -1,5 +1,7 @@
package com.ajou.hertz.domain.user.service;
+import java.util.Optional;
+
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +42,10 @@ private User getByEmail(String email) {
return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundByEmailException(email));
}
+ public Optional findDtoByKakaoUid(String kakaoUid) {
+ return userRepository.findByKakaoUid(kakaoUid).map(UserDto::from);
+ }
+
/**
* 유저 id로 유저를 조회한다.
*
@@ -71,4 +77,24 @@ public UserDto getDtoByEmail(String email) {
public boolean existsByEmail(String email) {
return userRepository.existsByEmail(email);
}
+
+ /**
+ * 전달된 kakaoUid를 사용 중인 회원의 존재 여부를 조회한다.
+ *
+ * @param kakaoUid kakao user id
+ * @return 전달된 kakaoUid를 사용 중인 회원의 존재 여부
+ */
+ public boolean existsByKakaoUid(String kakaoUid) {
+ return userRepository.existsByKakaoUid(kakaoUid);
+ }
+
+ /**
+ * 전달된 phone를 사용 중인 회원의 존재 여부를 조회한다.
+ *
+ * @param phone phone number
+ * @return 전달된 phone를 사용 중인 회원의 존재 여부
+ */
+ public boolean existsByPhone(String phone) {
+ return userRepository.existsByPhone(phone);
+ }
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index c86d7f4..888a534 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -34,3 +34,6 @@ aws.s3.bucket-name=${AWS_S3_BUCKET_NAME}
aws.s3.access-key=${AWS_S3_IAM_ACCESS_KEY}
aws.s3.secret-key=${AWS_S3_IAM_SECRET_KEY}
aws.cloud-front.base-url=${AWS_CLOUD_FRONT_BASE_URL}
+
+kakao.rest-api-key=${KAKAO_REST_API_KEY}
+kakao.client-secret=${KAKAO_CLIENT_SECRET}
diff --git a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java
index c23fa65..c459705 100644
--- a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java
+++ b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java
@@ -19,8 +19,10 @@
import com.ajou.hertz.common.auth.controller.AuthControllerV1;
import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest;
import com.ajou.hertz.common.auth.dto.request.LoginRequest;
import com.ajou.hertz.common.auth.service.AuthService;
+import com.ajou.hertz.common.kakao.service.KakaoService;
import com.ajou.hertz.config.ControllerTestConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -32,6 +34,9 @@ class AuthControllerV1Test {
@MockBean
private AuthService authService;
+ @MockBean
+ private KakaoService kakaoService;
+
private final MockMvc mvc;
private final ObjectMapper objectMapper;
@@ -59,7 +64,32 @@ public AuthControllerV1Test(MockMvc mvc, ObjectMapper objectMapper) {
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value(expectedResult.token()));
then(authService).should().login(any(LoginRequest.class));
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ }
+
+ @Test
+ void 카카오_로그인을_위한_정보가_주어지고_카카오_로그인을_진행한다() throws Exception {
+ // given
+ KakaoLoginRequest kakaoLoginRequest = createKakaoLoginRequest();
+ JwtTokenInfoDto expectedResult = createJwtTokenInfoDto();
+ given(kakaoService.login(any(KakaoLoginRequest.class))).willReturn(expectedResult);
+
+ // when & then
+ mvc.perform(
+ post("/v1/auth/kakao/login")
+ .header(API_MINOR_VERSION_HEADER_NAME, 1)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(kakaoLoginRequest))
+ )
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.token").value(expectedResult.token()));
+ then(kakaoService).should().login(any(KakaoLoginRequest.class));
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ }
+
+ private void verifyEveryMocksShouldHaveNoMoreInteractions() {
then(authService).shouldHaveNoMoreInteractions();
+ then(kakaoService).shouldHaveNoMoreInteractions();
}
private LoginRequest createLoginRequest() throws Exception {
@@ -72,6 +102,16 @@ private LoginRequest createLoginRequest() throws Exception {
);
}
+ private static KakaoLoginRequest createKakaoLoginRequest() throws Exception {
+ Constructor kakaoLoginRequestConstructor =
+ KakaoLoginRequest.class.getDeclaredConstructor(String.class, String.class);
+ kakaoLoginRequestConstructor.setAccessible(true);
+ return kakaoLoginRequestConstructor.newInstance(
+ "authorization-code",
+ "https://redirect-uri"
+ );
+ }
+
private JwtTokenInfoDto createJwtTokenInfoDto() {
return new JwtTokenInfoDto(
"access-token",
diff --git a/src/test/java/com/ajou/hertz/unit/common/kakao/service/KakaoServiceTest.java b/src/test/java/com/ajou/hertz/unit/common/kakao/service/KakaoServiceTest.java
new file mode 100644
index 0000000..be7c0f1
--- /dev/null
+++ b/src/test/java/com/ajou/hertz/unit/common/kakao/service/KakaoServiceTest.java
@@ -0,0 +1,218 @@
+package com.ajou.hertz.unit.common.kakao.service;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.BDDMockito.*;
+
+import java.lang.reflect.Constructor;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Optional;
+import java.util.Set;
+
+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.test.context.event.annotation.BeforeTestMethod;
+
+import com.ajou.hertz.common.auth.JwtTokenProvider;
+import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto;
+import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest;
+import com.ajou.hertz.common.kakao.client.KakaoAuthClient;
+import com.ajou.hertz.common.kakao.client.KakaoClient;
+import com.ajou.hertz.common.kakao.dto.request.IssueKakaoAccessTokenRequest;
+import com.ajou.hertz.common.kakao.dto.response.KakaoTokenResponse;
+import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
+import com.ajou.hertz.common.kakao.service.KakaoService;
+import com.ajou.hertz.common.properties.KakaoProperties;
+import com.ajou.hertz.domain.user.constant.Gender;
+import com.ajou.hertz.domain.user.constant.RoleType;
+import com.ajou.hertz.domain.user.dto.UserDto;
+import com.ajou.hertz.domain.user.service.UserCommandService;
+import com.ajou.hertz.domain.user.service.UserQueryService;
+
+@DisplayName("[Unit] Service - Kakao")
+@ExtendWith(MockitoExtension.class)
+class KakaoServiceTest {
+
+ @InjectMocks
+ private KakaoService sut;
+
+ @Mock
+ private UserQueryService userQueryService;
+
+ @Mock
+ private UserCommandService userCommandService;
+
+ @Mock
+ private KakaoClient kakaoClient;
+
+ @Mock
+ private KakaoAuthClient kakaoAuthClient;
+
+ @Mock
+ private KakaoProperties kakaoProperties;
+
+ @Mock
+ private JwtTokenProvider jwtTokenProvider;
+
+ @BeforeTestMethod
+ public void setUp() {
+ given(kakaoProperties.restApiKey()).willReturn("kakao-rest-api-key");
+ given(kakaoProperties.clientSecret()).willReturn("kakao-client-secret");
+ }
+
+ @Test
+ void 인가_코드와_리다이렉트_주소가_주어지고_주어진_정보로_카카오_로그인을_진행한다() throws Exception {
+ // given
+ KakaoLoginRequest kakaoLoginRequest = createKakaoLoginRequest();
+ KakaoTokenResponse kakaoTokenResponse = createKakaoTokenResponse();
+ String authorizationHeaderToGettingKakaoUserInfo = "Bearer " + kakaoTokenResponse.getAccessToken();
+ KakaoUserInfoResponse kakaoUserInfoResponse = createKakaoUserInfoResponse();
+ UserDto userDto = createUserDto();
+ JwtTokenInfoDto expectedResult = createJwtTokenInfoDto();
+ given(kakaoAuthClient.issueAccessToken(any(IssueKakaoAccessTokenRequest.class))).willReturn(kakaoTokenResponse);
+ given(
+ kakaoClient.getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true)
+ ).willReturn(kakaoUserInfoResponse);
+ given(userQueryService.findDtoByKakaoUid(kakaoUserInfoResponse.id())).willReturn(Optional.of(userDto));
+ given(jwtTokenProvider.createAccessToken(userDto)).willReturn(expectedResult);
+
+ // when
+ JwtTokenInfoDto actualResult = sut.login(kakaoLoginRequest);
+
+ // then
+ then(kakaoProperties).should().restApiKey();
+ then(kakaoProperties).should().clientSecret();
+ then(kakaoAuthClient).should().issueAccessToken(any(IssueKakaoAccessTokenRequest.class));
+ then(kakaoClient).should().getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true);
+ then(userQueryService).should().findDtoByKakaoUid(kakaoUserInfoResponse.id());
+ then(jwtTokenProvider).should().createAccessToken(userDto);
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(actualResult)
+ .hasFieldOrPropertyWithValue("token", expectedResult.token());
+ }
+
+ @Test
+ void 인가_코드와_리다이렉트_주소가_주어지고_주어진_정보로_카카오_로그인을_진행한다_만약_신규_회원이라면_회원가입한다() throws Exception {
+ // given
+ KakaoLoginRequest kakaoLoginRequest = createKakaoLoginRequest();
+ KakaoTokenResponse kakaoTokenResponse = createKakaoTokenResponse();
+ String authorizationHeaderToGettingKakaoUserInfo = "Bearer " + kakaoTokenResponse.getAccessToken();
+ KakaoUserInfoResponse kakaoUserInfoResponse = createKakaoUserInfoResponse();
+ UserDto userDto = createUserDto();
+ JwtTokenInfoDto expectedResult = createJwtTokenInfoDto();
+ given(kakaoAuthClient.issueAccessToken(any(IssueKakaoAccessTokenRequest.class))).willReturn(kakaoTokenResponse);
+ given(
+ kakaoClient.getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true)
+ ).willReturn(kakaoUserInfoResponse);
+ given(userQueryService.findDtoByKakaoUid(kakaoUserInfoResponse.id())).willReturn(Optional.empty());
+ given(userCommandService.createNewUserWithKakao(kakaoUserInfoResponse)).willReturn(userDto);
+ given(jwtTokenProvider.createAccessToken(userDto)).willReturn(expectedResult);
+
+ // when
+ JwtTokenInfoDto actualResult = sut.login(kakaoLoginRequest);
+
+ // then
+ then(kakaoProperties).should().restApiKey();
+ then(kakaoProperties).should().clientSecret();
+ then(kakaoAuthClient).should().issueAccessToken(any(IssueKakaoAccessTokenRequest.class));
+ then(kakaoClient).should().getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true);
+ then(userQueryService).should().findDtoByKakaoUid(kakaoUserInfoResponse.id());
+ then(userCommandService).should().createNewUserWithKakao(kakaoUserInfoResponse);
+ then(jwtTokenProvider).should().createAccessToken(userDto);
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(actualResult)
+ .hasFieldOrPropertyWithValue("token", expectedResult.token());
+ }
+
+ private void verifyEveryMocksShouldHaveNoMoreInteractions() {
+ then(userQueryService).shouldHaveNoMoreInteractions();
+ then(userCommandService).shouldHaveNoMoreInteractions();
+ then(kakaoClient).shouldHaveNoMoreInteractions();
+ then(kakaoAuthClient).shouldHaveNoMoreInteractions();
+ then(kakaoProperties).shouldHaveNoMoreInteractions();
+ then(jwtTokenProvider).shouldHaveNoMoreInteractions();
+ }
+
+ private UserDto createUserDto(long id) throws Exception {
+ Constructor userResponseConstructor = UserDto.class.getDeclaredConstructor(
+ Long.class, Set.class, String.class, String.class, String.class,
+ String.class, LocalDate.class, Gender.class, String.class, String.class
+ );
+ userResponseConstructor.setAccessible(true);
+ return userResponseConstructor.newInstance(
+ id,
+ Set.of(RoleType.USER),
+ "test@mail.com",
+ "$2a$abc123",
+ "kakao-user-id",
+ "https://user-default-profile-image",
+ LocalDate.of(2024, 1, 1),
+ Gender.ETC,
+ "01012345678",
+ "https://contack-link"
+ );
+ }
+
+ private UserDto createUserDto() throws Exception {
+ return createUserDto(1L);
+ }
+
+ private static KakaoLoginRequest createKakaoLoginRequest() throws Exception {
+ Constructor kakaoLoginRequestConstructor =
+ KakaoLoginRequest.class.getDeclaredConstructor(String.class, String.class);
+ kakaoLoginRequestConstructor.setAccessible(true);
+ return kakaoLoginRequestConstructor.newInstance(
+ "authorization-code",
+ "https://redirect-uri"
+ );
+ }
+
+ private static KakaoTokenResponse createKakaoTokenResponse() throws Exception {
+ Constructor kakaoTokenResponseConstructor = KakaoTokenResponse.class.getDeclaredConstructor(
+ String.class, String.class, Integer.class, String.class, Integer.class
+ );
+ kakaoTokenResponseConstructor.setAccessible(true);
+ return kakaoTokenResponseConstructor.newInstance("bearer", "access-token", 43199, "refresh-token", 5184000);
+ }
+
+ private static KakaoUserInfoResponse createKakaoUserInfoResponse() {
+ return new KakaoUserInfoResponse(
+ "12345",
+ new KakaoUserInfoResponse.KakaoAccount(
+ true,
+ new KakaoUserInfoResponse.KakaoAccount.Profile(
+ "https://profile-image-url",
+ "https://thumbnail-image-url",
+ true
+ ),
+ true,
+ true,
+ true,
+ true,
+ "test@mail.com",
+ true,
+ true,
+ "01012345678",
+ true,
+ true,
+ null,
+ true,
+ null,
+ true,
+ true,
+ "male"
+ )
+ );
+ }
+
+ private static JwtTokenInfoDto createJwtTokenInfoDto() {
+ return new JwtTokenInfoDto(
+ "access-token",
+ LocalDateTime.of(2024, 1, 1, 0, 0)
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java
index f3d05df..a0a62da 100644
--- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java
+++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java
@@ -1,21 +1,27 @@
package com.ajou.hertz.unit.domain.user.service;
import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.params.provider.Arguments.*;
import static org.mockito.BDDMockito.*;
import java.lang.reflect.Constructor;
import java.time.LocalDate;
import java.util.Set;
+import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.event.annotation.BeforeTestMethod;
+import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
import com.ajou.hertz.common.properties.HertzProperties;
import com.ajou.hertz.domain.user.constant.Gender;
import com.ajou.hertz.domain.user.constant.RoleType;
@@ -23,6 +29,8 @@
import com.ajou.hertz.domain.user.dto.request.SignUpRequest;
import com.ajou.hertz.domain.user.entity.User;
import com.ajou.hertz.domain.user.exception.UserEmailDuplicationException;
+import com.ajou.hertz.domain.user.exception.UserKakaoUidDuplicationException;
+import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException;
import com.ajou.hertz.domain.user.repository.UserRepository;
import com.ajou.hertz.domain.user.service.UserCommandService;
import com.ajou.hertz.domain.user.service.UserQueryService;
@@ -57,8 +65,9 @@ public void setUp() {
long userId = 1L;
SignUpRequest signUpRequest = createSignUpRequest();
String passwordEncoded = "$2a$abc123";
- User expectedResult = createUser(userId, passwordEncoded);
+ User expectedResult = createUser(userId, passwordEncoded, "12345");
given(userQueryService.existsByEmail(signUpRequest.getEmail())).willReturn(false);
+ given(userQueryService.existsByPhone(signUpRequest.getPhone())).willReturn(false);
given(passwordEncoder.encode(signUpRequest.getPassword())).willReturn(passwordEncoded);
given(userRepository.save(any(User.class))).willReturn(expectedResult);
@@ -67,6 +76,7 @@ public void setUp() {
// then
then(userQueryService).should().existsByEmail(signUpRequest.getEmail());
+ then(userQueryService).should().existsByPhone(signUpRequest.getPhone());
then(passwordEncoder).should().encode(signUpRequest.getPassword());
then(userRepository).should().save(any(User.class));
verifyEveryMocksShouldHaveNoMoreInteractions();
@@ -77,7 +87,7 @@ public void setUp() {
void 주어진_회원_정보로_신규_회원을_등록한다_이미_사용_중인_이메일이라면_예외가_발생한다() throws Exception {
// given
String email = "test@test.com";
- SignUpRequest signUpRequest = createSignUpRequest(email);
+ SignUpRequest signUpRequest = createSignUpRequest(email, "01012345678");
given(userQueryService.existsByEmail(email)).willReturn(true);
// when
@@ -89,13 +99,85 @@ public void setUp() {
assertThat(t).isInstanceOf(UserEmailDuplicationException.class);
}
+ @Test
+ void 주어진_회원_정보로_신규_회원을_등록한다_이미_사용_중인_전화번호라면_예외가_발생한다() throws Exception {
+ // given
+ String phone = "01012345678";
+ SignUpRequest signUpRequest = createSignUpRequest(phone, phone);
+ given(userQueryService.existsByEmail(signUpRequest.getEmail())).willReturn(false);
+ given(userQueryService.existsByPhone(phone)).willReturn(true);
+
+ // when
+ Throwable t = catchThrowable(() -> sut.createNewUser(signUpRequest));
+
+ // then
+ then(userQueryService).should().existsByEmail(signUpRequest.getEmail());
+ then(userQueryService).should().existsByPhone(phone);
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(t).isInstanceOf(UserPhoneDuplicationException.class);
+ }
+
+ @MethodSource("testDataForCreateNewUserWithKakao")
+ @ParameterizedTest
+ void 주어진_카카오_유저_정보로_신규_회원을_등록한다(KakaoUserInfoResponse kakaoUserInfo, User expectedResult) throws Exception {
+ // given
+ given(userQueryService.existsByEmail(kakaoUserInfo.email())).willReturn(false);
+ given(userQueryService.existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber())).willReturn(false);
+ given(userQueryService.existsByKakaoUid(kakaoUserInfo.id())).willReturn(false);
+ given(passwordEncoder.encode(anyString())).willReturn(expectedResult.getPassword());
+ given(userRepository.save(any(User.class))).willReturn(expectedResult);
+
+ // when
+ UserDto actualResult = sut.createNewUserWithKakao(kakaoUserInfo);
+
+ // then
+ then(userQueryService).should().existsByEmail(kakaoUserInfo.email());
+ then(userQueryService).should().existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber());
+ then(userQueryService).should().existsByKakaoUid(kakaoUserInfo.id());
+ then(passwordEncoder).should().encode(anyString());
+ then(userRepository).should().save(any(User.class));
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(actualResult)
+ .hasFieldOrPropertyWithValue("id", expectedResult.getId())
+ .hasFieldOrPropertyWithValue("kakaoUid", expectedResult.getKakaoUid())
+ .hasFieldOrPropertyWithValue("password", expectedResult.getPassword());
+ }
+
+ static Stream testDataForCreateNewUserWithKakao() throws Exception {
+ return Stream.of(
+ arguments(createKakaoUserInfoResponse("male"), createUser(1L, "$2a$abc123", "12345", Gender.MALE)),
+ arguments(createKakaoUserInfoResponse("female"), createUser(1L, "$2a$abc123", "12345", Gender.FEMALE)),
+ arguments(createKakaoUserInfoResponse(""), createUser(1L, "$2a$abc123", "12345", null)),
+ arguments(createKakaoUserInfoResponse(null), createUser(1L, "$2a$abc123", "12345", null))
+ );
+ }
+
+ @Test
+ void 주어진_카카오_유저_정보로_신규_회원을_등록한다_이미_가입한_카카오_계정이라면_예외가_발생한다() throws Exception {
+ // given
+ KakaoUserInfoResponse kakaoUserInfo = createKakaoUserInfoResponse();
+ given(userQueryService.existsByEmail(kakaoUserInfo.email())).willReturn(false);
+ given(userQueryService.existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber())).willReturn(false);
+ given(userQueryService.existsByKakaoUid(kakaoUserInfo.id())).willReturn(true);
+
+ // when
+ Throwable t = catchThrowable(() -> sut.createNewUserWithKakao(kakaoUserInfo));
+
+ // then
+ then(userQueryService).should().existsByEmail(kakaoUserInfo.email());
+ then(userQueryService).should().existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber());
+ then(userQueryService).should().existsByKakaoUid(kakaoUserInfo.id());
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(t).isInstanceOf(UserKakaoUidDuplicationException.class);
+ }
+
private void verifyEveryMocksShouldHaveNoMoreInteractions() {
then(userQueryService).shouldHaveNoMoreInteractions();
then(userRepository).shouldHaveNoMoreInteractions();
then(passwordEncoder).shouldHaveNoMoreInteractions();
}
- private User createUser(Long id, String password) throws Exception {
+ private static User createUser(Long id, String password, String kakaoUid, Gender gender) throws Exception {
Constructor userConstructor = User.class.getDeclaredConstructor(
Long.class, Set.class, String.class, String.class, String.class,
String.class, LocalDate.class, Gender.class, String.class, String.class
@@ -106,16 +188,20 @@ private User createUser(Long id, String password) throws Exception {
Set.of(RoleType.USER),
"test@test.com",
password,
- "kakao-user-id",
+ kakaoUid,
"https://user-default-profile-image-url",
LocalDate.of(2024, 1, 1),
- Gender.ETC,
+ gender,
"010-1234-5678",
null
);
}
- private SignUpRequest createSignUpRequest(String email) throws Exception {
+ private static User createUser(Long id, String password, String kakaoUid) throws Exception {
+ return createUser(id, password, kakaoUid, Gender.ETC);
+ }
+
+ private SignUpRequest createSignUpRequest(String email, String phone) throws Exception {
Constructor signUpRequestConstructor = SignUpRequest.class.getDeclaredConstructor(
String.class, String.class, LocalDate.class, Gender.class, String.class
);
@@ -125,11 +211,45 @@ private SignUpRequest createSignUpRequest(String email) throws Exception {
"1q2w3e4r!",
LocalDate.of(2024, 1, 1),
Gender.ETC,
- "01012345678"
+ phone
);
}
private SignUpRequest createSignUpRequest() throws Exception {
- return createSignUpRequest("test@test.com");
+ return createSignUpRequest("test@test.com", "01012345678");
+ }
+
+ private static KakaoUserInfoResponse createKakaoUserInfoResponse(String gender) {
+ return new KakaoUserInfoResponse(
+ "12345",
+ new KakaoUserInfoResponse.KakaoAccount(
+ true,
+ new KakaoUserInfoResponse.KakaoAccount.Profile(
+ "https://profile-image-url",
+ "https://thumbnail-image-url",
+ true
+ ),
+ true,
+ true,
+ true,
+ true,
+ "test@mail.com",
+ true,
+ true,
+ "01012345678",
+ true,
+ true,
+ null,
+ true,
+ null,
+ true,
+ true,
+ gender
+ )
+ );
+ }
+
+ private static KakaoUserInfoResponse createKakaoUserInfoResponse() {
+ return createKakaoUserInfoResponse("male");
}
}
\ No newline at end of file
diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java
index 918408e..f4e4bb6 100644
--- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java
+++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java
@@ -70,7 +70,7 @@ class UserQueryServiceTest {
void 이메일이_주어지고_주어진_이메일로_유저를_조회하면_조회된_유저_정보가_반환된다() throws Exception {
// given
String email = "test@mail.com";
- User expectedResult = createUser(1L, email);
+ User expectedResult = createUser(1L, email, "1234");
given(userRepository.findByEmail(email)).willReturn(Optional.of(expectedResult));
// when
@@ -100,7 +100,26 @@ class UserQueryServiceTest {
}
@Test
- void 전달된_이메일을_사용_중인_회원의_존재_여부를_조회한다() {
+ void 카카오_유저_ID가_주어지고_주어진_카카오_유저_ID로_유저를_조회하면_조회된_Optional_유저_정보가_반환된다() throws Exception {
+ // given
+ String kakaoUid = "12345";
+ User expectedResult = createUser(1L, "test@mail.com", kakaoUid);
+ given(userRepository.findByKakaoUid(kakaoUid)).willReturn(Optional.of(expectedResult));
+
+ // when
+ Optional actualResult = sut.findDtoByKakaoUid(kakaoUid);
+
+ // then
+ then(userRepository).should().findByKakaoUid(kakaoUid);
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(actualResult).isNotEmpty();
+ assertThat(actualResult.get())
+ .hasFieldOrPropertyWithValue("id", expectedResult.getId())
+ .hasFieldOrPropertyWithValue("kakaoUid", expectedResult.getKakaoUid());
+ }
+
+ @Test
+ void 이메일이_주어지고_주어진_이메일을_사용_중인_회원의_존재_여부를_조회한다() {
// given
String email = "test@test.com";
boolean expectedResult = true;
@@ -115,11 +134,43 @@ class UserQueryServiceTest {
assertThat(actualResult).isEqualTo(expectedResult);
}
+ @Test
+ void 전화번호가_주어지고_주어진_전화번호를_사용_중인_회원의_존재_여부를_조회한다() {
+ // given
+ String phone = "01012345678";
+ boolean expectedResult = true;
+ given(userRepository.existsByPhone(phone)).willReturn(expectedResult);
+
+ // when
+ boolean actualResult = sut.existsByPhone(phone);
+
+ // then
+ then(userRepository).should().existsByPhone(phone);
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(actualResult).isEqualTo(expectedResult);
+ }
+
+ @Test
+ void 카카오_유저_ID가_주어지고_주어진_ID를_사용_중인_회원의_존재_여부를_조회한다() {
+ // given
+ String kakaoUid = "1234";
+ boolean expectedResult = true;
+ given(userRepository.existsByKakaoUid(kakaoUid)).willReturn(expectedResult);
+
+ // when
+ boolean actualResult = sut.existsByKakaoUid(kakaoUid);
+
+ // then
+ then(userRepository).should().existsByKakaoUid(kakaoUid);
+ verifyEveryMocksShouldHaveNoMoreInteractions();
+ assertThat(actualResult).isEqualTo(expectedResult);
+ }
+
private void verifyEveryMocksShouldHaveNoMoreInteractions() {
then(userRepository).shouldHaveNoMoreInteractions();
}
- private User createUser(Long id, String email) throws Exception {
+ private User createUser(Long id, String email, String kakaoUid) throws Exception {
Constructor userConstructor = User.class.getDeclaredConstructor(
Long.class, Set.class, String.class, String.class, String.class,
String.class, LocalDate.class, Gender.class, String.class, String.class
@@ -130,16 +181,16 @@ private User createUser(Long id, String email) throws Exception {
Set.of(RoleType.USER),
email,
"password",
- "kakao-user-id",
+ kakaoUid,
"https://user-default-profile-image-url",
LocalDate.of(2024, 1, 1),
Gender.ETC,
- "010-1234-5678",
+ "01012345678",
null
);
}
private User createUser(Long id) throws Exception {
- return createUser(id, "test@mail.com");
+ return createUser(id, "test@mail.com", "12345");
}
}
\ No newline at end of file