Merge pull request 'test' (#383) from test into main

Reviewed-on: #383
This commit is contained in:
2026-01-28 15:40:25 +00:00
44 changed files with 779 additions and 113 deletions

View File

@@ -41,6 +41,8 @@ dependencies {
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
implementation("com.nimbusds:nimbus-jose-jwt:9.37.3")
// querydsl (추가 설정) // querydsl (추가 설정)
implementation("com.querydsl:querydsl-jpa:$querydslVersion") implementation("com.querydsl:querydsl-jpa:$querydslVersion")
kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa") kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa")

View File

@@ -148,6 +148,7 @@ class AdminChatCharacterController(
runCatching { CharacterType.valueOf(it) } runCatching { CharacterType.valueOf(it) }
.getOrDefault(CharacterType.Character) .getOrDefault(CharacterType.Character)
} ?: CharacterType.Character, } ?: CharacterType.Character,
region = request.region,
tags = request.tags, tags = request.tags,
values = request.values, values = request.values,
hobbies = request.hobbies, hobbies = request.hobbies,
@@ -203,6 +204,7 @@ class AdminChatCharacterController(
body["name"] = request.name body["name"] = request.name
body["systemPrompt"] = request.systemPrompt body["systemPrompt"] = request.systemPrompt
body["description"] = request.description body["description"] = request.description
body["region"] = request.region
request.age?.let { body["age"] = it } request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = it } request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it } request.mbti?.let { body["mbti"] = it }

View File

@@ -20,6 +20,7 @@ data class ChatCharacterDetailResponse(
val speechPattern: String?, val speechPattern: String?,
val speechStyle: String?, val speechStyle: String?,
val appearance: String?, val appearance: String?,
val region: String,
val isActive: Boolean, val isActive: Boolean,
val tags: List<String>, val tags: List<String>,
val hobbies: List<String>, val hobbies: List<String>,
@@ -67,6 +68,7 @@ data class ChatCharacterDetailResponse(
speechPattern = chatCharacter.speechPattern, speechPattern = chatCharacter.speechPattern,
speechStyle = chatCharacter.speechStyle, speechStyle = chatCharacter.speechStyle,
appearance = chatCharacter.appearance, appearance = chatCharacter.appearance,
region = chatCharacter.region,
isActive = chatCharacter.isActive, isActive = chatCharacter.isActive,
tags = chatCharacter.tagMappings.map { it.tag.tag }, tags = chatCharacter.tagMappings.map { it.tag.tag },
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby }, hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },

View File

