LINE 로그인 지원 추가

회원 로그인에 LINE 공급자를 추가한다
This commit is contained in:
2026-01-28 20:07:14 +09:00
parent 81f3bc0bad
commit 6e0b3ddf8e
13 changed files with 296 additions and 1 deletions

View File

@@ -108,6 +108,7 @@ class AdminMemberService(
MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty() MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty()
MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty() MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty()
MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", 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!! val signUpDate = it.createdAt!!

View File

@@ -73,6 +73,7 @@ class SecurityConfig(
.antMatchers("/member/login/google").permitAll() .antMatchers("/member/login/google").permitAll()
.antMatchers("/member/login/kakao").permitAll() .antMatchers("/member/login/kakao").permitAll()
.antMatchers("/member/login/apple").permitAll() .antMatchers("/member/login/apple").permitAll()
.antMatchers("/member/login/line").permitAll()
.antMatchers("/creator-admin/member/login").permitAll() .antMatchers("/creator-admin/member/login").permitAll()
.antMatchers("/member/forgot-password").permitAll() .antMatchers("/member/forgot-password").permitAll()
.antMatchers("/stplat/terms_of_service").permitAll() .antMatchers("/stplat/terms_of_service").permitAll()

View File

@@ -1285,6 +1285,11 @@ class SodaMessageSource {
Lang.EN to "Apple sign-in failed. Please try again.", Lang.EN to "Apple sign-in failed. Please try again.",
Lang.JA to "Appleでログインできませんでした。もう一度お試しください。" 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( "member.social.email_consent_required" to mapOf(
Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.", Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.",
Lang.EN to "You must agree to provide your email to use the service.", Lang.EN to "You must agree to provide your email to use the service.",
@@ -1633,6 +1638,11 @@ class SodaMessageSource {
Lang.KO to "애플", Lang.KO to "애플",
Lang.EN to "Apple", Lang.EN to "Apple",
Lang.JA to "Apple" Lang.JA to "Apple"
),
"member.provider.line" to mapOf(
Lang.KO to "라인",
Lang.EN to "LINE",
Lang.JA to "LINE"
) )
) )

View File

@@ -27,6 +27,7 @@ data class Member(
val kakaoId: Long? = null, val kakaoId: Long? = null,
val googleId: String? = null, val googleId: String? = null,
val appleId: String? = null, val appleId: String? = null,
val lineId: String? = null,
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
val provider: MemberProvider = MemberProvider.EMAIL, val provider: MemberProvider = MemberProvider.EMAIL,
@@ -158,5 +159,5 @@ enum class MemberRole {
} }
enum class MemberProvider { enum class MemberProvider {
EMAIL, KAKAO, GOOGLE, APPLE EMAIL, KAKAO, GOOGLE, APPLE, LINE
} }

View File

@@ -369,6 +369,19 @@ class MemberController(
return processSocialLogin(MemberProvider.APPLE, token, request, nonce) return processSocialLogin(MemberProvider.APPLE, token, request, nonce)
} }
@PostMapping("/login/line")
fun loginLine(
@RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> {
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( private fun processSocialLogin(
provider: MemberProvider, provider: MemberProvider,
token: String, token: String,
@@ -417,6 +430,7 @@ class MemberController(
MemberProvider.GOOGLE -> "member.social.google_login_failed" MemberProvider.GOOGLE -> "member.social.google_login_failed"
MemberProvider.KAKAO -> "member.social.kakao_login_failed" MemberProvider.KAKAO -> "member.social.kakao_login_failed"
MemberProvider.APPLE -> "member.social.apple_login_failed" MemberProvider.APPLE -> "member.social.apple_login_failed"
MemberProvider.LINE -> "member.social.line_login_failed"
else -> "common.error.bad_request" else -> "common.error.bad_request"
} }
} }

View File

@@ -25,6 +25,7 @@ interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository
fun findByGoogleId(googleId: String): Member? fun findByGoogleId(googleId: String): Member?
fun findByKakaoId(kakaoId: Long): Member? fun findByKakaoId(kakaoId: Long): Member?
fun findByAppleId(appleId: String): Member? fun findByAppleId(appleId: String): Member?
fun findByLineId(lineId: String): Member?
} }
interface MemberQueryRepository { interface MemberQueryRepository {

View File

@@ -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.apple.AppleUserInfo
import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo 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.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.Stipulation
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository
@@ -792,6 +793,7 @@ class MemberService(
MemberProvider.KAKAO -> "member.provider.kakao" MemberProvider.KAKAO -> "member.provider.kakao"
MemberProvider.GOOGLE -> "member.provider.google" MemberProvider.GOOGLE -> "member.provider.google"
MemberProvider.APPLE -> "member.provider.apple" MemberProvider.APPLE -> "member.provider.apple"
MemberProvider.LINE -> "member.provider.line"
} }
return messageSource.getMessage(key, langContext.lang) ?: provider.name return messageSource.getMessage(key, langContext.lang) ?: provider.name
} }
@@ -990,6 +992,63 @@ class MemberService(
return MemberResolveResult(member = member, isNew = true) 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? { private fun findMemberByUsername(username: String): Member? {
return if (username.startsWith("member:")) { return if (username.startsWith("member:")) {
val id = username.substringAfter("member:").toLongOrNull() val id = username.substringAfter("member:").toLongOrNull()

View File

@@ -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"
}
}

View File

@@ -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<GrantedAuthority>? = 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
}
}

View File

@@ -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<String, String>().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<LineVerifyResponse> = restTemplate.postForEntity(
url,
entity,
LineVerifyResponse::class.java
)
if (response.statusCode.is2xxSuccessful) {
response.body
} else {
null
}
} catch (ex: Exception) {
ex.printStackTrace()
null
}
}
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.member.social.line
data class LineUserInfo(
val sub: String,
val email: String? = null
)

View File

@@ -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
)

View File

@@ -35,6 +35,9 @@ apple:
iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt
bundleId: ${APPLE_BUNDLE_ID} bundleId: ${APPLE_BUNDLE_ID}
line:
channelId: ${LINE_CHANNEL_ID}
agora: agora:
appId: ${AGORA_APP_ID} appId: ${AGORA_APP_ID}
appCertificate: ${AGORA_APP_CERTIFICATE} appCertificate: ${AGORA_APP_CERTIFICATE}