From 7fac4140334bb86e226ea3d93773d5c417335f45 Mon Sep 17 00:00:00 2001 From: Dhani5703 Date: Tue, 23 Sep 2025 14:47:22 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20API?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=EC=9D=84=20=EC=9C=84=ED=95=9C=20WebClient?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20OAuth=20=EC=84=A4=EC=A0=95=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + src/main/resources/application-oauth.yml | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 098a50d..040b610 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation("com.google.api-client:google-api-client:2.7.0") implementation("com.google.oauth-client:google-oauth-client-jetty:1.36.0") implementation("com.google.apis:google-api-services-oauth2:v2-rev20200213-2.0.0") + implementation("org.springframework.boot:spring-boot-starter-webflux") // For WebClient (Kakao API calls) // test testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/resources/application-oauth.yml b/src/main/resources/application-oauth.yml index f0caca8..d6de12b 100644 --- a/src/main/resources/application-oauth.yml +++ b/src/main/resources/application-oauth.yml @@ -7,5 +7,12 @@ app: auth-uri: https://accounts.google.com/o/oauth2/auth token-uri: https://oauth2.googleapis.com/token user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET:} + redirect-uri: ${KAKAO_REDIRECT_URI} + auth-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} From 788b28e1bcd5c49c095c7c01c9f64aa6659258c3 Mon Sep 17 00:00:00 2001 From: Dhani5703 Date: Tue, 23 Sep 2025 14:48:34 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20OAuth?= =?UTF-8?q?2=20=EC=84=A4=EC=A0=95=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/oauth/KakaoOAuthProperties.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthProperties.kt diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthProperties.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthProperties.kt new file mode 100644 index 0000000..c7745f9 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthProperties.kt @@ -0,0 +1,25 @@ +package com.wq.auth.api.external.oauth + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * 카카오 OAuth2 설정 프로퍼티 + * + * application.yml의 app.oauth.kakao 설정을 바인딩합니다. + * + * @param clientId 카카오 OAuth2 클라이언트 ID (REST API 키) + * @param clientSecret 카카오 OAuth2 클라이언트 시크릿 + * @param redirectUri 리다이렉트 URI + * @param authUri 카카오 OAuth2 인증 URI + * @param tokenUri 카카오 OAuth2 토큰 발급 URI + * @param userInfoUri 카카오 사용자 정보 조회 URI + */ +@ConfigurationProperties(prefix = "app.oauth.kakao") +data class KakaoOAuthProperties( + val clientId: String, + val clientSecret: String? = null, + val redirectUri: String, + val authUri: String, + val tokenUri: String, + val userInfoUri: String +) From 1874e809eeca8fbb2a29312dda0987cd28c5b7ad Mon Sep 17 00:00:00 2001 From: Dhani5703 Date: Tue, 23 Sep 2025 14:49:06 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20OAuth?= =?UTF-8?q?2=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/dto/KakaoUserInfoResponse.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/external/oauth/dto/KakaoUserInfoResponse.kt diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/dto/KakaoUserInfoResponse.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/KakaoUserInfoResponse.kt new file mode 100644 index 0000000..61de092 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/dto/KakaoUserInfoResponse.kt @@ -0,0 +1,85 @@ +package com.wq.auth.api.external.oauth.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * 카카오 OAuth2 사용자 정보 응답 DTO + * + * 카카오 UserInfo API (/v2/user/me)의 응답을 파싱하기 위한 데이터 클래스입니다. + * + * 카카오 API 응답 구조: + * { + * "id": 1234567890, + * "kakao_account": { + * "email": "user@example.com", + * "email_needs_agreement": false, + * "is_email_valid": true, + * "is_email_verified": true, + * "profile": { + * "nickname": "홍길동", + * "thumbnail_image_url": "...", + * "profile_image_url": "..." + * } + * } + * } + */ +data class KakaoUserInfoResponse( + @field:JsonProperty("id") + val id: Long, + + @field:JsonProperty("kakao_account") + val kakaoAccount: KakaoAccount +) { + /** + * 닉네임을 생성합니다. + * 우선순위: profile.nickname -> email의 @ 앞부분 + */ + fun getNickname(): String { + return kakaoAccount.profile?.nickname?.takeIf { it.isNotBlank() } + ?: kakaoAccount.email?.substringBefore("@") + ?: "카카오사용자$id" + } + + /** + * 카카오 제공자 ID를 반환합니다. + */ + fun getProviderId(): String = id.toString() + + /** + * 이메일을 반환합니다. + */ + fun getEmail(): String = kakaoAccount.email ?: "" + + /** + * 이메일 검증 여부를 반환합니다. + */ + fun isEmailVerified(): Boolean = kakaoAccount.isEmailVerified ?: false +} + +data class KakaoAccount( + @field:JsonProperty("email") + val email: String? = null, + + @field:JsonProperty("email_needs_agreement") + val emailNeedsAgreement: Boolean = false, + + @field:JsonProperty("is_email_valid") + val isEmailValid: Boolean = false, + + @field:JsonProperty("is_email_verified") + val isEmailVerified: Boolean? = false, + + @field:JsonProperty("profile") + val profile: KakaoProfile? = null +) + +data class KakaoProfile( + @field:JsonProperty("nickname") + val nickname: String? = null, + + @field:JsonProperty("thumbnail_image_url") + val thumbnailImageUrl: String? = null, + + @field:JsonProperty("profile_image_url") + val profileImageUrl: String? = null +) From 0e18a88ccb07fe0a59969d95920dc5b557176a09 Mon Sep 17 00:00:00 2001 From: Dhani5703 Date: Tue, 23 Sep 2025 14:50:31 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20OAuth?= =?UTF-8?q?2=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/external/oauth/KakaoOAuthClient.kt | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt diff --git a/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt b/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt new file mode 100644 index 0000000..9e651cd --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/external/oauth/KakaoOAuthClient.kt @@ -0,0 +1,185 @@ +package com.wq.auth.api.external.oauth + +import com.fasterxml.jackson.databind.ObjectMapper +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.external.oauth.dto.KakaoUserInfoResponse +import com.wq.auth.domain.oauth.OAuthClient +import com.wq.auth.domain.oauth.OAuthUser +import com.wq.auth.domain.oauth.error.SocialLoginException +import com.wq.auth.domain.oauth.error.SocialLoginExceptionCode +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.http.* +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestTemplate +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.HttpServerErrorException + +/** + * 카카오 OAuth2 클라이언트 + * + * 카카오 OAuth2 API와 통신하여 인가 코드를 액세스 토큰으로 교환하고, + * 액세스 토큰을 사용하여 사용자 정보를 조회합니다. + */ +@Component +class KakaoOAuthClient( + private val kakaoOAuthProperties: KakaoOAuthProperties, + private val objectMapper: ObjectMapper +) : OAuthClient { + private val log = KotlinLogging.logger {} + private val restTemplate = RestTemplate() + + /** + * 인가 코드를 사용하여 액세스 토큰을 획득합니다. + * + * @param authorizationCode 카카오로부터 받은 인가 코드 + * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) + * @param redirectUri 리다이렉트 URI (선택사항) + * @return 카카오 액세스 토큰 + * @throws SocialLoginException 토큰 획득 실패 시 + */ + fun getAccessToken(authorizationCode: String, codeVerifier: String, redirectUri: String? = null): String { + log.info { "카카오 액세스 토큰 요청 시작" } + log.info { "redirectUri: ${redirectUri ?: kakaoOAuthProperties.redirectUri}" } + + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_FORM_URLENCODED + } + + val body: MultiValueMap = LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("client_id", kakaoOAuthProperties.clientId) + if (!kakaoOAuthProperties.clientSecret.isNullOrBlank()) { + add("client_secret", kakaoOAuthProperties.clientSecret) + } + add("redirect_uri", redirectUri ?: kakaoOAuthProperties.redirectUri) + add("code", authorizationCode) + // 카카오는 PKCE를 지원하지만 선택사항이므로 codeVerifier가 있을 때만 추가 + if (codeVerifier.isNotBlank()) { + add("code_verifier", codeVerifier) + } + } + + val request = HttpEntity(body, headers) + + try { + val response = restTemplate.postForEntity( + kakaoOAuthProperties.tokenUri, + request, + String::class.java + ) + + if (response.statusCode == HttpStatus.OK && response.body != null) { + val tokenResponse = objectMapper.readTree(response.body!!) + val accessToken = tokenResponse.get("access_token")?.asText() + + if (accessToken != null) { + log.info { "카카오 액세스 토큰 획득 성공" } + return accessToken + } else { + log.error { "카카오 액세스 토큰이 응답에 없습니다: ${response.body}" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED) + } + } else { + log.error { "카카오 토큰 요청 실패: ${response.statusCode}" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED) + } + + } catch (e: HttpClientErrorException) { + log.error(e) { "카카오 토큰 요청 클라이언트 오류: ${e.statusCode} - ${e.responseBodyAsString}" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_INVALID_AUTHORIZATION_CODE, e) + } catch (e: HttpServerErrorException) { + log.error(e) { "카카오 서버 오류: ${e.statusCode}" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED, e) + } catch (e: SocialLoginException) { + throw e // 이미 SocialLoginException인 경우 그대로 전파 + } catch (e: Exception) { + log.error(e) { "카카오 토큰 요청 중 예상치 못한 오류 발생" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_TOKEN_REQUEST_FAILED, e) + } + } + + /** + * 액세스 토큰을 사용하여 카카오 사용자 정보를 조회합니다. + * + * @param accessToken 카카오 액세스 토큰 + * @return 카카오 사용자 정보 + * @throws SocialLoginException 사용자 정보 조회 실패 시 + */ + fun getUserInfo(accessToken: String): KakaoUserInfoResponse { + log.info { "카카오 사용자 정보 조회 시작" } + + val headers = HttpHeaders().apply { + set("Authorization", "Bearer $accessToken") + contentType = MediaType.APPLICATION_JSON + } + + val request = HttpEntity(headers) + + try { + val response = restTemplate.exchange( + kakaoOAuthProperties.userInfoUri, + HttpMethod.GET, + request, + String::class.java + ) + + if (response.statusCode == HttpStatus.OK && response.body != null) { + val userInfo = objectMapper.readValue(response.body!!, KakaoUserInfoResponse::class.java) + log.info { "카카오 사용자 정보 조회 성공: ${userInfo.getEmail()}" } + return userInfo + } else { + log.error { "카카오 사용자 정보 조회 실패: ${response.statusCode}" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED) + } + + } catch (e: HttpClientErrorException) { + log.error(e) { "카카오 사용자 정보 조회 클라이언트 오류: ${e.statusCode}" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED, e) + } catch (e: HttpServerErrorException) { + log.error(e) { "카카오 서버 오류: ${e.statusCode}" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED, e) + } catch (e: SocialLoginException) { + throw e // 이미 SocialLoginException인 경우 그대로 전파 + } catch (e: Exception) { + log.error(e) { "카카오 사용자 정보 조회 중 예상치 못한 오류 발생" } + throw SocialLoginException(SocialLoginExceptionCode.KAKAO_USER_INFO_REQUEST_FAILED, e) + } + } + + /** + * OAuthClient 인터페이스 구현: 인가 코드를 사용하여 도메인 사용자 정보를 조회합니다. + * + * @param authCode 카카오로부터 받은 인가 코드 + * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) + * @param redirectUri 리다이렉트 URI (선택사항) + * @return 도메인 사용자 정보 + */ + override fun getUserFromAuthCode(authCode: String, codeVerifier: String, redirectUri: String?): OAuthUser { + val accessToken = getAccessToken(authCode, codeVerifier, redirectUri) + val kakaoUserInfo = getUserInfo(accessToken) + + return OAuthUser( + providerId = kakaoUserInfo.getProviderId(), + email = kakaoUserInfo.getEmail(), + verifiedEmail = kakaoUserInfo.isEmailVerified(), + name = kakaoUserInfo.getNickname(), + givenName = kakaoUserInfo.getNickname(), + providerType = ProviderType.KAKAO + ) + } + + /** + * 인가 코드를 사용하여 사용자 정보를 직접 조회합니다. (기존 호환성 유지용) + * + * @param authorizationCode 카카오로부터 받은 인가 코드 + * @param codeVerifier PKCE 검증용 코드 검증자 (카카오는 선택사항) + * @param redirectUri 리다이렉트 URI (선택사항) + * @return 카카오 사용자 정보 + */ + fun getUserInfoFromAuthCode(authorizationCode: String, codeVerifier: String, redirectUri: String? = null): KakaoUserInfoResponse { + val accessToken = getAccessToken(authorizationCode, codeVerifier, redirectUri) + return getUserInfo(accessToken) + } +} From 351e3f4980e0ef1cd0902c1bbe8d2377340a7a45 Mon Sep 17 00:00:00 2001 From: Dhani5703 Date: Tue, 23 Sep 2025 14:52:18 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wq/auth/domain/auth/SocialLoginService.kt | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt b/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt index 1f1ad74..342a4c1 100644 --- a/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt +++ b/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt @@ -9,7 +9,8 @@ import com.wq.auth.api.domain.auth.entity.ProviderType import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity import com.wq.auth.domain.auth.request.SocialLoginRequest import com.wq.auth.domain.auth.response.SocialLoginResult -import com.wq.auth.domain.oauth.OAuthClient +import com.wq.auth.api.external.oauth.GoogleOAuthClient +import com.wq.auth.api.external.oauth.KakaoOAuthClient import com.wq.auth.domain.oauth.OAuthUser import com.wq.auth.domain.oauth.error.SocialLoginException import com.wq.auth.domain.oauth.error.SocialLoginExceptionCode @@ -31,7 +32,8 @@ import java.time.LocalDateTime @Service @Transactional(readOnly = true) class SocialLoginService( - private val oauthClient: OAuthClient, + private val googleOAuthClient: GoogleOAuthClient, + private val kakaoOAuthClient: KakaoOAuthClient, private val memberRepository: MemberRepository, private val authProviderRepository: AuthProviderRepository, private val jwtProvider: JwtProvider, @@ -51,21 +53,64 @@ class SocialLoginService( return when (request.providerType) { ProviderType.GOOGLE -> processGoogleLogin(request) - ProviderType.KAKAO -> throw SocialLoginException(SocialLoginExceptionCode.UNSUPPORTED_PROVIDER) + ProviderType.KAKAO -> processKakaoLogin(request) ProviderType.NAVER -> throw SocialLoginException(SocialLoginExceptionCode.UNSUPPORTED_PROVIDER) ProviderType.EMAIL -> throw SocialLoginException(SocialLoginExceptionCode.UNSUPPORTED_PROVIDER) ProviderType.PHONE -> throw SocialLoginException(SocialLoginExceptionCode.UNSUPPORTED_PROVIDER) } } + /** + * 카카오 소셜 로그인을 처리합니다. + */ + private fun processKakaoLogin(request: SocialLoginRequest): SocialLoginResult { + log.info { "카카오 소셜 로그인 처리 시작" } + + // 1. 카카오 OAuth 클라이언트를 통해 사용자 정보 조회 + val oauthUser = kakaoOAuthClient.getUserFromAuthCode( + request.authCode, + request.codeVerifier, + request.redirectUri + ) + + log.info { "OAuth 사용자 정보 조회 완료: ${oauthUser.email}" } + + // 2. 기존 회원 확인 또는 신규 회원 생성 + val (member, isNewMember) = findOrCreateMember(oauthUser, oauthUser.providerType) + + // 3. AuthProvider 엔티티 생성/업데이트 + createOrUpdateAuthProvider(member, oauthUser, oauthUser.providerType) + + // 4. 로그인 시간 업데이트 + member.lastLoginAt = LocalDateTime.now() + memberRepository.save(member) + + // 5. JWT 토큰 발급 + val accessToken = jwtProvider.createAccessToken(member.opaqueId, member.role) + val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) + + // 6. RefreshToken 저장 + val jti = jwtProvider.getJti(refreshToken) + val opaqueId = jwtProvider.getOpaqueId(refreshToken) + val refreshTokenEntity = RefreshTokenEntity.of(member, jti, opaqueId) + refreshTokenRepository.save(refreshTokenEntity) + + log.info { "카카오 소셜 로그인 완료: ${member.opaqueId}, 신규 회원: $isNewMember" } + + return SocialLoginResult( + accessToken = accessToken, + refreshToken = refreshToken + ) + } + /** * Google 소셜 로그인을 처리합니다. */ private fun processGoogleLogin(request: SocialLoginRequest): SocialLoginResult { log.info { "Google 소셜 로그인 처리 시작" } - // 1. OAuth 클라이언트를 통해 사용자 정보 조회 - val oauthUser = oauthClient.getUserFromAuthCode( + // 1. 구글 OAuth 클라이언트를 통해 사용자 정보 조회 + val oauthUser = googleOAuthClient.getUserFromAuthCode( request.authCode, request.codeVerifier, request.redirectUri From 323c4a285de70f4e69aa0074203a11a7f8dedc74 Mon Sep 17 00:00:00 2001 From: Dhani5703 Date: Tue, 23 Sep 2025 14:53:43 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/auth/SocialLoginController.kt | 80 +++++++++++++++++++ .../request/KakaoSocialLoginRequestDto.kt | 15 ++++ .../wq/auth/shared/config/SecurityConfig.kt | 1 + 3 files changed, 96 insertions(+) create mode 100644 src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt index fad4d8c..b9e04ad 100644 --- a/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/SocialLoginController.kt @@ -1,6 +1,7 @@ package com.wq.auth.api.controller.auth import com.wq.auth.api.controller.auth.request.GoogleSocialLoginRequestDto +import com.wq.auth.api.controller.auth.request.KakaoSocialLoginRequestDto import com.wq.auth.api.controller.auth.request.SocialLoginRequestDto import com.wq.auth.api.controller.auth.request.toDomain import com.wq.auth.api.domain.auth.entity.ProviderType @@ -192,6 +193,85 @@ class SocialLoginController( return Responses.success("Google 로그인이 완료되었습니다") } + + /** + * 카카오 소셜 로그인 (편의 메서드) + * + * 카카오 전용 엔드포인트로, providerType을 별도로 지정하지 않아도 됩니다. + * + * @param request 카카오 소셜 로그인 요청 + * @param response HTTP 응답 객체 + * @return JWT 토큰과 사용자 정보가 포함된 응답 + */ + @Operation( + summary = "카카오 소셜 로그인", + description = """ + 카카오 전용 편의 메서드로, providerType을 별도로 지정하지 않아도 됩니다. + + **사용 방법:** + 1. 프론트엔드에서 카카오 OAuth2 인증 URL로 사용자를 리다이렉트 + 2. 사용자가 카카오에서 인증 완료 후 받은 인가 코드(code)를 이 API로 전송 + 3. 백엔드에서 카카오 API를 통해 사용자 정보 조회 후 JWT 토큰 발급 + + **리다이렉트 URI:** + - 환경변수(properties)에 설정된 기본값 사용 + - 카카오 OAuth2 설정의 승인된 리다이렉트 URI와 일치해야 함 + + **PKCE (Proof Key for Code Exchange):** + - 카카오는 PKCE를 지원하지만 선택사항 + - 보안 강화를 위해 사용 권장 + + **토큰 반환 방식:** + - Access Token: Authorization 헤더에 Bearer 방식으로 반환 + - Refresh Token: HttpOnly 쿠키로 설정 (XSS 공격 방지) + """ + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "카카오 로그인 성공 - Authorization 헤더에 Bearer 토큰, HttpOnly 쿠키에 리프레시 토큰 설정", + content = [Content(schema = Schema(implementation = SuccessResponse::class))] + ), + ApiResponse( + responseCode = "400", + description = "인가 코드(code) 파라미터가 누락되거나 잘못된 형식", + content = [Content(schema = Schema(implementation = FailResponse::class))] + ), + ApiResponse( + responseCode = "401", + description = "카카오 인가 코드가 유효하지 않거나 만료됨", + content = [Content(schema = Schema(implementation = FailResponse::class))] + ), + ApiResponse( + responseCode = "500", + description = "카카오 API 호출 실패 또는 서버 내부 오류", + content = [Content(schema = Schema(implementation = FailResponse::class))] + ) + ] + ) + @PublicApi("카카오 소셜 로그인") + @PostMapping("/api/v1/auth/kakao/login") + fun kakaoLogin( + @Valid @RequestBody request: KakaoSocialLoginRequestDto, + response: HttpServletResponse + ): SuccessResponse { + val serviceRequest = SocialLoginRequestDto( + authCode = request.authCode, + codeVerifier = request.codeVerifier, + providerType = ProviderType.KAKAO, + ) + + val loginResult = socialLoginService.processSocialLogin(serviceRequest.toDomain()) + + // RefreshToken을 HttpOnly 쿠키에 설정 + setRefreshTokenCookie(response, loginResult.refreshToken) + + // Authorization 헤더에 AccessToken 설정 + response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer ${loginResult.accessToken}") + + return Responses.success("카카오 로그인이 완료되었습니다") + } /** * RefreshToken을 HttpOnly 쿠키로 설정합니다. diff --git a/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt b/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt new file mode 100644 index 0000000..3e48038 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/controller/auth/request/KakaoSocialLoginRequestDto.kt @@ -0,0 +1,15 @@ +package com.wq.auth.api.controller.auth.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +@Schema(description = "카카오 소셜 로그인 요청 바디") +data class KakaoSocialLoginRequestDto( + @field:NotBlank(message = "authCode는 필수입니다") + @field:Schema(description = "카카오 OAuth2에서 받은 인가 코드", example = "9d8fYl7x2zQ...") + val authCode: String, + + @field:NotBlank(message = "codeVerifier는 필수입니다") + @field:Schema(description = "PKCE 검증용 코드 검증자 (카카오는 선택사항이지만 보안을 위해 권장)", example = "NgAfIySigI...IVxKxbmrpg") + val codeVerifier: String +) diff --git a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt index 7e37735..231d6ba 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/SecurityConfig.kt @@ -53,6 +53,7 @@ class SecurityConfig( "api/v1/auth/members/refresh", //액세스 토큰 재발급 "/api/public/**", // 공개 API "/api/v1/auth/google/login", + "/api/v1/auth/kakao/login", "/api/v1/auth/**", // 소셜 로그인 API "/actuator/health", // 헬스체크 "/swagger-ui/**", // Swagger UI From 668d87cab6b82e0b37393f4ec39641f88de02b93 Mon Sep 17 00:00:00 2001 From: Mint Date: Wed, 24 Sep 2025 16:23:16 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20cors=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt b/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt index 3675fdb..0de8bfd 100644 --- a/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt +++ b/src/main/kotlin/com/wq/auth/shared/config/WebConfig.kt @@ -17,6 +17,7 @@ class WebConfig( registry.addMapping("/api/**") .allowedOrigins(*origins) // 환경변수로 설정된 특정 origin들만 허용 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .exposedHeaders("Authorization") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600) From 239a65cb0ab12687c75ee6970a06808beaaf9c94 Mon Sep 17 00:00:00 2001 From: Dhani5703 Date: Fri, 26 Sep 2025 20:31:38 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix=20:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EC=83=81=ED=99=94=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 - .../api/domain/auth/GoogleLoginProvider.kt | 62 ++++++ .../api/domain/auth/KakaoLoginProvider.kt | 62 ++++++ .../wq/auth/api/domain/auth/LoginProvider.kt | 92 +++++++++ .../wq/auth/domain/auth/SocialLoginService.kt | 191 +----------------- 5 files changed, 222 insertions(+), 186 deletions(-) create mode 100644 src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt create mode 100644 src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt create mode 100644 src/main/kotlin/com/wq/auth/api/domain/auth/LoginProvider.kt diff --git a/build.gradle.kts b/build.gradle.kts index 213f4a4..516864d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,7 +45,6 @@ dependencies { implementation("com.google.api-client:google-api-client:2.7.0") implementation("com.google.oauth-client:google-oauth-client-jetty:1.36.0") implementation("com.google.apis:google-api-services-oauth2:v2-rev20200213-2.0.0") - implementation("org.springframework.boot:spring-boot-starter-webflux") // For WebClient (Kakao API calls) // test testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt new file mode 100644 index 0000000..7141aa2 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/GoogleLoginProvider.kt @@ -0,0 +1,62 @@ +package com.wq.auth.api.domain.auth + +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.external.oauth.GoogleOAuthClient +import com.wq.auth.domain.auth.request.SocialLoginRequest +import com.wq.auth.domain.auth.response.SocialLoginResult +import com.wq.auth.security.jwt.JwtProvider +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class GoogleLoginProvider( + private val googleOAuthClient: GoogleOAuthClient, + private val authProviderRepository: AuthProviderRepository, + private val memberRepository: MemberRepository, + private val jwtProvider: JwtProvider, + private val refreshTokenRepository: RefreshTokenRepository +) : AbstractLoginProvider(authProviderRepository, memberRepository) { + private val log = KotlinLogging.logger {} + + override fun processLogin(request: SocialLoginRequest): SocialLoginResult { + + log.info { "Google 소셜 로그인 처리 시작" } + + val oauthUser = googleOAuthClient.getUserFromAuthCode( + request.authCode, + request.codeVerifier, + request.redirectUri + ) + + log.info { "OAuth 사용자 정보 조회 완료: ${oauthUser.email}" } + + val (member, isNewMember) = findOrCreateMember(oauthUser, oauthUser.providerType) + + createOrUpdateAuthProvider(member, oauthUser, oauthUser.providerType) + + member.lastLoginAt = LocalDateTime.now() + memberRepository.save(member) + + val accessToken = jwtProvider.createAccessToken(member.opaqueId, member.role) + val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) + + val jti = jwtProvider.getJti(refreshToken) + val opaqueId = jwtProvider.getOpaqueId(refreshToken) + val refreshTokenEntity = RefreshTokenEntity.of(member, jti, opaqueId) + refreshTokenRepository.save(refreshTokenEntity) + + log.info { "소셜 로그인 완료: ${member.opaqueId}, 신규 회원: $isNewMember" } + + return SocialLoginResult( + accessToken = accessToken, + refreshToken = refreshToken + ) + } + + override fun support(providerType: ProviderType): Boolean { + return providerType == ProviderType.GOOGLE + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt new file mode 100644 index 0000000..7adf162 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/KakaoLoginProvider.kt @@ -0,0 +1,62 @@ +package com.wq.auth.api.domain.auth + +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.external.oauth.KakaoOAuthClient +import com.wq.auth.domain.auth.request.SocialLoginRequest +import com.wq.auth.domain.auth.response.SocialLoginResult +import com.wq.auth.security.jwt.JwtProvider +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class KakaoLoginProvider( + private val kakaoOAuthClient: KakaoOAuthClient, + private val authProviderRepository: AuthProviderRepository, + private val memberRepository: MemberRepository, + private val jwtProvider: JwtProvider, + private val refreshTokenRepository: RefreshTokenRepository +) : AbstractLoginProvider(authProviderRepository, memberRepository) { + + private val log = KotlinLogging.logger {} + override fun processLogin(request: SocialLoginRequest): SocialLoginResult { + log.info { "카카오 소셜 로그인 처리 시작" } + + val oauthUser = kakaoOAuthClient.getUserFromAuthCode( + request.authCode, + request.codeVerifier, + request.redirectUri + ) + + log.info { "OAuth 사용자 정보 조회 완료: ${oauthUser.email}" } + + val (member, isNewMember) = findOrCreateMember(oauthUser, oauthUser.providerType) + + createOrUpdateAuthProvider(member, oauthUser, oauthUser.providerType) + + member.lastLoginAt = LocalDateTime.now() + memberRepository.save(member) + + val accessToken = jwtProvider.createAccessToken(member.opaqueId, member.role) + val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) + + val jti = jwtProvider.getJti(refreshToken) + val opaqueId = jwtProvider.getOpaqueId(refreshToken) + val refreshTokenEntity = RefreshTokenEntity.of(member, jti, opaqueId) + refreshTokenRepository.save(refreshTokenEntity) + + log.info { "카카오 소셜 로그인 완료: ${member.opaqueId}, 신규 회원: $isNewMember" } + + return SocialLoginResult( + accessToken = accessToken, + refreshToken = refreshToken + ) + } + + override fun support(providerType: ProviderType): Boolean { + return providerType == ProviderType.KAKAO + } + +} diff --git a/src/main/kotlin/com/wq/auth/api/domain/auth/LoginProvider.kt b/src/main/kotlin/com/wq/auth/api/domain/auth/LoginProvider.kt new file mode 100644 index 0000000..53cccc8 --- /dev/null +++ b/src/main/kotlin/com/wq/auth/api/domain/auth/LoginProvider.kt @@ -0,0 +1,92 @@ +package com.wq.auth.api.domain.auth + +import com.wq.auth.api.domain.auth.entity.AuthProviderEntity +import com.wq.auth.api.domain.auth.entity.ProviderType +import com.wq.auth.api.domain.member.MemberRepository +import com.wq.auth.api.domain.member.entity.MemberEntity +import com.wq.auth.domain.auth.request.SocialLoginRequest +import com.wq.auth.domain.auth.response.SocialLoginResult +import com.wq.auth.domain.oauth.OAuthUser +import io.github.oshai.kotlinlogging.KotlinLogging + +interface LoginProvider { + fun processLogin(loginRequest: SocialLoginRequest): SocialLoginResult + fun support(providerType: ProviderType): Boolean +} + +abstract class AbstractLoginProvider( + private val authProviderRepository: AuthProviderRepository, + private val memberRepository: MemberRepository, +) : LoginProvider { + private val log = KotlinLogging.logger {} + /** + * 기존 회원을 찾거나 신규 회원을 생성합니다. + * + * @param oauthUser OAuth 사용자 정보 + * @param providerType 소셜 제공자 타입 + * @return Pair<회원 엔티티, 신규 회원 여부> + */ + fun findOrCreateMember( + oauthUser: OAuthUser, + providerType: ProviderType + ): Pair { + + // AuthProvider 테이블에서 기존 회원 확인 + val existingAuthProvider = authProviderRepository.findByProviderIdAndProviderType( + oauthUser.providerId, + providerType + ) + + if (existingAuthProvider.isPresent) { + log.info { "기존 회원 발견: ${existingAuthProvider.get().member.opaqueId}" } + return Pair(existingAuthProvider.get().member, false) + } + + // 신규 회원 생성 + log.info { "신규 회원 생성: ${oauthUser.email}" } + val newMember = MemberEntity.Companion.createSocialMember( + nickname = oauthUser.getNickname(), + isEmailVerified = oauthUser.verifiedEmail, + primaryEmail = oauthUser.email + ) + + val savedMember = memberRepository.save(newMember) + log.info { "신규 회원 생성 완료: ${savedMember.opaqueId}" } + + return Pair(savedMember, true) + } + + /** + * AuthProvider 엔티티를 생성하거나 업데이트합니다. + * + * @param member 회원 엔티티 + * @param oauthUser OAuth 사용자 정보 + * @param providerType 소셜 제공자 타입 + */ + fun createOrUpdateAuthProvider( + member: MemberEntity, + oauthUser: OAuthUser, + providerType: ProviderType + ) { + val existingAuthProvider = authProviderRepository.findByMemberAndProviderType(member, providerType) + + if (existingAuthProvider.isPresent) { + // 기존 AuthProvider 업데이트 + val authProvider = existingAuthProvider.get() + // providerId와 email을 업데이트하는 메서드 호출 (엔티티에 setter 메서드가 있어야 함) + authProvider.updateProviderInfo(oauthUser.providerId, oauthUser.email) + authProviderRepository.save(authProvider) + log.info { "AuthProvider 업데이트 완료: ${member.opaqueId}" } + } else { + // 새로운 AuthProvider 생성 + val authProvider = AuthProviderEntity( + member = member, + providerType = providerType, + providerId = oauthUser.providerId, + email = oauthUser.email + ) + authProviderRepository.save(authProvider) + log.info { "AuthProvider 생성 완료: ${member.opaqueId}" } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt b/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt index 342a4c1..734b7f1 100644 --- a/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt +++ b/src/main/kotlin/com/wq/auth/domain/auth/SocialLoginService.kt @@ -1,24 +1,11 @@ package com.wq.auth.domain.auth -import com.wq.auth.api.domain.auth.AuthProviderRepository -import com.wq.auth.api.domain.auth.RefreshTokenRepository -import com.wq.auth.api.domain.member.MemberRepository -import com.wq.auth.api.domain.auth.entity.AuthProviderEntity -import com.wq.auth.api.domain.member.entity.MemberEntity -import com.wq.auth.api.domain.auth.entity.ProviderType -import com.wq.auth.api.domain.auth.entity.RefreshTokenEntity +import com.wq.auth.api.domain.auth.LoginProvider import com.wq.auth.domain.auth.request.SocialLoginRequest import com.wq.auth.domain.auth.response.SocialLoginResult -import com.wq.auth.api.external.oauth.GoogleOAuthClient -import com.wq.auth.api.external.oauth.KakaoOAuthClient -import com.wq.auth.domain.oauth.OAuthUser -import com.wq.auth.domain.oauth.error.SocialLoginException -import com.wq.auth.domain.oauth.error.SocialLoginExceptionCode -import com.wq.auth.security.jwt.JwtProvider import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.LocalDateTime /** * 소셜 로그인 서비스 @@ -32,12 +19,7 @@ import java.time.LocalDateTime @Service @Transactional(readOnly = true) class SocialLoginService( - private val googleOAuthClient: GoogleOAuthClient, - private val kakaoOAuthClient: KakaoOAuthClient, - private val memberRepository: MemberRepository, - private val authProviderRepository: AuthProviderRepository, - private val jwtProvider: JwtProvider, - private val refreshTokenRepository: RefreshTokenRepository + private val loginProviders: List, ) { private val log = KotlinLogging.logger {} @@ -51,169 +33,8 @@ class SocialLoginService( fun processSocialLogin(request: SocialLoginRequest): SocialLoginResult { log.info { "소셜 로그인 처리 시작: ${request.providerType}" } - return when (request.providerType) { - ProviderType.GOOGLE -> processGoogleLogin(request) - ProviderType.KAKAO -> processKakaoLogin(request) - ProviderType.NAVER -> throw SocialLoginException(SocialLoginExceptionCode.UNSUPPORTED_PROVIDER) - ProviderType.EMAIL -> throw SocialLoginException(SocialLoginExceptionCode.UNSUPPORTED_PROVIDER) - ProviderType.PHONE -> throw SocialLoginException(SocialLoginExceptionCode.UNSUPPORTED_PROVIDER) - } + return loginProviders.find{it.support(request.providerType) } + ?.processLogin(request) + ?:throw Exception() } - - /** - * 카카오 소셜 로그인을 처리합니다. - */ - private fun processKakaoLogin(request: SocialLoginRequest): SocialLoginResult { - log.info { "카카오 소셜 로그인 처리 시작" } - - // 1. 카카오 OAuth 클라이언트를 통해 사용자 정보 조회 - val oauthUser = kakaoOAuthClient.getUserFromAuthCode( - request.authCode, - request.codeVerifier, - request.redirectUri - ) - - log.info { "OAuth 사용자 정보 조회 완료: ${oauthUser.email}" } - - // 2. 기존 회원 확인 또는 신규 회원 생성 - val (member, isNewMember) = findOrCreateMember(oauthUser, oauthUser.providerType) - - // 3. AuthProvider 엔티티 생성/업데이트 - createOrUpdateAuthProvider(member, oauthUser, oauthUser.providerType) - - // 4. 로그인 시간 업데이트 - member.lastLoginAt = LocalDateTime.now() - memberRepository.save(member) - - // 5. JWT 토큰 발급 - val accessToken = jwtProvider.createAccessToken(member.opaqueId, member.role) - val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) - - // 6. RefreshToken 저장 - val jti = jwtProvider.getJti(refreshToken) - val opaqueId = jwtProvider.getOpaqueId(refreshToken) - val refreshTokenEntity = RefreshTokenEntity.of(member, jti, opaqueId) - refreshTokenRepository.save(refreshTokenEntity) - - log.info { "카카오 소셜 로그인 완료: ${member.opaqueId}, 신규 회원: $isNewMember" } - - return SocialLoginResult( - accessToken = accessToken, - refreshToken = refreshToken - ) - } - - /** - * Google 소셜 로그인을 처리합니다. - */ - private fun processGoogleLogin(request: SocialLoginRequest): SocialLoginResult { - log.info { "Google 소셜 로그인 처리 시작" } - - // 1. 구글 OAuth 클라이언트를 통해 사용자 정보 조회 - val oauthUser = googleOAuthClient.getUserFromAuthCode( - request.authCode, - request.codeVerifier, - request.redirectUri - ) - - log.info { "OAuth 사용자 정보 조회 완료: ${oauthUser.email}" } - - // 2. 기존 회원 확인 또는 신규 회원 생성 - val (member, isNewMember) = findOrCreateMember(oauthUser, oauthUser.providerType) - - // 3. AuthProvider 엔티티 생성/업데이트 - createOrUpdateAuthProvider(member, oauthUser, oauthUser.providerType) - - // 4. 로그인 시간 업데이트 - member.lastLoginAt = LocalDateTime.now() - memberRepository.save(member) - - // 5. JWT 토큰 발급 - val accessToken = jwtProvider.createAccessToken(member.opaqueId, member.role) - val refreshToken = jwtProvider.createRefreshToken(member.opaqueId) - - // 6. RefreshToken 저장 - val jti = jwtProvider.getJti(refreshToken) - val opaqueId = jwtProvider.getOpaqueId(refreshToken) - val refreshTokenEntity = RefreshTokenEntity.of(member, jti, opaqueId) - refreshTokenRepository.save(refreshTokenEntity) - - log.info { "소셜 로그인 완료: ${member.opaqueId}, 신규 회원: $isNewMember" } - - return SocialLoginResult( - accessToken = accessToken, - refreshToken = refreshToken - ) - } - - /** - * 기존 회원을 찾거나 신규 회원을 생성합니다. - * - * @param oauthUser OAuth 사용자 정보 - * @param providerType 소셜 제공자 타입 - * @return Pair<회원 엔티티, 신규 회원 여부> - */ - private fun findOrCreateMember( - oauthUser: OAuthUser, - providerType: ProviderType - ): Pair { - - // AuthProvider 테이블에서 기존 회원 확인 - val existingAuthProvider = authProviderRepository.findByProviderIdAndProviderType( - oauthUser.providerId, - providerType - ) - - if (existingAuthProvider.isPresent) { - log.info { "기존 회원 발견: ${existingAuthProvider.get().member.opaqueId}" } - return Pair(existingAuthProvider.get().member, false) - } - - // 신규 회원 생성 - log.info { "신규 회원 생성: ${oauthUser.email}" } - val newMember = MemberEntity.Companion.createSocialMember( - nickname = oauthUser.getNickname(), - isEmailVerified = oauthUser.verifiedEmail, - primaryEmail = oauthUser.email - ) - - val savedMember = memberRepository.save(newMember) - log.info { "신규 회원 생성 완료: ${savedMember.opaqueId}" } - - return Pair(savedMember, true) - } - - /** - * AuthProvider 엔티티를 생성하거나 업데이트합니다. - * - * @param member 회원 엔티티 - * @param oauthUser OAuth 사용자 정보 - * @param providerType 소셜 제공자 타입 - */ - private fun createOrUpdateAuthProvider( - member: MemberEntity, - oauthUser: OAuthUser, - providerType: ProviderType - ) { - val existingAuthProvider = authProviderRepository.findByMemberAndProviderType(member, providerType) - - if (existingAuthProvider.isPresent) { - // 기존 AuthProvider 업데이트 - val authProvider = existingAuthProvider.get() - // providerId와 email을 업데이트하는 메서드 호출 (엔티티에 setter 메서드가 있어야 함) - authProvider.updateProviderInfo(oauthUser.providerId, oauthUser.email) - authProviderRepository.save(authProvider) - log.info { "AuthProvider 업데이트 완료: ${member.opaqueId}" } - } else { - // 새로운 AuthProvider 생성 - val authProvider = AuthProviderEntity( - member = member, - providerType = providerType, - providerId = oauthUser.providerId, - email = oauthUser.email - ) - authProviderRepository.save(authProvider) - log.info { "AuthProvider 생성 완료: ${member.opaqueId}" } - } - } -} \ No newline at end of file +}