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

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