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/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index d143d4d0..3eddd75c 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,7 @@ class SecurityConfig( .antMatchers("/member/login").permitAll() .antMatchers("/member/login/google").permitAll() .antMatchers("/member/login/kakao").permitAll() + .antMatchers("/member/login/apple").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/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 4d72b4f2..ead65727 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,11 @@ 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.email_consent_required" to mapOf( Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.", Lang.EN to "You must agree to provide your email to use the service.", 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 06fa296d..988d05ea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -343,7 +343,8 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { - return processSocialLogin(MemberProvider.GOOGLE, authHeader, request) + val token = extractBearerToken(authHeader, MemberProvider.GOOGLE) + return processSocialLogin(MemberProvider.GOOGLE, token, request, null) } @PostMapping("/login/kakao") @@ -351,27 +352,37 @@ class MemberController( @RequestHeader("Authorization") authHeader: String, @RequestBody request: SocialLoginRequest ): ApiResponse { - 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 { + 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( provider: MemberProvider, - authHeader: String, - request: SocialLoginRequest + token: String, + request: SocialLoginRequest, + nonce: String? ): ApiResponse { - 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 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()) { trackingService.saveTrackingHistory( @@ -392,4 +403,21 @@ 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" + 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 add3d49d..f71a47a8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -24,6 +24,7 @@ interface MemberRepository : JpaRepository, MemberQueryRepository fun findByNickname(nickname: String): Member? fun findByGoogleId(googleId: String): Member? fun findByKakaoId(kakaoId: Long): Member? + fun findByAppleId(appleId: String): Member? } interface MemberQueryRepository { 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 497fe4d5..ca30f1ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -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.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.stipulation.Stipulation @@ -932,6 +933,63 @@ class MemberService( 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? { return if (username.startsWith("member:")) { val id = username.substringAfter("member:").toLongOrNull() 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 index bb500dff..d0501ca0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/SocialAuthService.kt @@ -8,6 +8,7 @@ interface SocialAuthService { token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse } 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 cf2b72f1..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 @@ -27,7 +27,8 @@ class GoogleAuthService( token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse { val googleUserInfo = googleService.getUserInfo(token) ?: throw SodaException(messageKey = "member.social.google_login_failed") 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 45f96ad8..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 @@ -27,7 +27,8 @@ class KakaoAuthService( token: String, container: String, marketingPid: String?, - pushToken: String? + pushToken: String?, + nonce: String? ): SocialLoginResponse { val kakaoUserInfo = kakaoService.getUserInfo(token) ?: throw SodaException(messageKey = "member.social.kakao_login_failed") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa3ad691..303a1229 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,7 @@ bootpay: apple: iapVerifyUrl: https://buy.itunes.apple.com/verifyReceipt iapVerifySandboxUrl: https://sandbox.itunes.apple.com/verifyReceipt + bundleId: ${APPLE_BUNDLE_ID} agora: appId: ${AGORA_APP_ID}