diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt index 11820065..6b6334be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -108,6 +108,7 @@ class AdminMemberService( MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty() MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty() MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", langContext.lang).orEmpty() + MemberProvider.LINE -> messageSource.getMessage("member.provider.line", langContext.lang).orEmpty() } val signUpDate = it.createdAt!! 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 3eddd75c..3b847923 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -73,6 +73,7 @@ class SecurityConfig( .antMatchers("/member/login/google").permitAll() .antMatchers("/member/login/kakao").permitAll() .antMatchers("/member/login/apple").permitAll() + .antMatchers("/member/login/line").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/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index ead65727..a95615d7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1285,6 +1285,11 @@ class SodaMessageSource { Lang.EN to "Apple sign-in failed. Please try again.", Lang.JA to "Appleでログインできませんでした。もう一度お試しください。" ), + "member.social.line_login_failed" to mapOf( + Lang.KO to "라인 로그인을 하지 못했습니다. 다시 시도해 주세요", + Lang.EN to "LINE sign-in failed. Please try again.", + Lang.JA to "LINEでログインできませんでした。もう一度お試しください。" + ), "member.social.email_consent_required" to mapOf( Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.", Lang.EN to "You must agree to provide your email to use the service.", @@ -1633,6 +1638,11 @@ class SodaMessageSource { Lang.KO to "애플", Lang.EN to "Apple", Lang.JA to "Apple" + ), + "member.provider.line" to mapOf( + Lang.KO to "라인", + Lang.EN to "LINE", + Lang.JA to "LINE" ) ) 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 49295f15..0a588b94 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -27,6 +27,7 @@ data class Member( val kakaoId: Long? = null, val googleId: String? = null, val appleId: String? = null, + val lineId: String? = null, @Enumerated(EnumType.STRING) val provider: MemberProvider = MemberProvider.EMAIL, @@ -158,5 +159,5 @@ enum class MemberRole { } enum class MemberProvider { - EMAIL, KAKAO, GOOGLE, APPLE + EMAIL, KAKAO, GOOGLE, APPLE, LINE } 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 988d05ea..e5d9368f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -369,6 +369,19 @@ class MemberController( return processSocialLogin(MemberProvider.APPLE, token, request, nonce) } + @PostMapping("/login/line") + fun loginLine( + @RequestBody request: SocialLoginRequest + ): ApiResponse { + val errorKey = socialLoginErrorKey(MemberProvider.LINE) + val token = request.identityToken?.takeIf { it.isNotBlank() } + ?: throw SodaException(messageKey = errorKey) + val nonce = request.nonce?.takeIf { it.isNotBlank() } + ?: throw SodaException(messageKey = errorKey) + + return processSocialLogin(MemberProvider.LINE, token, request, nonce) + } + private fun processSocialLogin( provider: MemberProvider, token: String, @@ -417,6 +430,7 @@ class MemberController( MemberProvider.GOOGLE -> "member.social.google_login_failed" MemberProvider.KAKAO -> "member.social.kakao_login_failed" MemberProvider.APPLE -> "member.social.apple_login_failed" + MemberProvider.LINE -> "member.social.line_login_failed" else -> "common.error.bad_request" } } 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 f71a47a8..7b09a29e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -25,6 +25,7 @@ interface MemberRepository : JpaRepository, MemberQueryRepository fun findByGoogleId(googleId: String): Member? fun findByKakaoId(kakaoId: Long): Member? fun findByAppleId(appleId: String): Member? + fun findByLineId(lineId: 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 ca30f1ec..02684ef2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -40,6 +40,7 @@ import kr.co.vividnext.sodalive.member.social.MemberResolveResult import kr.co.vividnext.sodalive.member.social.apple.AppleUserInfo import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo import kr.co.vividnext.sodalive.member.social.kakao.KakaoUserInfo +import kr.co.vividnext.sodalive.member.social.line.LineUserInfo import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository @@ -792,6 +793,7 @@ class MemberService( MemberProvider.KAKAO -> "member.provider.kakao" MemberProvider.GOOGLE -> "member.provider.google" MemberProvider.APPLE -> "member.provider.apple" + MemberProvider.LINE -> "member.provider.line" } return messageSource.getMessage(key, langContext.lang) ?: provider.name } @@ -990,6 +992,63 @@ class MemberService( return MemberResolveResult(member = member, isNew = true) } + @Transactional + fun findOrRegister( + lineUserInfo: LineUserInfo, + container: String, + marketingPid: String?, + pushToken: String? + ): MemberResolveResult { + val findMember = repository.findByLineId(lineUserInfo.sub) + if (findMember != null) { + if (findMember.isActive) { + return MemberResolveResult(member = findMember, isNew = false) + } else { + throw SodaException(messageKey = "member.validation.inactive_account") + } + } + + val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") + + val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") + + val email = lineUserInfo.email + checkEmail(email) + + val nickname = nicknameGenerateService.generateUniqueNickname() + val member = Member( + lineId = lineUserInfo.sub, + email = email, + password = "", + nickname = nickname, + profileImage = "profile/default-profile.png", + gender = Gender.NONE, + provider = MemberProvider.LINE, + container = container, + countryCode = countryContext.countryCode + ) + + if (!marketingPid.isNullOrBlank()) { + member.activePid = marketingPid + member.partnerExpirationDatetime = LocalDateTime.now().plusYears(1) + } + + repository.save(member) + agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy) + + if (pushToken != null) { + pushTokenService.registerToken( + memberId = member.id!!, + token = pushToken, + deviceType = container + ) + } + + return MemberResolveResult(member = member, isNew = true) + } + private fun findMemberByUsername(username: String): Member? { return if (username.startsWith("member:")) { val id = username.substringAfter("member:").toLongOrNull() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt new file mode 100644 index 00000000..10851acc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt @@ -0,0 +1,112 @@ +package kr.co.vividnext.sodalive.member.social.line + +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.MemberProvider +import kr.co.vividnext.sodalive.member.MemberService +import kr.co.vividnext.sodalive.member.login.LoginResponse +import kr.co.vividnext.sodalive.member.social.SocialAuthService +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 +import java.time.Instant + +@Service +class LineAuthService( + private val lineService: LineService, + private val memberService: MemberService, + private val tokenProvider: TokenProvider, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String, + + @Value("\${line.channel-id}") + private val lineChannelId: String +) : SocialAuthService { + override fun getProvider(): MemberProvider = MemberProvider.LINE + + override fun authenticate( + token: String, + container: String, + marketingPid: String?, + pushToken: String?, + nonce: String? + ): SocialLoginResponse { + val rawNonce = nonce?.takeIf { it.isNotBlank() } + ?: throw SodaException(messageKey = "member.social.line_login_failed") + + if (lineChannelId.isBlank()) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + val verifyResponse = lineService.verifyIdToken(token, lineChannelId, rawNonce) + ?: throw SodaException(messageKey = "member.social.line_login_failed") + + validateVerifyResponse(verifyResponse, lineChannelId, rawNonce) + + val lineUserInfo = LineUserInfo( + sub = verifyResponse.sub, + email = verifyResponse.email + ) + + val memberResolveResult = memberService.findOrRegister(lineUserInfo, container, marketingPid, pushToken) + val member = memberResolveResult.member + val principal = MemberAdapter(member) + val authToken = LineAuthenticationToken(token, 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, + isNew = memberResolveResult.isNew + ) + } + + private fun validateVerifyResponse(response: LineVerifyResponse, clientId: String, nonce: String) { + if (response.iss != ISSUER) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + if (response.aud != clientId) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + val now = Instant.now().epochSecond + if (response.exp <= now) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + if (response.iat > now) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + if (response.nonce != null && response.nonce != nonce) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + } + + companion object { + private const val ISSUER = "https://access.line.me" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt new file mode 100644 index 00000000..d9dedac3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.member.social.line + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority + +class LineAuthenticationToken( + 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/line/LineService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt new file mode 100644 index 00000000..cfb87ae3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.member.social.line + +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestTemplate + +@Service +class LineService( + private val restTemplate: RestTemplate = RestTemplate() +) { + fun verifyIdToken(idToken: String, clientId: String, nonce: String?): LineVerifyResponse? { + val url = "https://api.line.me/oauth2/v2.1/verify" + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_FORM_URLENCODED + } + + val body = LinkedMultiValueMap().apply { + add("id_token", idToken) + add("client_id", clientId) + if (!nonce.isNullOrBlank()) { + add("nonce", nonce) + } + } + + val entity = HttpEntity(body, headers) + + return try { + val response: ResponseEntity = restTemplate.postForEntity( + url, + entity, + LineVerifyResponse::class.java + ) + + if (response.statusCode.is2xxSuccessful) { + response.body + } else { + null + } + } catch (ex: Exception) { + ex.printStackTrace() + null + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt new file mode 100644 index 00000000..80d29507 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member.social.line + +data class LineUserInfo( + val sub: String, + val email: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt new file mode 100644 index 00000000..9de18aa2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.member.social.line + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LineVerifyResponse( + val iss: String, + val sub: String, + val aud: String, + val exp: Long, + val iat: Long, + val nonce: String? = null, + val name: String? = null, + val picture: String? = null, + val email: String? = null +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 303a1229..378e5137 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,9 @@ apple: iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt bundleId: ${APPLE_BUNDLE_ID} +line: + channelId: ${LINE_CHANNEL_ID} + agora: appId: ${AGORA_APP_ID} appCertificate: ${AGORA_APP_CERTIFICATE}