Spring Framework에서 제공하는 오픈 소스 기반의 Gateway 서비스
기본 설정 및 라우팅에 대해서는 이 글 참고!
게이트웨이로 들어오는 요청에 대하여 JWT 토큰의 유효 여부를 검증한 후, 검증을 통과한 요청만 해당 서비스로 전달
서비스로 들어오는 모든 요청을 JWT 토큰이 필요한 요청과 필요하지 않은 요청으로 나눌 수 있는데, 이 요청들은 모두 게이트웨이를 거쳐서 서비스로 전달되므로, 각 서비스 별로 토큰 검증 필터를 구현하기 보다는 게이트웨이에서 한 번만 구현하여 검증을 거친 후에 통과한 요청만 서비스로 전달하는 방식이 더욱 효율적임.
일반적인 Spring Web MVC 프로젝트에서는 tomcat 기반의 Spring Security를 사용하여 토큰 검증 필터를 구현하지만, Spring Cloud Gateway는 Spring Webflux 프로젝트, 즉, netty 기반이므로 Spring Security를 함께 사용할 수 없음. 따라서, Spring Cloud gateway에서 제공하는 AbstractGatewayFilterFactory
를 상속하여 구현하여야 함.
AuthorizationHeaderFiler
요청에 토큰이 존재하는지, 존재한다면 유효한 토큰인지 검증 ➡️ 유효하지 않은 경우 예외 처리
SecretKey
객체 생성private final SecretKey key;
public AuthorizationHeaderFilter(@Value("${jwt.secret-key}") String key) {
super(Config.class);
byte[] keyBytes = Decoders.BASE64.decode(key);
this.key = new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
}
⭐ 헤더에
Authorization
이 없다면 토큰이 없다는 뜻이므로 예외 처리!
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest req = exchange.getRequest();
HttpHeaders header = req.getHeaders();
if(!header.containsKey(HttpHeaders.AUTHORIZATION)) {
throw new BaseException(NOT_FOUND_TOKEN);
}
};
}
⭐ 올바른 형식이 아니거나, 토큰의 유효기간이 지나 만료된 경우 예외 처리!
Bearer
로 시작하지 않는 경우 @Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
...
String authorization = header.get(HttpHeaders.AUTHORIZATION).get(0);
if(authorization == null || !authorization.startsWith("Bearer")) {
throw new BaseException(INVALID_TOKEN);
}
String jwt = authorization.substring(7);
isValid(jwt);
return chain.filter(exchange);
};
}
isValid(jwt)
: 토큰 유효성 검증parseClaimsJws(jwt)
: 토큰이 올바른 Secret Key로 서명되었는지 + 만료되지 않았는지 검증
ExpiredJwtException
: 토큰이 만료된 경우에 대한 예외 처리Exception
: 그 외의 예외 처리 (유효하지 않은 토큰)private void isValid(String jwt) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwt);
} catch(ExpiredJwtException e) {
throw new BaseException(EXPIRED_TOKEN);
} catch(Exception e) {
throw new BaseException(INVALID_TOKEN);
}
}
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final SecretKey key;
public AuthorizationHeaderFilter(@Value("${jwt.secret-key}") String key) {
super(Config.class);
byte[] keyBytes = Decoders.BASE64.decode(key);
this.key = new SecretKeySpec(keyBytes, SignatureAlgorithm.HS256.getJcaName());
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest req = exchange.getRequest();
HttpHeaders header = req.getHeaders();
if(!header.containsKey(HttpHeaders.AUTHORIZATION)) {
throw new BaseException(NOT_FOUND_TOKEN);
}
String authorization = header.get(HttpHeaders.AUTHORIZATION).get(0);
if(authorization == null || !authorization.startsWith("Bearer")) {
throw new BaseException(INVALID_TOKEN);
}
String jwt = authorization.substring(7);
isValid(jwt);
return chain.filter(exchange);
};
}
private void isValid(String jwt) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jwt)
.getBody()
.getSubject();
} catch(ExpiredJwtException e) {
throw new BaseException(EXPIRED_TOKEN);
} catch(Exception e) {
throw new BaseException(INVALID_TOKEN);
}
}
public static class Config {
}
}
.yml
에서 토큰이 필요한 요청에 대한 라우팅에 토큰 검증 필터 적용
한 서비스 내에서도 토큰이 필요한 요청과 필요하지 않은 요청이 각각 존재하므로, 토큰이 필요한 요청에 대한 라우팅과 필요하지 않은 요청에 대한 라우팅을 분리해서 토큰이 필요한 요청에 대한 라우팅에만 토큰 검증 필터를 적용해야 함!
ex) sns 서비스에 대한 라우팅
sns-service-get
: 단순 조회 요청이므로 토큰 불필요 ➡️ 토큰 검증 필터 미적용- id: sns-service-get
uri: lb://SNS-SERVICE
predicates:
- Path=/sns-service/**, /sns-service/bookmark/**, /sns-service/follow/**
- Method= GET
filters: # 경로 필터만 적용
- StripPrefix=1
sns-service-filter
: 포스팅 & 좋아요 & 팔로우 CRUD와 관련된 요청이므로 토큰 필요 ➡️ 토큰 검증 필터 적용
filters
내에 name: 필터명
을 통해 커스텀 필터 여러 개 적용 가능!- id: sns-service-filter # 토큰이 필요한 요청 : 토큰 검증 필터 적용
uri: lb://SNS-SERVICE
predicates:
- Path=/sns-service/posts/**, /sns-service/bookmark/**, /sns-service/follow/**
- Method=PUT, POST, DELETE
filters:
- StripPrefix=1
- name: AuthorizationHeaderFilter
⭐
ErrorWebExceptionHandler
: 예외 전역 처리
Spring Web MVC 기반 애플리케이션에서 @ExceptionHandler
를 사용하여 컨트롤러에서 발생한 예외를 처리하는 것처럼, Spring Webflux 기반 애플리케이션에서는 ErrorWebExceptionHandler
를 구현하여 필터에서 발생한 예외를 처리할 수 있음.
MVC 프로젝트와 동일하게 BaseException
, BaseResponse
, BaseResponseStatus
를 사용하여 커스텀할 예정!
BaseException
: 커스텀 예외@Getter
public class BaseException extends RuntimeException{
private BaseResponseStatus status;
public BaseException(BaseResponseStatus status) {
this.status = status;
}
}
BaseResponse
: 커스텀 응답 형식public record BaseResponse<T>(HttpStatusCode httpStatus, Boolean isSuccess, String message, int code, T result) {
public BaseResponse(BaseResponseStatus status) {
this(status.getHttpStatusCode(), false, status.getMessage(), status.getCode(), null);
}
public BaseResponse(BaseResponseStatus status, T result) {
this(status.getHttpStatusCode(), false, status.getMessage(), status.getCode(), result);
}
}
BaseResponseStatus
: 커스텀 응답 상태 코드 + 메세지@Getter
@RequiredArgsConstructor
public enum BaseResponseStatus {
NOT_FOUND_TOKEN(HttpStatus.UNAUTHORIZED, false, 401, "토큰이 필요한 요청입니다."),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, false, 401, "유효하지 않은 토큰입니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, false, 401, "만료된 토큰입니다."),
INVALID_ACCESS(HttpStatus.FORBIDDEN, false, 403, "잘못된 접근입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, 500, "서버 에러");
private final HttpStatusCode httpStatusCode;
private final boolean isSuccess;
private final int code;
private final String message;
}
ErrorResponse
BaseResponse
생성@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
BaseResponse<?> baseResponse;
if (ex instanceof BaseException e) {
baseResponse = new BaseResponse<>(e.getStatus());
response.setStatusCode(baseResponse.httpStatus());
} else {
baseResponse = new BaseResponse<>(BaseResponseStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
BaseResponse
➡️ JSON 변환private final ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
...
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
try {
return response
.writeWith(Mono.just(response.bufferFactory()
.wrap(objectMapper.writeValueAsBytes(baseResponse))));
} catch (JsonProcessingException e) {
return Mono.error(e);
}
}
@Order(-1)
: 핸들러의 우선순위 설정 (낮은 숫자일수록 우선순위가 높음) ➡️ 커스텀 핸들러에 우선순위를 설정해주지 않으면 기본 핸들러인 DefaultErrorWebExceptionHandler
가 실행되므로, 커스텀 핸들러를 사용하기 위해서는 반드시 우선순위를 설정해줘야 함!@Order(-1)
@RequiredArgsConstructor
@Component
public class ErrorResponse implements ErrorWebExceptionHandler {
private final ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
BaseResponse<?> baseResponse;
if (ex instanceof BaseException e) {
baseResponse = new BaseResponse<>(e.getStatus());
response.setStatusCode(baseResponse.httpStatus());
} else {
baseResponse = new BaseResponse<>(BaseResponseStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
}
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
try {
return response
.writeWith(Mono.just(response.bufferFactory()
.wrap(objectMapper.writeValueAsBytes(baseResponse))));
} catch (JsonProcessingException e) {
return Mono.error(e);
}
}
}
➕ 커스텀 예외인 BaseException
과 그 외의 예외를 구분하여 처리
BaseException
: BaseResponseStatus
에 지정한 상태코드 + 메세지result
에 실제 원인을 담은 메세지 전달