From edd6655f84a46e034656d0da79997432dd28a096 Mon Sep 17 00:00:00 2001 From: Kang Dong Hyeon Date: Thu, 19 Jun 2025 20:41:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20OAuth2=20=EC=9D=B8=EC=A6=9D=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EC=A0=95=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application.yml에 프로필 설정 및 설정 파일 임포트 추가 - 테스트용 클라이언트 설정을 application-test.yml에 추가 - 보안 관련 파일(security 폴더)을 .gitignore에 추가하여 민감 정보 보호 - OAuth2 Client Credentials Grant 방식을 위한 기본 설정 완료 - JWT 토큰 검증을 위한 JWK Set 엔드포인트 구성 - 모니터링과 같이 기본적으로 접근하는 필터 설정 --- authorization-server/.gitignore | 2 + .../config/AppConfig.java | 96 ++++++++++++++++ .../config/AuthorizationServerConfig.java | 29 +++++ .../config/AuthorizationServerProperties.java | 20 ++++ .../config/DefaultSecurityConfig.java | 23 ++++ .../src/main/resources/application-local.yml | 26 +++++ .../src/main/resources/application.yml | 5 + .../AuthorizationServerIntegrationTest.java | 106 ++++++++++++++++++ .../src/test/resources/application-test.yml | 10 ++ 9 files changed, 317 insertions(+) create mode 100644 authorization-server/src/main/java/com/synapse/authorization_server/config/AppConfig.java create mode 100644 authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerConfig.java create mode 100644 authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerProperties.java create mode 100644 authorization-server/src/main/java/com/synapse/authorization_server/config/DefaultSecurityConfig.java create mode 100644 authorization-server/src/main/resources/application-local.yml create mode 100644 authorization-server/src/test/java/com/synapse/authorization_server/integrationtest/AuthorizationServerIntegrationTest.java diff --git a/authorization-server/.gitignore b/authorization-server/.gitignore index c2065bc..01c9e5e 100644 --- a/authorization-server/.gitignore +++ b/authorization-server/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +**/security/ diff --git a/authorization-server/src/main/java/com/synapse/authorization_server/config/AppConfig.java b/authorization-server/src/main/java/com/synapse/authorization_server/config/AppConfig.java new file mode 100644 index 0000000..1095cfe --- /dev/null +++ b/authorization-server/src/main/java/com/synapse/authorization_server/config/AppConfig.java @@ -0,0 +1,96 @@ +package com.synapse.authorization_server.config; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; + +import lombok.RequiredArgsConstructor; + +/** + * 인가 서버의 통신 방식은 CLIENT_CREDENTIALS 방식만 허용합니다. + * 마이크로 서비스끼리의 통신이기 때문에 다른 방식은 사용하지 않습니다. 또한 Basic 처리방식으로 body처리가 아닌 header 처리로 합니다. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(AuthorizationServerProperties.class) +@RequiredArgsConstructor +public class AppConfig { + private final AuthorizationServerProperties authorizationServerProperties; + + @Bean + public RegisteredClientRepository registeredClientRepository(PasswordEncoder passwordEncoder) { + List registeredClients = authorizationServerProperties.clients().stream() + .map(client -> RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId(client.clientId()) + .clientSecret(passwordEncoder.encode(client.clientSecret())) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scopes(scopes -> scopes.addAll(client.scopes())) + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(Duration.ofHours(client.accessTokenTtlInHours())) + .build()) + .build()) + .collect(Collectors.toList()); + + return new InMemoryRegisteredClientRepository(registeredClients); + } + + @Bean + public JWKSource jwkSource() throws NoSuchAlgorithmException { + RSAKey rsaKey = generateRsa(); + JWKSet jwkSet = new JWKSet(rsaKey); + return new ImmutableJWKSet<>(jwkSet); + } + + private RSAKey generateRsa() throws NoSuchAlgorithmException { + KeyPair keyPair = generateKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + return new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + } + + private KeyPair generateKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder() + .issuer(authorizationServerProperties.issuerUri()) + .build(); + } +} diff --git a/authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerConfig.java b/authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerConfig.java new file mode 100644 index 0000000..e75b5e0 --- /dev/null +++ b/authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerConfig.java @@ -0,0 +1,29 @@ +package com.synapse.authorization_server.config; + +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.web.builders.HttpSecurity; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration(proxyBeanMethods = false) +public class AuthorizationServerConfig { + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer(); + RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher(); + + http.securityMatcher(endpointsMatcher) + .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + .with(authorizationServerConfigurer, withDefaults()); + + return http.build(); + } +} diff --git a/authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerProperties.java b/authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerProperties.java new file mode 100644 index 0000000..8be54a0 --- /dev/null +++ b/authorization-server/src/main/java/com/synapse/authorization_server/config/AuthorizationServerProperties.java @@ -0,0 +1,20 @@ +package com.synapse.authorization_server.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "authorization-server") +public record AuthorizationServerProperties( + String issuerUri, + List clients +) { + public record RegisteredClient( + String clientId, + String clientSecret, + List scopes, + long accessTokenTtlInHours + ) { + + } +} diff --git a/authorization-server/src/main/java/com/synapse/authorization_server/config/DefaultSecurityConfig.java b/authorization-server/src/main/java/com/synapse/authorization_server/config/DefaultSecurityConfig.java new file mode 100644 index 0000000..3217378 --- /dev/null +++ b/authorization-server/src/main/java/com/synapse/authorization_server/config/DefaultSecurityConfig.java @@ -0,0 +1,23 @@ +package com.synapse.authorization_server.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.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration +public class DefaultSecurityConfig { + @Bean + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated() + ) + .formLogin(withDefaults()); + + return http.build(); + } +} diff --git a/authorization-server/src/main/resources/application-local.yml b/authorization-server/src/main/resources/application-local.yml new file mode 100644 index 0000000..d21db71 --- /dev/null +++ b/authorization-server/src/main/resources/application-local.yml @@ -0,0 +1,26 @@ +authorization-server: + issuer-uri: ${local-authorization.issuer-uri} + clients: + - client-id: ${local-authorization.clients.account-service-client.client-id} + client-secret: ${local-authorization.clients.account-service-client.client-secret} + scopes: + - ${local-authorization.clients.account-service-client.scopes} + access-token-ttl-in-hours: ${local-authorization.clients.account-service-client.access-token-ttl-in-hours} + + - client-id: ${local-authorization.clients.payment-service-client.client-id} + client-secret: ${local-authorization.clients.payment-service-client.client-secret} + scopes: + - ${local-authorization.clients.payment-service-client.scopes} + access-token-ttl-in-hours: ${local-authorization.clients.payment-service-client.access-token-ttl-in-hours} + + - client-id: ${local-authorization.clients.chat-service-client.client-id} + client-secret: ${local-authorization.clients.chat-service-client.client-secret} + scopes: + - ${local-authorization.clients.chat-service-client.scopes} + access-token-ttl-in-hours: ${local-authorization.clients.chat-service-client.access-token-ttl-in-hours} + + - client-id: ${local-authorization.clients.alarm-service-client.client-id} + client-secret: ${local-authorization.clients.alarm-service-client.client-secret} + scopes: + - ${local-authorization.clients.alarm-service-client.scopes} + access-token-ttl-in-hours: ${local-authorization.clients.alarm-service-client.access-token-ttl-in-hours} diff --git a/authorization-server/src/main/resources/application.yml b/authorization-server/src/main/resources/application.yml index 387926b..0db1681 100644 --- a/authorization-server/src/main/resources/application.yml +++ b/authorization-server/src/main/resources/application.yml @@ -4,3 +4,8 @@ server: spring: application: name: authorization-server + profiles: + active: local + config: + import: + - security/application-authorization.yml diff --git a/authorization-server/src/test/java/com/synapse/authorization_server/integrationtest/AuthorizationServerIntegrationTest.java b/authorization-server/src/test/java/com/synapse/authorization_server/integrationtest/AuthorizationServerIntegrationTest.java new file mode 100644 index 0000000..459692e --- /dev/null +++ b/authorization-server/src/test/java/com/synapse/authorization_server/integrationtest/AuthorizationServerIntegrationTest.java @@ -0,0 +1,106 @@ +package com.synapse.authorization_server.integrationtest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Base64; + +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.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * 실제 애플리케이션의 전체 컨텍스트를 로드하여 통합 테스트를 진행합니다. + * 이 테스트는 Authorization Server가 클라이언트의 요청에 대해 정상적으로 토큰을 발급하는지 검증합니다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class AuthorizationServerIntegrationTest { + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("토큰 발급 성공: 올바른 클라이언트 정보로 요청 시, Access Token을 성공적으로 발급한다") + void issueToken_success_withValidClient() throws Exception { + // given + String clientId = "test-client"; + String clientSecret = "test-secret"; + + // 요청 바디(form-data) 구성 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "client_credentials"); + params.add("scope", "api.internal test.scope"); + + // when + ResultActions actions = mockMvc.perform(post("/oauth2/token") + .params(params) + .header(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes()))); + + // then + actions + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").exists()) + .andExpect(jsonPath("$.token_type").value("Bearer")) + .andExpect(jsonPath("$.scope").value("api.internal test.scope")) + .andExpect(jsonPath("$.expires_in").isNumber()); + } + + @Test + @DisplayName("토큰 발급 실패: 잘못된 Client Secret으로 요청 시, 401 Unauthorized를 응답한다") + void issueToken_fail_withInvalidClientSecret() throws Exception { + // given + String clientId = "test-client"; + String wrongClientSecret = "wrong-secret"; // 잘못된 시크릿 + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "client_credentials"); + + // when + ResultActions actions = mockMvc.perform(post("/oauth2/token") + .params(params) + .header(HttpHeaders.AUTHORIZATION, "Basic " + + Base64.getEncoder().encodeToString((clientId + ":" + wrongClientSecret).getBytes()))); + + // then + actions + .andDo(print()) + .andExpect(status().isUnauthorized()) // 401 Unauthorized 상태 코드 확인 + .andExpect(jsonPath("$.error").value("invalid_client")); + } + + @Test + @DisplayName("토큰 발급 실패: 허용되지 않은 Scope을 요청 시, 400 Bad Request를 응답한다") + void issueToken_fail_withInvalidScope() throws Exception { + // given + String clientId = "test-client"; + String clientSecret = "test-secret"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "client_credentials"); + params.add("scope", "api.unauthorized"); // 등록되지 않은 scope + + // when + ResultActions actions = mockMvc.perform(post("/oauth2/token") + .params(params) + .header(HttpHeaders.AUTHORIZATION, + "Basic " + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes()))); + + // then + actions + .andDo(print()) + .andExpect(status().isBadRequest()) // 400 Bad Request 상태 코드 확인 + .andExpect(jsonPath("$.error").value("invalid_scope")); + } +} diff --git a/authorization-server/src/test/resources/application-test.yml b/authorization-server/src/test/resources/application-test.yml index 54755f0..7489651 100644 --- a/authorization-server/src/test/resources/application-test.yml +++ b/authorization-server/src/test/resources/application-test.yml @@ -13,3 +13,13 @@ logging: spring: transaction: interceptor: info + +authorization-server: + issuer-uri: http://localhost:1010 + clients: + - client-id: test-client + client-secret: test-secret + scopes: + - api.internal + - test.scope + access-token-ttl-in-hours: 1