diff --git a/build.gradle.kts b/build.gradle.kts index 4096c5e..9c57580 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,6 +58,8 @@ dependencies { //aws secretmanager implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.0")) implementation("io.awspring.cloud:spring-cloud-aws-starter-secrets-manager") + //apple auth + implementation("com.nimbusds:nimbus-jose-jwt:9.40") } kotlin { diff --git a/src/main/kotlin/onku/backend/domain/member/Member.kt b/src/main/kotlin/onku/backend/domain/member/Member.kt index 753a49b..2d2f811 100644 --- a/src/main/kotlin/onku/backend/domain/member/Member.kt +++ b/src/main/kotlin/onku/backend/domain/member/Member.kt @@ -24,7 +24,7 @@ class Member( val socialType: SocialType, @Column(name = "social_id", nullable = false, length = 100) - val socialId: Long, + val socialId: String, @Column(name = "has_info", nullable = false) var hasInfo: Boolean = false, diff --git a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt index 7271de9..bf520f1 100644 --- a/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt +++ b/src/main/kotlin/onku/backend/domain/member/repository/MemberRepository.kt @@ -8,7 +8,7 @@ import org.springframework.data.jpa.repository.Query interface MemberRepository : JpaRepository { fun findByEmail(email: String): Member? - fun findBySocialIdAndSocialType(socialId: Long, socialType: SocialType): Member? + fun findBySocialIdAndSocialType(socialId: String, socialType: SocialType): Member? @Query(""" select m.id from Member m where m.hasInfo = true diff --git a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt index 53af2b6..5cb9dd6 100644 --- a/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt +++ b/src/main/kotlin/onku/backend/domain/member/service/MemberService.kt @@ -8,6 +8,7 @@ import onku.backend.domain.member.enums.Role import onku.backend.domain.member.enums.SocialType import onku.backend.domain.member.repository.MemberProfileRepository import onku.backend.domain.member.repository.MemberRepository +import onku.backend.global.auth.AuthErrorCode import onku.backend.global.exception.CustomException import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service @@ -24,24 +25,28 @@ class MemberService( ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) @Transactional - fun upsertSocialMember(email: String?, socialId: Long, type: SocialType): Member { - val existing = memberRepository.findBySocialIdAndSocialType(socialId, type) - if (existing != null) { - if (!email.isNullOrBlank() && existing.email != email) { - existing.updateEmail(email) - } - return existing + fun upsertSocialMember(email: String?, socialId: String, type: SocialType): Member { + // social로 먼저 조회: Apple 재로그인 시 email 누락 때문 + val bySocial = memberRepository.findBySocialIdAndSocialType(socialId, type) + if (bySocial != null) { + bySocial.updateEmail(email) + return bySocial + } + + // email이 있으면 email로 조회: email 중복 삽입 방지 + val byEmail = email?.let { memberRepository.findByEmail(it) } + if (byEmail != null) { + return byEmail } - val created = Member( - email = email, - role = Role.USER, + // 신규 생성: email 없으면 생성 불가 처리 + val safeEmail = email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + val newMember = Member( + email = safeEmail, socialType = type, - socialId = socialId, - hasInfo = false, - approval = ApprovalStatus.PENDING + socialId = socialId ) - return memberRepository.save(created) + return memberRepository.save(newMember) } @Transactional diff --git a/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt index c2e15f4..3580e00 100644 --- a/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt +++ b/src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt @@ -9,12 +9,15 @@ enum class AuthErrorCode( override val status: HttpStatus ) : ApiErrorCode { - OAUTH_EMAIL_SCOPE_REQUIRED("AUTH400", "카카오 프로필에 이메일이 없습니다. 카카오 동의 항목(이메일)을 활성화해 주세요.", HttpStatus.BAD_REQUEST), + OAUTH_EMAIL_SCOPE_REQUIRED("AUTH400", "응답값에 이메일이 없습니다.", HttpStatus.BAD_REQUEST), INVALID_REFRESH_TOKEN("AUTH401", "유효하지 않은 리프레시 토큰입니다.", HttpStatus.UNAUTHORIZED), EXPIRED_REFRESH_TOKEN("AUTH401", "만료된 리프레시 토큰입니다.", HttpStatus.UNAUTHORIZED), KAKAO_TOKEN_EMPTY_RESPONSE("AUTH502", "카카오 토큰 응답이 비어 있습니다.", HttpStatus.BAD_GATEWAY), KAKAO_PROFILE_EMPTY_RESPONSE("AUTH502", "카카오 프로필 응답이 비어 있습니다.", HttpStatus.BAD_GATEWAY), KAKAO_API_COMMUNICATION_ERROR("AUTH502", "카카오 API 통신 중 오류가 발생했습니다.", HttpStatus.BAD_GATEWAY), INVALID_REDIRECT_URI("AUTH400", "유효하지 않은 리다이렉트 URI입니다.", HttpStatus.BAD_REQUEST), - KAKAO_USER_ID_MISSING("AUTH400", "카카오 사용자 ID가 없습니다.", HttpStatus.BAD_REQUEST) + APPLE_API_COMMUNICATION_ERROR("AUTH502", "애플 API 통신 중 오류가 발생했습니다.", HttpStatus.BAD_GATEWAY), + APPLE_TOKEN_EMPTY_RESPONSE("AUTH502", "애플 토큰 응답이 비어 있습니다.", HttpStatus.BAD_GATEWAY), + APPLE_ID_TOKEN_INVALID("AUTH401", "유효하지 않은 애플 ID 토큰입니다.", HttpStatus.UNAUTHORIZED), + APPLE_JWKS_FETCH_FAILED("AUTH502", "애플 공개 키(JWKS)를 불러오는 데 실패했습니다.", HttpStatus.BAD_GATEWAY) } diff --git a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt index bd406dd..535c41a 100644 --- a/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt +++ b/src/main/kotlin/onku/backend/global/auth/config/SecurityConfig.kt @@ -28,6 +28,7 @@ class SecurityConfig( ) private val ALLOWED_POST = arrayOf( "/api/v1/auth/kakao", + "/api/v1/auth/apple", "/api/v1/auth/reissue", ) diff --git a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt index d9e6cd6..7c2fe16 100644 --- a/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt +++ b/src/main/kotlin/onku/backend/global/auth/controller/AuthController.kt @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import onku.backend.domain.member.Member import onku.backend.global.annotation.CurrentMember +import onku.backend.global.auth.dto.AppleLoginRequest import onku.backend.global.auth.dto.AuthLoginResult import onku.backend.global.auth.dto.KakaoLoginRequest import onku.backend.global.auth.service.AuthService @@ -22,6 +23,11 @@ class AuthController( fun kakaoLogin(@RequestBody req: KakaoLoginRequest): ResponseEntity> = authService.kakaoLogin(req) + @PostMapping("/apple") + @Operation(summary = "애플 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.") + fun appleLogin(@RequestBody req: AppleLoginRequest): ResponseEntity> = + authService.appleLogin(req) + @PostMapping("/reissue") @Operation(summary = "AT 재발급", description = "RT를 헤더로 받아 AT를 재발급합니다.") fun reissue(@RequestHeader("X-Refresh-Token") refreshToken: String): ResponseEntity> = diff --git a/src/main/kotlin/onku/backend/global/auth/dto/AppleLoginRequest.kt b/src/main/kotlin/onku/backend/global/auth/dto/AppleLoginRequest.kt new file mode 100644 index 0000000..f55aa87 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/dto/AppleLoginRequest.kt @@ -0,0 +1,5 @@ +package onku.backend.global.auth.dto + +data class AppleLoginRequest( + val code: String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/service/AppleService.kt b/src/main/kotlin/onku/backend/global/auth/service/AppleService.kt new file mode 100644 index 0000000..98a80fc --- /dev/null +++ b/src/main/kotlin/onku/backend/global/auth/service/AppleService.kt @@ -0,0 +1,148 @@ +package onku.backend.global.auth.service + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.crypto.RSASSAVerifier +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.jwt.JWTClaimsSet +import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.exception.CustomException +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.client.RestClient +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.interfaces.ECPrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.time.Instant +import java.util.* + +@Service +class AppleService { + private val client = RestClient.create() + + data class AppleTokenResponse( + val access_token: String?, + val token_type: String?, + val expires_in: Long?, + val refresh_token: String?, + val id_token: String? + ) + + data class AppleIdTokenPayload( + val sub: String, + val email: String? + ) + + fun exchangeCodeForToken( + code: String, + clientId: String, + clientSecret: String, + redirectUri: String + ): AppleTokenResponse { + val form: MultiValueMap = LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("code", code) + add("client_id", clientId) + add("client_secret", clientSecret) + add("redirect_uri", redirectUri) + } + + return try { + val res: ResponseEntity = client.post() + .uri("https://appleid.apple.com/auth/token") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form) + .retrieve() + .toEntity(AppleTokenResponse::class.java) + + res.body ?: throw CustomException(AuthErrorCode.APPLE_TOKEN_EMPTY_RESPONSE) + } catch (e: Exception) { + throw CustomException(AuthErrorCode.APPLE_API_COMMUNICATION_ERROR) + } + } + + fun createClientSecret( + teamId: String, + clientId: String, + keyId: String, + privateKeyRaw: String + ): String { + val now = Instant.now() + val exp = now.plusSeconds(60 * 5) + + val claims = JWTClaimsSet.Builder() + .issuer(teamId) + .subject(clientId) + .audience("https://appleid.apple.com") + .issueTime(Date.from(now)) + .expirationTime(Date.from(exp)) + .build() + + val header = JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(keyId) + .build() + + val jwt = SignedJWT(header, claims) + val ecPrivateKey = parseEcPrivateKey(privateKeyRaw) as ECPrivateKey + jwt.sign(ECDSASigner(ecPrivateKey)) + return jwt.serialize() + } + + fun verifyAndParseIdToken(idToken: String, expectedAud: String): AppleIdTokenPayload { + val jwt = SignedJWT.parse(idToken) + val kid = jwt.header.keyID ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val jwkSet = try { + JWKSet.load(java.net.URL("https://appleid.apple.com/auth/keys")) + } catch (e: Exception) { + throw CustomException(AuthErrorCode.APPLE_JWKS_FETCH_FAILED) + } + + val jwk = jwkSet.keys.firstOrNull { it.keyID == kid } + ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val rsaKey = jwk as? RSAKey ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + val publicKey = rsaKey.toRSAPublicKey() + + val verified = jwt.verify(RSASSAVerifier(publicKey)) + if (!verified) throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val claims = jwt.jwtClaimsSet + + if (claims.issuer != "https://appleid.apple.com") { + throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + } + + val audOk = claims.audience?.contains(expectedAud) == true + if (!audOk) throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val exp = claims.expirationTime ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + if (exp.before(Date())) throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + + val sub = claims.subject ?: throw CustomException(AuthErrorCode.APPLE_ID_TOKEN_INVALID) + val email = claims.getStringClaim("email") + + return AppleIdTokenPayload(sub = sub, email = email) + } + + private fun parseEcPrivateKey(raw: String): PrivateKey { + val pem = raw.trim() + val base64 = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\n", "\n") + .replace("\n", "") + .trim() + + val decoded = Base64.getDecoder().decode(base64) + val spec = PKCS8EncodedKeySpec(decoded) + val kf = KeyFactory.getInstance("EC") + return kf.generatePrivate(spec) + } +} \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt index 98aa884..22fade7 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthService.kt @@ -1,6 +1,7 @@ package onku.backend.global.auth.service import onku.backend.domain.member.Member +import onku.backend.global.auth.dto.AppleLoginRequest import onku.backend.global.auth.dto.AuthLoginResult import onku.backend.global.auth.dto.KakaoLoginRequest import onku.backend.global.response.SuccessResponse @@ -8,6 +9,7 @@ import org.springframework.http.ResponseEntity interface AuthService { fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity> + fun appleLogin(dto: AppleLoginRequest): ResponseEntity> fun reissueAccessToken(refreshToken: String): ResponseEntity> fun logout(refreshToken: String): ResponseEntity> fun withdraw(member: Member, refreshToken: String): ResponseEntity> diff --git a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt index 949d04b..6a1e119 100644 --- a/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt +++ b/src/main/kotlin/onku/backend/global/auth/service/AuthServiceImpl.kt @@ -6,9 +6,11 @@ import onku.backend.domain.member.enums.ApprovalStatus import onku.backend.domain.member.enums.SocialType import onku.backend.domain.member.service.MemberService import onku.backend.global.auth.AuthErrorCode +import onku.backend.global.auth.dto.AppleLoginRequest import onku.backend.global.auth.dto.AuthLoginResult import onku.backend.global.auth.dto.KakaoLoginRequest import onku.backend.global.auth.jwt.JwtUtil +import onku.backend.global.config.AppleProps import onku.backend.global.config.KakaoProps import onku.backend.global.exception.CustomException import onku.backend.global.redis.cache.RefreshTokenCache @@ -26,11 +28,13 @@ import java.time.Duration class AuthServiceImpl( private val memberService: MemberService, private val kakaoService: KakaoService, + private val appleService: AppleService, private val jwtUtil: JwtUtil, private val refreshTokenCacheUtil: RefreshTokenCache, @Value("\${jwt.refresh-ttl}") private val refreshTtl: Duration, @Value("\${jwt.onboarding-ttl}") private val onboardingTtl: Duration, private val kakaoProps: KakaoProps, + private val appleProps: AppleProps ) : AuthService { @Transactional @@ -45,7 +49,7 @@ class AuthServiceImpl( ) val profile = kakaoService.getProfile(token.accessToken) - val socialId = profile.id + val socialId = profile.id.toString() val email = profile.kakaoAccount?.email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) @@ -55,6 +59,47 @@ class AuthServiceImpl( type = SocialType.KAKAO ) + return buildLoginResponse(member) + } + + @Transactional + override fun appleLogin(dto: AppleLoginRequest): ResponseEntity> { + val redirectUri = appleProps.redirectUri + + val clientSecret = appleService.createClientSecret( + teamId = appleProps.teamId, + clientId = appleProps.clientId, + keyId = appleProps.keyId, + privateKeyRaw = appleProps.privateKey + ) + + val tokenRes = appleService.exchangeCodeForToken( + code = dto.code, + clientId = appleProps.clientId, + clientSecret = clientSecret, + redirectUri = redirectUri + ) + + val idToken = tokenRes.id_token + ?: throw CustomException(AuthErrorCode.APPLE_TOKEN_EMPTY_RESPONSE) + + val payload = appleService.verifyAndParseIdToken( + idToken = idToken, + expectedAud = appleProps.clientId + ) + + val member = memberService.upsertSocialMember( + email = payload.email, + socialId = payload.sub, + type = SocialType.APPLE + ) + + return buildLoginResponse(member) + } + + private fun buildLoginResponse(member: Member): ResponseEntity> { + val email = member.email ?: throw CustomException(AuthErrorCode.OAUTH_EMAIL_SCOPE_REQUIRED) + return when (member.approval) { ApprovalStatus.APPROVED -> { val roles = member.role.authorities() @@ -84,7 +129,6 @@ class AuthServiceImpl( ApprovalStatus.PENDING -> { if (member.hasInfo) { - // 온보딩 제출 완료(프로필 있음) → 온보딩 토큰 미발급 ResponseEntity .status(HttpStatus.ACCEPTED) .body( @@ -98,7 +142,6 @@ class AuthServiceImpl( ) ) } else { - // 온보딩 제출 전(프로필 없음) → 온보딩 토큰 발급 val onboarding = jwtUtil.createOnboardingToken(email, onboardingTtl.toMinutes()) val headers = HttpHeaders().apply { add(HttpHeaders.AUTHORIZATION, "Bearer $onboarding") @@ -174,7 +217,12 @@ class AuthServiceImpl( @Transactional override fun withdraw(member: Member, refreshToken: String): ResponseEntity> { - kakaoService.adminUnlink(member.socialId, kakaoProps.adminKey) + if (member.socialType == SocialType.KAKAO) { // 카카오만 탈퇴 시 unlink 수행 + val kakaoId = member.socialId.toLongOrNull() + ?: throw CustomException(AuthErrorCode.KAKAO_API_COMMUNICATION_ERROR) + kakaoService.adminUnlink(kakaoId, kakaoProps.adminKey) + } + deleteRefreshTokenBy(refreshToken) val memberId = member.id ?: throw CustomException(MemberErrorCode.MEMBER_NOT_FOUND) memberService.deleteMemberById(memberId) diff --git a/src/main/kotlin/onku/backend/global/config/AppleProps.kt b/src/main/kotlin/onku/backend/global/config/AppleProps.kt new file mode 100644 index 0000000..31a1f00 --- /dev/null +++ b/src/main/kotlin/onku/backend/global/config/AppleProps.kt @@ -0,0 +1,12 @@ +package onku.backend.global.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "oauth.apple") +data class AppleProps( + val clientId: String, + val teamId: String, + val keyId: String, + val privateKey: String, + val redirectUri: String +) \ No newline at end of file diff --git a/src/main/kotlin/onku/backend/global/config/KakaoConfig.kt b/src/main/kotlin/onku/backend/global/config/PropsConfig.kt similarity index 60% rename from src/main/kotlin/onku/backend/global/config/KakaoConfig.kt rename to src/main/kotlin/onku/backend/global/config/PropsConfig.kt index a0139f7..85002a3 100644 --- a/src/main/kotlin/onku/backend/global/config/KakaoConfig.kt +++ b/src/main/kotlin/onku/backend/global/config/PropsConfig.kt @@ -4,5 +4,10 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration @Configuration -@EnableConfigurationProperties(KakaoProps::class) -class KakaoConfig \ No newline at end of file +@EnableConfigurationProperties( + value = [ + KakaoProps::class, + AppleProps::class + ] +) +class PropsConfig \ No newline at end of file