diff --git a/.github/workflows/ci-pull-request.yml b/.github/workflows/ci-pull-request.yml index a94739c..cd87c1f 100644 --- a/.github/workflows/ci-pull-request.yml +++ b/.github/workflows/ci-pull-request.yml @@ -3,19 +3,14 @@ name: CI - Pull request on: pull_request: branches: [ main ] - issue_comment: - types: [ created ] permissions: contents: read - issues: write - pull-requests: write jobs: auto-check: name: 자동 검증 (ktlint + test) runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} steps: - name: Repository 접근 uses: actions/checkout@v4 @@ -43,66 +38,7 @@ jobs: run: ./gradlew ktlintCheck - name: 테스트 실행 - run: ./gradlew test + run: ./gradlew :linktrip-application:test - build: - name: ready for launch (CI) - runs-on: ubuntu-latest - if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, '빌드검증')}} - steps: - - name: Get PR branch - id: get_branch - run: | - PR_NUMBER=$(jq --raw-output .issue.number "$GITHUB_EVENT_PATH") - BRANCH_NAME=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ - "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | \ - jq -r .head.ref) - echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT - - - name: Repository 접근 - uses: actions/checkout@v4 - with: - ref: ${{ steps.get_branch.outputs.branch }} - - - name: JDK 21 셋팅 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Gradle 의존성 캐싱 - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: ktlint check - run: ./gradlew ktlintCheck - - - name: 프로젝트 빌드 (테스트 포함) - run: ./gradlew build - - - name: Set build status output - id: set_status - run: echo "build_status=${{ job.status }}" >> $GITHUB_OUTPUT - - - name: Comment on Pull Request - if: always() - uses: actions/github-script@v7 - with: - script: | - const status = '${{ steps.set_status.outputs.build_status }}'; - const message = status === 'success' ? '빌드 성공 \n‍🚀' : '❌❌ 빌드 실패 ❌❌\n 코드 확인이 필요해요😵‍💫'; - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `${message}` - }); + - name: 프로젝트 빌드 + run: ./gradlew build -x test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d85d096..257cec8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ kotlin-logging = { group = "io.github.microutils", name = "kotlin-logging-jvm", # Spring spring-web = { group = "org.springframework", name = "spring-web" } spring-transaction = { group = "org.springframework", name = "spring-tx" } +spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" } spring-boot-starter-autoconfigure = { group = "org.springframework.boot", name = "spring-boot-autoconfigure" } spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } @@ -88,10 +89,10 @@ test-runtime = ["junit"] # 모듈별 bootstrap = ["spring-boot-starter-web", "spring-boot-starter-actuator"] domain-application = ["spring-boot-docs", "spring-transaction", "kotlinx-coroutines-core"] -adaptor-input-http = ["spring-boot-starter-web", "spring-boot-starter-aop", "spring-boot-docs", "spring-boot-starter-validation", "jjwt-api", "jjwt-impl", "jjwt-jackson"] +adaptor-input-http = ["spring-boot-starter-web", "spring-boot-starter-aop", "spring-boot-docs", "spring-boot-starter-validation", "spring-boot-starter-security", "jjwt-api", "jjwt-impl", "jjwt-jackson"] adaptor-persistence-mysql = ["spring-boot-starter-jpa", "mysql"] adaptor-cache-redis = ["spring-boot-starter-cache", "spring-boot-starter-redis"] adaptor-cache-caffeine = ["spring-boot-starter-cache", "caffeine", "bucket4j-core"] adaptor-storage-aws = ["spring-web", "aws-sdk-s3"] -adaptor-output-http = ["spring-web", "google-genai"] +adaptor-output-http = ["spring-web", "google-genai", "jjwt-api", "jjwt-impl", "jjwt-jackson"] adaptor-input-batch = ["spring-boot-starter-batch"] diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt index d5fcf38..8a8144b 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt @@ -2,31 +2,40 @@ package com.linktrip.application.domain.member import com.linktrip.application.port.input.AuthUseCase import com.linktrip.application.port.input.AuthUseCase.AuthResult +import com.linktrip.application.port.output.auth.OAuthPort import com.linktrip.application.port.output.auth.TokenProvider -import com.linktrip.application.port.output.persistence.MemberPort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException +import mu.KotlinLogging import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional + +private val logger = KotlinLogging.logger {} @Service class AuthService( - private val memberPort: MemberPort, + private val oAuthPorts: List, + private val memberService: MemberService, private val tokenProvider: TokenProvider, ) : AuthUseCase { - @Transactional - override fun authenticateBySerial(serialNumber: String): AuthResult { - val existingMember = memberPort.findBySerialNumber(serialNumber) - - val member = - existingMember ?: memberPort.save( - Member.create(serialNumber = serialNumber), - ) + override fun socialLogin( + providerType: ProviderType, + accessToken: String, + ): AuthResult { + val oAuthPort = getOAuthPort(providerType) + val oAuthInfo = oAuthPort.requestUserInfo(accessToken) + logger.info { "OAuth 사용자 정보 조회 완료: provider=$providerType" } - val accessToken = tokenProvider.create(member.id) + val result = memberService.findOrCreateByOAuth(providerType, oAuthInfo) + val token = tokenProvider.create(result.member.id) return AuthResult( - memberId = member.id, - accessToken = accessToken, - isNewMember = existingMember == null, + memberId = result.member.id, + accessToken = token, + isNewMember = result.isNewMember, ) } + + private fun getOAuthPort(providerType: ProviderType): OAuthPort = + oAuthPorts.find { it.getProviderType() == providerType } + ?: throw LinktripException(ExceptionCode.BAD_REQUEST_OAUTH_PROVIDER, providerType.name) } diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt index ed3080f..e8222cb 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt @@ -5,15 +5,23 @@ import java.time.LocalDateTime data class Member( val id: String, - val serialNumber: String, + val email: String?, + val providerType: ProviderType, + val providerId: String, val createdAt: LocalDateTime = LocalDateTime.now(), val updatedAt: LocalDateTime = LocalDateTime.now(), ) { companion object { - fun create(serialNumber: String): Member = + fun create( + email: String?, + providerType: ProviderType, + providerId: String, + ): Member = Member( id = IdGenerator.generate(), - serialNumber = serialNumber, + email = email, + providerType = providerType, + providerId = providerId, ) } } diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/MemberService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/MemberService.kt new file mode 100644 index 0000000..79b088d --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/MemberService.kt @@ -0,0 +1,40 @@ +package com.linktrip.application.domain.member + +import com.linktrip.application.port.output.auth.OAuthInfo +import com.linktrip.application.port.output.persistence.MemberPort +import mu.KotlinLogging +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +private val logger = KotlinLogging.logger {} + +@Service +class MemberService( + private val memberPort: MemberPort, +) { + @Transactional + fun findOrCreateByOAuth( + providerType: ProviderType, + oAuthInfo: OAuthInfo, + ): MemberLoginResult { + memberPort.findByProviderTypeAndProviderId(providerType, oAuthInfo.providerId) + ?.let { return MemberLoginResult(member = it, isNewMember = false) } + + val newMember = + memberPort.save( + Member.create( + email = oAuthInfo.email, + providerType = providerType, + providerId = oAuthInfo.providerId, + ), + ) + + logger.info { "신규 회원 생성: memberId=${newMember.id}, provider=$providerType" } + return MemberLoginResult(member = newMember, isNewMember = true) + } + + data class MemberLoginResult( + val member: Member, + val isNewMember: Boolean, + ) +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/ProviderType.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/ProviderType.kt new file mode 100644 index 0000000..ac6f37c --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/ProviderType.kt @@ -0,0 +1,7 @@ +package com.linktrip.application.domain.member + +enum class ProviderType { + KAKAO, + GOOGLE, + APPLE, +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt index 50b5033..354d4f4 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt @@ -1,7 +1,12 @@ package com.linktrip.application.port.input +import com.linktrip.application.domain.member.ProviderType + interface AuthUseCase { - fun authenticateBySerial(serialNumber: String): AuthResult + fun socialLogin( + providerType: ProviderType, + accessToken: String, + ): AuthResult data class AuthResult( val memberId: String, diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthInfo.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthInfo.kt new file mode 100644 index 0000000..89d21ca --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthInfo.kt @@ -0,0 +1,9 @@ +package com.linktrip.application.port.output.auth + +import com.linktrip.application.domain.member.ProviderType + +data class OAuthInfo( + val providerType: ProviderType, + val providerId: String, + val email: String?, +) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthPort.kt new file mode 100644 index 0000000..45d6f51 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthPort.kt @@ -0,0 +1,9 @@ +package com.linktrip.application.port.output.auth + +import com.linktrip.application.domain.member.ProviderType + +interface OAuthPort { + fun getProviderType(): ProviderType + + fun requestUserInfo(accessToken: String): OAuthInfo +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt index ac71808..7d4bac1 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt @@ -1,9 +1,13 @@ package com.linktrip.application.port.output.persistence import com.linktrip.application.domain.member.Member +import com.linktrip.application.domain.member.ProviderType interface MemberPort { - fun findBySerialNumber(serialNumber: String): Member? + fun findByProviderTypeAndProviderId( + providerType: ProviderType, + providerId: String, + ): Member? fun save(member: Member): Member } diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt index 028ba97..9f54974 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt @@ -1,13 +1,15 @@ package com.linktrip.application.domain.member +import com.linktrip.application.port.output.auth.OAuthInfo +import com.linktrip.application.port.output.auth.OAuthPort import com.linktrip.application.port.output.auth.TokenProvider import com.linktrip.application.port.output.persistence.MemberPort import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any @@ -23,20 +25,39 @@ class AuthServiceTest { @Mock lateinit var tokenProvider: TokenProvider - @InjectMocks + @Mock + lateinit var kakaoOAuthPort: OAuthPort + lateinit var service: AuthService + @BeforeEach + fun setUp() { + whenever(kakaoOAuthPort.getProviderType()).thenReturn(ProviderType.KAKAO) + val memberService = MemberService(memberPort) + service = AuthService(listOf(kakaoOAuthPort), memberService, tokenProvider) + } + @Test - fun `이미 가입된 시리얼 번호로 인증하면_기존 회원 정보를 조회하고_새로 저장하지 않으며_isNewMember가 false이다`() { - // given - 이미 가입된 시리얼 번호의 회원 - val existingMember = Member(id = "member-1", serialNumber = "serial-123") - whenever(memberPort.findBySerialNumber("serial-123")).thenReturn(existingMember) + fun `이미 가입된 프로바이더로 로그인하면 기존 회원 정보를 반환하고 isNewMember가 false이다`() { + // given + val oAuthInfo = OAuthInfo(ProviderType.KAKAO, "kakao-123", "test@email.com") + whenever(kakaoOAuthPort.requestUserInfo("access-token")).thenReturn(oAuthInfo) + + val existingMember = + Member( + id = "member-1", + email = "test@email.com", + providerType = ProviderType.KAKAO, + providerId = "kakao-123", + ) + whenever(memberPort.findByProviderTypeAndProviderId(ProviderType.KAKAO, "kakao-123")) + .thenReturn(existingMember) whenever(tokenProvider.create("member-1")).thenReturn("token-abc") - // when - 기존 시리얼 번호로 인증한다 - val result = service.authenticateBySerial("serial-123") + // when + val result = service.socialLogin(ProviderType.KAKAO, "access-token") - // then - 기존 회원 정보를 반환하고, 새로 저장하지 않으며, isNewMember가 false이다 + // then assertEquals("member-1", result.memberId) assertEquals("token-abc", result.accessToken) assertFalse(result.isNewMember) @@ -44,17 +65,27 @@ class AuthServiceTest { } @Test - fun `처음 보는 시리얼 번호로 인증하면_새 회원을 생성하여 저장하고_isNewMember가 true이다`() { - // given - DB에 존재하지 않는 신규 시리얼 번호 - whenever(memberPort.findBySerialNumber("new-serial")).thenReturn(null) - val savedMember = Member(id = "new-member-1", serialNumber = "new-serial") + fun `신규 프로바이더로 로그인하면 새 회원을 생성하고 isNewMember가 true이다`() { + // given + val oAuthInfo = OAuthInfo(ProviderType.KAKAO, "kakao-456", "new@email.com") + whenever(kakaoOAuthPort.requestUserInfo("new-access-token")).thenReturn(oAuthInfo) + whenever(memberPort.findByProviderTypeAndProviderId(ProviderType.KAKAO, "kakao-456")) + .thenReturn(null) + + val savedMember = + Member( + id = "new-member-1", + email = "new@email.com", + providerType = ProviderType.KAKAO, + providerId = "kakao-456", + ) whenever(memberPort.save(any())).thenReturn(savedMember) whenever(tokenProvider.create("new-member-1")).thenReturn("new-token") - // when - 신규 시리얼 번호로 인증한다 - val result = service.authenticateBySerial("new-serial") + // when + val result = service.socialLogin(ProviderType.KAKAO, "new-access-token") - // then - 새 회원이 저장되고, isNewMember가 true이다 + // then assertEquals("new-member-1", result.memberId) assertEquals("new-token", result.accessToken) assertTrue(result.isNewMember) @@ -62,16 +93,26 @@ class AuthServiceTest { } @Test - fun `기존 회원이든 신규 회원이든_인증 시 항상 memberId 기반의 JWT 토큰이 생성된다`() { - // given - 기존 회원 - val member = Member(id = "m1", serialNumber = "s1") - whenever(memberPort.findBySerialNumber("s1")).thenReturn(member) - whenever(tokenProvider.create("m1")).thenReturn("token") + fun `소셜 로그인 시 항상 memberId 기반의 JWT 토큰이 생성된다`() { + // given + val oAuthInfo = OAuthInfo(ProviderType.KAKAO, "kakao-789", "user@email.com") + whenever(kakaoOAuthPort.requestUserInfo("token")).thenReturn(oAuthInfo) + + val member = + Member( + id = "m1", + email = "user@email.com", + providerType = ProviderType.KAKAO, + providerId = "kakao-789", + ) + whenever(memberPort.findByProviderTypeAndProviderId(ProviderType.KAKAO, "kakao-789")) + .thenReturn(member) + whenever(tokenProvider.create("m1")).thenReturn("jwt-token") - // when - 인증을 수행한다 - service.authenticateBySerial("s1") + // when + service.socialLogin(ProviderType.KAKAO, "token") - // then - memberId 기반으로 토큰이 생성된다 + // then verify(tokenProvider).create("m1") } } diff --git a/linktrip-bootstrap/src/main/resources/application.yml b/linktrip-bootstrap/src/main/resources/application.yml index c532c81..b2c0a38 100644 --- a/linktrip-bootstrap/src/main/resources/application.yml +++ b/linktrip-bootstrap/src/main/resources/application.yml @@ -42,5 +42,9 @@ gcp: vertex-ai: location: ${GCP_VERTEX_AI_LOCATION:us-central1} +oauth: + apple: + bundle-id: ${APPLE_BUNDLE_ID:} + youtube: api-key: ${YOUTUBE_API_KEY} diff --git a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt index e90eb96..b992d1c 100644 --- a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt +++ b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt @@ -9,6 +9,7 @@ enum class ExceptionCode( BAD_REQUEST_VIDEO(400, "유효하지 않은 유튜브 영상입니다."), BAD_REQUEST_ITINERARY_ITEMS(400, "유효하지 않은 여행 일정 항목입니다."), BAD_REQUEST_DISCOVER_QUERY(400, "country와 region은 동시에 사용할 수 없습니다."), + BAD_REQUEST_OAUTH_PROVIDER(400, "지원하지 않는 OAuth 프로바이더입니다."), // 401 UNAUTHORIZED_TOKEN_EXPIRED(401, "만료된 토큰입니다."), @@ -17,6 +18,7 @@ enum class ExceptionCode( UNAUTHORIZED_AUTHENTICATION_FAILED(401, "인증 정보가 없습니다."), // 403 + FORBIDDEN_ACCESS_DENIED(403, "접근이 거부되었습니다."), FORBIDDEN_TRIP_PLAN(403, "해당 여행 계획에 접근할 수 없습니다."), // 404 @@ -37,4 +39,5 @@ enum class ExceptionCode( BAD_GATEWAY_GOOGLE_PLACES(502, "Google Places API 호출 중 오류가 발생했습니다."), BAD_GATEWAY_GEMINI(502, "Gemini AI API 호출 중 오류가 발생했습니다."), BAD_GATEWAY_DISCORD(502, "Discord API 호출 중 오류가 발생했습니다."), + BAD_GATEWAY_OAUTH_PROVIDER(502, "OAuth 프로바이더 API 호출 중 오류가 발생했습니다."), } diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt index 5e87bc9..ed0c305 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt @@ -1,8 +1,5 @@ package com.linktrip.input.http.config -import com.fasterxml.jackson.databind.ObjectMapper -import com.linktrip.application.port.output.auth.TokenProvider -import com.linktrip.input.http.filter.JwtAuthenticationFilter import com.linktrip.input.http.filter.MdcLoggingFilter import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.context.annotation.Bean @@ -17,15 +14,4 @@ class FilterConfig { order = 1 addUrlPatterns("/*") } - - @Bean - fun jwtAuthenticationFilter( - tokenProvider: TokenProvider, - objectMapper: ObjectMapper, - ): FilterRegistrationBean = - FilterRegistrationBean().apply { - filter = JwtAuthenticationFilter(tokenProvider, objectMapper) - order = 2 - addUrlPatterns("/*") - } } diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt index 8bc0018..b7cdc18 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt @@ -1,7 +1,7 @@ package com.linktrip.input.http.config -import com.linktrip.input.http.auth.AuthenticatedMemberArgumentResolver import com.linktrip.input.http.ratelimit.RateLimitInterceptor +import com.linktrip.input.http.security.AuthenticatedMemberArgumentResolver import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.InterceptorRegistry diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt index b5f50f5..be2ade4 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt @@ -1,9 +1,9 @@ package com.linktrip.input.http.controller import com.linktrip.application.port.input.AuthUseCase -import com.linktrip.input.http.controller.dto.request.AuthRequest +import com.linktrip.input.http.controller.dto.request.OAuthLoginRequest import com.linktrip.input.http.controller.dto.response.ApiResponse -import com.linktrip.input.http.controller.dto.response.AuthResponse +import com.linktrip.input.http.controller.dto.response.OAuthLoginResponse import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -17,14 +17,19 @@ class AuthController( ) { @PostMapping("/login") fun login( - @Validated @RequestBody request: AuthRequest, - ): ApiResponse { - val result = authUseCase.authenticateBySerial(request.serialNumber) + @Validated @RequestBody request: OAuthLoginRequest, + ): ApiResponse { + val result = + authUseCase.socialLogin( + providerType = request.providerType, + accessToken = request.accessToken, + ) val response = - AuthResponse( + OAuthLoginResponse( memberId = result.memberId, accessToken = result.accessToken, + isNewMember = result.isNewMember, ) return if (result.isNewMember) { diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/TripPlanController.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/TripPlanController.kt index 6e6b87f..5717880 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/TripPlanController.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/TripPlanController.kt @@ -3,13 +3,13 @@ package com.linktrip.input.http.controller import com.linktrip.application.port.input.TripPlanUseCase import com.linktrip.application.port.input.UpdateTripPlanCommand import com.linktrip.application.port.input.UpdateTripPlanItemCommand -import com.linktrip.input.http.auth.AuthenticatedMember import com.linktrip.input.http.config.PaginationDefaults import com.linktrip.input.http.controller.docs.TripPlanDocs import com.linktrip.input.http.controller.dto.request.UpdateTripPlanRequest import com.linktrip.input.http.controller.dto.response.ApiResponse import com.linktrip.input.http.controller.dto.response.TripPlanCursorResponse import com.linktrip.input.http.controller.dto.response.TripPlanDetailResponse +import com.linktrip.input.http.security.AuthenticatedMember import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt index 07b4540..b6ce859 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt @@ -8,7 +8,6 @@ import com.linktrip.application.port.input.VideoAnalyzeUseCase import com.linktrip.application.port.input.VideoScheduleUseCase import com.linktrip.common.exception.ExceptionCode import com.linktrip.common.exception.LinktripException -import com.linktrip.input.http.auth.AuthenticatedMember import com.linktrip.input.http.config.PaginationDefaults import com.linktrip.input.http.controller.docs.VideoDocs import com.linktrip.input.http.controller.dto.request.VideoAnalyzeRequest @@ -18,6 +17,7 @@ import com.linktrip.input.http.controller.dto.response.DiscoverVideoCursorRespon import com.linktrip.input.http.controller.dto.response.DiscoverVideoResponses import com.linktrip.input.http.controller.dto.response.VideoAnalyzeAcceptResponse import com.linktrip.input.http.controller.dto.response.VideoAnalyzeResponse +import com.linktrip.input.http.security.AuthenticatedMember import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt deleted file mode 100644 index fde1777..0000000 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.linktrip.input.http.controller.dto.request - -import jakarta.validation.constraints.NotBlank - -data class AuthRequest( - @field:NotBlank(message = "시리얼 넘버는 필수입니다.") - val serialNumber: String, -) diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt new file mode 100644 index 0000000..0a03dbf --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt @@ -0,0 +1,12 @@ +package com.linktrip.input.http.controller.dto.request + +import com.linktrip.application.domain.member.ProviderType +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull + +data class OAuthLoginRequest( + @field:NotBlank(message = "액세스 토큰은 필수입니다.") + val accessToken: String, + @field:NotNull(message = "프로바이더 타입은 필수입니다.") + val providerType: ProviderType, +) diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/AuthResponse.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/OAuthLoginResponse.kt similarity index 65% rename from linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/AuthResponse.kt rename to linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/OAuthLoginResponse.kt index fd32a35..d545ce9 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/AuthResponse.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/OAuthLoginResponse.kt @@ -1,6 +1,7 @@ package com.linktrip.input.http.controller.dto.response -data class AuthResponse( +data class OAuthLoginResponse( val memberId: String, val accessToken: String, + val isNewMember: Boolean, ) diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/FilterPaths.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/FilterPaths.kt deleted file mode 100644 index 0919fcb..0000000 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/FilterPaths.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.linktrip.input.http.filter - -object FilterPaths { - val JWT_WHITELIST = - listOf( - "/api/health", - "/api/auth/login", - "/api/swagger-ui", - "/api/api-docs", - "/api/v3/api-docs", - "/api/test/", - "/actuator", - ) - - val LOGGING_SKIP = - listOf( - "/api/health/", - ) -} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt index 7027ef5..19531e5 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.linktrip.application.port.output.auth.TokenProvider import com.linktrip.common.exception.ExceptionCode import com.linktrip.input.http.controller.dto.response.ExceptionResponse +import com.linktrip.input.http.security.SecurityPaths import jakarta.servlet.Filter import jakarta.servlet.FilterChain import jakarta.servlet.ServletRequest @@ -54,7 +55,10 @@ class JwtAuthenticationFilter( chain.doFilter(request, response) } - private fun isWhitelisted(uri: String): Boolean = FilterPaths.JWT_WHITELIST.any { uri.startsWith(it) } + private fun isWhitelisted(uri: String): Boolean = + SecurityPaths.PUBLIC_ENDPOINTS.any { + uri.startsWith(it.removeSuffix("/**")) + } private fun extractToken(request: HttpServletRequest): String? { val header = request.getHeader(AUTHORIZATION_HEADER) ?: return null diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt index 8f718f4..80e61ef 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt @@ -25,7 +25,7 @@ class MdcLoggingFilter : Filter { val httpRequest = request as HttpServletRequest val httpResponse = response as HttpServletResponse val uri = httpRequest.requestURI - val skipLogging = FilterPaths.LOGGING_SKIP.any { uri.startsWith(it) } + val skipLogging = LOGGING_SKIP.any { uri.startsWith(it) } val startTime = System.currentTimeMillis() try { @@ -81,4 +81,11 @@ class MdcLoggingFilter : Filter { request.getHeader("X-Forwarded-For")?.split(",")?.firstOrNull()?.trim() ?: request.getHeader("X-Real-IP") ?: request.remoteAddr + + companion object { + private val LOGGING_SKIP = + listOf( + "/api/health/", + ) + } } diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt index 1d798fe..0149dff 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt @@ -3,9 +3,9 @@ package com.linktrip.input.http.ratelimit import com.linktrip.application.port.output.ratelimit.RateLimitBucketStore import com.linktrip.common.exception.ExceptionCode import com.linktrip.common.exception.LinktripException -import com.linktrip.input.http.filter.JwtAuthenticationFilter import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component import org.springframework.web.servlet.HandlerInterceptor @@ -19,7 +19,7 @@ class RateLimitInterceptor( handler: Any, ): Boolean { val memberId = - request.getAttribute(JwtAuthenticationFilter.MEMBER_ID_ATTRIBUTE) as? String + SecurityContextHolder.getContext().authentication?.principal as? String ?: return true val policy = diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/AuthenticatedMember.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMember.kt similarity index 74% rename from linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/AuthenticatedMember.kt rename to linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMember.kt index 4afe42c..5f56e90 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/AuthenticatedMember.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMember.kt @@ -1,4 +1,4 @@ -package com.linktrip.input.http.auth +package com.linktrip.input.http.security @Target(AnnotationTarget.VALUE_PARAMETER) @Retention(AnnotationRetention.RUNTIME) diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/AuthenticatedMemberArgumentResolver.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt similarity index 72% rename from linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/AuthenticatedMemberArgumentResolver.kt rename to linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt index 200c89d..32a5424 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/AuthenticatedMemberArgumentResolver.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt @@ -1,10 +1,9 @@ -package com.linktrip.input.http.auth +package com.linktrip.input.http.security import com.linktrip.common.exception.ExceptionCode import com.linktrip.common.exception.LinktripException -import com.linktrip.input.http.filter.JwtAuthenticationFilter -import jakarta.servlet.http.HttpServletRequest import org.springframework.core.MethodParameter +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest @@ -23,11 +22,11 @@ class AuthenticatedMemberArgumentResolver : HandlerMethodArgumentResolver { webRequest: NativeWebRequest, binderFactory: WebDataBinderFactory?, ): String { - val request = - webRequest.getNativeRequest(HttpServletRequest::class.java) - ?: throw LinktripException(ExceptionCode.INTERNAL_SERVER_ERROR) + val authentication = + SecurityContextHolder.getContext().authentication + ?: throw LinktripException(ExceptionCode.UNAUTHORIZED_AUTHENTICATION_FAILED) - return request.getAttribute(JwtAuthenticationFilter.MEMBER_ID_ATTRIBUTE) as? String + return authentication.principal as? String ?: throw LinktripException(ExceptionCode.UNAUTHORIZED_AUTHENTICATION_FAILED) } } diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt new file mode 100644 index 0000000..ad995e1 --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt @@ -0,0 +1,32 @@ +package com.linktrip.input.http.security + +import com.linktrip.application.port.output.auth.TokenProvider +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.core.Authentication +import org.springframework.stereotype.Component + +@Component +class JwtAuthenticationProvider( + private val tokenProvider: TokenProvider, +) : AuthenticationProvider { + override fun authenticate(authentication: Authentication): Authentication { + val jwt = authentication.credentials as String + + try { + if (!tokenProvider.validate(jwt)) { + throw BadCredentialsException("유효하지 않은 JWT 토큰입니다.") + } + + val memberId = tokenProvider.extractMemberId(jwt) + return PostAuthorizationToken(memberId) + } catch (e: BadCredentialsException) { + throw e + } catch (e: Exception) { + throw BadCredentialsException("JWT 인증 처리 중 오류가 발생했습니다.", e) + } + } + + override fun supports(authentication: Class<*>): Boolean = + PreAuthorizationToken::class.java.isAssignableFrom(authentication) +} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt new file mode 100644 index 0000000..c16e8cd --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt @@ -0,0 +1,54 @@ +package com.linktrip.input.http.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.web.filter.OncePerRequestFilter + +class JwtSecurityFilter( + private val authenticationManager: AuthenticationManager, + private val authenticationEntryPoint: AuthenticationEntryPoint, +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val token = extractToken(request) + + if (token == null) { + filterChain.doFilter(request, response) + return + } + + val postAuthToken = + try { + authenticationManager.authenticate(PreAuthorizationToken(token)) + } catch (e: AuthenticationException) { + SecurityContextHolder.clearContext() + authenticationEntryPoint.commence(request, response, e) + return + } + + SecurityContextHolder.getContext().authentication = postAuthToken + filterChain.doFilter(request, response) + } + + private fun extractToken(request: HttpServletRequest): String? { + val header = request.getHeader(AUTHORIZATION_HEADER) ?: return null + return if (header.startsWith(BEARER_PREFIX)) { + header.substring(BEARER_PREFIX.length) + } else { + null + } + } + + companion object { + private const val AUTHORIZATION_HEADER = "Authorization" + private const val BEARER_PREFIX = "Bearer " + } +} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/JwtTokenProvider.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt similarity index 85% rename from linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/JwtTokenProvider.kt rename to linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt index 0b3dc44..6a9a068 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/auth/JwtTokenProvider.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt @@ -1,4 +1,4 @@ -package com.linktrip.input.http.auth +package com.linktrip.input.http.security import com.linktrip.application.port.output.auth.TokenProvider import com.linktrip.common.exception.ExceptionCode @@ -47,6 +47,9 @@ class JwtTokenProvider( } catch (e: JwtException) { logger.warn { "유효하지 않은 JWT 토큰: ${e.message}" } false + } catch (e: IllegalArgumentException) { + logger.warn { "잘못된 JWT 토큰: ${e.message}" } + false } override fun extractMemberId(token: String): String = @@ -63,5 +66,8 @@ class JwtTokenProvider( } catch (e: JwtException) { logger.warn { "유효하지 않은 JWT 토큰으로 memberId 추출 시도: ${e.message}" } throw LinktripException(ExceptionCode.UNAUTHORIZED_TOKEN_INVALID) + } catch (e: IllegalArgumentException) { + logger.warn { "잘못된 JWT 토큰으로 memberId 추출 시도: ${e.message}" } + throw LinktripException(ExceptionCode.UNAUTHORIZED_TOKEN_INVALID) } } diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt new file mode 100644 index 0000000..736abd9 --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt @@ -0,0 +1,15 @@ +package com.linktrip.input.http.security + +import org.springframework.security.authentication.AbstractAuthenticationToken + +class PostAuthorizationToken( + private val memberId: String, +) : AbstractAuthenticationToken(emptyList()) { + init { + isAuthenticated = true + } + + override fun getCredentials(): Any? = null + + override fun getPrincipal(): String = memberId +} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PreAuthorizationToken.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PreAuthorizationToken.kt new file mode 100644 index 0000000..41f59b4 --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PreAuthorizationToken.kt @@ -0,0 +1,15 @@ +package com.linktrip.input.http.security + +import org.springframework.security.authentication.AbstractAuthenticationToken + +class PreAuthorizationToken( + private val jwt: String, +) : AbstractAuthenticationToken(null) { + init { + isAuthenticated = false + } + + override fun getCredentials(): String = jwt + + override fun getPrincipal(): Any? = null +} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt new file mode 100644 index 0000000..72f672a --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt @@ -0,0 +1,60 @@ +package com.linktrip.input.http.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.annotation.Order +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.ProviderManager +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val authenticationProvider: AuthenticationProvider, + private val authenticationEntryPoint: AuthenticationEntryPoint, + private val accessDeniedHandler: AccessDeniedHandler, +) { + @Bean + fun authenticationManager(): AuthenticationManager = ProviderManager(authenticationProvider) + + @Bean + @Order(1) + fun publicFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain = + httpSecurity + .securityMatcher(*SecurityPaths.PUBLIC_ENDPOINTS) + .csrf { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { it.anyRequest().permitAll() } + .httpBasic { it.disable() } + .formLogin { it.disable() } + .build() + + @Bean + @Order(2) + fun protectedFilterChain( + httpSecurity: HttpSecurity, + authenticationManager: AuthenticationManager, + ): SecurityFilterChain = + httpSecurity + .csrf { it.disable() } + .formLogin { it.disable() } + .httpBasic { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .exceptionHandling { + it.authenticationEntryPoint(authenticationEntryPoint) + it.accessDeniedHandler(accessDeniedHandler) + } + .authorizeHttpRequests { it.anyRequest().authenticated() } + .addFilterBefore( + JwtSecurityFilter(authenticationManager, authenticationEntryPoint), + UsernamePasswordAuthenticationFilter::class.java, + ) + .build() +} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt new file mode 100644 index 0000000..32abb02 --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt @@ -0,0 +1,15 @@ +package com.linktrip.input.http.security + +object SecurityPaths { + val PUBLIC_ENDPOINTS = + arrayOf( + "/auth/login/**", + "/health/**", + "/swagger-ui/**", + "/api-docs/**", + "/v3/api-docs/**", + "/actuator/health/**", + "/test/**", + "/actuator/**", + ) +} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAccessDeniedHandler.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAccessDeniedHandler.kt new file mode 100644 index 0000000..f09b456 --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAccessDeniedHandler.kt @@ -0,0 +1,38 @@ +package com.linktrip.input.http.security.handler + +import com.fasterxml.jackson.databind.ObjectMapper +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.input.http.controller.dto.response.ExceptionResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component + +@Component +class JwtAccessDeniedHandler( + private val objectMapper: ObjectMapper, +) : AccessDeniedHandler { + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException, + ) { + val exceptionCode = ExceptionCode.FORBIDDEN_ACCESS_DENIED + + response.status = exceptionCode.statusCode + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.writer.write( + objectMapper.writeValueAsString( + ExceptionResponse( + code = exceptionCode.name, + message = exceptionCode.defaultMessage, + cause = null, + timestamp = System.currentTimeMillis(), + ), + ), + ) + } +} diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt new file mode 100644 index 0000000..fa5e2cc --- /dev/null +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt @@ -0,0 +1,38 @@ +package com.linktrip.input.http.security.handler + +import com.fasterxml.jackson.databind.ObjectMapper +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.input.http.controller.dto.response.ExceptionResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class JwtAuthenticationEntryPoint( + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException, + ) { + val exceptionCode = ExceptionCode.UNAUTHORIZED_AUTHENTICATION_FAILED + + response.status = exceptionCode.statusCode + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = Charsets.UTF_8.name() + response.writer.write( + objectMapper.writeValueAsString( + ExceptionResponse( + code = exceptionCode.name, + message = exceptionCode.defaultMessage, + cause = null, + timestamp = System.currentTimeMillis(), + ), + ), + ) + } +} diff --git a/linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt b/linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt index b66bc0d..386c5d0 100644 --- a/linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt +++ b/linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt @@ -4,7 +4,7 @@ import com.linktrip.application.port.output.ratelimit.RateLimitBucketStore import com.linktrip.application.port.output.ratelimit.RateLimitPolicy import com.linktrip.common.exception.ExceptionCode import com.linktrip.common.exception.LinktripException -import com.linktrip.input.http.filter.JwtAuthenticationFilter +import com.linktrip.input.http.security.PostAuthorizationToken import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -20,6 +20,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder @ExtendWith(MockitoExtension::class) class RateLimitInterceptorTest { @@ -29,11 +30,19 @@ class RateLimitInterceptorTest { @InjectMocks lateinit var interceptor: RateLimitInterceptor + private fun setAuthentication(memberId: String) { + SecurityContextHolder.getContext().authentication = PostAuthorizationToken(memberId) + } + + private fun clearAuthentication() { + SecurityContextHolder.clearContext() + } + @Test fun `인증된 사용자의 요청이 rate limit 이내이면 통과한다`() { // given val request = MockHttpServletRequest("POST", "/video/analyze") - request.setAttribute(JwtAuthenticationFilter.MEMBER_ID_ATTRIBUTE, "member1") + setAuthentication("member1") whenever(rateLimitBucketStore.tryConsume(eq("member1:VIDEO_ANALYZE"), eq(RateLimitPolicy.VIDEO_ANALYZE))) .thenReturn(true) @@ -42,13 +51,14 @@ class RateLimitInterceptorTest { // then assertTrue(result) + clearAuthentication() } @Test fun `rate limit을 초과하면 LinktripException(RATE_LIMIT_EXCEEDED)을 던진다`() { // given val request = MockHttpServletRequest("POST", "/video/analyze") - request.setAttribute(JwtAuthenticationFilter.MEMBER_ID_ATTRIBUTE, "member1") + setAuthentication("member1") whenever(rateLimitBucketStore.tryConsume(eq("member1:VIDEO_ANALYZE"), eq(RateLimitPolicy.VIDEO_ANALYZE))) .thenReturn(false) @@ -58,11 +68,13 @@ class RateLimitInterceptorTest { interceptor.preHandle(request, MockHttpServletResponse(), Any()) } assertEquals(ExceptionCode.TOO_MANY_REQUESTS.statusCode, exception.statusCode) + clearAuthentication() } @Test fun `인증되지 않은 요청은 rate limit 체크 없이 통과한다`() { - // given - memberId 속성 없음 + // given + clearAuthentication() val request = MockHttpServletRequest("POST", "/video/analyze") // when @@ -77,7 +89,7 @@ class RateLimitInterceptorTest { fun `일반 경로도 DEFAULT 정책으로 rate limit이 적용된다`() { // given val request = MockHttpServletRequest("GET", "/video/abc123/schedule") - request.setAttribute(JwtAuthenticationFilter.MEMBER_ID_ATTRIBUTE, "member1") + setAuthentication("member1") whenever(rateLimitBucketStore.tryConsume(eq("member1:DEFAULT"), eq(RateLimitPolicy.DEFAULT))) .thenReturn(true) @@ -87,5 +99,6 @@ class RateLimitInterceptorTest { // then assertTrue(result) verify(rateLimitBucketStore).tryConsume(eq("member1:DEFAULT"), eq(RateLimitPolicy.DEFAULT)) + clearAuthentication() } } diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt new file mode 100644 index 0000000..f1cd684 --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt @@ -0,0 +1,104 @@ +package com.linktrip.output.http.oauth.adapter + +import com.fasterxml.jackson.databind.ObjectMapper +import com.linktrip.application.domain.member.ProviderType +import com.linktrip.application.port.output.auth.OAuthInfo +import com.linktrip.application.port.output.auth.OAuthPort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException +import com.linktrip.output.http.oauth.dto.ApplePublicKeyResponse +import io.jsonwebtoken.Jwts +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.springframework.web.client.body +import java.math.BigInteger +import java.security.KeyFactory +import java.security.interfaces.RSAPublicKey +import java.security.spec.RSAPublicKeySpec +import java.util.Base64 + +private val logger = KotlinLogging.logger {} + +@Component +class AppleOAuthAdapter( + @param:Qualifier("appleOAuthRestClient") private val restClient: RestClient, + private val objectMapper: ObjectMapper, + @param:Value("\${oauth.apple.bundle-id}") private val appleBundleId: String, +) : OAuthPort { + override fun getProviderType(): ProviderType = ProviderType.APPLE + + override fun requestUserInfo(accessToken: String): OAuthInfo { + val publicKeys = fetchApplePublicKeys() + val claims = validateAndExtractClaims(accessToken, publicKeys) + + val providerId = + claims.subject + ?.takeIf { it.isNotBlank() } + ?: throw LinktripException(ExceptionCode.UNAUTHORIZED_TOKEN_INVALID) + val email = claims["email"] as? String + + logger.debug { "Apple 사용자 정보 조회 성공" } + + return OAuthInfo( + providerType = ProviderType.APPLE, + providerId = providerId, + email = email, + ) + } + + private fun fetchApplePublicKeys(): ApplePublicKeyResponse = + restClient.get() + .uri("/auth/keys") + .retrieve() + .body() + ?: throw LinktripException(ExceptionCode.BAD_GATEWAY_OAUTH_PROVIDER) + + private fun validateAndExtractClaims( + idToken: String, + publicKeys: ApplePublicKeyResponse, + ): io.jsonwebtoken.Claims { + try { + val tokenParts = idToken.split(".") + if (tokenParts.size != 3) throw LinktripException(ExceptionCode.UNAUTHORIZED_TOKEN_INVALID) + + val headerJson = String(Base64.getUrlDecoder().decode(tokenParts[0])) + val headerMap = objectMapper.readValue(headerJson, Map::class.java) + val kid = + headerMap["kid"] as? String + ?: throw LinktripException(ExceptionCode.UNAUTHORIZED_TOKEN_INVALID) + + val matchingKey = + publicKeys.keys.find { it.kid == kid } + ?: throw LinktripException(ExceptionCode.UNAUTHORIZED_TOKEN_INVALID) + + val publicKey = generatePublicKey(matchingKey) + + return Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(APPLE_ISSUER) + .requireAudience(appleBundleId) + .build() + .parseSignedClaims(idToken) + .payload + } catch (e: LinktripException) { + throw e + } catch (e: Exception) { + throw LinktripException(ExceptionCode.UNAUTHORIZED_TOKEN_INVALID) + } + } + + private fun generatePublicKey(key: ApplePublicKeyResponse.AppleKey): RSAPublicKey { + val nBytes = Base64.getUrlDecoder().decode(key.n) + val eBytes = Base64.getUrlDecoder().decode(key.e) + val spec = RSAPublicKeySpec(BigInteger(1, nBytes), BigInteger(1, eBytes)) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePublic(spec) as RSAPublicKey + } + + companion object { + private const val APPLE_ISSUER = "https://appleid.apple.com" + } +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt new file mode 100644 index 0000000..3a9e0fc --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt @@ -0,0 +1,40 @@ +package com.linktrip.output.http.oauth.adapter + +import com.linktrip.application.domain.member.ProviderType +import com.linktrip.application.port.output.auth.OAuthInfo +import com.linktrip.application.port.output.auth.OAuthPort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException +import com.linktrip.output.http.oauth.dto.GoogleUserInfoResponse +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +private val logger = KotlinLogging.logger {} + +@Component +class GoogleOAuthAdapter( + @param:Qualifier("googleOAuthRestClient") private val restClient: RestClient, +) : OAuthPort { + override fun getProviderType(): ProviderType = ProviderType.GOOGLE + + override fun requestUserInfo(accessToken: String): OAuthInfo { + val response = + restClient.get() + .uri("/oauth2/v1/userinfo") + .header("Authorization", "Bearer $accessToken") + .retrieve() + .body() + ?: throw LinktripException(ExceptionCode.BAD_GATEWAY_OAUTH_PROVIDER) + + logger.debug { "Google 사용자 정보 조회 성공: id=${response.id}" } + + return OAuthInfo( + providerType = ProviderType.GOOGLE, + providerId = response.id, + email = response.email, + ) + } +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/KakaoOAuthAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/KakaoOAuthAdapter.kt new file mode 100644 index 0000000..86ae336 --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/KakaoOAuthAdapter.kt @@ -0,0 +1,40 @@ +package com.linktrip.output.http.oauth.adapter + +import com.linktrip.application.domain.member.ProviderType +import com.linktrip.application.port.output.auth.OAuthInfo +import com.linktrip.application.port.output.auth.OAuthPort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException +import com.linktrip.output.http.oauth.dto.KakaoUserInfoResponse +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +private val logger = KotlinLogging.logger {} + +@Component +class KakaoOAuthAdapter( + @param:Qualifier("kakaoOAuthRestClient") private val restClient: RestClient, +) : OAuthPort { + override fun getProviderType(): ProviderType = ProviderType.KAKAO + + override fun requestUserInfo(accessToken: String): OAuthInfo { + val response = + restClient.get() + .uri("/v2/user/me") + .header("Authorization", "Bearer $accessToken") + .retrieve() + .body() + ?: throw LinktripException(ExceptionCode.BAD_GATEWAY_OAUTH_PROVIDER) + + logger.debug { "Kakao 사용자 정보 조회 성공: id=${response.id}" } + + return OAuthInfo( + providerType = ProviderType.KAKAO, + providerId = response.id.toString(), + email = response.kakaoAccount?.email, + ) + } +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt new file mode 100644 index 0000000..70e941e --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt @@ -0,0 +1,60 @@ +package com.linktrip.output.http.oauth.config + +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException +import mu.KotlinLogging +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.web.client.RestClient +import java.time.Duration + +private val logger = KotlinLogging.logger {} + +@Configuration +class OAuthRestClientConfig { + @Bean("kakaoOAuthRestClient") + fun kakaoOAuthRestClient(): RestClient = + RestClient.builder() + .baseUrl(KAKAO_USER_INFO_URL) + .defaultStatusHandler({ it.isError }) { request, response -> + logger.error { "Kakao API 호출 실패: status=${response.statusCode}" } + throw LinktripException(ExceptionCode.BAD_GATEWAY_OAUTH_PROVIDER) + } + .requestFactory(clientHttpRequestFactory()) + .build() + + @Bean("googleOAuthRestClient") + fun googleOAuthRestClient(): RestClient = + RestClient.builder() + .baseUrl(GOOGLE_USER_INFO_URL) + .defaultStatusHandler({ it.isError }) { request, response -> + logger.error { "Google API 호출 실패: status=${response.statusCode}" } + throw LinktripException(ExceptionCode.BAD_GATEWAY_OAUTH_PROVIDER) + } + .requestFactory(clientHttpRequestFactory()) + .build() + + @Bean("appleOAuthRestClient") + fun appleOAuthRestClient(): RestClient = + RestClient.builder() + .baseUrl(APPLE_BASE_URL) + .defaultStatusHandler({ it.isError }) { request, response -> + logger.error { "Apple API 호출 실패: status=${response.statusCode}" } + throw LinktripException(ExceptionCode.BAD_GATEWAY_OAUTH_PROVIDER) + } + .requestFactory(clientHttpRequestFactory()) + .build() + + private fun clientHttpRequestFactory() = + SimpleClientHttpRequestFactory().apply { + setConnectTimeout(Duration.ofSeconds(5)) + setReadTimeout(Duration.ofSeconds(10)) + } + + companion object { + private const val KAKAO_USER_INFO_URL = "https://kapi.kakao.com" + private const val GOOGLE_USER_INFO_URL = "https://www.googleapis.com" + private const val APPLE_BASE_URL = "https://appleid.apple.com" + } +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/ApplePublicKeyResponse.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/ApplePublicKeyResponse.kt new file mode 100644 index 0000000..f9145c9 --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/ApplePublicKeyResponse.kt @@ -0,0 +1,14 @@ +package com.linktrip.output.http.oauth.dto + +data class ApplePublicKeyResponse( + val keys: List, +) { + data class AppleKey( + val kty: String, + val kid: String, + val use: String, + val alg: String, + val n: String, + val e: String, + ) +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/GoogleUserInfoResponse.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/GoogleUserInfoResponse.kt new file mode 100644 index 0000000..510333b --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/GoogleUserInfoResponse.kt @@ -0,0 +1,6 @@ +package com.linktrip.output.http.oauth.dto + +data class GoogleUserInfoResponse( + val id: String, + val email: String?, +) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/KakaoUserInfoResponse.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/KakaoUserInfoResponse.kt new file mode 100644 index 0000000..ca7f70d --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/KakaoUserInfoResponse.kt @@ -0,0 +1,13 @@ +package com.linktrip.output.http.oauth.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoUserInfoResponse( + val id: Long, + @field:JsonProperty("kakao_account") + val kakaoAccount: KakaoAccount?, +) { + data class KakaoAccount( + val email: String?, + ) +} diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt index a495a99..ffb2237 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt @@ -1,17 +1,31 @@ package com.linktrip.output.persistence.mysql.adapter import com.linktrip.application.domain.member.Member +import com.linktrip.application.domain.member.ProviderType import com.linktrip.application.port.output.persistence.MemberPort import com.linktrip.output.persistence.mysql.entity.MemberEntity import com.linktrip.output.persistence.mysql.repository.MemberJpaRepository +import mu.KotlinLogging +import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Component +private val logger = KotlinLogging.logger {} + @Component class MemberAdapter( private val memberJpaRepository: MemberJpaRepository, ) : MemberPort { - override fun findBySerialNumber(serialNumber: String): Member? = - memberJpaRepository.findBySerialNumber(serialNumber)?.toDomain() + override fun findByProviderTypeAndProviderId( + providerType: ProviderType, + providerId: String, + ): Member? = memberJpaRepository.findByProviderTypeAndProviderId(providerType, providerId)?.toDomain() - override fun save(member: Member): Member = memberJpaRepository.save(MemberEntity.from(member)).toDomain() + override fun save(member: Member): Member = + try { + memberJpaRepository.save(MemberEntity.from(member)).toDomain() + } catch (_: DataIntegrityViolationException) { + logger.warn { "동시 요청으로 인한 중복 회원 저장 시도 감지: provider=${member.providerType}" } + memberJpaRepository.findByProviderTypeAndProviderId(member.providerType, member.providerId)!! + .toDomain() + } } diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt index 95e6a6b..4193762 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt @@ -1,9 +1,13 @@ package com.linktrip.output.persistence.mysql.entity import com.linktrip.application.domain.member.Member +import com.linktrip.application.domain.member.ProviderType import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.Id +import jakarta.persistence.Index import jakarta.persistence.Table import jakarta.persistence.UniqueConstraint @@ -11,20 +15,33 @@ import jakarta.persistence.UniqueConstraint @Table( name = "member", uniqueConstraints = [ - UniqueConstraint(name = "uk_member_serial_number", columnNames = ["serial_number"]), + UniqueConstraint( + name = "uk_member_provider", + columnNames = ["provider_type", "provider_id"], + ), + ], + indexes = [ + Index(name = "idx_member_email", columnList = "email"), ], ) class MemberEntity( @Id @Column(length = 36) val id: String, - @Column(name = "serial_number", nullable = false, length = 255) - val serialNumber: String, + @Column(name = "email", length = 255) + val email: String?, + @Enumerated(EnumType.STRING) + @Column(name = "provider_type", nullable = false, length = 20) + val providerType: ProviderType, + @Column(name = "provider_id", nullable = false, length = 255) + val providerId: String, ) : BaseTimeEntity() { fun toDomain(): Member = Member( id = this.id, - serialNumber = this.serialNumber, + email = this.email, + providerType = this.providerType, + providerId = this.providerId, createdAt = this.createdAt, updatedAt = this.updatedAt, ) @@ -33,7 +50,9 @@ class MemberEntity( fun from(member: Member): MemberEntity = MemberEntity( id = member.id, - serialNumber = member.serialNumber, + email = member.email, + providerType = member.providerType, + providerId = member.providerId, ) } } diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt index eaf9588..662d5ca 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt @@ -1,8 +1,12 @@ package com.linktrip.output.persistence.mysql.repository +import com.linktrip.application.domain.member.ProviderType import com.linktrip.output.persistence.mysql.entity.MemberEntity import org.springframework.data.jpa.repository.JpaRepository interface MemberJpaRepository : JpaRepository { - fun findBySerialNumber(serialNumber: String): MemberEntity? + fun findByProviderTypeAndProviderId( + providerType: ProviderType, + providerId: String, + ): MemberEntity? }