JwtUtil.class
@Component
public class JwtUtil {
public static final String BEARER_PREFIX = "Bearer ";
// Base64 Encode 한 SecretKey
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
// 로그 설정
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
logger.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
// 토큰 검증
public void validateToken(String token) throws SecurityException, MalformedJwtException, SignatureException, ExpiredJwtException, UnsupportedJwtException, IllegalArgumentException {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
API Gateway Filter
- API Gateway에서 클라이언트 요청에 따라 라우팅을 하기 전에 Filter를 사용해서 요청에 대한 검사 가능
- API Gateway Filter는 AbstractGatewayFilterFactory<C>를 상속
- AbstractGatewayFilterFactory를 상속한 필터 클래스는 별다른 매개 변수가 존재하지 않을지라도 반드시 구성 클래스가 필요
Config Class
@Component
@Slf4j(topic = "API Gateway-GlobalFilter")
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
public static class Config{}
}
- 현재는 비어있는 상태지만 필터의 동작을 커스터마이징할 때, 예를 들어 어떤 설정값을 받아 필터의 동작을 제어하고자 할 때, 이 클래스에 필요한 필드를 추가 가능
super(Config.class)
@Component
@Slf4j(topic = "API Gateway-GlobalFilter")
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
private final JwtUtil jwtUtil;
public static class Config{}
public GlobalFilter(JwtUtil jwtUtil){
super(Config.class);
this.jwtUtil = jwtUtil;
}
}
- super(Config.class)는 AbstractGatewayFilterFactory(부모)를 호출하면서 Config 클래스를 전달
- AbstractGatewayFilterFactory는 이 필터(GlobalFilter)가 Config 클래스를 설정 클래스로 사용됨을 Spring Cloud Gateway에 알림
- super(Config.class)를 반드시 작성해주어야만 함
super(Config.class)가 없을 때 발생 에러
java.lang.ClassCastException: class java.lang.Object cannot be cast to class io.spring.apigateway.filter.GlobalFilter$Config (java.lang.Object is in module java.base of loader 'bootstrap'; io.spring.apigateway.filter.GlobalFilter$Config is in unnamed module of loader 'app')
apply 메서드
@Override
public GatewayFilter apply(Config config) {
}
- AbstractGatewayFilterFactory를 상속 받게 되면 apply 메서드를 반드시 구현
- 람다 방식으로 간단히 구현할 수 있지만, 구조를 직관적으로 보기 위해 익명 클래스도 확인
@Override
public GatewayFilter apply(Config config) {
return new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 필터 로직
return chain.filter(exchange);
}
};
}
@Override
public GatewayFilter apply(Config config) {
return ((exchange, chain) -> {
// 필터 로직
});
}
로직 구현
@Override
public GatewayFilter apply(Config config) {
return ((exchange, chain) -> {
// ServerWebExchange에서 Request를 추출
// ServerHttpRequest = HttpServletRequest와 비슷
ServerHttpRequest request = exchange.getRequest();
// request에서 Header 추출
HttpHeaders headers = request.getHeaders();
// 헤더명에 Authorization(JWT)인 것은 추출
String authorization = headers.getFirst("Authorization");
// 로그인 없이 접속할 수 있는 경로 whiteList 선언
List<String> whiteList = Arrays.asList(
"/member-service/api/member/regist",
"/member-service/api/member/login"
);
// 클라이언트 요청 경로 추출
String path = request.getURI().getPath();
log.info("Request Path = {}", path);
// 요청 경로가 whiteList에 해당할 경우 다음 필터로 요청 전달
if(whiteList.contains(path)){
log.info("Routing login or regist");
return chain.filter(exchange);
}
// 헤더에 Authorization이 없다면 로그인되지 않은 회원으로 에러 발생
if(authorization == null){
log.info("UNAUTHORIZED, Required Login");
return onError(exchange, "로그인 후 이용 가능합니다.", HttpStatus.UNAUTHORIZED);
}
// 헤더에서 'Bearer ' 제거
String token = jwtUtil.substringToken(authorization);
// JWT 검증 시 에러가 발생할 경우 try ~ catch
try {
jwtUtil.validateToken(token);
} catch (SecurityException | MalformedJwtException | SignatureException e) {
return onError(exchange, "유효하지 않는 JWT 서명 입니다.", HttpStatus.UNAUTHORIZED);
} catch (ExpiredJwtException e) {
return onError(exchange, "만료된 JWT 토큰 입니다.", HttpStatus.UNAUTHORIZED);
} catch (UnsupportedJwtException e) {
return onError(exchange, "지원되지 않는 JWT 토큰 입니다.", HttpStatus.UNAUTHORIZED);
} catch (IllegalArgumentException e) {
return onError(exchange, "잘못된 JWT 토큰 입니다.", HttpStatus.UNAUTHORIZED);
}
// JWT 검증에 문제가 없었다면 다음 필터로 요청 전달
return chain.filter(exchange);
});
}
Mono<Void> 구현
- 리액티브 프로그래밍에서는 에러를 던지는 throw 대신 Mono와 Flux를 통해 예외를 관리하고 처리
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus){
// Response 객체 추출
ServerHttpResponse response = exchange.getResponse();
// 응답의 HTTP 상태 코드 설정
response.setStatusCode(httpStatus);
log.info(err);
// 응답 완료 -> Mono<Void>를 반환
// WebFlux의 리액티브 체인에서 오류 발생 시 후속 처리를
// 중단하고 클라이언트에 응답을 반환하는 데 사용
return response.setComplete();
}
- 이 메서드는 상태 코드만 설정하고 응답을 완료
- 클라이언트에게 구체적인 에러 메세지나 JSON 형태의 응답을 반환하려면 응답 본문을 설정하는 추가 작업이 필요
onError 확장
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus){
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.info(err);
byte[] bytes = err.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bytes);
response.getHeaders().setContentType(MediaType.TEXT_PLAIN);
return response.writeWith(Mono.just(buffer)).doOnError(error -> DataBufferUtils.release(buffer));
}
- byte[] bytes = err.getBytes(StandardCharsets.UTF_8)
- 오류 메시지(err)를 UTF-8 인코딩된 바이트 배열로 변환
- 이 변환은 응답 본문에 이 메시지를 텍스트로 포함시키기 위해 필요
- DataBuffer buffer = response.bufferFactory().wrap(bytes)
- 바이트 배열을 DataBuffer로 래핑
- DataBuffer는 Spring WebFlux에서 데이터 처리를 위한 기본 단위
- response.getHeaders().setContentType(MediaType.TEXT_PLAIN)
- 응답의 콘텐츠 유형을 text/plain으로 설정
- 이 설정은 클라이언트가 응답 본문을 어떻게 해석해야 하는지를 결정
- return response.writeWith(Mono.just(buffer))
- 변환된 데이터 버퍼를 사용해 비동기적으로 응답 본문을 작성
- 이 메서드는 Mono<Void>를 반환하는데, 이는 응답 쓰기가 완료되었음을 나타냄
- .doOnError(error -> DataBufferUtils.release(buffer))
- 응답 쓰기 도중 오류가 발생하면 DataBuffer를 해제
- DataBuffer는 메모리를 차지하므로 명시적으로 해제해 메모리 누수를 방지
Application.yml 설정
spring:
application:
name: api-gateway
cloud:
gateway:
default-filters:
- name : GlobalFilter
routes:
- id: member-service
uri: lb://member-service
predicates:
- Path=/member-service/**
filters:
- StripPrefix=1
- id: order-service
uri: lb://order-service
predicates:
- Path=/order-service/**
filters:
- StripPrefix=1
- id: game-service
uri: lb://game-service
predicates:
- Path=/game-service/**
filters:
- StripPrefix=1
- 각 route마다의 filters에 Filter를 추가하여 개별 Filter를 적용할 수도 있지만, 모든 요청에 대해서 접근이 가능한 경로(Whitelist)를 제외한 나머지 경로에 대해 JWT를 확인하기 때문에 모든 서비스에 적용
Postman 테스트
로그인 되지 않은 상태 - JWT가 필요한 API 요청
로그인 되지 않은 상태 - 누구나 접근 가능한 API (로그인, 회원가입)
로그인 성공 → JWT가 필요한 API 호출
'MicroService Architecture' 카테고리의 다른 글
API Gateway 라이브러리 선택 및 사용 예제 (0) | 2024.08.23 |
---|---|
FeignClient(OpenFeign) 사용 이유, 예제 (0) | 2024.08.21 |
Feign Client / Web Client / RestTemplate (0) | 2024.08.21 |
[Spring Cloud] Netflix Eureka 개념, 용어 정리 및 사용 예제 (0) | 2024.08.20 |
Service Discovery 개념, 종류, 특징 (0) | 2024.08.20 |