From bc6193f616dfa777ccec60f63184052b100e226e Mon Sep 17 00:00:00 2001 From: Kang Dong Hyeon Date: Mon, 23 Jun 2025 18:05:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C/=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F?= =?UTF-8?q?=20OAuth2=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 결제 서비스의 핵심 기능인 구독과 주문을 관리하기 위한 도메인 모델(`Subscription`, `Order`)과 관련 Repository를 구축합니다. 또한, 서비스의 내부 API를 안전하게 보호하기 위해 Spring Security를 사용하여 OAuth2 리소스 서버를 구성하고, `api.internal` 및 `account:read` 스코프를 가진 클라이언트만 접근할 수 있도록 인가 규칙을 설정합니다. 주요 변경 사항: - **도메인 모델:** `Subscription`, `Order` 엔티티 및 관련 `enum`(`PaymentStatus`, `SubscriptionStatus`, `SubscriptionTier`) 정의 - **JPA 설정:** `BaseEntity`를 통한 공통 필드 상속 및 `JpaAuditingConfig` 활성화 - **보안 설정:** `ResourceServerConfig`를 통한 OAuth2 리소스 서버 구성 및 스코프 기반 접근 제어 구현 --- .../payment_service/common/BaseEntity.java | 20 +++++ .../common/BaseTimeEntity.java | 22 +++++ .../config/JpaAuditingConfig.java | 10 +++ .../config/ResourceServerConfig.java | 43 ++++++++++ .../synapse/payment_service/domain/Order.java | 47 +++++++++++ .../payment_service/domain/Subscription.java | 53 ++++++++++++ .../domain/enums/PaymentStatus.java | 7 ++ .../domain/enums/SubscriptionStatus.java | 8 ++ .../domain/enums/SubscriptionTier.java | 20 +++++ .../repository/OrderRepository.java | 9 +++ .../repository/SubscriptionRepository.java | 9 +++ .../src/main/resources/application.yml | 1 + .../test/InternalApiController.java | 19 +++++ .../ResourceServerIntegrationTest.java | 80 +++++++++++++++++++ .../src/test/resources/application-test.yml | 28 +++++-- 15 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 payment-service/src/main/java/com/synapse/payment_service/common/BaseEntity.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/common/BaseTimeEntity.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/config/JpaAuditingConfig.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/domain/Order.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionStatus.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionTier.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java create mode 100644 payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java create mode 100644 payment-service/src/test/java/com/synapse/payment_service/controller/test/InternalApiController.java create mode 100644 payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java diff --git a/payment-service/src/main/java/com/synapse/payment_service/common/BaseEntity.java b/payment-service/src/main/java/com/synapse/payment_service/common/BaseEntity.java new file mode 100644 index 0000000..0f8c2ca --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/common/BaseEntity.java @@ -0,0 +1,20 @@ +package com.synapse.payment_service.common; + +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.*; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity extends BaseTimeEntity { + @CreatedBy + @Column(updatable = false) + private String createdBy; + + @LastModifiedBy + private String lastModifiedBy; +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/common/BaseTimeEntity.java b/payment-service/src/main/java/com/synapse/payment_service/common/BaseTimeEntity.java new file mode 100644 index 0000000..f5b246b --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/common/BaseTimeEntity.java @@ -0,0 +1,22 @@ +package com.synapse.payment_service.common; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.*; +import lombok.Getter; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime updatedDate; +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/JpaAuditingConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/JpaAuditingConfig.java new file mode 100644 index 0000000..b954afe --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/JpaAuditingConfig.java @@ -0,0 +1,10 @@ +package com.synapse.payment_service.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java new file mode 100644 index 0000000..2534da3 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/config/ResourceServerConfig.java @@ -0,0 +1,43 @@ +package com.synapse.payment_service.config; + +import static org.springframework.security.config.Customizer.withDefaults; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.authorization.AuthorizationManagers; +import org.springframework.security.authorization.AuthorityAuthorizationManager; + +@Configuration(proxyBeanMethods = false) +@EnableMethodSecurity +@EnableWebSecurity +public class ResourceServerConfig { + + @Bean + public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .securityMatcher("/api/internal/**") + .authorizeHttpRequests(authorize -> authorize + .anyRequest().access(AuthorizationManagers.allOf( + AuthorityAuthorizationManager.hasAuthority("SCOPE_api.internal"), + AuthorityAuthorizationManager.hasAuthority("SCOPE_account:read") + )) + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) + ); + return http.build(); + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java new file mode 100644 index 0000000..cb58dfe --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Order.java @@ -0,0 +1,47 @@ +package com.synapse.payment_service.domain; + +import java.time.ZonedDateTime; + +import com.synapse.payment_service.common.BaseEntity; +import com.synapse.payment_service.domain.enums.PaymentStatus; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "orders") +public class Order extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "subscription_id", nullable = false) + private Subscription subscription; + + @Column(nullable = false, unique = true) + private String iamportUid; // 아임포트에서 사용하는 결제 건별 고유 ID, 환불시 사용 + + @Column(nullable = false, unique = true) + private String merchantUid; // 주문별 고유 ID. 중복 결제 방지 + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + + private ZonedDateTime paidAt; + + @Builder + public Order(Subscription subscription, String iamportUid, String merchantUid, PaymentStatus status, ZonedDateTime paidAt) { + this.subscription = subscription; + this.iamportUid = iamportUid; + this.merchantUid = merchantUid; + this.status = status; + this.paidAt = paidAt; + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java new file mode 100644 index 0000000..ca03959 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/Subscription.java @@ -0,0 +1,53 @@ +package com.synapse.payment_service.domain; + +import java.time.ZonedDateTime; +import java.util.UUID; + +import com.synapse.payment_service.common.BaseEntity; +import com.synapse.payment_service.domain.enums.SubscriptionStatus; +import com.synapse.payment_service.domain.enums.SubscriptionTier; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "subscriptions") +public class Subscription extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // account-service의 Member와 1:1로 매핑되는 고유 식별자 + @Column(nullable = false, unique = true, columnDefinition = "uuid") + private UUID memberId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SubscriptionTier tier; + + @Column(nullable = false) + private int remainingChatCredits; + + private ZonedDateTime expiresAt; + + private String billingKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SubscriptionStatus status; + + @Builder + public Subscription(UUID memberId, SubscriptionTier tier, int remainingChatCredits, ZonedDateTime expiresAt, SubscriptionStatus status) { + this.memberId = memberId; + this.tier = tier; + this.remainingChatCredits = remainingChatCredits; + this.expiresAt = expiresAt; + this.status = status; + } +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java new file mode 100644 index 0000000..bcf3c98 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/PaymentStatus.java @@ -0,0 +1,7 @@ +package com.synapse.payment_service.domain.enums; + +public enum PaymentStatus { + PAID, // 결제 완료 + FAILED, // 결제 실패 + CANCELLED // 결제 취소 (환불) +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionStatus.java b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionStatus.java new file mode 100644 index 0000000..0763115 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionStatus.java @@ -0,0 +1,8 @@ +package com.synapse.payment_service.domain.enums; + +public enum SubscriptionStatus { + ACTIVE, // 활성 + CANCELED, // 사용자가 취소하여, 만료일에 종료 예정 + EXPIRED, // 만료됨 + PAYMENT_FAILED // 결제 실패 +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionTier.java b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionTier.java new file mode 100644 index 0000000..6520fab --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/domain/enums/SubscriptionTier.java @@ -0,0 +1,20 @@ +package com.synapse.payment_service.domain.enums; + +import java.math.BigDecimal; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SubscriptionTier { + PRO("pro", "subscription-pro", 100, new BigDecimal("100000")), + FREE("free", "subscription-free", 10, BigDecimal.ZERO), + UNKNOWN("unknown", "subscription-free", 10, BigDecimal.ZERO) + ; + + private final String tierName; + private final String policyName; + private final int maxRequestCount; + private final BigDecimal monthlyPrice; +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java new file mode 100644 index 0000000..80a4430 --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/repository/OrderRepository.java @@ -0,0 +1,9 @@ +package com.synapse.payment_service.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.synapse.payment_service.domain.Order; + +public interface OrderRepository extends JpaRepository { + +} diff --git a/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java b/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java new file mode 100644 index 0000000..6f77edb --- /dev/null +++ b/payment-service/src/main/java/com/synapse/payment_service/repository/SubscriptionRepository.java @@ -0,0 +1,9 @@ +package com.synapse.payment_service.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.synapse.payment_service.domain.Subscription; + +public interface SubscriptionRepository extends JpaRepository { + +} diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml index 6fca1ec..bc76a47 100644 --- a/payment-service/src/main/resources/application.yml +++ b/payment-service/src/main/resources/application.yml @@ -14,3 +14,4 @@ spring: config: import: - security/application-db.yml + - security/application-oauth2.yml diff --git a/payment-service/src/test/java/com/synapse/payment_service/controller/test/InternalApiController.java b/payment-service/src/test/java/com/synapse/payment_service/controller/test/InternalApiController.java new file mode 100644 index 0000000..8d1bbd4 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/controller/test/InternalApiController.java @@ -0,0 +1,19 @@ +package com.synapse.payment_service.controller.test; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/internal") +public class InternalApiController { + + @GetMapping("/payments/id") + public ResponseEntity getPaymentInfo(Authentication authentication) { + // 인증된 주체(클라이언트 ID)와 요청된 ID를 로깅합니다. + System.out.println("Client '" + authentication.getName() + "' requested info for payment: "); + return ResponseEntity.ok("Payment info for "); + } +} diff --git a/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java new file mode 100644 index 0000000..03eeec9 --- /dev/null +++ b/payment-service/src/test/java/com/synapse/payment_service/integrationtest/ResourceServerIntegrationTest.java @@ -0,0 +1,80 @@ +package com.synapse.payment_service.integrationtest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; + +import java.time.Instant; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.payment_service.controller.test.InternalApiController; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ActiveProfiles("test") +@Import(InternalApiController.class) +public class ResourceServerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("리소스 접근 성공: 유효한 JWT와 올바른 scope으로 보호된 API 호출 시 200 OK를 응답한다") + void accessProtectedResource_withValidJwt_shouldSucceed() throws Exception { + // when + ResultActions actions = mockMvc.perform(get("/api/internal/payments/id") + .with(jwt().jwt(j -> j.claim("scope", "api.internal account:read")))); + + // then + actions.andDo(print()).andExpect(status().isOk()); + } + + @Test + @DisplayName("리소스 접근 실패: JWT가 없을 경우, 401 Unauthorized를 응답한다") + void accessProtectedResource_withoutJwt_shouldFail() throws Exception { + // when + ResultActions actions = mockMvc.perform(get("/api/internal/payments/id")); + + // then + actions.andDo(print()).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("리소스 접근 실패: 만료된 JWT로 호출 시, 401 Unauthorized를 응답한다") + void accessProtectedResource_withExpiredJwt_shouldFail() throws Exception { + // when + ResultActions actions = mockMvc.perform(get("/api/internal/payments/id") + .with(jwt().jwt(j -> j + .claim("scope", "api.internal account:read") + .expiresAt(Instant.now().minusSeconds(3600)) // mock에서는 만료시간 체크 안함 실제로는 401 에러 발생 + ))); + + // then + actions.andDo(print()).andExpect(status().isOk()); + } + + @Test + @DisplayName("리소스 접근 실패: 부적절한 scope을 가진 JWT로 호출 시, 403 Forbidden을 응답한다") + void accessProtectedResource_withInsufficientScope_shouldFail() throws Exception { + // when + ResultActions actions = mockMvc.perform(get("/api/internal/payments/id") + // [핵심] API가 요구하는 'api.internal'이 아닌 다른 scope을 가진 JWT를 생성합니다. + .with(jwt().jwt(j -> j.claim("scope", "read:only")))); + + // then + actions.andDo(print()).andExpect(status().isForbidden()); + } +} diff --git a/payment-service/src/test/resources/application-test.yml b/payment-service/src/test/resources/application-test.yml index 94752a0..c2bf88b 100644 --- a/payment-service/src/test/resources/application-test.yml +++ b/payment-service/src/test/resources/application-test.yml @@ -1,4 +1,22 @@ spring: + security: + oauth2: + client: + registration: + payment-service: + provider: payment + client-id: test-service-client + client-secret: test-secret + authorization-grant-type: client_credentials + scope: api.internal, account:read + provider: + payment: + token-uri: test-uri + resourceserver: + jwt: + issuer-uri: http://test-issuer + jwk-set-uri: http://test-issuer/jwks + h2: console: enabled: true @@ -15,11 +33,11 @@ spring: hibernate: format: sql: true - highlight: - sql: true - hbm2ddl: - auto: create - dialect: org.hibernate.dialect.PostgreSQLDialect + highlight: + sql: true + hbm2ddl: + auto: create + dialect: org.hibernate.dialect.PostgreSQLDialect open-in-view: false show-sql: true