Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ISSUE-14 애플 & 카카오 소셜 로그인 연동 로직 구현 #15

Merged
merged 16 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ object Versions {
const val MYSQL = "8.0.33"
const val JJWT = "0.12.6"
const val JDK = 17
const val OPEN_FEIGN = "4.1.0"
}
12 changes: 0 additions & 12 deletions core/src/main/kotlin/org/doorip/core/TestService.kt

This file was deleted.

25 changes: 16 additions & 9 deletions core/src/main/kotlin/org/doorip/core/auth/AuthService.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package org.doorip.core.auth

import java.time.Duration
import org.doorip.domain.AlreadyExistingUserException
import org.doorip.domain.UnauthenticatedException
import org.doorip.domain.entity.AuthPlatform
import org.doorip.domain.entity.Token
import org.doorip.domain.entity.UserId
import org.doorip.domain.entity.UserInfo
import org.doorip.domain.repository.AccessTokenRepository
import org.doorip.domain.repository.AuthRepository
import org.doorip.domain.repository.RefreshTokenRepository
Expand All @@ -18,10 +20,10 @@ internal class AuthService(
private val refreshTokenRepository: RefreshTokenRepository,
private val userRepository: UserRepository,
private val authRepository: AuthRepository,
) {
) : AuthUseCase {

@Transactional(readOnly = true)
fun signIn(token: String, platform: String): Token {
@Transactional
override fun signIn(token: String, platform: String): UserInfo {
val authPlatform = AuthPlatform.toAuthPlatform(platform)
val platformId = authRepository.getPlatformId(token, authPlatform)

Expand All @@ -30,11 +32,16 @@ internal class AuthService(
platform = authPlatform,
) ?: throw UnauthenticatedException

return issueToken(user.id)
val userInfo = UserInfo(
user = user,
token = issueToken(user.id),
)

return userInfo
}

@Transactional
fun signUp(token: String, platform: String, name: String, intro: String): Token {
override fun signUp(token: String, platform: String, name: String, intro: String): Token {
val authPlatform = AuthPlatform.toAuthPlatform(platform)
val platformId = authRepository.getPlatformId(token, authPlatform)

Expand All @@ -43,23 +50,23 @@ internal class AuthService(
platform = authPlatform,
name = name,
intro = intro,
)
) ?: throw AlreadyExistingUserException

return issueToken(user.id)
}

fun signOut(userId: UserId) {
override fun signOut(userId: UserId) {
refreshTokenRepository.deleteRefreshToken(userId)
}

fun reissue(refreshToken: String): Token {
override fun reissue(refreshToken: String): Token {
val userId = refreshTokenRepository.getUserId(refreshToken) ?: throw UnauthenticatedException

return issueToken(userId)
}

@Transactional
fun withdraw(userId: UserId) {
override fun withdraw(userId: UserId) {
refreshTokenRepository.deleteRefreshToken(userId)
userRepository.withdraw(userId)
}
Expand Down
13 changes: 13 additions & 0 deletions core/src/main/kotlin/org/doorip/core/auth/AuthUseCase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.doorip.core.auth

import org.doorip.domain.entity.Token
import org.doorip.domain.entity.UserId
import org.doorip.domain.entity.UserInfo

interface AuthUseCase {
fun signIn(token: String, platform: String): UserInfo
fun signUp(token: String, platform: String, name: String, intro: String): Token
fun signOut(userId: UserId)
fun reissue(refreshToken: String): Token
fun withdraw(userId: UserId)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data object UnauthenticatedException : ClientException("e4010", "인증 과정
data object InvalidRequestValueException : ClientException("e4000", "잘못된 요청입니다.") { private fun readResolve(): Any = InvalidRequestValueException }
data object MethodNotAllowedException : ClientException("e4050", "잘못된 HTTP method 요청입니다.") { private fun readResolve(): Any = MethodNotAllowedException }
data object ConflictException : ClientException("e4090", "이미 존재하는 리소스입니다.") { private fun readResolve(): Any = ConflictException }
data object AlreadyExistingUserException : ClientException("e4091", "이미 존재하는 회원입니다.") { private fun readResolve(): Any = AlreadyExistingUserException }

// Server Exception
sealed class ServerException(
Expand Down
5 changes: 5 additions & 0 deletions domain/src/main/kotlin/org/doorip/domain/entity/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ data class User(
val intro: String,
val result: Int?,
)

data class UserInfo(
val user: User,
val token: Token,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.doorip.domain.entity.User
import org.doorip.domain.entity.UserId

interface UserRepository {
fun create(platformId: String, platform: AuthPlatform, name: String, intro: String): User
fun create(platformId: String, platform: AuthPlatform, name: String, intro: String): User?
fun getUser(platformId: String, platform: AuthPlatform): User?
fun withdraw(userId: UserId)
}
3 changes: 3 additions & 0 deletions gateway/oauth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ plugins {

dependencies {
implementation(project(":domain"))
implementation(project(":support:jwt"))

implementation("org.springframework.cloud:spring-cloud-starter-openfeign:${Versions.OPEN_FEIGN}")
}

tasks {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package org.doorip.gateway.oauth

import org.doorip.domain.entity.AuthPlatform
import org.doorip.domain.repository.AuthRepository
import org.doorip.gateway.oauth.apple.AppleAuthService
import org.doorip.gateway.oauth.kakao.KakaoAuthService
import org.springframework.stereotype.Component

@Component
internal class AuthGateway : AuthRepository {
internal class AuthGateway(
private val appleAuthService: AppleAuthService,
private val kakaoAuthService: KakaoAuthService,
) : AuthRepository {

override fun getPlatformId(token: String, platform: AuthPlatform): String {
// TODO

return token
override fun getPlatformId(token: String, platform: AuthPlatform): String = when (platform) {
AuthPlatform.APPLE -> appleAuthService.getPlatformId(token)
AuthPlatform.KAKAO -> kakaoAuthService.getPlatformId(token)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.doorip.gateway.oauth.apple

import org.springframework.stereotype.Component

@Component
internal class AppleAuthService(
private val keyGenerator: ApplePublicKeyGenerator,
private val validator: AppleIdTokenValidator,
) {

internal fun getPlatformId(token: String): String {
val publicKey = keyGenerator.generatePublicKey(token)

return validator.validateAndGetSubject(token, publicKey)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.doorip.gateway.oauth.apple

import org.doorip.gateway.oauth.apple.dto.ApplePublicKeys
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping

@FeignClient(value = "apple-client", url = "https://appleid.apple.com/auth/keys")
internal interface AppleFeignClient {
@GetMapping
fun getApplePublicKeys(): ApplePublicKeys
}
Comment on lines +7 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

애플 서버 응답 실패 시 예외 처리 고려
FeignClient 호출 시 애플 서버가 응답하지 않거나 오류를 반환할 경우를 대비한 예외 처리가 필요합니다. 재시도 로직, 에러 로그, 혹은 사용자에게 알림을 주는 설계 등을 검토해 보세요.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.doorip.gateway.oauth.apple

import org.doorip.domain.UnauthenticatedException
import org.doorip.support.jwt.JwtProvider
import org.doorip.support.jwt.SignatureKey
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

@Component
internal class AppleIdTokenValidator(
private val jwtProvider: JwtProvider,
) {
@Value("\${oauth.apple.iss}")
lateinit var iss: String

@Value("\${oauth.apple.client-id}")
lateinit var clientId: String
Comment on lines +13 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

lateinit var 키워드 사용이 조금 아쉬워 보입니다. @Value로 값을 주입받을 경우, 생성자 주입이나 ConfigurationProperties 방식을 고려하시면 테스트나 유지보수에 좀 더 유연해질 수 있어요.

🧰 Tools
🪛 detekt (1.23.7)

[warning] 13-14: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


[warning] 16-17: Usages of lateinit should be avoided.

(detekt.potential-bugs.LateinitUsage)


internal fun validateAndGetSubject(token: String, key: SignatureKey): String {
return jwtProvider.validateAndGetSubject(
token = token,
key = key,
iss = iss,
aud = clientId,
) ?: throw UnauthenticatedException
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.doorip.gateway.oauth.apple

import feign.FeignException
import org.doorip.domain.UnauthenticatedException
import org.doorip.gateway.oauth.apple.dto.ApplePublicKeys
import org.doorip.support.jwt.JwtProvider
import org.doorip.support.jwt.RSAPublicKey
import org.doorip.support.jwt.SignatureKey
import org.springframework.stereotype.Component

@Component
internal class ApplePublicKeyGenerator(
private val httpClient: AppleFeignClient,
private val jwtProvider: JwtProvider,
) {

internal fun generatePublicKey(token: String): SignatureKey {
val applePublicKeys = getApplePublicKeys()

val header = jwtProvider.parseHeader(token) ?: throw UnauthenticatedException

val alg = header["alg"] ?: throw UnauthenticatedException
val kid = header["kid"] ?: throw UnauthenticatedException
val key = applePublicKeys[alg, kid] ?: throw UnauthenticatedException

return RSAPublicKey(key.n, key.e, key.kty)
}
Comment on lines +17 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

공개 키 생성 과정에서 토큰 헤더 정보(alg, kid)를 확인하고 없는 경우 예외를 발생시키는 로직이 신뢰도를 높여줍니다. 다만, algkid에 대한 유효성 검증 범위를 조금 더 넓히면(예: RSA 알고리즘만 허용 등) 보안성을 더 강화할 수 있을 것 같아요.


private fun getApplePublicKeys(): ApplePublicKeys {
try {
return httpClient.getApplePublicKeys()
} catch (e: FeignException) {
throw UnauthenticatedException
}
}
Comment on lines +29 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

예외를 잡은 뒤 throw UnauthenticatedException으로 다시 단순화 처리하고 있는데, Feign 수준에서 어떤 문제가 발생했는지 로깅을 남기거나, 예외 메시지를 조금 상세히 전달하면 문제 파악이 한결 쉬워질 수 있을 것 같아요.

🧰 Tools
🪛 detekt (1.23.7)

[warning] 32-32: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.doorip.gateway.oauth.apple.dto

internal data class ApplePublicKey(
val kty: String,
val kid: String,
val use: String,
val alg: String,
val n: String,
val e: String,
)
Comment on lines +3 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

ApplePublicKey 클래스에 대한 간단한 점검
모든 속성이 String으로 지정되어 있어 처리하기 편리하지만, 실제 키 값 중 일부는 암호화 자료 형태(BigInteger 등)를 고려해야 할 수도 있습니다. 필요하다면 추후 확장을 검토해 보세요.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.doorip.gateway.oauth.apple.dto

internal data class ApplePublicKeys(
val keys: List<ApplePublicKey>,
) {

operator fun get(alg: String, kid: String): ApplePublicKey? {
return keys.firstOrNull { key ->
key.alg == alg && key.kid == kid
}
}
}
Comment on lines +3 to +12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

keys 프로퍼티 접근 시 성능 고려
get(alg, kid) 함수는 내부적으로 List를 순회하여 일치하는 키를 찾고 있습니다. 향후 keys가 커지면 탐색 비용이 증가할 수 있으므로, 키를 맵 형태로 보관하는 등 대안도 검토해 보세요.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.doorip.gateway.oauth.config

import org.springframework.cloud.openfeign.EnableFeignClients
import org.springframework.context.annotation.Configuration

@EnableFeignClients(basePackages = ["org.doorip"])
@Configuration
internal class OpenFeignConfig
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Feign 클라이언트 설정 개선을 제안드립니다 😊

현재 설정은 기본적인 기능만 제공하고 있어요. 실제 운영 환경에서는 다음과 같은 추가 설정들이 필요할 것 같습니다:

  1. basePackages 범위가 너무 넓어요. 필요한 패키지만 지정하는 것이 좋습니다.
  2. 타임아웃 설정이 없어요.
  3. 에러 처리와 로깅 설정이 필요해요.

다음과 같이 개선하는 것은 어떨까요?

 @EnableFeignClients(basePackages = ["org.doorip"])
 @Configuration
-internal class OpenFeignConfig
+internal class OpenFeignConfig {
+    @Bean
+    fun feignLoggerLevel(): Logger.Level = Logger.Level.FULL
+
+    @Bean
+    fun errorDecoder(): ErrorDecoder = CustomErrorDecoder()
+
+    @Bean
+    fun requestInterceptor(): RequestInterceptor =
+        RequestInterceptor { template ->
+            template.header("Accept", "application/json")
+        }
+}

Committable suggestion skipped: line range outside the PR's diff.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.doorip.gateway.oauth.kakao

import feign.FeignException
import org.doorip.domain.UnauthenticatedException
import org.springframework.stereotype.Component

@Component
internal class KakaoAuthService(
private val httpClient: KakaoFeignClient,
) {

fun getPlatformId(token: String): String {
try {
val kakaoAccessTokenInfo = httpClient.getKakaoAccessTokenInfo(HEADER_BEARER + token)

return kakaoAccessTokenInfo.id.toString()
Comment on lines +12 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Bearer 토큰 문자열 처리에 주의하세요.

Token 문자열 앞에 "Bearer "를 명시적으로 더하는 것은 직관적이지만, 토큰 포맷이 바뀔 경우를 대비해 유연성을 고려할 수도 있습니다. 예를 들어, 토큰 생성 로직에서 이미 포함되어 있다면 중복이 될 수 있으니 주의 부탁드립니다.

} catch (ex: FeignException) {
throw UnauthenticatedException
}
Comment on lines +17 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

예외 정보를 좀 더 풍부하게 전달하면 좋겠습니다.

현재 FeignException을 잡은 뒤 UnauthenticatedException으로 단순히 던지고 있는데, 원인과 추가 정보를 함께 제공하면 디버깅 시 도움이 됩니다. 예를 들어, 로그를 남기거나 UnauthenticatedException에 원본 예외를 감싸서 전달해 보세요.

} catch (ex: FeignException) {
-    throw UnauthenticatedException
+    throw UnauthenticatedException("카카오 인증 실패", ex)
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (ex: FeignException) {
throw UnauthenticatedException
}
} catch (ex: FeignException) {
throw UnauthenticatedException("카카오 인증 실패", ex)
}
🧰 Tools
🪛 detekt (1.23.7)

[warning] 17-17: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

}

companion object {
private const val HEADER_BEARER = "Bearer "
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.doorip.gateway.oauth.kakao

import org.doorip.gateway.oauth.kakao.dto.KakaoAccessTokenInfo
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestHeader

@FeignClient(name = "kakao-kakao-feign", url = "https://kapi.kakao.com/v1/user/access_token_info")
internal interface KakaoFeignClient {
@GetMapping
fun getKakaoAccessTokenInfo(@RequestHeader("Authorization") token: String): KakaoAccessTokenInfo
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.doorip.gateway.oauth.kakao.dto

internal data class KakaoAccessTokenInfo(
val id: Long,
)
4 changes: 4 additions & 0 deletions gateway/oauth/src/main/resources/application-oauth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
oauth:
apple:
iss: https://appleid.apple.com
client-id: ${APPLE_CLIENT_ID}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.doorip.gateway.rdb

import jakarta.persistence.Column
import jakarta.persistence.EntityListeners
import jakarta.persistence.MappedSuperclass
import java.time.LocalDateTime
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
internal abstract class BaseJpaEntity {
@CreatedDate
@Column(name = "created_at", updatable = false, nullable = false)
var createdDate: LocalDateTime = LocalDateTime.now()
Comment on lines +14 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

생성일 기본값 표현에 대한 주의사항
LocalDateTime.now()를 기본값으로 설정하면 애플리케이션이 실행되고 있는 서버의 로컬 시간대를 사용합니다. 여러 서버에서 동작할 경우 시간이 달라질 수 있으므로, UTC 일관성을 유지하거나 OffsetDateTime/ZonedDateTime 등으로 처리하는 방안을 고려해 보세요.


@LastModifiedDate
@Column(name = "updated_at", nullable = false)
var updatedDate: LocalDateTime = LocalDateTime.now()
Comment on lines +18 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

마지막 수정 시각에도 같은 고려가 필요합니다.
@LastModifiedDate 역시 서버의 로컬 타임존 문제를 동일하게 겪을 수 있습니다. 향후 국제화나 서버 이중화 시점을 고려한다면, 시간대 처리를 일관성 있게 유지하는 방법을 추가적으로 검토하시면 좋겠습니다.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.doorip.gateway.rdb.config

import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaAuditing

@EnableJpaAuditing
@Configuration
internal class JpaConfig
Comment on lines +6 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

JPA Auditing 설정에 대한 제안

JPA Auditing 기본 설정이 잘 되어있네요! 😊 하지만 더 견고한 설정을 위해 몇 가지 제안드립니다:

  1. 감사(Auditing) 정보를 더 세밀하게 관리하기 위해 AuditorAware 구현을 추가하면 좋을 것 같아요.
  2. 클래스에 간단한 KDoc 문서를 추가하면 다른 개발자분들이 이해하기 쉬울 것 같습니다.

아래와 같이 개선해보는 건 어떨까요?

 @EnableJpaAuditing
 @Configuration
-internal class JpaConfig
+/**
+ * JPA Auditing을 위한 설정 클래스입니다.
+ * 엔티티의 생성일시와 수정일시를 자동으로 관리합니다.
+ */
+internal class JpaConfig {
+    @Bean
+    fun auditorProvider(): AuditorAware<String> {
+        return AuditorAware { Optional.of("SYSTEM") }
+    }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@EnableJpaAuditing
@Configuration
internal class JpaConfig
@EnableJpaAuditing
@Configuration
/**
* JPA Auditing을 위한 설정 클래스입니다.
* 엔티티의 생성일시와 수정일시를 자동으로 관리합니다.
*/
internal class JpaConfig {
@Bean
fun auditorProvider(): AuditorAware<String> {
return AuditorAware { Optional.of("SYSTEM") }
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.doorip.domain.entity.UserId
import org.doorip.domain.repository.RefreshTokenRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
internal class RefreshTokenGateway(
Expand Down Expand Up @@ -48,6 +49,7 @@ internal class RefreshTokenGateway(
return encoder.encodeToString(savedRefreshToken.refreshToken)
}

@Transactional
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

삭제 로직의 안정성 검토
deleteRefreshToken 메서드가 트랜잭션 범위 내에서 실행되므로, 중간에 예외가 발생해도 일관된 데이터 상태를 유지할 수 있습니다. 단, deleteByUserId 실행 후 에러가 발생할 가능성을 염두에 두고 적절한 예외 처리를 고려해보시는 것이 좋겠습니다. 필요하다면 코틀린 runCatching { ... } 등을 사용해 예외 상황을 처리하거나 롤백 로직을 명시적으로 다룰 수도 있습니다.

override fun deleteRefreshToken(userId: UserId) {
refreshTokenJpaRepository.deleteByUserId(userId)
}
Expand Down
Loading
Loading