LINE 로그인 지원 추가
회원 로그인에 LINE 공급자를 추가한다
This commit is contained in:
@@ -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!!
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package kr.co.vividnext.sodalive.member.social.line
|
||||||
|
|
||||||
|
data class LineUserInfo(
|
||||||
|
val sub: String,
|
||||||
|
val email: String? = null
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user