diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 8334478..8c4bc9f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -71,6 +71,7 @@ class SecurityConfig( .antMatchers("/member/signup/v2").permitAll() .antMatchers("/member/login").permitAll() .antMatchers("/member/login/google").permitAll() + .antMatchers("/member/login/kakao").permitAll() .antMatchers("/creator-admin/member/login").permitAll() .antMatchers("/member/forgot-password").permitAll() .antMatchers("/stplat/terms_of_service").permitAll() 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 9118118..0c55544 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -12,6 +12,7 @@ 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 kr.co.vividnext.sodalive.member.social.kakao.KakaoAuthService import org.springframework.data.domain.Pageable import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.userdetails.User @@ -31,6 +32,7 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/member") class MemberController( private val service: MemberService, + private val kakaoAuthService: KakaoAuthService, private val googleAuthService: GoogleAuthService, private val trackingService: AdTrackingService ) { @@ -342,4 +344,27 @@ class MemberController( return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) } + + @PostMapping("/login/kakao") + fun loginKakao( + @RequestHeader("Authorization") authHeader: String, + @RequestBody request: SocialLoginRequest + ): ApiResponse { + if (!authHeader.startsWith("Bearer ")) { + throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") + } + + val token = authHeader.substring(7) + val response = kakaoAuthService.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 f4da096..4d7ead6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -22,6 +22,7 @@ interface MemberRepository : JpaRepository, MemberQueryRepository fun findByEmail(email: String): Member? fun findByNickname(nickname: String): Member? fun findByGoogleId(googleId: String): Member? + fun findByKakaoId(kakaoId: Long): 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 ad44fa0..93cf16e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -33,6 +33,7 @@ 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.social.kakao.KakaoUserInfo import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository @@ -770,4 +771,37 @@ class MemberService( return member } + + @Transactional + fun findOrRegister(kakaoUserInfo: KakaoUserInfo, container: String, marketingPid: String?): Member { + repository.findByKakaoId(kakaoUserInfo.id)?.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( + kakaoId = kakaoUserInfo.id, + email = kakaoUserInfo.email, + password = "", + nickname = nickname, + profileImage = "profile/default-profile.png", + gender = Gender.NONE, + provider = MemberProvider.KAKAO, + 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/social/kakao/KakaoAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt new file mode 100644 index 0000000..49525bf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt @@ -0,0 +1,54 @@ +package kr.co.vividnext.sodalive.member.social.kakao + +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 KakaoAuthService( + private val kakaoService: KakaoService, + private val memberService: MemberService, + private val tokenProvider: TokenProvider, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun authenticate(accessToken: String, container: String, marketingPid: String?): SocialLoginResponse { + val kakaoUserInfo = kakaoService.getUserInfo(accessToken) + ?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") + val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid) + val principal = MemberAdapter(member) + val authToken = KakaoAuthenticationToken(accessToken, 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/kakao/KakaoAuthenticationToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthenticationToken.kt new file mode 100644 index 0000000..d8e5aa9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthenticationToken.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.member.social.kakao + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority + +class KakaoAuthenticationToken( + val accessToken: String, + authorities: Collection? = null +) : AbstractAuthenticationToken(authorities) { + private var principal: Any? = null + init { isAuthenticated = authorities != null } + override fun getCredentials(): Any = accessToken + override fun getPrincipal(): Any? = principal + fun setPrincipal(principal: Any) { this.principal = principal } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt new file mode 100644 index 0000000..ffd1204 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt @@ -0,0 +1,53 @@ +package kr.co.vividnext.sodalive.member.social.kakao + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.web.client.RestTemplate + +@Service +class KakaoService( + private val restTemplate: RestTemplate = RestTemplate(), + private val objectMapper: ObjectMapper = ObjectMapper() +) { + fun getUserInfo(accessToken: String): KakaoUserInfo? { + val url = "https://kapi.kakao.com/v2/user/me" + val headers = HttpHeaders().apply { + set("Authorization", "Bearer $accessToken") + } + val entity = HttpEntity(headers) + + try { + val response: ResponseEntity = restTemplate.exchange( + url, + HttpMethod.GET, + entity, + String::class.java + ) + + if (response.statusCode == HttpStatus.OK) { + // 응답 JSON을 KakaoUserInfo와 직접 매핑하려면 DTO의 구조와 JSON 구조가 일치해야 합니다. + // 실제 응답 JSON은 중첩 구조를 가지므로, 필요한 필드만 추출하는 방법을 사용합니다. + val jsonNode = objectMapper.readTree(response.body ?: return null) + val id = jsonNode.get("id").asLong() + val kakaoAccount = jsonNode.get("kakao_account") + val email = kakaoAccount?.get("email")?.asText() + ?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") + val properties = jsonNode.get("properties") + val nickname = properties?.get("nickname")?.asText() + + return KakaoUserInfo(id, email, nickname) + } else { + println("카카오 사용자 정보 요청 실패: ${response.statusCode}") + } + } catch (ex: Exception) { + ex.printStackTrace() + } + return null + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt new file mode 100644 index 0000000..9ddcc19 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.member.social.kakao + +data class KakaoUserInfo( + val id: Long, + val email: String, + val nickname: String? +)