From 5598bca8d30a2868e7f0cbb010c7294b97345d18 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 4 Apr 2025 13:21:49 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B5=AC=EA=B8=80=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + .../kr/co/vividnext/sodalive/member/Member.kt | 11 ++++ .../sodalive/member/MemberController.kt | 26 +++++++++ .../sodalive/member/MemberRepository.kt | 1 + .../sodalive/member/MemberService.kt | 33 ++++++++++++ .../sodalive/member/login/LoginRequest.kt | 2 + .../member/social/SocialLoginResponse.kt | 9 ++++ .../member/social/google/GoogleAuthService.kt | 54 +++++++++++++++++++ .../google/GoogleAuthenticationToken.kt | 15 ++++++ .../member/social/google/GoogleService.kt | 45 ++++++++++++++++ .../member/social/google/GoogleUserInfo.kt | 7 +++ src/main/resources/application.yml | 3 ++ 12 files changed, 208 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialLoginResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthenticationToken.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt diff --git a/build.gradle.kts b/build.gradle.kts index 46061c5..e9cca79 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -65,6 +65,8 @@ dependencies { // android publisher implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0") + implementation("com.google.api-client:google-api-client:1.32.1") + implementation("org.apache.poi:poi-ooxml:5.2.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt index c99c541..43921e9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -24,6 +24,13 @@ data class Member( var nickname: String, var profileImage: String? = null, + val kakaoId: Long? = null, + val googleId: String? = null, + val appleId: String? = null, + + @Enumerated(EnumType.STRING) + val provider: MemberProvider = MemberProvider.EMAIL, + @Enumerated(value = EnumType.STRING) var gender: Gender = Gender.NONE, @@ -140,3 +147,7 @@ enum class Gender { enum class MemberRole { ADMIN, BOT, USER, CREATOR, AGENT } + +enum class MemberProvider { + EMAIL, KAKAO, GOOGLE, APPLE +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 04a5d1c..9118118 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -8,8 +8,10 @@ import kr.co.vividnext.sodalive.member.block.MemberBlockRequest import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest import kr.co.vividnext.sodalive.member.login.LoginRequest import kr.co.vividnext.sodalive.member.login.LoginResponse +import kr.co.vividnext.sodalive.member.login.SocialLoginRequest import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2 +import kr.co.vividnext.sodalive.member.social.google.GoogleAuthService import org.springframework.data.domain.Pageable import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.userdetails.User @@ -29,6 +31,7 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/member") class MemberController( private val service: MemberService, + private val googleAuthService: GoogleAuthService, private val trackingService: AdTrackingService ) { @GetMapping("/check/email") @@ -316,4 +319,27 @@ class MemberController( if (member == null) throw SodaException("로그인 정보를 확인해주세요.") ApiResponse.ok(service.getMemberProfile(memberId = id, myMemberId = member.id!!)) } + + @PostMapping("/login/google") + fun loginGoogle( + @RequestHeader("Authorization") authHeader: String, + @RequestBody request: SocialLoginRequest + ): ApiResponse { + if (!authHeader.startsWith("Bearer ")) { + throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요") + } + + val token = authHeader.substring(7) + val response = googleAuthService.authenticate(token, request.container, request.marketingPid) + + if (!response.marketingPid.isNullOrBlank()) { + trackingService.saveTrackingHistory( + pid = response.marketingPid, + type = AdTrackingHistoryType.SIGNUP, + memberId = response.memberId + ) + } + + return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index e67a033..f4da096 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -21,6 +21,7 @@ import org.springframework.stereotype.Repository interface MemberRepository : JpaRepository, MemberQueryRepository { fun findByEmail(email: String): Member? fun findByNickname(nickname: String): Member? + fun findByGoogleId(googleId: String): Member? } interface MemberQueryRepository { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 5e7642f..c7bbae9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -32,6 +32,7 @@ import kr.co.vividnext.sodalive.member.signUp.SignUpRequest import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2 import kr.co.vividnext.sodalive.member.signUp.SignUpResponse import kr.co.vividnext.sodalive.member.signUp.SignUpValidator +import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository @@ -736,4 +737,36 @@ class MemberService( return member.activePid } + + @Transactional + fun findOrRegister(googleUserInfo: GoogleUserInfo, container: String, marketingPid: String?): Member { + repository.findByGoogleId(googleUserInfo.sub)?.let { return it } + + val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) + ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + + val nickname = nicknameGenerateService.generateUniqueNickname() + val member = Member( + googleId = googleUserInfo.sub, + email = googleUserInfo.email, + password = "", + nickname = nickname, + profileImage = "profile/default-profile.png", + gender = Gender.NONE, + container = container + ) + + if (!marketingPid.isNullOrBlank()) { + member.activePid = marketingPid + member.partnerExpirationDatetime = LocalDateTime.now().plusYears(1) + } + + repository.save(member) + agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) + + return member + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt index f49585f..da16dc4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt @@ -6,3 +6,5 @@ data class LoginRequest( val isAdmin: Boolean = false, val isCreator: Boolean = false ) + +data class SocialLoginRequest(val container: String, val marketingPid: String? = null) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialLoginResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialLoginResponse.kt new file mode 100644 index 0000000..624f83a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialLoginResponse.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.member.social + +import kr.co.vividnext.sodalive.member.login.LoginResponse + +data class SocialLoginResponse( + val memberId: Long, + val marketingPid: String?, + val loginResponse: LoginResponse +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt new file mode 100644 index 0000000..495e269 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt @@ -0,0 +1,54 @@ +package kr.co.vividnext.sodalive.member.social.google + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberService +import kr.co.vividnext.sodalive.member.login.LoginResponse +import kr.co.vividnext.sodalive.member.social.SocialLoginResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Service + +@Service +class GoogleAuthService( + private val googleService: GoogleService, + private val memberService: MemberService, + private val tokenProvider: TokenProvider, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun authenticate(idToken: String, container: String, marketingPid: String?): SocialLoginResponse { + val googleUserInfo = googleService.getUserInfo(idToken) + ?: throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요") + val member = memberService.findOrRegister(googleUserInfo, container, marketingPid) + val principal = MemberAdapter(member) + val authToken = GoogleAuthenticationToken(idToken, principal.authorities) + authToken.setPrincipal(principal) + SecurityContextHolder.getContext().authentication = authToken + + val jwt = tokenProvider.createToken( + authentication = authToken, + memberId = member.id!! + ) + + val loginResponse = LoginResponse( + userId = member.id!!, + token = jwt, + nickname = member.nickname, + email = member.email, + profileImage = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) + + return SocialLoginResponse( + memberId = member.id!!, + marketingPid = marketingPid, + loginResponse = loginResponse + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthenticationToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthenticationToken.kt new file mode 100644 index 0000000..9bfa330 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthenticationToken.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.member.social.google + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority + +class GoogleAuthenticationToken( + private val idToken: String, + authorities: Collection? = null +) : AbstractAuthenticationToken(authorities) { + private var principal: Any? = null + init { isAuthenticated = authorities != null } + override fun getCredentials(): Any = idToken + override fun getPrincipal(): Any? = principal + fun setPrincipal(principal: Any) { this.principal = principal } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt new file mode 100644 index 0000000..fa31395 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.member.social.google + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class GoogleService( + @Value("\${google.web-client-id}") + private val googleWebClientId: String +) { + private val transport = GoogleNetHttpTransport.newTrustedTransport() + private val jsonFactory = GsonFactory.getDefaultInstance() + private val clientIds = listOf(googleWebClientId) + + fun getUserInfo(idToken: String): GoogleUserInfo? { + val verifier = GoogleIdTokenVerifier.Builder(transport, jsonFactory) + .setAudience(clientIds) + .setIssuers(listOf("accounts.google.com", "https://accounts.google.com")) + .build() + + return try { + val token = verifier.verify(idToken) + + if (token != null) { + val payload = token.payload + val email = payload.email ?: throw SodaException("이메일 제공에 동의하셔야 서비스 이용이 가능합니다.") + + GoogleUserInfo( + sub = payload.subject, + email = email, + name = payload["name"] as? String + ) + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt new file mode 100644 index 0000000..e54db6a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.member.social.google + +data class GoogleUserInfo( + val sub: String, + val email: String, + val name: String? +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6dc7c92..fbc0fd4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,6 +25,9 @@ agora: firebase: secretKeyPath: ${GOOGLE_APPLICATION_CREDENTIALS} +google: + webClientId: ${GOOGLE_WEB_CLIENT_ID} + cloud: aws: credentials: