diff --git a/build.gradle.kts b/build.gradle.kts index 6397fe8b..37cdf45a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + implementation("com.nimbusds:nimbus-jose-jwt:9.37.3") + // querydsl (추가 설정) implementation("com.querydsl:querydsl-jpa:$querydslVersion") kapt("com.querydsl:querydsl-apt:$querydslVersion:jpa") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index 5e5b79b2..f04c6e7f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -148,6 +148,7 @@ class AdminChatCharacterController( runCatching { CharacterType.valueOf(it) } .getOrDefault(CharacterType.Character) } ?: CharacterType.Character, + region = request.region, tags = request.tags, values = request.values, hobbies = request.hobbies, @@ -203,6 +204,7 @@ class AdminChatCharacterController( body["name"] = request.name body["systemPrompt"] = request.systemPrompt body["description"] = request.description + body["region"] = request.region request.age?.let { body["age"] = it } request.gender?.let { body["gender"] = it } request.mbti?.let { body["mbti"] = it } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt index 32338e66..934ff252 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt @@ -20,6 +20,7 @@ data class ChatCharacterDetailResponse( val speechPattern: String?, val speechStyle: String?, val appearance: String?, + val region: String, val isActive: Boolean, val tags: List, val hobbies: List, @@ -67,6 +68,7 @@ data class ChatCharacterDetailResponse( speechPattern = chatCharacter.speechPattern, speechStyle = chatCharacter.speechStyle, appearance = chatCharacter.appearance, + region = chatCharacter.region, isActive = chatCharacter.isActive, tags = chatCharacter.tagMappings.map { it.tag.tag }, hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby }, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt index ca203ebd..dc1a4ef9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt @@ -38,6 +38,7 @@ data class ChatCharacterRegisterRequest( @JsonProperty("speechPattern") val speechPattern: String?, @JsonProperty("speechStyle") val speechStyle: String?, @JsonProperty("appearance") val appearance: String?, + @JsonProperty("region") val region: String = "KR", @JsonProperty("originalTitle") val originalTitle: String? = null, @JsonProperty("originalLink") val originalLink: String? = null, @JsonProperty("originalWorkId") val originalWorkId: Long? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt index bf0977c7..6bad1303 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt @@ -14,6 +14,7 @@ data class ChatCharacterListResponse( val mbti: String?, val speechStyle: String?, val speechPattern: String?, + val region: String, val tags: List, val createdAt: String?, val updatedAt: String? @@ -48,6 +49,7 @@ data class ChatCharacterListResponse( mbti = chatCharacter.mbti, speechStyle = chatCharacter.speechStyle, speechPattern = chatCharacter.speechPattern, + region = chatCharacter.region, tags = chatCharacter.tagMappings.map { it.tag.tag }, createdAt = createdAtStr, updatedAt = updatedAtStr diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt index 29e59710..6b6334be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -108,6 +108,7 @@ class AdminMemberService( MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty() MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty() MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", langContext.lang).orEmpty() + MemberProvider.LINE -> messageSource.getMessage("member.provider.line", langContext.lang).orEmpty() } val signUpDate = it.createdAt!! @@ -126,7 +127,7 @@ class AdminMemberService( GetAdminMemberListResponseItem( id = it.id!!, - email = it.email, + email = it.email ?: "", nickname = it.nickname, profileUrl = if (it.profileImage != null) { "$cloudFrontHost/${it.profileImage}" @@ -160,6 +161,7 @@ class AdminMemberService( val member = repository.findByIdAndActive(memberId = request.memberId) ?: 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]) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 11a3fe78..b5476b55 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -106,7 +106,8 @@ class HomeService( val latestContentThemeList = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, - contentType = contentType + contentType = contentType, + excludeThemes = listOf("다시듣기") ) val latestContentList = contentService.getLatestContentByTheme( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt index b1943d43..7f0ab270 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt @@ -5,7 +5,6 @@ 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.core.annotation.AuthenticationPrincipal -import org.springframework.security.core.userdetails.User import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -29,6 +28,12 @@ class ChargeTempController(private val service: ChargeTempService) { @PostMapping("/verify") fun verify( @RequestBody request: VerifyRequest, - @AuthenticationPrincipal user: User - ) = ApiResponse.ok(service.verify(user, request)) + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) { + throw SodaException(messageKey = "common.error.bad_credentials") + } + + ApiResponse.ok(service.verify(member, request)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt index 2eaa248a..4a206375 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt @@ -15,10 +15,8 @@ import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member -import kr.co.vividnext.sodalive.member.MemberRepository import org.springframework.beans.factory.annotation.Value import org.springframework.data.repository.findByIdOrNull -import org.springframework.security.core.userdetails.User import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,7 +24,6 @@ import org.springframework.transaction.annotation.Transactional @Transactional(readOnly = true) class ChargeTempService( private val chargeRepository: ChargeRepository, - private val memberRepository: MemberRepository, private val objectMapper: ObjectMapper, private val messageSource: SodaMessageSource, @@ -54,11 +51,9 @@ class ChargeTempService( } @Transactional - fun verify(user: User, verifyRequest: VerifyRequest) { + fun verify(member: Member, verifyRequest: VerifyRequest) { val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) ?: 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) { val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt index 5a628513..87369943 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt @@ -67,6 +67,10 @@ class ChatCharacter( @Column(nullable = false) var characterType: CharacterType = CharacterType.Character, + // 리전 (기본값 KR, 수정 불가) + @Column(nullable = false) + val region: String = "KR", + var isActive: Boolean = true ) : BaseEntity() { var imagePath: String? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 60974827..47ac7565 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -582,6 +582,7 @@ class ChatCharacterService( originalTitle: String? = null, originalLink: String? = null, characterType: CharacterType = CharacterType.Character, + region: String = "KR", tags: List = emptyList(), values: List = emptyList(), hobbies: List = emptyList(), @@ -600,7 +601,8 @@ class ChatCharacterService( appearance = appearance, originalTitle = originalTitle, originalLink = originalLink, - characterType = characterType + characterType = characterType, + region = region ) // 관련 엔티티 연결 @@ -630,6 +632,7 @@ class ChatCharacterService( originalTitle: String? = null, originalLink: String? = null, characterType: CharacterType = CharacterType.Character, + region: String = "KR", tags: List = emptyList(), values: List = emptyList(), hobbies: List = emptyList(), @@ -653,6 +656,7 @@ class ChatCharacterService( originalTitle = originalTitle, originalLink = originalLink, characterType = characterType, + region = region, tags = tags, values = values, hobbies = hobbies, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt index 443e58cc..ecb706c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt @@ -1,5 +1,6 @@ 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.SodaMessageSource import org.slf4j.LoggerFactory @@ -20,10 +21,14 @@ class SodaExceptionHandler( private val messageSource: SodaMessageSource ) { private val logger = LoggerFactory.getLogger(this::class.java) + private val logLang = Lang.KO @ExceptionHandler(SodaException::class) 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) } ?: e.message?.takeIf { it.isNotBlank() } ?: messageSource.getMessage("common.error.unknown", langContext.lang) @@ -35,35 +40,40 @@ class SodaExceptionHandler( @ExceptionHandler(MaxUploadSizeExceededException::class) 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) ApiResponse.error(message = message) } @ExceptionHandler(AccessDeniedException::class) 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) ApiResponse.error(message = message) } @ExceptionHandler(InternalAuthenticationServiceException::class) 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) ApiResponse.error(message) } @ExceptionHandler(BadCredentialsException::class) 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) ApiResponse.error(message) } @ExceptionHandler(DataIntegrityViolationException::class) 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) ApiResponse.error(message) } @@ -71,7 +81,10 @@ class SodaExceptionHandler( @ResponseStatus(value = HttpStatus.NOT_FOUND) @ExceptionHandler(AdsChargeException::class) 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) } ?: e.message?.takeIf { it.isNotBlank() } ?: messageSource.getMessage("common.error.invalid_request", langContext.lang) @@ -81,7 +94,8 @@ class SodaExceptionHandler( @ExceptionHandler(Exception::class) fun handleException(e: Exception) = run { 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) ApiResponse.error(message) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index d143d4d0..3b847923 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -72,6 +72,8 @@ class SecurityConfig( .antMatchers("/member/login").permitAll() .antMatchers("/member/login/google").permitAll() .antMatchers("/member/login/kakao").permitAll() + .antMatchers("/member/login/apple").permitAll() + .antMatchers("/member/login/line").permitAll() .antMatchers("/creator-admin/member/login").permitAll() .antMatchers("/member/forgot-password").permitAll() .antMatchers("/stplat/terms_of_service").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index 0301f86e..f34e2b9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -34,15 +34,20 @@ class AudioContentThemeService( isAdult: Boolean = false, isFree: Boolean = false, isPointAvailableOnly: Boolean = false, - contentType: ContentType + contentType: ContentType, + excludeThemes: List = emptyList() ): List { - val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent( + var themesWithIds = queryRepository.getActiveThemeWithIdsOfContent( isAdult = isAdult, isFree = isFree, isPointAvailableOnly = isPointAvailableOnly, contentType = contentType ) + if (excludeThemes.isNotEmpty()) { + themesWithIds = themesWithIds.filter { it.theme !in excludeThemes } + } + /** * langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환 * 번역이 없으면 번역 API 호출 후 저장하고 반환 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt index 9bfd9b76..d46f1dd5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt @@ -87,7 +87,7 @@ class CreatorAdminMemberService( userId = member.id!!, token = jwt, nickname = member.nickname, - email = member.email, + email = member.email ?: "", profileImage = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 4d72b4f2..a95615d7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1280,6 +1280,16 @@ class SodaMessageSource { Lang.EN to "Kakao login failed. Please try again.", 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( Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.", Lang.EN to "You must agree to provide your email to use the service.", @@ -1628,6 +1638,11 @@ class SodaMessageSource { Lang.KO to "애플", Lang.EN to "Apple", Lang.JA to "Apple" + ), + "member.provider.line" to mapOf( + Lang.KO to "라인", + Lang.EN to "LINE", + Lang.JA to "LINE" ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt index a30b31c6..0a588b94 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt @@ -19,7 +19,7 @@ import javax.persistence.OneToOne @Entity data class Member( - val email: String, + var email: String? = null, var password: String, var nickname: String, var profileImage: String? = null, @@ -27,6 +27,7 @@ data class Member( val kakaoId: Long? = null, val googleId: String? = null, val appleId: String? = null, + val lineId: String? = null, @Enumerated(EnumType.STRING) val provider: MemberProvider = MemberProvider.EMAIL, @@ -158,5 +159,5 @@ enum class MemberRole { } enum class MemberProvider { - EMAIL, KAKAO, GOOGLE, APPLE + EMAIL, KAKAO, GOOGLE, APPLE, LINE } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt index ce42b377..c81e1c20 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt @@ -4,7 +4,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.User class MemberAdapter(val member: Member) : User( - member.email, + member.email ?: "member:${member.id}", member.password, listOf(SimpleGrantedAuthority("ROLE_${member.role.name}")) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 3bc333ae..e5d9368f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -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.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.member.signUp.SignUpRequestV2 -import kr.co.vividnext.sodalive.member.social.google.GoogleAuthService -import kr.co.vividnext.sodalive.member.social.kakao.KakaoAuthService +import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.UserActionService import org.springframework.data.domain.Pageable @@ -36,8 +35,7 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/member") class MemberController( private val service: MemberService, - private val kakaoAuthService: KakaoAuthService, - private val googleAuthService: GoogleAuthService, + private val socialAuthServiceResolver: SocialAuthServiceResolver, private val trackingService: AdTrackingService, private val userActionService: UserActionService, private val messageSource: SodaMessageSource, @@ -345,31 +343,8 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { - if (!authHeader.startsWith("Bearer ")) { - throw SodaException(messageKey = "member.social.google_login_failed") - } - - 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) + val token = extractBearerToken(authHeader, MemberProvider.GOOGLE) + return processSocialLogin(MemberProvider.GOOGLE, token, request, null) } @PostMapping("/login/kakao") @@ -377,12 +352,50 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { - if (!authHeader.startsWith("Bearer ")) { - throw SodaException(messageKey = "member.social.kakao_login_failed") - } + val token = extractBearerToken(authHeader, MemberProvider.KAKAO) + return processSocialLogin(MemberProvider.KAKAO, token, request, null) + } - val token = authHeader.substring(7) - val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken) + @PostMapping("/login/apple") + fun loginApple( + @RequestBody request: SocialLoginRequest + ): ApiResponse { + 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 { + 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 { + 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()) { trackingService.saveTrackingHistory( @@ -403,4 +416,22 @@ class MemberController( val message = messageSource.getMessage("member.signup.success", langContext.lang) 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" + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index 9487cdf2..7b09a29e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -20,10 +20,12 @@ import org.springframework.stereotype.Repository @Repository interface MemberRepository : JpaRepository, MemberQueryRepository { - fun findByEmail(email: String): Member? + fun findByEmail(email: String?): Member? fun findByNickname(nickname: String): Member? fun findByGoogleId(googleId: String): Member? fun findByKakaoId(kakaoId: Long): Member? + fun findByAppleId(appleId: String): Member? + fun findByLineId(lineId: String): Member? } interface MemberQueryRepository { @@ -51,7 +53,7 @@ interface MemberQueryRepository { fun getMessageRecipientPushToken(messageId: Long): PushTokenInfo? fun getIndividualRecipientPushTokens(recipients: List, isAuth: Boolean?): List fun getChangeNicknamePrice(memberId: Long): GetChangeNicknamePriceResponse - fun getMemberByEmail(email: String): Member? + fun getMemberByEmail(email: String?): Member? fun getChangeNoticeRecipientPushTokens(creatorId: Long): List fun getPushTokenFromReservationList(roomId: Long): List @@ -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 .selectFrom(member) .where(member.email.eq(email)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 5d053cc1..02684ef2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -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.SignUpValidator import kr.co.vividnext.sodalive.member.social.MemberResolveResult +import kr.co.vividnext.sodalive.member.social.apple.AppleUserInfo import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo import kr.co.vividnext.sodalive.member.social.kakao.KakaoUserInfo +import kr.co.vividnext.sodalive.member.social.line.LineUserInfo import kr.co.vividnext.sodalive.member.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.StipulationAgree import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository @@ -346,7 +348,7 @@ class MemberService( userId = member.id!!, token = jwt, nickname = member.nickname, - email = member.email, + email = member.email ?: "", profileImage = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { @@ -454,8 +456,16 @@ class MemberService( } override fun loadUserByUsername(username: String): UserDetails { - val member = repository.findByEmail(email = username) - ?: throw UsernameNotFoundException(username) + val member = if (username.startsWith("member:")) { + 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) } @@ -592,7 +602,7 @@ class MemberService( @Transactional fun signOut(signOutRequest: SignOutRequest, user: User) { - val member = repository.findByEmail(user.username) + val member = findMemberByUsername(user.username) ?: throw SodaException(messageKey = "common.error.bad_credentials") if ( member.provider == MemberProvider.EMAIL && @@ -620,11 +630,7 @@ class MemberService( @Transactional fun updateNickname(profileUpdateRequest: ProfileUpdateRequest, user: User) { - if (profileUpdateRequest.email != user.username) { - throw SodaException(messageKey = "common.error.bad_credentials") - } - - val member = repository.findByEmail(user.username) + val member = findMemberByUsername(user.username) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (profileUpdateRequest.nickname != null) { @@ -652,11 +658,7 @@ class MemberService( @Transactional fun profileUpdate(profileUpdateRequest: ProfileUpdateRequest, user: User): ProfileResponse { - if (profileUpdateRequest.email != user.username) { - throw SodaException(messageKey = "common.error.bad_credentials") - } - - val member = repository.findByEmail(user.username) + val member = findMemberByUsername(user.username) ?: throw SodaException(messageKey = "common.error.bad_credentials") if (profileUpdateRequest.modifyPassword != null) { @@ -729,7 +731,7 @@ class MemberService( @Transactional 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") val metadata = ObjectMetadata() @@ -791,6 +793,7 @@ class MemberService( MemberProvider.KAKAO -> "member.provider.kakao" MemberProvider.GOOGLE -> "member.provider.google" MemberProvider.APPLE -> "member.provider.apple" + MemberProvider.LINE -> "member.provider.line" } return messageSource.getMessage(key, langContext.lang) ?: provider.name } @@ -932,7 +935,138 @@ class MemberService( 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) if (member != null) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt index 17ad822c..12475347 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileResponse.kt @@ -17,7 +17,7 @@ data class ProfileResponse( ) { constructor(member: Member, cloudFrontHost: String, container: String) : this( userId = member.id!!, - email = member.email, + email = member.email ?: "", nickname = member.nickname, gender = member.gender, profileUrl = if (member.profileImage != null) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt index aeaac650..806f00d8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/ProfileUpdateRequest.kt @@ -1,7 +1,7 @@ package kr.co.vividnext.sodalive.member data class ProfileUpdateRequest( - val email: String, + val email: String? = null, val password: String? = null, val modifyPassword: String? = null, val nickname: String? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt index 0895b915..a20b6992 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/login/LoginRequest.kt @@ -10,5 +10,7 @@ data class LoginRequest( data class SocialLoginRequest( val container: String, val pushToken: String? = null, - val marketingPid: String? = null + val marketingPid: String? = null, + val identityToken: String? = null, + val nonce: String? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt new file mode 100644 index 00000000..d0501ca0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthServiceResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthServiceResolver.kt new file mode 100644 index 00000000..f89529fe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthServiceResolver.kt @@ -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 +) { + private val serviceMap: Map = services.associateBy { it.getProvider() } + + fun resolve(provider: MemberProvider): SocialAuthService { + return serviceMap[provider] ?: throw IllegalArgumentException("Unsupported social provider: $provider") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthService.kt new file mode 100644 index 00000000..259f1599 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthService.kt @@ -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 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthenticationToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthenticationToken.kt new file mode 100644 index 00000000..f63f17b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleAuthenticationToken.kt @@ -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? = null +) : AbstractAuthenticationToken(authorities) { + private var principal: Any? = null + + init { + isAuthenticated = authorities != null + } + + override fun getCredentials(): Any = idToken + + override fun getPrincipal(): Any? = principal + + fun setPrincipal(principal: Any) { + this.principal = principal + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt new file mode 100644 index 00000000..a0df3d66 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleIdentityTokenVerifier.kt @@ -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 = JWKSourceBuilder.create(jwkUrl) + .build() + + private val jwtProcessor: ConfigurableJWTProcessor = + DefaultJWTProcessor().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" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleUserInfo.kt new file mode 100644 index 00000000..68e03439 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/apple/AppleUserInfo.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member.social.apple + +data class AppleUserInfo( + val sub: String, + val email: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt index 99530a6a..7455b70d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt @@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.member.social.google 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 @@ -18,19 +20,22 @@ class GoogleAuthService( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String -) { - fun authenticate( - idToken: String, +) : SocialAuthService { + override fun getProvider(): MemberProvider = MemberProvider.GOOGLE + + override fun authenticate( + token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse { - val googleUserInfo = googleService.getUserInfo(idToken) + val googleUserInfo = googleService.getUserInfo(token) ?: throw SodaException(messageKey = "member.social.google_login_failed") val memberResolveResult = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken) val member = memberResolveResult.member val principal = MemberAdapter(member) - val authToken = GoogleAuthenticationToken(idToken, principal.authorities) + val authToken = GoogleAuthenticationToken(token, principal.authorities) authToken.setPrincipal(principal) SecurityContextHolder.getContext().authentication = authToken @@ -43,7 +48,7 @@ class GoogleAuthService( userId = member.id!!, token = jwt, nickname = member.nickname, - email = member.email, + email = member.email ?: "", profileImage = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt index e6ac4088..c1f3e4c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt @@ -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.javanet.GoogleNetHttpTransport 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.stereotype.Service @@ -27,7 +26,7 @@ class GoogleService( if (token != null) { val payload = token.payload - val email = payload.email ?: throw SodaException(messageKey = "member.social.email_consent_required") + val email = payload.email GoogleUserInfo( sub = payload.subject, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt index e54db6ad..2d98ebca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleUserInfo.kt @@ -2,6 +2,6 @@ package kr.co.vividnext.sodalive.member.social.google data class GoogleUserInfo( val sub: String, - val email: String, + val email: String?, val name: String? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt index ed4cf0c5..6641d07d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt @@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.member.social.kakao 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 @@ -18,19 +20,22 @@ class KakaoAuthService( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String -) { - fun authenticate( - accessToken: String, +) : SocialAuthService { + override fun getProvider(): MemberProvider = MemberProvider.KAKAO + + override fun authenticate( + token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse { - val kakaoUserInfo = kakaoService.getUserInfo(accessToken) + val kakaoUserInfo = kakaoService.getUserInfo(token) ?: throw SodaException(messageKey = "member.social.kakao_login_failed") val memberResolveResult = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken) val member = memberResolveResult.member val principal = MemberAdapter(member) - val authToken = KakaoAuthenticationToken(accessToken, principal.authorities) + val authToken = KakaoAuthenticationToken(token, principal.authorities) authToken.setPrincipal(principal) SecurityContextHolder.getContext().authentication = authToken @@ -43,7 +48,7 @@ class KakaoAuthService( userId = member.id!!, token = jwt, nickname = member.nickname, - email = member.email, + email = member.email ?: "", profileImage = if (member.profileImage != null) { "$cloudFrontHost/${member.profileImage}" } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt index 8ad65c56..13d89f07 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.member.social.kakao import com.fasterxml.jackson.databind.ObjectMapper -import kr.co.vividnext.sodalive.common.SodaException import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod @@ -37,7 +36,6 @@ class KakaoService( val id = jsonNode.get("id").asLong() val kakaoAccount = jsonNode.get("kakao_account") val email = kakaoAccount?.get("email")?.asText() - ?: throw SodaException(messageKey = "member.social.kakao_login_failed") val properties = jsonNode.get("properties") val nickname = properties?.get("nickname")?.asText() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt index 9ddcc199..9c6596ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoUserInfo.kt @@ -2,6 +2,6 @@ package kr.co.vividnext.sodalive.member.social.kakao data class KakaoUserInfo( val id: Long, - val email: String, + val email: String?, val nickname: String? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt new file mode 100644 index 00000000..10851acc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthService.kt @@ -0,0 +1,112 @@ +package kr.co.vividnext.sodalive.member.social.line + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberProvider +import kr.co.vividnext.sodalive.member.MemberService +import kr.co.vividnext.sodalive.member.login.LoginResponse +import kr.co.vividnext.sodalive.member.social.SocialAuthService +import kr.co.vividnext.sodalive.member.social.SocialLoginResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class LineAuthService( + private val lineService: LineService, + private val memberService: MemberService, + private val tokenProvider: TokenProvider, + + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String, + + @Value("\${line.channel-id}") + private val lineChannelId: String +) : SocialAuthService { + override fun getProvider(): MemberProvider = MemberProvider.LINE + + override fun authenticate( + token: String, + container: String, + marketingPid: String?, + pushToken: String?, + nonce: String? + ): SocialLoginResponse { + val rawNonce = nonce?.takeIf { it.isNotBlank() } + ?: throw SodaException(messageKey = "member.social.line_login_failed") + + if (lineChannelId.isBlank()) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + val verifyResponse = lineService.verifyIdToken(token, lineChannelId, rawNonce) + ?: throw SodaException(messageKey = "member.social.line_login_failed") + + validateVerifyResponse(verifyResponse, lineChannelId, rawNonce) + + val lineUserInfo = LineUserInfo( + sub = verifyResponse.sub, + email = verifyResponse.email + ) + + val memberResolveResult = memberService.findOrRegister(lineUserInfo, container, marketingPid, pushToken) + val member = memberResolveResult.member + val principal = MemberAdapter(member) + val authToken = LineAuthenticationToken(token, principal.authorities) + authToken.setPrincipal(principal) + SecurityContextHolder.getContext().authentication = authToken + + val jwt = tokenProvider.createToken( + authentication = authToken, + memberId = member.id!! + ) + + val loginResponse = LoginResponse( + userId = member.id!!, + token = jwt, + nickname = member.nickname, + email = member.email ?: "", + profileImage = if (member.profileImage != null) { + "$cloudFrontHost/${member.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + } + ) + + return SocialLoginResponse( + memberId = member.id!!, + marketingPid = marketingPid, + loginResponse = loginResponse, + isNew = memberResolveResult.isNew + ) + } + + private fun validateVerifyResponse(response: LineVerifyResponse, clientId: String, nonce: String) { + if (response.iss != ISSUER) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + if (response.aud != clientId) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + val now = Instant.now().epochSecond + if (response.exp <= now) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + if (response.iat > now) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + + if (response.nonce != null && response.nonce != nonce) { + throw SodaException(messageKey = "member.social.line_login_failed") + } + } + + companion object { + private const val ISSUER = "https://access.line.me" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt new file mode 100644 index 00000000..d9dedac3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineAuthenticationToken.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.member.social.line + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority + +class LineAuthenticationToken( + private val idToken: String, + authorities: Collection? = null +) : AbstractAuthenticationToken(authorities) { + private var principal: Any? = null + + init { + isAuthenticated = authorities != null + } + + override fun getCredentials(): Any = idToken + + override fun getPrincipal(): Any? = principal + + fun setPrincipal(principal: Any) { + this.principal = principal + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt new file mode 100644 index 00000000..cfb87ae3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineService.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.member.social.line + +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestTemplate + +@Service +class LineService( + private val restTemplate: RestTemplate = RestTemplate() +) { + fun verifyIdToken(idToken: String, clientId: String, nonce: String?): LineVerifyResponse? { + val url = "https://api.line.me/oauth2/v2.1/verify" + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_FORM_URLENCODED + } + + val body = LinkedMultiValueMap().apply { + add("id_token", idToken) + add("client_id", clientId) + if (!nonce.isNullOrBlank()) { + add("nonce", nonce) + } + } + + val entity = HttpEntity(body, headers) + + return try { + val response: ResponseEntity = restTemplate.postForEntity( + url, + entity, + LineVerifyResponse::class.java + ) + + if (response.statusCode.is2xxSuccessful) { + response.body + } else { + null + } + } catch (ex: Exception) { + ex.printStackTrace() + null + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt new file mode 100644 index 00000000..80d29507 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineUserInfo.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.member.social.line + +data class LineUserInfo( + val sub: String, + val email: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt new file mode 100644 index 00000000..9de18aa2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/line/LineVerifyResponse.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.member.social.line + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LineVerifyResponse( + val iss: String, + val sub: String, + val aud: String, + val exp: Long, + val iat: Long, + val nonce: String? = null, + val name: String? = null, + val picture: String? = null, + val email: String? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt index d9b8e7b8..2e768433 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuController.kt @@ -1,9 +1,10 @@ package kr.co.vividnext.sodalive.menu 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.core.annotation.AuthenticationPrincipal -import org.springframework.security.core.userdetails.User import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -13,5 +14,13 @@ import org.springframework.web.bind.annotation.RestController class MenuController(private val service: MenuService) { @GetMapping @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)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt index 3ebd9d39..957b7bb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt @@ -1,18 +1,13 @@ package kr.co.vividnext.sodalive.menu -import kr.co.vividnext.sodalive.common.SodaException -import kr.co.vividnext.sodalive.member.MemberRepository -import org.springframework.security.core.userdetails.User +import kr.co.vividnext.sodalive.member.Member import org.springframework.stereotype.Service @Service class MenuService( - private val repository: MenuRepository, - private val memberRepository: MemberRepository + private val repository: MenuRepository ) { - fun getMenus(user: User): List { - val member = memberRepository.findByEmail(user.username) - ?: throw SodaException(messageKey = "common.error.bad_credentials") + fun getMenus(member: Member): List { return repository.getMenu(member.role) } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa3ad691..378e5137 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,10 @@ bootpay: apple: iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt + bundleId: ${APPLE_BUNDLE_ID} + +line: + channelId: ${LINE_CHANNEL_ID} agora: appId: ${AGORA_APP_ID}