[MSA] Spring Cloud Gateway (2) : JWT 검증 필터 (+ 예외 처리)

진예·2024년 8월 12일
0

Backend : Spring

목록 보기
6/7
post-thumbnail

💡 Spring Cloud Gateway

Spring Framework에서 제공하는 오픈 소스 기반의 Gateway 서비스

기본 설정 및 라우팅에 대해서는 이 글 참고!


✅ JWT 검증 필터

게이트웨이로 들어오는 요청에 대하여 JWT 토큰의 유효 여부를 검증한 후, 검증을 통과한 요청만 해당 서비스로 전달

서비스로 들어오는 모든 요청을 JWT 토큰이 필요한 요청필요하지 않은 요청으로 나눌 수 있는데, 이 요청들은 모두 게이트웨이를 거쳐서 서비스로 전달되므로, 각 서비스 별로 토큰 검증 필터를 구현하기 보다는 게이트웨이에서 한 번만 구현하여 검증을 거친 후에 통과한 요청만 서비스로 전달하는 방식이 더욱 효율적임.

일반적인 Spring Web MVC 프로젝트에서는 tomcat 기반의 Spring Security를 사용하여 토큰 검증 필터를 구현하지만, Spring Cloud GatewaySpring Webflux 프로젝트, 즉, netty 기반이므로 Spring Security를 함께 사용할 수 없음. 따라서, Spring Cloud gateway에서 제공하는 AbstractGatewayFilterFactory상속하여 구현하여야 함.


⚙️ AuthorizationHeaderFiler

요청에 토큰이 존재하는지, 존재한다면 유효한 토큰인지 검증 ➡️ 유효하지 않은 경우 예외 처리

1. 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());
}

2. 토큰 존재 여부 검증

⭐ 헤더에 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);
		}
	};
}

2. 토큰 유효성 검증

올바른 형식이 아니거나, 토큰의 유효기간이 지나 만료된 경우 예외 처리!

  • 올바르지 않은 형식 : 토큰이 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

1. 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);
	}
}

2. 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에 지정한 상태코드 + 메세지
  • 그 외의 예외 : 상태코드 500 + + result에 실제 원인을 담은 메세지 전달
profile
백엔드 개발자👩🏻‍💻가 되고 싶다

0개의 댓글