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