From c182410cf3699ccecd50281d1f06319ee1ec3243 Mon Sep 17 00:00:00 2001 From: Kang Dong Hyeon Date: Fri, 20 Jun 2025 18:25:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth2 리소스 서버 기능을 위한 보안 설정을 추가하고, 관련 통합 테스트 환경을 구축합니다. - `ResourceServerConfig`를 추가하여 `/api/internal/**` 경로에 대한 JWT 기반의 접근 제어를 설정합니다. - `SecurityConfig`와 충돌을 피하기 위해 `@Order`를 사용하여 필터 체인의 우선순위를 명시적으로 지정합니다. - `ResourceServerIntegrationTest`를 작성하여 JWT 유효성, 만료, scope에 따른 API 접근 시나리오를 검증합니다. - 테스트 환경에서 JWT 만료 시간 검증이 동작하도록 `application-test.yml`에 `issuer-uri`를 추가하고, 테스트용 API 컨트롤러(`InternalApiController`)를 구현합니다. --- account-service/build.gradle | 3 + .../config/ResourceServerConfig.java | 28 +++++++ .../config/SecurityConfig.java | 15 ++-- .../test/InternalApiController.java | 22 ++++++ .../ResourceServerIntegrationTest.java | 78 +++++++++++++++++++ .../ApplicationIntegrationTest.java} | 10 +-- .../src/test/resources/application-test.yml | 13 ++++ 7 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 account-service/src/main/java/com/synapse/account_service/config/ResourceServerConfig.java create mode 100644 account-service/src/main/java/com/synapse/account_service/controller/test/InternalApiController.java create mode 100644 account-service/src/test/java/com/synapse/account_service/integrationtest/ResourceServerIntegrationTest.java rename account-service/src/test/java/com/synapse/account_service/{TestConfig.java => support/ApplicationIntegrationTest.java} (53%) diff --git a/account-service/build.gradle b/account-service/build.gradle index 1c750b3..8b38864 100644 --- a/account-service/build.gradle +++ b/account-service/build.gradle @@ -28,12 +28,15 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' // OAuth2 Client implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // OAuth2 Resource Server + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' // Test testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.security:spring-security-test' // Spring Web implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/account-service/src/main/java/com/synapse/account_service/config/ResourceServerConfig.java b/account-service/src/main/java/com/synapse/account_service/config/ResourceServerConfig.java new file mode 100644 index 0000000..6da8f9b --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/config/ResourceServerConfig.java @@ -0,0 +1,28 @@ +package com.synapse.account_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.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration(proxyBeanMethods = false) +@EnableMethodSecurity +public class ResourceServerConfig { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain securityResourceServerFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/internal/**") + .authorizeHttpRequests(authorize -> authorize + .anyRequest().hasAuthority("SCOPE_api.internal") + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())); + return http.build(); + } +} \ No newline at end of file diff --git a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java index 29ece7f..c26a096 100644 --- a/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java +++ b/account-service/src/main/java/com/synapse/account_service/config/SecurityConfig.java @@ -25,10 +25,11 @@ import lombok.RequiredArgsConstructor; -@Configuration +@Configuration(proxyBeanMethods = false) @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + private final CustomUserDetailsService customUserDetailsService; private final LoginSuccessHandler loginSuccessHandler; private final LoginFailureHandler loginFailureHandler; @@ -43,11 +44,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticat .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/accounts/signup", "/api/accounts/login", "/", "/api/accounts/token/reissue").permitAll() - .anyRequest().authenticated() - ) - .addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo @@ -58,6 +54,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticat .failureHandler(loginFailureHandler) ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/accounts/signup", "/api/accounts/login", "/", + "/api/accounts/token/reissue") + .permitAll() + .anyRequest().authenticated()) + .addFilterAt(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling( exceptionHandlingConfigurer -> exceptionHandlingConfigurer.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))); diff --git a/account-service/src/main/java/com/synapse/account_service/controller/test/InternalApiController.java b/account-service/src/main/java/com/synapse/account_service/controller/test/InternalApiController.java new file mode 100644 index 0000000..20d9849 --- /dev/null +++ b/account-service/src/main/java/com/synapse/account_service/controller/test/InternalApiController.java @@ -0,0 +1,22 @@ +package com.synapse.account_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; + +/** + * 내부 api를 테스트하는 테스트 api controller 입니다. + */ +@RestController +@RequestMapping("/api/internal") +public class InternalApiController { + + @GetMapping("/accounts/id") + public ResponseEntity getAccountInfo(Authentication authentication) { + // 인증된 주체(클라이언트 ID)와 요청된 ID를 로깅합니다. + System.out.println("Client '" + authentication.getName() + "' requested info for account: "); + return ResponseEntity.ok("Account info for "); + } +} diff --git a/account-service/src/test/java/com/synapse/account_service/integrationtest/ResourceServerIntegrationTest.java b/account-service/src/test/java/com/synapse/account_service/integrationtest/ResourceServerIntegrationTest.java new file mode 100644 index 0000000..aaee59c --- /dev/null +++ b/account-service/src/test/java/com/synapse/account_service/integrationtest/ResourceServerIntegrationTest.java @@ -0,0 +1,78 @@ +package com.synapse.account_service.integrationtest; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +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 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.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import com.synapse.account_service.controller.test.InternalApiController; +import com.synapse.account_service.support.ApplicationIntegrationTest; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@Import(InternalApiController.class) +public class ResourceServerIntegrationTest extends ApplicationIntegrationTest { + + @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/accounts/id") + .with(jwt().jwt(j -> j.claim("scope", "api.internal")))); + + // 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/accounts/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/accounts/id") + .with(jwt().jwt(j -> j + .claim("scope", "api.internal") + .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/accounts/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/account-service/src/test/java/com/synapse/account_service/TestConfig.java b/account-service/src/test/java/com/synapse/account_service/support/ApplicationIntegrationTest.java similarity index 53% rename from account-service/src/test/java/com/synapse/account_service/TestConfig.java rename to account-service/src/test/java/com/synapse/account_service/support/ApplicationIntegrationTest.java index 55b385d..b527bc7 100644 --- a/account-service/src/test/java/com/synapse/account_service/TestConfig.java +++ b/account-service/src/test/java/com/synapse/account_service/support/ApplicationIntegrationTest.java @@ -1,12 +1,12 @@ -package com.synapse.account_service; +package com.synapse.account_service.support; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; -@Transactional + @ActiveProfiles("test") -@SpringBootTest(classes = {AccountServiceApplication.class}) -public class TestConfig { - +public abstract class ApplicationIntegrationTest { + } diff --git a/account-service/src/test/resources/application-test.yml b/account-service/src/test/resources/application-test.yml index 8ee9a54..06e97a3 100644 --- a/account-service/src/test/resources/application-test.yml +++ b/account-service/src/test/resources/application-test.yml @@ -9,6 +9,19 @@ spring: scope: - email - profile + account-service: + provider: account + client-id: test-client-id + client-secret: test-client-secret + authorization-grant-type: client_credentials + scope: api.internal + provider: + account: + token-uri: test-uri + resourceserver: + jwt: + issuer-uri: https://test-issuer + jwk-set-uri: https://test-issuer/jwks h2: console: enabled: true