test #383

Merged
klaus merged 7 commits from test into main 2026-01-28 15:40:26 +00:00
15 changed files with 311 additions and 20 deletions
Showing only changes of commit 81f3bc0bad - Show all commits

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

@@ -72,6 +72,7 @@ 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("/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

@@ -1280,6 +1280,11 @@ 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.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.",

View File

@@ -343,7 +343,8 @@ class MemberController(
@RequestHeader("Authorization") authHeader: String, @RequestHeader("Authorization") authHeader: String,
@RequestBody request: SocialLoginRequest @RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> { ): ApiResponse<LoginResponse> {
return processSocialLogin(MemberProvider.GOOGLE, authHeader, request) val token = extractBearerToken(authHeader, MemberProvider.GOOGLE)
return processSocialLogin(MemberProvider.GOOGLE, token, request, null)
} }
@PostMapping("/login/kakao") @PostMapping("/login/kakao")
@@ -351,27 +352,37 @@ class MemberController(
@RequestHeader("Authorization") authHeader: String, @RequestHeader("Authorization") authHeader: String,
@RequestBody request: SocialLoginRequest @RequestBody request: SocialLoginRequest
): ApiResponse<LoginResponse> { ): ApiResponse<LoginResponse> {
return processSocialLogin(MemberProvider.KAKAO, authHeader, request) val token = extractBearerToken(authHeader, MemberProvider.KAKAO)
return processSocialLogin(MemberProvider.KAKAO, token, request, null)
}
@PostMapping("/login/apple")
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)
} }
private fun processSocialLogin( private fun processSocialLogin(
provider: MemberProvider, provider: MemberProvider,
authHeader: String, token: String,
request: SocialLoginRequest request: SocialLoginRequest,
nonce: String?
): ApiResponse<LoginResponse> { ): ApiResponse<LoginResponse> {
val errorKey = when (provider) {
MemberProvider.GOOGLE -> "member.social.google_login_failed"
MemberProvider.KAKAO -> "member.social.kakao_login_failed"
else -> "common.error.bad_request"
}
if (!authHeader.startsWith("Bearer ")) {
throw SodaException(messageKey = errorKey)
}
val token = authHeader.substring(7)
val authService = socialAuthServiceResolver.resolve(provider) val authService = socialAuthServiceResolver.resolve(provider)
val response = authService.authenticate(token, request.container, request.marketingPid, request.pushToken) 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(
@@ -392,4 +403,21 @@ 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"
else -> "common.error.bad_request"
}
}
} }

View File

@@ -24,6 +24,7 @@ interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository
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?
} }
interface MemberQueryRepository { interface MemberQueryRepository {

View File

@@ -37,6 +37,7 @@ 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.stipulation.Stipulation import kr.co.vividnext.sodalive.member.stipulation.Stipulation
@@ -932,6 +933,63 @@ class MemberService(
return MemberResolveResult(member = member, isNew = true) return MemberResolveResult(member = member, isNew = true)
} }
@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)
}
private fun findMemberByUsername(username: String): Member? { private fun findMemberByUsername(username: String): Member? {
return if (username.startsWith("member:")) { return if (username.startsWith("member:")) {
val id = username.substringAfter("member:").toLongOrNull() val id = username.substringAfter("member:").toLongOrNull()

View File

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

@@ -8,6 +8,7 @@ interface SocialAuthService {
token: String, token: String,
container: String, container: String,
marketingPid: String?, marketingPid: String?,
pushToken: String? pushToken: String?,
nonce: String?
): SocialLoginResponse ): SocialLoginResponse
} }

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

@@ -27,7 +27,8 @@ class GoogleAuthService(
token: String, token: String,
container: String, container: String,
marketingPid: String?, marketingPid: String?,
pushToken: String? pushToken: String?,
nonce: String?
): SocialLoginResponse { ): SocialLoginResponse {
val googleUserInfo = googleService.getUserInfo(token) val googleUserInfo = googleService.getUserInfo(token)
?: throw SodaException(messageKey = "member.social.google_login_failed") ?: throw SodaException(messageKey = "member.social.google_login_failed")

View File

@@ -27,7 +27,8 @@ class KakaoAuthService(
token: String, token: String,
container: String, container: String,
marketingPid: String?, marketingPid: String?,
pushToken: String? pushToken: String?,
nonce: String?
): SocialLoginResponse { ): SocialLoginResponse {
val kakaoUserInfo = kakaoService.getUserInfo(token) val kakaoUserInfo = kakaoService.getUserInfo(token)
?: throw SodaException(messageKey = "member.social.kakao_login_failed") ?: throw SodaException(messageKey = "member.social.kakao_login_failed")

View File

@@ -33,6 +33,7 @@ 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}
agora: agora:
appId: ${AGORA_APP_ID} appId: ${AGORA_APP_ID}