@@ -38,6 +38,7 @@ data class ChatCharacterRegisterRequest(
@JsonProperty("speechPattern") val speechPattern: String?, @JsonProperty("speechPattern") val speechPattern: String?,
@JsonProperty("speechStyle") val speechStyle: String?, @JsonProperty("speechStyle") val speechStyle: String?,
@JsonProperty("appearance") val appearance: String?, @JsonProperty("appearance") val appearance: String?,
@JsonProperty("region") val region: String = "KR",
@JsonProperty("originalTitle") val originalTitle: String? = null, @JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: String? = null, @JsonProperty("originalLink") val originalLink: String? = null,
@JsonProperty("originalWorkId") val originalWorkId: Long? = null, @JsonProperty("originalWorkId") val originalWorkId: Long? = null,

View File

@@ -14,6 +14,7 @@ data class ChatCharacterListResponse(
val mbti: String?, val mbti: String?,
val speechStyle: String?, val speechStyle: String?,
val speechPattern: String?, val speechPattern: String?,
val region: String,
val tags: List<String>, val tags: List<String>,
val createdAt: String?, val createdAt: String?,
val updatedAt: String? val updatedAt: String?
@@ -48,6 +49,7 @@ data class ChatCharacterListResponse(
mbti = chatCharacter.mbti, mbti = chatCharacter.mbti,
speechStyle = chatCharacter.speechStyle, speechStyle = chatCharacter.speechStyle,
speechPattern = chatCharacter.speechPattern, speechPattern = chatCharacter.speechPattern,
region = chatCharacter.region,
tags = chatCharacter.tagMappings.map { it.tag.tag }, tags = chatCharacter.tagMappings.map { it.tag.tag },
createdAt = createdAtStr, createdAt = createdAtStr,
updatedAt = updatedAtStr updatedAt = updatedAtStr

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!!
@@ -126,7 +127,7 @@ class AdminMemberService(
GetAdminMemberListResponseItem( GetAdminMemberListResponseItem(
id = it.id!!, id = it.id!!,
email = it.email, email = it.email ?: "",
nickname = it.nickname, nickname = it.nickname,
profileUrl = if (it.profileImage != null) { profileUrl = if (it.profileImage != null) {
"$cloudFrontHost/${it.profileImage}" "$cloudFrontHost/${it.profileImage}"
@@ -160,6 +161,7 @@ class AdminMemberService(
val member = repository.findByIdAndActive(memberId = request.memberId) val member = repository.findByIdAndActive(memberId = request.memberId)
?: throw SodaException(messageKey = "admin.member.reset_password_invalid") ?: throw SodaException(messageKey = "admin.member.reset_password_invalid")
member.password = passwordEncoder.encode(member.email.split("@")[0]) val email = member.email ?: throw SodaException(message = "이메일이 없는 계정은 비밀번호 재설정이 불가능합니다.")
member.password = passwordEncoder.encode(email.split("@")[0])
} }
} }

View File

@@ -106,7 +106,8 @@ class HomeService(
val latestContentThemeList = contentThemeService.getActiveThemeOfContent( val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType,
excludeThemes = listOf("다시듣기")
) )
val latestContentList = contentService.getLatestContentByTheme( val latestContentList = contentService.getLatestContentByTheme(

View File

@@ -5,7 +5,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@@ -29,6 +28,12 @@ class ChargeTempController(private val service: ChargeTempService) {
@PostMapping("/verify") @PostMapping("/verify")
fun verify( fun verify(
@RequestBody request: VerifyRequest, @RequestBody request: VerifyRequest,
@AuthenticationPrincipal user: User @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = ApiResponse.ok(service.verify(user, request)) ) = run {
if (member == null) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
ApiResponse.ok(service.verify(member, request))
}
} }

View File

@@ -15,10 +15,8 @@ import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -26,7 +24,6 @@ import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true) @Transactional(readOnly = true)
class ChargeTempService( class ChargeTempService(
private val chargeRepository: ChargeRepository, private val chargeRepository: ChargeRepository,
private val memberRepository: MemberRepository,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val messageSource: SodaMessageSource, private val messageSource: SodaMessageSource,
@@ -54,11 +51,9 @@ class ChargeTempService(
} }
@Transactional @Transactional
fun verify(user: User, verifyRequest: VerifyRequest) { fun verify(member: Member, verifyRequest: VerifyRequest) {
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info") ?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
val member = memberRepository.findByEmail(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) { if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)

View File

@@ -67,6 +67,10 @@ class ChatCharacter(
@Column(nullable = false) @Column(nullable = false)
var characterType: CharacterType = CharacterType.Character, var characterType: CharacterType = CharacterType.Character,
// 리전 (기본값 KR, 수정 불가)
@Column(nullable = false)
val region: String = "KR",
var isActive: Boolean = true var isActive: Boolean = true
) : BaseEntity() { ) : BaseEntity() {
var imagePath: String? = null var imagePath: String? = null

View File

@@ -582,6 +582,7 @@ class ChatCharacterService(
originalTitle: String? = null, originalTitle: String? = null,
originalLink: String? = null, originalLink: String? = null,
characterType: CharacterType = CharacterType.Character, characterType: CharacterType = CharacterType.Character,
region: String = "KR",
tags: List<String> = emptyList(), tags: List<String> = emptyList(),
values: List<String> = emptyList(), values: List<String> = emptyList(),
hobbies: List<String> = emptyList(), hobbies: List<String> = emptyList(),
@@ -600,7 +601,8 @@ class ChatCharacterService(
appearance = appearance, appearance = appearance,
originalTitle = originalTitle, originalTitle = originalTitle,
originalLink = originalLink, originalLink = originalLink,
characterType = characterType characterType = characterType,
region = region
) )
// 관련 엔티티 연결 // 관련 엔티티 연결
@@ -630,6 +632,7 @@ class ChatCharacterService(
originalTitle: String? = null, originalTitle: String? = null,
originalLink: String? = null, originalLink: String? = null,
characterType: CharacterType = CharacterType.Character, characterType: CharacterType = CharacterType.Character,
region: String = "KR",
tags: List<String> = emptyList(), tags: List<String> = emptyList(),
values: List<String> = emptyList(), values: List<String> = emptyList(),
hobbies: List<String> = emptyList(), hobbies: List<String> = emptyList(),
@@ -653,6 +656,7 @@ class ChatCharacterService(
originalTitle = originalTitle, originalTitle = originalTitle,
originalLink = originalLink, originalLink = originalLink,
characterType = characterType, characterType = characterType,
region = region,
tags = tags, tags = tags,
values = values, values = values,
hobbies = hobbies, hobbies = hobbies,

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.common package kr.co.vividnext.sodalive.common
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -20,10 +21,14 @@ class SodaExceptionHandler(
private val messageSource: SodaMessageSource private val messageSource: SodaMessageSource
) { ) {
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
private val logLang = Lang.KO
@ExceptionHandler(SodaException::class) @ExceptionHandler(SodaException::class)
fun handleSodaException(e: SodaException) = run { fun handleSodaException(e: SodaException) = run {
logger.error("API error", e) val logMessage = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, logLang) }
?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.unknown", logLang)
logger.error("API error: {}", logMessage, e)
val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) } val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) }
?: e.message?.takeIf { it.isNotBlank() } ?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.unknown", langContext.lang) ?: messageSource.getMessage("common.error.unknown", langContext.lang)
@@ -35,35 +40,40 @@ class SodaExceptionHandler(
@ExceptionHandler(MaxUploadSizeExceededException::class) @ExceptionHandler(MaxUploadSizeExceededException::class)
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run { fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
logger.error("API error", e) val logMessage = messageSource.getMessage("common.error.max_upload_size", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang) val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang)
ApiResponse.error(message = message) ApiResponse.error(message = message)
} }
@ExceptionHandler(AccessDeniedException::class) @ExceptionHandler(AccessDeniedException::class)
fun handleAccessDeniedException(e: AccessDeniedException) = run { fun handleAccessDeniedException(e: AccessDeniedException) = run {
logger.error("API error", e) val logMessage = messageSource.getMessage("common.error.access_denied", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.access_denied", langContext.lang) val message = messageSource.getMessage("common.error.access_denied", langContext.lang)
ApiResponse.error(message = message) ApiResponse.error(message = message)
} }
@ExceptionHandler(InternalAuthenticationServiceException::class) @ExceptionHandler(InternalAuthenticationServiceException::class)
fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run { fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run {
logger.error("API error", e) val logMessage = messageSource.getMessage("common.error.bad_credentials", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang) val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang)
ApiResponse.error(message) ApiResponse.error(message)
} }
@ExceptionHandler(BadCredentialsException::class) @ExceptionHandler(BadCredentialsException::class)
fun handleBadCredentialsException(e: BadCredentialsException) = run { fun handleBadCredentialsException(e: BadCredentialsException) = run {
logger.error("API error", e) val logMessage = messageSource.getMessage("common.error.bad_credentials", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang) val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang)
ApiResponse.error(message) ApiResponse.error(message)
} }
@ExceptionHandler(DataIntegrityViolationException::class) @ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run { fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run {
logger.error("API error", e) val logMessage = messageSource.getMessage("common.error.already_registered", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.already_registered", langContext.lang) val message = messageSource.getMessage("common.error.already_registered", langContext.lang)
ApiResponse.error(message) ApiResponse.error(message)
} }
@@ -71,7 +81,10 @@ class SodaExceptionHandler(
@ResponseStatus(value = HttpStatus.NOT_FOUND) @ResponseStatus(value = HttpStatus.NOT_FOUND)
@ExceptionHandler(AdsChargeException::class) @ExceptionHandler(AdsChargeException::class)
fun handleAdsChargeException(e: AdsChargeException) = run { fun handleAdsChargeException(e: AdsChargeException) = run {
logger.error("API error - AdsChargeException ::: ", e) val logMessage = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, logLang) }
?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.invalid_request", logLang)
logger.error("API error - AdsChargeException: {}", logMessage, e)
val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) } val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) }
?: e.message?.takeIf { it.isNotBlank() } ?: e.message?.takeIf { it.isNotBlank() }
?: messageSource.getMessage("common.error.invalid_request", langContext.lang) ?: messageSource.getMessage("common.error.invalid_request", langContext.lang)
@@ -81,7 +94,8 @@ class SodaExceptionHandler(
@ExceptionHandler(Exception::class) @ExceptionHandler(Exception::class)
fun handleException(e: Exception) = run { fun handleException(e: Exception) = run {
if (e is ResponseStatusException) throw e if (e is ResponseStatusException) throw e
logger.error("API error", e) val logMessage = messageSource.getMessage("common.error.unknown", logLang)
logger.error("API error: {}", logMessage, e)
val message = messageSource.getMessage("common.error.unknown", langContext.lang) val message = messageSource.getMessage("common.error.unknown", langContext.lang)
ApiResponse.error(message) ApiResponse.error(message)
} }

View File

@@ -72,6 +72,8 @@ class SecurityConfig(
.antMatchers("/member/login").permitAll() .antMatchers("/member/login").permitAll()
.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/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

@@ -34,15 +34,20 @@ class AudioContentThemeService(
isAdult: Boolean = false, isAdult: Boolean = false,
isFree: Boolean = false, isFree: Boolean = false,
isPointAvailableOnly: Boolean = false, isPointAvailableOnly: Boolean = false,
contentType: ContentType contentType: ContentType,
excludeThemes: List<String> = emptyList()
): List<String> { ): List<String> {
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent( var themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
isAdult = isAdult, isAdult = isAdult,
isFree = isFree, isFree = isFree,
isPointAvailableOnly = isPointAvailableOnly, isPointAvailableOnly = isPointAvailableOnly,
contentType = contentType contentType = contentType
) )
if (excludeThemes.isNotEmpty()) {
themesWithIds = themesWithIds.filter { it.theme !in excludeThemes }
}
/** /**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환 * langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환 * 번역이 없으면 번역 API 호출 후 저장하고 반환

View File

@@ -87,7 +87,7 @@ class CreatorAdminMemberService(
userId = member.id!!, userId = member.id!!,
token = jwt, token = jwt,
nickname = member.nickname, nickname = member.nickname,
email = member.email, email = member.email ?: "",
profileImage = if (member.profileImage != null) { profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}" "$cloudFrontHost/${member.profileImage}"
} else { } else {

View File

@@ -1280,6 +1280,16 @@ class SodaMessageSource {
Lang.EN to "Kakao login failed. Please try again.", Lang.EN to "Kakao login failed. Please try again.",
Lang.JA to "Kakaoでログインできませんでした。もう一度お試しください。" Lang.JA to "Kakaoでログインできませんでした。もう一度お試しください。"
), ),
"member.social.apple_login_failed" to mapOf(
Lang.KO to "애플 로그인을 하지 못했습니다. 다시 시도해 주세요",
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( "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.",
@@ -1628,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

@@ -19,7 +19,7 @@ import javax.persistence.OneToOne
@Entity @Entity
data class Member( data class Member(
val email: String, var email: String? = null,
var password: String, var password: String,
var nickname: String, var nickname: String,
var profileImage: String? = null, var profileImage: String? = null,
@@ -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

@@ -4,7 +4,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.User
class MemberAdapter(val member: Member) : User( class MemberAdapter(val member: Member) : User(
member.email, member.email ?: "member:${member.id}",
member.password, member.password,
listOf(SimpleGrantedAuthority("ROLE_${member.role.name}")) listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))
) )

View File

@@ -13,8 +13,7 @@ import kr.co.vividnext.sodalive.member.login.LoginResponse
import kr.co.vividnext.sodalive.member.login.SocialLoginRequest import kr.co.vividnext.sodalive.member.login.SocialLoginRequest
import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.member.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2 import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
import kr.co.vividnext.sodalive.member.social.google.GoogleAuthService import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver
import kr.co.vividnext.sodalive.member.social.kakao.KakaoAuthService
import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
@@ -36,8 +35,7 @@ import org.springframework.web.multipart.MultipartFile
@RequestMapping("/member") @RequestMapping("/member")
class MemberController( class MemberController(
private val service: MemberService, private val service: MemberService,
private val kakaoAuthService: KakaoAuthService, private val socialAuthServiceResolver: SocialAuthServiceResolver,
private val googleAuthService: GoogleAuthService,
private val trackingService: AdTrackingService, private val trackingService: AdTrackingService,
private val userActionService: UserActionService, private val userActionService: UserActionService,
private val messageSource: SodaMessageSource, private val messageSource: SodaMessageSource,
@@ -345,31 +343,8 @@ class MemberController(
@RequestHeader("Authorization") authHeader: String, @RequestHeader("Authorization") authHeader: String,
@RequestBody request: SocialLoginRequest @RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> { ): ApiResponse<LoginResponse> {
if (!authHeader.startsWith("Bearer ")) { val token = extractBearerToken(authHeader, MemberProvider.GOOGLE)
throw SodaException(messageKey = "member.social.google_login_failed") return processSocialLogin(MemberProvider.GOOGLE, token, request, null)
}
val token = authHeader.substring(7)
val response = googleAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory(
pid = response.marketingPid,
type = AdTrackingHistoryType.SIGNUP,
memberId = response.memberId
)
}
if (response.isNew) {
userActionService.recordAction(
memberId = response.memberId,
isAuth = false,
actionType = ActionType.SIGN_UP
)
}
val message = messageSource.getMessage("member.signup.success", langContext.lang)
return ApiResponse.ok(message = message, data = response.loginResponse)
} }
@PostMapping("/login/kakao") @PostMapping("/login/kakao")
@@ -377,12 +352,50 @@ class MemberController(
@RequestHeader("Authorization") authHeader: String, @RequestHeader("Authorization") authHeader: String,
@RequestBody request: SocialLoginRequest @RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> { ): ApiResponse<LoginResponse> {
if (!authHeader.startsWith("Bearer ")) { val token = extractBearerToken(authHeader, MemberProvider.KAKAO)
throw SodaException(messageKey = "member.social.kakao_login_failed") return processSocialLogin(MemberProvider.KAKAO, token, request, null)
} }
val token = authHeader.substring(7) @PostMapping("/login/apple")
val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken) fun loginApple(
@RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> {
val errorKey = socialLoginErrorKey(MemberProvider.APPLE)
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.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(
provider: MemberProvider,
token: String,
request: SocialLoginRequest,
nonce: String?
): ApiResponse<LoginResponse> {
val authService = socialAuthServiceResolver.resolve(provider)
val response = authService.authenticate(
token = token,
container = request.container,
marketingPid = request.marketingPid,
pushToken = request.pushToken,
nonce = nonce
)
if (!response.marketingPid.isNullOrBlank()) { if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory( trackingService.saveTrackingHistory(
@@ -403,4 +416,22 @@ class MemberController(
val message = messageSource.getMessage("member.signup.success", langContext.lang) val message = messageSource.getMessage("member.signup.success", langContext.lang)
return ApiResponse.ok(message = message, data = response.loginResponse) return ApiResponse.ok(message = message, data = response.loginResponse)
} }
private fun extractBearerToken(authHeader: String, provider: MemberProvider): String {
val errorKey = socialLoginErrorKey(provider)
if (!authHeader.startsWith("Bearer ")) {
throw SodaException(messageKey = errorKey)
}
return authHeader.substring(7)
}
private fun socialLoginErrorKey(provider: MemberProvider): String {
return when (provider) {
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"
}
}
} }

View File

@@ -20,10 +20,12 @@ import org.springframework.stereotype.Repository
@Repository @Repository
interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository { interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository {
fun findByEmail(email: String): Member? fun findByEmail(email: String?): Member?
fun findByNickname(nickname: String): Member? fun findByNickname(nickname: String): Member?
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 findByLineId(lineId: String): Member?
} }
interface MemberQueryRepository { interface MemberQueryRepository {
@@ -51,7 +53,7 @@ interface MemberQueryRepository {
fun getMessageRecipientPushToken(messageId: Long): PushTokenInfo? fun getMessageRecipientPushToken(messageId: Long): PushTokenInfo?
fun getIndividualRecipientPushTokens(recipients: List<Long>, isAuth: Boolean?): List<PushTokenInfo> fun getIndividualRecipientPushTokens(recipients: List<Long>, isAuth: Boolean?): List<PushTokenInfo>
fun getChangeNicknamePrice(memberId: Long): GetChangeNicknamePriceResponse fun getChangeNicknamePrice(memberId: Long): GetChangeNicknamePriceResponse
fun getMemberByEmail(email: String): Member? fun getMemberByEmail(email: String?): Member?
fun getChangeNoticeRecipientPushTokens(creatorId: Long): List<PushTokenInfo> fun getChangeNoticeRecipientPushTokens(creatorId: Long): List<PushTokenInfo>
fun getPushTokenFromReservationList(roomId: Long): List<PushTokenInfo> fun getPushTokenFromReservationList(roomId: Long): List<PushTokenInfo>
@@ -363,7 +365,8 @@ class MemberQueryRepositoryImpl(
) )
} }
override fun getMemberByEmail(email: String): Member? { override fun getMemberByEmail(email: String?): Member? {
if (email == null) return null
return queryFactory return queryFactory
.selectFrom(member) .selectFrom(member)
.where(member.email.eq(email)) .where(member.email.eq(email))

View File

@@ -37,8 +37,10 @@ import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2
import kr.co.vividnext.sodalive.member.signUp.SignUpResponse import kr.co.vividnext.sodalive.member.signUp.SignUpResponse
import kr.co.vividnext.sodalive.member.signUp.SignUpValidator import kr.co.vividnext.sodalive.member.signUp.SignUpValidator
import kr.co.vividnext.sodalive.member.social.MemberResolveResult 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.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
@@ -346,7 +348,7 @@ class MemberService(
userId = member.id!!, userId = member.id!!,
token = jwt, token = jwt,
nickname = member.nickname, nickname = member.nickname,
email = member.email, email = member.email ?: "",
profileImage = if (member.profileImage != null) { profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}" "$cloudFrontHost/${member.profileImage}"
} else { } else {
@@ -454,8 +456,16 @@ class MemberService(
} }
override fun loadUserByUsername(username: String): UserDetails { override fun loadUserByUsername(username: String): UserDetails {
val member = repository.findByEmail(email = username) val member = if (username.startsWith("member:")) {
?: throw UsernameNotFoundException(username) val id = username.substringAfter("member:").toLongOrNull()
if (id != null) {
repository.findByIdOrNull(id)
} else {
null
}
} else {
repository.findByEmail(email = username)
} ?: throw UsernameNotFoundException(username)
return MemberAdapter(member) return MemberAdapter(member)
} }
@@ -592,7 +602,7 @@ class MemberService(
@Transactional @Transactional
fun signOut(signOutRequest: SignOutRequest, user: User) { fun signOut(signOutRequest: SignOutRequest, user: User) {
val member = repository.findByEmail(user.username) val member = findMemberByUsername(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if ( if (
member.provider == MemberProvider.EMAIL && member.provider == MemberProvider.EMAIL &&
@@ -620,11 +630,7 @@ class MemberService(
@Transactional @Transactional
fun updateNickname(profileUpdateRequest: ProfileUpdateRequest, user: User) { fun updateNickname(profileUpdateRequest: ProfileUpdateRequest, user: User) {
if (profileUpdateRequest.email != user.username) { val member = findMemberByUsername(user.username)
throw SodaException(messageKey = "common.error.bad_credentials")
}
val member = repository.findByEmail(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if (profileUpdateRequest.nickname != null) { if (profileUpdateRequest.nickname != null) {
@@ -652,11 +658,7 @@ class MemberService(
@Transactional @Transactional
fun profileUpdate(profileUpdateRequest: ProfileUpdateRequest, user: User): ProfileResponse { fun profileUpdate(profileUpdateRequest: ProfileUpdateRequest, user: User): ProfileResponse {
if (profileUpdateRequest.email != user.username) { val member = findMemberByUsername(user.username)
throw SodaException(messageKey = "common.error.bad_credentials")
}
val member = repository.findByEmail(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if (profileUpdateRequest.modifyPassword != null) { if (profileUpdateRequest.modifyPassword != null) {
@@ -729,7 +731,7 @@ class MemberService(
@Transactional @Transactional
fun profileImageUpdate(multipartFile: MultipartFile, user: User): String { fun profileImageUpdate(multipartFile: MultipartFile, user: User): String {
val member = repository.findByEmail(user.username) val member = findMemberByUsername(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
val metadata = ObjectMetadata() val metadata = ObjectMetadata()
@@ -791,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
} }
@@ -932,7 +935,138 @@ class MemberService(
return MemberResolveResult(member = member, isNew = true) return MemberResolveResult(member = member, isNew = true)
} }
private fun checkEmail(email: String) { @Transactional
fun findOrRegister(
appleUserInfo: AppleUserInfo,
container: String,
marketingPid: String?,
pushToken: String?
): MemberResolveResult {
val findMember = repository.findByAppleId(appleUserInfo.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 = appleUserInfo.email
checkEmail(email)
val nickname = nicknameGenerateService.generateUniqueNickname()
val member = Member(
appleId = appleUserInfo.sub,
email = email,
password = "",
nickname = nickname,
profileImage = "profile/default-profile.png",
gender = Gender.NONE,
provider = MemberProvider.APPLE,
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)
}
@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()
if (id != null) {
repository.findByIdOrNull(id)
} else {
null
}
} else {
repository.findByEmail(email = username)
}
}
private fun checkEmail(email: String?) {
if (email.isNullOrBlank()) {
return
}
val member = repository.findByEmail(email) val member = repository.findByEmail(email)
if (member != null) { if (member != null) {

View File

@@ -17,7 +17,7 @@ data class ProfileResponse(
) { ) {
constructor(member: Member, cloudFrontHost: String, container: String) : this( constructor(member: Member, cloudFrontHost: String, container: String) : this(
userId = member.id!!, userId = member.id!!,
email = member.email, email = member.email ?: "",
nickname = member.nickname, nickname = member.nickname,
gender = member.gender, gender = member.gender,
profileUrl = if (member.profileImage != null) { profileUrl = if (member.profileImage != null) {

View File

@@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.member package kr.co.vividnext.sodalive.member
data class ProfileUpdateRequest( data class ProfileUpdateRequest(
val email: String, val email: String? = null,
val password: String? = null, val password: String? = null,
val modifyPassword: String? = null, val modifyPassword: String? = null,
val nickname: String? = null, val nickname: String? = null,

View File

@@ -10,5 +10,7 @@ data class LoginRequest(
data class SocialLoginRequest( data class SocialLoginRequest(
val container: String, val container: String,
val pushToken: String? = null, val pushToken: String? = null,
val marketingPid: String? = null val marketingPid: String? = null,
val identityToken: String? = null,
val nonce: String? = null
) )

View File

@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.member.social
import kr.co.vividnext.sodalive.member.MemberProvider
interface SocialAuthService {
fun getProvider(): MemberProvider
fun authenticate(
token: String,
container: String,
marketingPid: String?,
pushToken: String?,
nonce: String?
): SocialLoginResponse
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.member.social
import kr.co.vividnext.sodalive.member.MemberProvider
import org.springframework.stereotype.Component
@Component
class SocialAuthServiceResolver(
val services: List<SocialAuthService>
) {
private val serviceMap: Map<MemberProvider, SocialAuthService> = services.associateBy { it.getProvider() }
fun resolve(provider: MemberProvider): SocialAuthService {
return serviceMap[provider] ?: throw IllegalArgumentException("Unsupported social provider: $provider")
}
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.member.social.apple
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
@Service
class AppleAuthService(
private val appleIdentityTokenVerifier: AppleIdentityTokenVerifier,
private val memberService: MemberService,
private val tokenProvider: TokenProvider,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : SocialAuthService {
override fun getProvider(): MemberProvider = MemberProvider.APPLE
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.apple_login_failed")
val appleUserInfo = appleIdentityTokenVerifier.verify(token, rawNonce)
val memberResolveResult = memberService.findOrRegister(appleUserInfo, container, marketingPid, pushToken)
val member = memberResolveResult.member
val principal = MemberAdapter(member)
val authToken = AppleAuthenticationToken(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
)
}
}

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.member.social.apple
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
class AppleAuthenticationToken(
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,93 @@
package kr.co.vividnext.sodalive.member.social.apple
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.jwk.source.JWKSource
import com.nimbusds.jose.jwk.source.JWKSourceBuilder
import com.nimbusds.jose.proc.JWSVerificationKeySelector
import com.nimbusds.jose.proc.SecurityContext
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.net.URL
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Base64
import java.util.Date
@Service
class AppleIdentityTokenVerifier(
@Value("\${apple.bundle-id}")
private val bundleId: String
) {
private val jwkUrl = URL("https://appleid.apple.com/auth/keys")
private val jwkSource: JWKSource<SecurityContext> = JWKSourceBuilder.create<SecurityContext>(jwkUrl)
.build()
private val jwtProcessor: ConfigurableJWTProcessor<SecurityContext> =
DefaultJWTProcessor<SecurityContext>().apply {
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, jwkSource)
}
fun verify(identityToken: String, rawNonce: String): AppleUserInfo {
if (bundleId.isBlank()) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
if (rawNonce.isBlank()) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val claims = try {
jwtProcessor.process(identityToken, null)
} catch (_: Exception) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
validateClaims(claims, rawNonce)
return AppleUserInfo(
sub = claims.subject ?: throw SodaException(messageKey = "member.social.apple_login_failed"),
email = claims.getStringClaim("email")
)
}
private fun validateClaims(claims: JWTClaimsSet, rawNonce: String) {
if (claims.issuer != ISSUER) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
if (!claims.audience.contains(bundleId)) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val now = Date()
val expirationTime = claims.expirationTime ?: throw SodaException(messageKey = "member.social.apple_login_failed")
if (expirationTime.before(now)) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val issuedAt = claims.issueTime ?: throw SodaException(messageKey = "member.social.apple_login_failed")
if (issuedAt.after(now)) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
val nonce = claims.getStringClaim("nonce") ?: throw SodaException(messageKey = "member.social.apple_login_failed")
val expectedNonce = hashNonce(rawNonce)
if (nonce != expectedNonce) {
throw SodaException(messageKey = "member.social.apple_login_failed")
}
}
private fun hashNonce(rawNonce: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashed = digest.digest(rawNonce.toByteArray(StandardCharsets.UTF_8))
return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed)
}
companion object {
private const val ISSUER = "https://appleid.apple.com"
}
}

View File

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

View File

@@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.member.social.google
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter 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.MemberService
import kr.co.vividnext.sodalive.member.login.LoginResponse 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 kr.co.vividnext.sodalive.member.social.SocialLoginResponse
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@@ -18,19 +20,22 @@ class GoogleAuthService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) : SocialAuthService {
fun authenticate( override fun getProvider(): MemberProvider = MemberProvider.GOOGLE
idToken: String,
override fun authenticate(
token: String,
container: String, container: String,
marketingPid: String?, marketingPid: String?,
pushToken: String? pushToken: String?,
nonce: String?
): SocialLoginResponse { ): SocialLoginResponse {
val googleUserInfo = googleService.getUserInfo(idToken) val googleUserInfo = googleService.getUserInfo(token)
?: throw SodaException(messageKey = "member.social.google_login_failed") ?: throw SodaException(messageKey = "member.social.google_login_failed")
val memberResolveResult = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken) val memberResolveResult = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken)
val member = memberResolveResult.member val member = memberResolveResult.member
val principal = MemberAdapter(member) val principal = MemberAdapter(member)
val authToken = GoogleAuthenticationToken(idToken, principal.authorities) val authToken = GoogleAuthenticationToken(token, principal.authorities)
authToken.setPrincipal(principal) authToken.setPrincipal(principal)
SecurityContextHolder.getContext().authentication = authToken SecurityContextHolder.getContext().authentication = authToken
@@ -43,7 +48,7 @@ class GoogleAuthService(
userId = member.id!!, userId = member.id!!,
token = jwt, token = jwt,
nickname = member.nickname, nickname = member.nickname,
email = member.email, email = member.email ?: "",
profileImage = if (member.profileImage != null) { profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}" "$cloudFrontHost/${member.profileImage}"
} else { } else {

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.member.social.google
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
import com.google.api.client.json.gson.GsonFactory import com.google.api.client.json.gson.GsonFactory
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -27,7 +26,7 @@ class GoogleService(
if (token != null) { if (token != null) {
val payload = token.payload val payload = token.payload
val email = payload.email ?: throw SodaException(messageKey = "member.social.email_consent_required") val email = payload.email
GoogleUserInfo( GoogleUserInfo(
sub = payload.subject, sub = payload.subject,

View File

@@ -2,6 +2,6 @@ package kr.co.vividnext.sodalive.member.social.google
data class GoogleUserInfo( data class GoogleUserInfo(
val sub: String, val sub: String,
val email: String, val email: String?,
val name: String? val name: String?
) )

View File

@@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.member.social.kakao
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter 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.MemberService
import kr.co.vividnext.sodalive.member.login.LoginResponse 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 kr.co.vividnext.sodalive.member.social.SocialLoginResponse
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@@ -18,19 +20,22 @@ class KakaoAuthService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) : SocialAuthService {
fun authenticate( override fun getProvider(): MemberProvider = MemberProvider.KAKAO
accessToken: String,
override fun authenticate(
token: String,
container: String, container: String,
marketingPid: String?, marketingPid: String?,
pushToken: String? pushToken: String?,
nonce: String?
): SocialLoginResponse { ): SocialLoginResponse {
val kakaoUserInfo = kakaoService.getUserInfo(accessToken) val kakaoUserInfo = kakaoService.getUserInfo(token)
?: throw SodaException(messageKey = "member.social.kakao_login_failed") ?: throw SodaException(messageKey = "member.social.kakao_login_failed")
val memberResolveResult = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken) val memberResolveResult = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken)
val member = memberResolveResult.member val member = memberResolveResult.member
val principal = MemberAdapter(member) val principal = MemberAdapter(member)
val authToken = KakaoAuthenticationToken(accessToken, principal.authorities) val authToken = KakaoAuthenticationToken(token, principal.authorities)
authToken.setPrincipal(principal) authToken.setPrincipal(principal)
SecurityContextHolder.getContext().authentication = authToken SecurityContextHolder.getContext().authentication = authToken
@@ -43,7 +48,7 @@ class KakaoAuthService(
userId = member.id!!, userId = member.id!!,
token = jwt, token = jwt,
nickname = member.nickname, nickname = member.nickname,
email = member.email, email = member.email ?: "",
profileImage = if (member.profileImage != null) { profileImage = if (member.profileImage != null) {
"$cloudFrontHost/${member.profileImage}" "$cloudFrontHost/${member.profileImage}"
} else { } else {

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.member.social.kakao package kr.co.vividnext.sodalive.member.social.kakao
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
@@ -37,7 +36,6 @@ class KakaoService(
val id = jsonNode.get("id").asLong() val id = jsonNode.get("id").asLong()
val kakaoAccount = jsonNode.get("kakao_account") val kakaoAccount = jsonNode.get("kakao_account")
val email = kakaoAccount?.get("email")?.asText() val email = kakaoAccount?.get("email")?.asText()
?: throw SodaException(messageKey = "member.social.kakao_login_failed")
val properties = jsonNode.get("properties") val properties = jsonNode.get("properties")
val nickname = properties?.get("nickname")?.asText() val nickname = properties?.get("nickname")?.asText()

View File

@@ -2,6 +2,6 @@ package kr.co.vividnext.sodalive.member.social.kakao
data class KakaoUserInfo( data class KakaoUserInfo(
val id: Long, val id: Long,
val email: String, val email: String?,
val nickname: String? val nickname: String?
) )

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

@@ -1,9 +1,10 @@
package kr.co.vividnext.sodalive.menu package kr.co.vividnext.sodalive.menu
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -13,5 +14,13 @@ import org.springframework.web.bind.annotation.RestController
class MenuController(private val service: MenuService) { class MenuController(private val service: MenuService) {
@GetMapping @GetMapping
@PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR')") @PreAuthorize("hasAnyRole('AGENT', 'ADMIN', 'CREATOR')")
fun getMenus(@AuthenticationPrincipal user: User) = ApiResponse.ok(service.getMenus(user)) fun getMenus(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) {
throw SodaException(messageKey = "common.error.bad_credentials")
}
ApiResponse.ok(service.getMenus(member))
}
} }

View File

@@ -1,18 +1,13 @@
package kr.co.vividnext.sodalive.menu package kr.co.vividnext.sodalive.menu
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
class MenuService( class MenuService(
private val repository: MenuRepository, private val repository: MenuRepository
private val memberRepository: MemberRepository
) { ) {
fun getMenus(user: User): List<GetMenuResponse> { fun getMenus(member: Member): List<GetMenuResponse> {
val member = memberRepository.findByEmail(user.username)
?: throw SodaException(messageKey = "common.error.bad_credentials")
return repository.getMenu(member.role) return repository.getMenu(member.role)
} }
} }

View File

@@ -33,6 +33,10 @@ bootpay:
apple: apple:
iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt
iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt
bundleId: ${APPLE_BUNDLE_ID}
line:
channelId: ${LINE_CHANNEL_ID}
agora: agora:
appId: ${AGORA_APP_ID} appId: ${AGORA_APP_ID}