diff --git a/.gitignore b/.gitignore index 3ad6ce9..fd05c33 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ out/ /.nb-gradle/ ### VS Code ### -.vscode/ \ No newline at end of file +.vscode/ + +.env \ No newline at end of file diff --git a/build.gradle b/build.gradle index c2da34b..096a4ae 100644 --- a/build.gradle +++ b/build.gradle @@ -25,8 +25,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/michelet/gateway/infrastructure/config/CorsConfig.java b/src/main/java/com/michelet/gateway/infrastructure/config/CorsConfig.java new file mode 100644 index 0000000..6f2e8a7 --- /dev/null +++ b/src/main/java/com/michelet/gateway/infrastructure/config/CorsConfig.java @@ -0,0 +1,41 @@ +package com.michelet.gateway.infrastructure.config; + +import java.util.List; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsWebFilter; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableConfigurationProperties(CorsProperties.class) +public class CorsConfig { + private final CorsProperties corsProperties; + + public CorsConfig(CorsProperties corsProperties) { + this.corsProperties = corsProperties; + } + + @Bean + public CorsWebFilter corsWebFilter(){ + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(corsProperties.allowedOrigins()); + config.setAllowedMethods(List.of( + HttpMethod.GET.name(), + HttpMethod.DELETE.name(), + HttpMethod.POST.name(), + HttpMethod.PUT.name(), + HttpMethod.PATCH.name(), + HttpMethod.OPTIONS.name() + )); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); //쿠키에 refreshToken + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsWebFilter(source); + } +} diff --git a/src/main/java/com/michelet/gateway/infrastructure/config/CorsProperties.java b/src/main/java/com/michelet/gateway/infrastructure/config/CorsProperties.java new file mode 100644 index 0000000..1c2d1f5 --- /dev/null +++ b/src/main/java/com/michelet/gateway/infrastructure/config/CorsProperties.java @@ -0,0 +1,10 @@ +package com.michelet.gateway.infrastructure.config; + +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "cors") +public record CorsProperties( + List allowedOrigins +) { +} diff --git a/src/main/java/com/michelet/gateway/infrastructure/config/SecurityConfig.java b/src/main/java/com/michelet/gateway/infrastructure/config/SecurityConfig.java new file mode 100644 index 0000000..00447b4 --- /dev/null +++ b/src/main/java/com/michelet/gateway/infrastructure/config/SecurityConfig.java @@ -0,0 +1,51 @@ +package com.michelet.gateway.infrastructure.config; + +import com.michelet.gateway.infrastructure.security.GatewayRoleConverter; +import java.nio.charset.StandardCharsets; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; +import org.springframework.security.web.server.SecurityWebFilterChain; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfig { + @Bean + public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, GatewayRoleConverter roleConverter){ + ReactiveJwtAuthenticationConverter jwtAuthenticationConverter = new ReactiveJwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(roleConverter); + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange( + exchanges-> exchanges.pathMatchers( + "/api/*/auth/login", + "/api/*/auth/reissue", + "/api/*/users/signup" + ).permitAll() + .pathMatchers("/api/*/admin/**").hasRole("MASTER") + .anyExchange().authenticated() + + ) + .oauth2ResourceServer(oauth2-> oauth2.jwt( + jwtSpec -> jwtSpec.jwtAuthenticationConverter(jwtAuthenticationConverter) + )).build(); + } + + @Bean + public ReactiveJwtDecoder reactiveJwtDecoder(@Value("${jwt.secret}") String secret){ + SecretKey secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + "HmacSHA256" + ); + return NimbusReactiveJwtDecoder.withSecretKey(secretKey).build(); + } + + +} diff --git a/src/main/java/com/michelet/gateway/infrastructure/security/GatewayRoleConverter.java b/src/main/java/com/michelet/gateway/infrastructure/security/GatewayRoleConverter.java new file mode 100644 index 0000000..90b5985 --- /dev/null +++ b/src/main/java/com/michelet/gateway/infrastructure/security/GatewayRoleConverter.java @@ -0,0 +1,22 @@ +package com.michelet.gateway.infrastructure.security; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; + +@Component +public class GatewayRoleConverter implements Converter> { + + @Override + public Flux convert(Jwt jwt) { + String role = jwt.getClaimAsString("role"); + + if(role == null || role.isBlank()) + return Flux.empty(); + + return Flux.just(new SimpleGrantedAuthority("ROLE_" +role)); + } +} diff --git a/src/main/java/com/michelet/gateway/infrastructure/security/JwtAuthenticationFilter.java b/src/main/java/com/michelet/gateway/infrastructure/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..43f8762 --- /dev/null +++ b/src/main/java/com/michelet/gateway/infrastructure/security/JwtAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.michelet.gateway.infrastructure.security; + +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class JwtAuthenticationFilter implements GlobalFilter, Ordered { + private static final String USER_ID_HEADER = "X-User-Id"; + private static final String USER_ROLE_HEADER = "X-User-Role"; + + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + return ReactiveSecurityContextHolder.getContext() + .flatMap(securityContext -> { + Authentication authentication = securityContext.getAuthentication(); + if(authentication == null){ + return chain.filter(exchange); + } + return addHeaders(authentication,exchange,chain); + }) + .switchIfEmpty(chain.filter(exchange)); + } + + public Mono addHeaders( + Authentication authentication, + ServerWebExchange exchange, + GatewayFilterChain chain + ){ + if(!(authentication instanceof JwtAuthenticationToken)) + return chain.filter(exchange); + Jwt jwt = ((JwtAuthenticationToken) authentication).getToken(); + + ServerHttpRequest mutatedRequest = exchange.getRequest() + .mutate() + .headers( + httpHeaders -> { + httpHeaders.remove(USER_ID_HEADER); + httpHeaders.remove(USER_ROLE_HEADER); + + String userId = jwt.getSubject(); + if (userId != null && !userId.isBlank()) { + httpHeaders.set(USER_ID_HEADER, userId); + } + + String role = jwt.getClaimAsString("role"); + if (role != null && !role.isBlank()) { + httpHeaders.set(USER_ROLE_HEADER, role); + } + } + ) + .build(); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 5; + } + +} diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 73ed6dc..fb8ab06 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -1,4 +1,4 @@ eureka: client: service-url: - defaultZone: http://eureka-server:8761/eureka/ \ No newline at end of file + defaultZone: http://${EUREKA_CONTAINER_NAME}:${EUREKA_PORT}/eureka/ \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index aa7c90c..9569c76 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,4 +1,8 @@ eureka: client: service-url: - defaultZone: http://localhost:8761/eureka/ + defaultZone: http://localhost:${EUREKA_PORT:8761}/eureka/ + +cors: + allowed-origins: + - http://localhost:3000 \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index aa23265..cce8780 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -5,4 +5,11 @@ eureka: spring: cloud: discovery: - enabled: false \ No newline at end of file + enabled: false + +jwt: + secret: test-secret-key-test-secret-key-test-secret-key + +cors: + allowed-origins: + - http://localhost:3000 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e966ddc..c66f354 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: - id: user-service uri: lb://USER-SERVICE predicates: - - Path=/api/v1/users/**,/api/v1/admin/users/** + - Path=/api/v1/users/**,/api/v1/admin/users/**,/api/v1/auth/**,/api/v1/admin/auth/** - id: timeslot-service uri: lb://TIMESLOT-SERVICE @@ -61,3 +61,9 @@ spring: server: port: 19000 + +jwt: + secret: ${JWT_SECRET} + +cors: + allowed-origins: [] \ No newline at end of file