From e0facacdc83a3637c97a73cf6ef28b575a81fe1c Mon Sep 17 00:00:00 2001 From: choyoungseo20 Date: Mon, 16 Jun 2025 00:00:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/api_payload/SuccessCode.java | 1 + .../user/controller/UserController.java | 22 ++ .../user/converter/UserConverter.java | 8 + .../silverbridgeX_user/user/dto/KakaoDto.java | 223 ++++++++++++++++++ .../user/service/KakaoService.java | 81 +++++++ .../user/service/UserService.java | 6 + src/main/resources/application.yml | 2 + 7 files changed, 343 insertions(+) create mode 100644 src/main/java/com/example/silverbridgeX_user/user/dto/KakaoDto.java create mode 100644 src/main/java/com/example/silverbridgeX_user/user/service/KakaoService.java diff --git a/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java b/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java index b563640..69dd7d9 100644 --- a/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java +++ b/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java @@ -16,6 +16,7 @@ public enum SuccessCode implements BaseCode { USER_SOCIAL_SIGNIN_SUCCESS(HttpStatus.CREATED, "USER_2011", "소셜 회원가입이 완료되었습니다."), USER_SOCIAL_LOGIN_SUCCESS(HttpStatus.CREATED, "USER_2012", "소셜 로그인이 완료되었습니다."), USER_KEY_LOGIN_SUCCESS(HttpStatus.CREATED, "USER_2013", "KEY 로그인이 완료되었습니다."), + USER_KAKAO_LOGIN_SUCCESS(HttpStatus.CREATED, "USER_2014", "카카오 로그인이 완료되었습니다."), USER_LOGOUT_SUCCESS(HttpStatus.OK, "USER_2001", "로그아웃 되었습니다."), USER_REISSUE_SUCCESS(HttpStatus.OK, "USER_2002", "토큰 재발급이 완료되었습니다."), USER_DELETE_SUCCESS(HttpStatus.OK, "USER_2003", "회원탈퇴가 완료되었습니다."), diff --git a/src/main/java/com/example/silverbridgeX_user/user/controller/UserController.java b/src/main/java/com/example/silverbridgeX_user/user/controller/UserController.java index cd6b8ad..fde3331 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/controller/UserController.java +++ b/src/main/java/com/example/silverbridgeX_user/user/controller/UserController.java @@ -5,14 +5,18 @@ import com.example.silverbridgeX_user.user.converter.UserConverter; import com.example.silverbridgeX_user.user.domain.User; import com.example.silverbridgeX_user.user.dto.JwtDto; +import com.example.silverbridgeX_user.user.dto.KakaoDto; import com.example.silverbridgeX_user.user.dto.UserRequestDto; import com.example.silverbridgeX_user.user.dto.UserRequestDto.UserAddressReqDto; import com.example.silverbridgeX_user.user.dto.UserRequestDto.UserNicknameReqDto; import com.example.silverbridgeX_user.user.dto.UserResponseDto.GuardianMyPageResDto; import com.example.silverbridgeX_user.user.dto.UserResponseDto.OlderMyPageResDto; import com.example.silverbridgeX_user.user.jwt.CustomUserDetails; +import com.example.silverbridgeX_user.user.service.KakaoService; import com.example.silverbridgeX_user.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -34,6 +38,7 @@ @RequestMapping("/members") public class UserController { private final UserService userService; + private final KakaoService kakaoService; @Operation(summary = "소셜 회원가입", description = "프론트에게 유저 정보 받아 소셜 회원가입 후, 토큰 반환하는 메서드입니다.") @ApiResponses({ @@ -75,6 +80,23 @@ public ApiResponse socialLogin( UserConverter.jwtDto(accessToken, refreshToken, user.getUsername())); } + @GetMapping("/code/kakao") + @Operation(summary = "카카오 로그인 API", description = "카카오 로그인 API입니다.") + @Parameters({ + @Parameter(name = "code", description = "카카오 API에 대한 response code, query parameter 입니다!") + }) + public ApiResponse kakaoLogin(@RequestParam("code") String code) { + String accessToken = kakaoService.getAccessTokenFromKakao(code); + + KakaoDto.KakaoUserInfoResponseDTO userInfo = kakaoService.getUserInfo(accessToken); + + String email = userInfo.getKakaoAccount().getEmail(); + + boolean isUser = userService.existByEmail(email); + + return ApiResponse.onSuccess(SuccessCode.USER_KAKAO_LOGIN_SUCCESS, UserConverter.toSocialLoginResponseDTO(isUser, email)); + } + @Operation(summary = "key 로그인", description = "노인의 key 로그인 후, 프론트에게 유저 정보 받아 토큰 반환하는 메서드입니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "USER_2013", description = "회원가입 & 로그인 성공"), diff --git a/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java b/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java index 5a49469..cd1aab4 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java +++ b/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java @@ -2,6 +2,7 @@ import com.example.silverbridgeX_user.user.domain.User; import com.example.silverbridgeX_user.user.dto.JwtDto; +import com.example.silverbridgeX_user.user.dto.KakaoDto; import com.example.silverbridgeX_user.user.dto.UserRequestDto; import com.example.silverbridgeX_user.user.dto.UserResponseDto.GuardianMyPageResDto; import com.example.silverbridgeX_user.user.dto.UserResponseDto.OlderInfoDto; @@ -54,4 +55,11 @@ public static GuardianMyPageResDto guardianMyPageResDto(User user, List ol .build(); } + public static KakaoDto.SocialLoginResponseDTO toSocialLoginResponseDTO(boolean isUser, String email) { + + return KakaoDto.SocialLoginResponseDTO.builder() + .isUser(isUser) + .email(email) + .build(); + } } diff --git a/src/main/java/com/example/silverbridgeX_user/user/dto/KakaoDto.java b/src/main/java/com/example/silverbridgeX_user/user/dto/KakaoDto.java new file mode 100644 index 0000000..c7fcf3c --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/user/dto/KakaoDto.java @@ -0,0 +1,223 @@ +package com.example.silverbridgeX_user.user.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Email; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Date; +import java.util.HashMap; + +public class KakaoDto { + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class KakaoTokenResponseDTO { + @JsonProperty("token_type") + public String tokenType; + @JsonProperty("access_token") + public String accessToken; + @JsonProperty("id_token") + public String idToken; + @JsonProperty("expires_in") + public Integer expiresIn; + @JsonProperty("refresh_token") + public String refreshToken; + @JsonProperty("refresh_token_expires_in") + public Integer refreshTokenExpiresIn; + @JsonProperty("scope") + public String scope; + } + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class KakaoUserInfoResponseDTO { + @JsonProperty("id") + public Long id; + + //자동 연결 설정을 비활성화한 경우만 존재. + //true : 연결 상태, false : 연결 대기 상태 + @JsonProperty("has_signed_up") + public Boolean hasSignedUp; + + //서비스에 연결 완료된 시각. UTC + @JsonProperty("connected_at") + public Date connectedAt; + + //카카오싱크 간편가입을 통해 로그인한 시각. UTC + @JsonProperty("synched_at") + public Date synchedAt; + + //사용자 프로퍼티 + @JsonProperty("properties") + public HashMap properties; + + //카카오 계정 정보 + @JsonProperty("kakao_account") + public KakaoAccount kakaoAccount; + + //uuid 등 추가 정보 + @JsonProperty("for_partner") + public Partner partner; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class KakaoAccount { + + //프로필 정보 제공 동의 여부 + @JsonProperty("profile_needs_agreement") + public Boolean isProfileAgree; + + //닉네임 제공 동의 여부 + @JsonProperty("profile_nickname_needs_agreement") + public Boolean isNickNameAgree; + + //프로필 사진 제공 동의 여부 + @JsonProperty("profile_image_needs_agreement") + public Boolean isProfileImageAgree; + + //사용자 프로필 정보 + @JsonProperty("profile") + public Profile profile; + + //이름 제공 동의 여부 + @JsonProperty("name_needs_agreement") + public Boolean isNameAgree; + + //카카오계정 이름 + @JsonProperty("name") + public String name; + + //이메일 제공 동의 여부 + @JsonProperty("email_needs_agreement") + public Boolean isEmailAgree; + + //이메일이 유효 여부 + // true : 유효한 이메일, false : 이메일이 다른 카카오 계정에 사용돼 만료 + @JsonProperty("is_email_valid") + public Boolean isEmailValid; + + //이메일이 인증 여부 + //true : 인증된 이메일, false : 인증되지 않은 이메일 + @JsonProperty("is_email_verified") + public Boolean isEmailVerified; + + //카카오계정 대표 이메일 + @JsonProperty("email") + public String email; + + //연령대 제공 동의 여부 + @JsonProperty("age_range_needs_agreement") + public Boolean isAgeAgree; + + //연령대 + //참고 https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + @JsonProperty("age_range") + public String ageRange; + + //출생 연도 제공 동의 여부 + @JsonProperty("birthyear_needs_agreement") + public Boolean isBirthYearAgree; + + //출생 연도 (YYYY 형식) + @JsonProperty("birthyear") + public String birthYear; + + //생일 제공 동의 여부 + @JsonProperty("birthday_needs_agreement") + public Boolean isBirthDayAgree; + + //생일 (MMDD 형식) + @JsonProperty("birthday") + public String birthDay; + + //생일 타입 + // SOLAR(양력) 혹은 LUNAR(음력) + @JsonProperty("birthday_type") + public String birthDayType; + + //성별 제공 동의 여부 + @JsonProperty("gender_needs_agreement") + public Boolean isGenderAgree; + + //성별 + @JsonProperty("gender") + public String gender; + + //전화번호 제공 동의 여부 + @JsonProperty("phone_number_needs_agreement") + public Boolean isPhoneNumberAgree; + + //전화번호 + //국내 번호인 경우 +82 00-0000-0000 형식 + @JsonProperty("phone_number") + public String phoneNumber; + + //CI 동의 여부 + @JsonProperty("ci_needs_agreement") + public Boolean isCIAgree; + + //CI, 연계 정보 + @JsonProperty("ci") + public String ci; + + //CI 발급 시각, UTC + @JsonProperty("ci_authenticated_at") + public Date ciCreatedAt; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class Profile { + + //닉네임 + @JsonProperty("nickname") + public String nickName; + + //프로필 미리보기 이미지 URL + @JsonProperty("thumbnail_image_url") + public String thumbnailImageUrl; + + //프로필 사진 URL + @JsonProperty("profile_image_url") + public String profileImageUrl; + + //프로필 사진 URL 기본 프로필인지 여부 + //true : 기본 프로필, false : 사용자 등록 + @JsonProperty("is_default_image") + public String isDefaultImage; + + //닉네임이 기본 닉네임인지 여부 + //true : 기본 닉네임, false : 사용자 등록 + @JsonProperty("is_default_nickname") + public Boolean isDefaultNickName; + + } + } + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class Partner { + //고유 ID + @JsonProperty("uuid") + public String uuid; + } + } + + @Builder + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class SocialLoginResponseDTO { + private boolean isUser; + @Email + private String email; + } + +} diff --git a/src/main/java/com/example/silverbridgeX_user/user/service/KakaoService.java b/src/main/java/com/example/silverbridgeX_user/user/service/KakaoService.java new file mode 100644 index 0000000..4af3ffa --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/user/service/KakaoService.java @@ -0,0 +1,81 @@ +package com.example.silverbridgeX_user.user.service; + +import com.example.silverbridgeX_user.user.dto.KakaoDto; +import io.netty.handler.codec.http.HttpHeaderValues; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@RequiredArgsConstructor +@Service +public class KakaoService { + + private String clientId; + private final String KAUTH_TOKEN_URL_HOST ; + private final String KAUTH_USER_URL_HOST; + + @Autowired + public KakaoService(@Value("${kakao.client_id}") String clientId) { + this.clientId = clientId; + KAUTH_TOKEN_URL_HOST ="https://kauth.kakao.com"; + KAUTH_USER_URL_HOST = "https://kapi.kakao.com"; + } + + public String getAccessTokenFromKakao(String code) { + + KakaoDto.KakaoTokenResponseDTO kakaoTokenResponseDto = WebClient.create(KAUTH_TOKEN_URL_HOST).post() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .path("/oauth/token") + .queryParam("grant_type", "authorization_code") + .queryParam("client_id", clientId) + .queryParam("code", code) + .build(true)) + .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + //TODO : Custom Exception + .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(KakaoDto.KakaoTokenResponseDTO.class) + .block(); + + + log.info(" [Kakao Service] Access Token ------> {}", kakaoTokenResponseDto.getAccessToken()); + log.info(" [Kakao Service] Refresh Token ------> {}", kakaoTokenResponseDto.getRefreshToken()); + //제공 조건: OpenID Connect가 활성화 된 앱의 토큰 발급 요청인 경우 또는 scope에 openid를 포함한 추가 항목 동의 받기 요청을 거친 토큰 발급 요청인 경우 + log.info(" [Kakao Service] Id Token ------> {}", kakaoTokenResponseDto.getIdToken()); + log.info(" [Kakao Service] Scope ------> {}", kakaoTokenResponseDto.getScope()); + + return kakaoTokenResponseDto.getAccessToken(); + } + + public KakaoDto.KakaoUserInfoResponseDTO getUserInfo(String accessToken) { + + KakaoDto.KakaoUserInfoResponseDTO userInfo = WebClient.create(KAUTH_USER_URL_HOST).get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .path("/v2/user/me") + .build(true)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) // access token 인가 + .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + //TODO : Custom Exception + .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(KakaoDto.KakaoUserInfoResponseDTO.class) + .block(); + + log.info("[ Kakao Service ] Auth ID ---> {} ", userInfo.getId()); + log.info("[ Kakao Service ] NickName ---> {} ", userInfo.getKakaoAccount().getProfile().getNickName()); + log.info("[ Kakao Service ] Email ---> {} ", userInfo.getKakaoAccount().getEmail()); + + return userInfo; + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java b/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java index 6b1b78c..b27b957 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java +++ b/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java @@ -66,6 +66,12 @@ public User findByEmail(String email) { .orElseThrow(() -> new GeneralException(ErrorCode.USER_NOT_FOUND_BY_EMAIL)); } + @Transactional + public boolean existByEmail(String email) { + return userRepository.existsByEmail(email); + } + + @Transactional public Boolean checkMemberByEmail(String email) { return userRepository.existsByEmail(email); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1dcd684..fd678b6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,6 +36,8 @@ kakaopay: secret_key: ${kakao.secret.key} cid: ${kakao.cid} +kakao: + client_id: ${kakao.client.id} --- # local profile