Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/onku/backend/domain/member/Member.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import org.springframework.data.jpa.repository.Query

interface MemberRepository : JpaRepository<Member, Long> {
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
Expand Down
33 changes: 19 additions & 14 deletions src/main/kotlin/onku/backend/domain/member/service/MemberService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/main/kotlin/onku/backend/global/auth/AuthErrorCode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class SecurityConfig(
)
private val ALLOWED_POST = arrayOf(
"/api/v1/auth/kakao",
"/api/v1/auth/apple",
"/api/v1/auth/reissue",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,11 @@ class AuthController(
fun kakaoLogin(@RequestBody req: KakaoLoginRequest): ResponseEntity<SuccessResponse<AuthLoginResult>> =
authService.kakaoLogin(req)

@PostMapping("/apple")
@Operation(summary = "애플 로그인", description = "인가코드를 body로 받아 사용자를 식별합니다.")
fun appleLogin(@RequestBody req: AppleLoginRequest): ResponseEntity<SuccessResponse<AuthLoginResult>> =
authService.appleLogin(req)

@PostMapping("/reissue")
@Operation(summary = "AT 재발급", description = "RT를 헤더로 받아 AT를 재발급합니다.")
fun reissue(@RequestHeader("X-Refresh-Token") refreshToken: String): ResponseEntity<SuccessResponse<String>> =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package onku.backend.global.auth.dto

data class AppleLoginRequest(
val code: String
)
148 changes: 148 additions & 0 deletions src/main/kotlin/onku/backend/global/auth/service/AppleService.kt
Original file line number Diff line number Diff line change
@@ -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<String, String> = LinkedMultiValueMap<String, String>().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<AppleTokenResponse> = 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)
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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
import org.springframework.http.ResponseEntity

interface AuthService {
fun kakaoLogin(dto: KakaoLoginRequest): ResponseEntity<SuccessResponse<AuthLoginResult>>
fun appleLogin(dto: AppleLoginRequest): ResponseEntity<SuccessResponse<AuthLoginResult>>
fun reissueAccessToken(refreshToken: String): ResponseEntity<SuccessResponse<String>>
fun logout(refreshToken: String): ResponseEntity<SuccessResponse<String>>
fun withdraw(member: Member, refreshToken: String): ResponseEntity<SuccessResponse<String>>
Expand Down
Loading