Spring Cloud Eureka Server / Spring Cloud Gateway (Spring Eureka Client) 간 연동을 통해 마이크로서비스 서버 딕셔너리, 로드밸런싱, 로깅 추적, JWT 인증 을 수행하도록 한다.
- 클라이언트(웹브라우저 / FeignClient / REST API 등) 에서 Gateway 서버로 요청을 한다.
- 해당 요청에 상응하는 Gateway 필터가 수행된다.
- JWT 인증이 필요한 요청은 JWT 인증을 수행한다.- 모든 필터 검증이 완료 되면 Gateway와 연동된 Eureka Server 에 등록된 Eureka Client 목록 중 해당 요청에 상응하는 마이크로서비스로 Request 를 전달한다.
- 요청을 전달받은 마이크로서비스가 수행 후 Gateway 로 결과를 리턴 하고 Gateway 에서 응답한다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(EurekaServerMarkerConfiguration.class)
public @interface EnableEurekaServer {
}
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServiceApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServiceApplication.class, args);
}
}
eureka:
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://localhost:${server.port}/eureka/
Spring Cloud Gateway 도 Eureka Client 에 등록 필요
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(EnableDiscoveryClientImportSelector.class)
public @interface EnableDiscoveryClient {
boolean autoRegister() default true;
}
EnableDiscoveryClientImportSelector -> AutoServiceRegistrationConfiguration -> AutoServiceRegistrationProperties 활성화
@Order(Ordered.LOWEST_PRECEDENCE - 100)
public class EnableDiscoveryClientImportSelector extends SpringFactoryImportSelector<EnableDiscoveryClient> {
@Override
public String[] selectImports(AnnotationMetadata metadata) {
String[] imports = super.selectImports(metadata);
AnnotationAttributes attributes = AnnotationAttributes
.fromMap(metadata.getAnnotationAttributes(getAnnotationClass().getName(), true));
boolean autoRegister = attributes.getBoolean("autoRegister");
if (autoRegister) {
List<String> importsList = new ArrayList<>(Arrays.asList(imports));
importsList.add("org.springframework.cloud.client.serviceregistry.AutoServiceRegistrationConfiguration");
imports = importsList.toArray(new String[0]);
}
else {
Environment env = getEnvironment();
if (env instanceof ConfigurableEnvironment configEnv) {
LinkedHashMap<String, Object> map = new LinkedHashMap<>();
map.put("spring.cloud.service-registry.auto-registration.enabled", false);
MapPropertySource propertySource = new MapPropertySource("springCloudDiscoveryClient", map);
configEnv.getPropertySources().addLast(propertySource);
}
}
return imports;
}
@Override
protected boolean isEnabled() {
return getEnvironment().getProperty("spring.cloud.discovery.enabled", Boolean.class, Boolean.TRUE);
}
@Override
protected boolean hasDefaultFactory() {
return true;
}
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(AutoServiceRegistrationProperties.class)
@ConditionalOnProperty(value = "spring.cloud.service-registry.auto-registration.enabled", matchIfMissing = true)
public class AutoServiceRegistrationConfiguration {
}
EurekaClientAutoConfiguration 에서 AutoServiceRegistrationProperties 빈 설정 체크 후 EurekaAutoServiceRegistration 빈 등록
@Bean
@ConditionalOnBean(AutoServiceRegistrationProperties.class)
@ConditionalOnProperty(value = "spring.cloud.service-registry.auto-registration.enabled", matchIfMissing = true)
public EurekaAutoServiceRegistration eurekaAutoServiceRegistration(ApplicationContext context,
EurekaServiceRegistry registry, EurekaRegistration registration) {
return new EurekaAutoServiceRegistration(context, registry, registration);
}
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:9761/eureka
spring.cloud.gateway.default-filters
spring.cloud.gateway.globalcors
spring.cloud.gateway.routes
spring:
cloud:
gateway:
default-filters: # 모든 서비스에 적용되는 default-filter
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
- name: LoggingFilter
args:
preLogger: true
postLogger: true
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: 'http://localhost:8081' # front-app url
allow-credentials: true # JWT 나 쿠키를 사용해 메시지를 보내야 할 경우 true
allowedHeaders: '*'
allowedMethods: # 전체 메서드 명시
- PUT
- GET
- POST
- DELETE
- OPTIONS
- PATCH
exposed-headers:
- Authorization
- Refresh-Token
max-age: 3600
routes: # predicates 에 매칭되는 uri, filter 정의
- id: auth-service
uri: lb://AUTH-SERVICE
predicates:
- Path=/auth/**, /member/**
filters:
- JwtValidationCheckFilter
- id: ediary-diary
uri: lb://EDIARY-DIARY
predicates:
- Path=/diary/**
filters:
- RemoveRequestHeader=Cookie
# - RewritePath=/diary/(?<segment>.*), /$\{segment}
- RewritePath=/diary(?:/(?<segment>.*))?, /$\{segment}
- JwtValidationCheckFilter
- id: ediary-point
uri: lb://EDIARY-POINT
predicates:
- Path=/point/**
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/point(?:/(?<segment>.*))?, /$\{segment}
- JwtValidationCheckFilter
@Slf4j
@Component
public class LoggingFilter extends AbstractGatewayFilterFactory<LoggingFilter.Config> {
public LoggingFilter() {
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return new OrderedGatewayFilter((exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (config.isPreLogger()) {
log.info("Logging Pre Filter : request id -> {}", request.getId());
}
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
if (config.isPostLogger()) {
log.info("Logging Post Filter : response code -> {}", response.getStatusCode());
}
}));
}, Ordered.HIGHEST_PRECEDENCE);
}
@Setter
@Getter
public static class Config {
private boolean preLogger;
private boolean postLogger;
}
}
@Slf4j
@Component
public class JwtValidationCheckFilter extends AbstractGatewayFilterFactory<JwtValidationCheckFilter.Config> {
private static final String TOKEN_PREFIX = "Bearer ";
private final Environment env;
public JwtValidationCheckFilter(Environment env) {
super(Config.class);
this.env = env;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
//로그인, 회원가입 요청 Valid Check Pass!
if (path.startsWith("/auth") || path.startsWith("/oauth2") || path.startsWith("/login/oauth2")) {
return chain.filter(exchange);
}
HttpHeaders headers = request.getHeaders();
if (!headers.containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "No Authorization Header", HttpStatus.UNAUTHORIZED);
}
String bearerToken = headers.get(HttpHeaders.AUTHORIZATION).get(0);
String decodedToken = URLDecoder.decode(bearerToken, StandardCharsets.UTF_8);
if (!decodedToken.startsWith(TOKEN_PREFIX)) {
return onError(exchange, "Invalid prefix in token", HttpStatus.UNAUTHORIZED);
}
String jwt = decodedToken.substring(7);
if (!isJwtValid(jwt)) {
return onError(exchange, "JWT Token is not valid", HttpStatus.UNAUTHORIZED);
}
return chain.filter(exchange);
};
}
private Mono<Void> onError(ServerWebExchange exchange, String error,
HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(error);
return response.setComplete();
}
private String resolveToken(String bearerToken) {
String decodedToken = URLDecoder.decode(bearerToken, StandardCharsets.UTF_8);
if (decodedToken.startsWith(TOKEN_PREFIX)) {
return decodedToken.substring(7);
}
return null;
}
private boolean isJwtValid(String jwt) {
String secretKey = env.getProperty("jwt.secret-key");
SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
String subject = null;
try {
JwtParser parser = Jwts.parser()
.verifyWith(key)
.build();
subject = parser
.parseSignedClaims(jwt)
.getPayload()
.getSubject();
} catch (Exception e) {
log.error(e.getMessage(), e);
return false;
}
if (subject == null || subject.isEmpty()) {
return false;
}
return true;
}
public static class Config {
}
}
front-end("front-msa" 브랜치) : https://github.com/onlydev7777/emotion-diary-react
back-end : https://github.com/onlydev7777/emotion-diary-msa
inflearn-msa : https://github.com/onlydev7777/springboot-msa-3.0/tree/master