
본 내용은 인프런의 이도원님의 SpringCloud 강의를 참고하여 작성되었습니다.

LoadBalancer로 활용되는 SpringCloud API Gateway의 Filter를 활용하여 클라이언트의 요청이 권한을 가지고 있는지 검증해보자!
dependencies {
...
implementation 'io.jsonwebtoken:jjwt:0.9.1'
...
}
API Gateway에서 사용자 권한을 확인이 필요하다. JWT Token을 Decoding 하는 과정을 거쳐야하기 때문에 관련 의존성을 추가해주자.
[AuthorizationHeadrFilter.java]
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
Environment env;
public AuthorizationHeaderFilter(Environment env) {
super(Config.class);
this.env = env;
}
public static class Config {
//
}
...
SpringCloud Gateway 필터를 정의 및 동작하기 위해 apply 메소드를 구현해야 한다. 따라서 AbstractGatewayFilterFactory를 상속하자.
AbstractGatewayFilterFactory는 GatewayFilterFactory 인터페이스를 구현하고 있다. (apply는 GatewayFilterFactory 인터페이스에 정의되어 있는 추상 메서드)
[AuthorizationHeadrFilter.java]
...
@Override
public GatewayFilter apply(Config config) {
// ServerWebExchange 파라미터는 필터가 동작하는 동안 현재 요청 및 응답에 대한 정보를 제공한다.
// 비동기 서버 Netty 에서는 동기 서버(ex:tomcat)와 다르게 request/response 객체를 선언할 때 Server~ 를 사용한다.
GatewayFilter filter = (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
// 요청 헤더에 "Authorization" 헤더가 포함되어 있는지 확인한다.
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
// "Authorization" 헤더가 없는 경우, UNAUTHORIZED(401) 상태로 에러 응답을 반환.
return onError(exchange, "No Authorization header", HttpStatus.UNAUTHORIZED);
}
// "Authorization" 헤더에서 JWT 토큰을 추출.
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace("Bearer", "");
// 추출한 JWT 토큰의 유효성을 확인.
if (!isJwtValid(jwt)) {
return onError(exchange, "JWT Token is not valid", HttpStatus.UNAUTHORIZED);
}
// JWT 토큰이 유효한 경우, 다음 필터로 요청을 전달.
return chain.filter(exchange);
};
return filter;
}
우리가 Override할 apply 메서드를 살펴보면 GatewayFilter를 return Type으로 가지고 있다.
apply 추상 메서드 Override -> 함수형 인터페이스 GateWayFilter 인스턴스를 익명 클래스로 생성
(람다식 형태로 Filter 메서드를 구체화)
전달받은 Request Header에서 JWT 토큰을 추출하고 isValid() 메서드를 통해 유효성을 확인해주자.
[AuthorizationHeadrFilter.java]
private boolean isJwtValid(String jwt) {
// 반환값으로 사용할 boolean 변수를 초기값 true로 설정
boolean returnValue = true;
// JWT의 'subject'를 저장할 변수 초기화
String subject = null;
try {
// JWT 토큰을 파싱하고 검증하는 부분
subject = Jwts.parser()
.setSigningKey(env.getProperty("token.secret")) // 토큰의 비밀 키 설정
.parseClaimsJws(jwt) // JWT 토큰 파싱 및 검증
.getBody()
.getSubject(); // 토큰에서 'subject' 정보 추출
} catch (Exception e) {
// 예외가 발생하면 JWT가 유효하지 않다고 판단하고 반환값을 false로 변경
returnValue = false;
}
// 'subject' 값이 비어있으면 JWT가 유효하지 않다고 판단하고 반환값을 false로 변경
if (subject == null || subject.isEmpty()) {
returnValue = false;
}
// 최종적으로 JWT의 유효성 여부를 나타내는 반환값을 반환
return returnValue;
}
JWT Token을 생성하는 부분을 다시한번 확인해보자.
[AuthenticationFilter.java]
// JWT Token을 생성하는 부분
String token = Jwts.builder()
// Payload 세팅: Subject를 유저의 코드로 설정
.setSubject(userDetails.getUserCode())
// 토큰 만료 시간 설정 (현재 시간 + 지정된 만료 시간)
.setExpiration(new Date(System.currentTimeMillis() + Long.parseLong(env.getProperty("token.expiration_time")))
// Signature(서명) 알고리즘 및 비밀 키를 사용하여 서명 생성
.signWith(SignatureAlgorithm.HS512, env.getProperty("token.secret"))
.compact();
Payload의 Subject를 유저 코드로 설정하고, Signature(서명) 생성시에 HS512 알고리즘으로과 비밀키(token.secret)를 활용했다.
Decoding 시에도 토큰의 비밀키를 활용하여 subject(유저 코드)를 추출할 수 있다.
[AuthorizationHeadrFilter.java]
// Mono, Flux -> Spring WebFlux (기존의 SpringMVC 방식이 아니기때문에 Servlet 을 사용하지 않음)
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(err);
return response.setComplete();
}
예외처리를 담당하는 onError 메서드와 filter 추상 메서드는 Mono를 return Type 으로 사용한다. 간단하게 Mono에 대해 설명하고 넘어가겠다.
SpringCloud에서는 비동기 처리를 위해 동기적으로 작동하는 SpringMVC를 대체하는 Spring WebFlux 프레임워크를 사용한다. 비동기적인 데이터 스트림의 처리를 리액터 라이브러리가 제공하는 데이터 타입인 Mono 와 Flux 로 다루어야 한다.
리엑터 라이브러리(Reactor library) 와 넷티(Netty)를 기반으로 동작하는 Spring WebFlux에 대해서는 추후의 포스팅에 대해 다루도록 하겠다.
[GatewayFilter.java]
public interface GatewayFilter extends ShortcutConfigurable {
/**
* Name key.
*/
String NAME_KEY = "name";
/**
* Value key.
*/
String VALUE_KEY = "value";
/**
* Process the Web request and (optionally) delegate to the next {@code WebFilter}
* through the given {@link GatewayFilterChain}.
* @param exchange the current server exchange
* @param chain provides a way to delegate to the next filter
* @return {@code Mono<Void>} to indicate when request processing is complete
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
그렇다면 GatewayFilter를 인스턴스로 생성해야 하는데 함수형 인터페이스로 정의되어 있기 때문에 람다식을 통해 Filter 메서드를 구현하여 인스턴스를 생성하자.
요청에 대한 권한을 검증할 AuthoriztionHeaderFilter 구현이 모두 끝났다면 구성 정보 파일에서 Filter를 적용할 요청을 지정할 수 있다.
[application.yml]
...
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/signup
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/signin
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
회원가입과 로그인 (singin, singup) 은 권한을 확인할 필요가 없으니 필터를 추가하지 않는다.
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- AuthorizationHeaderFilter
이를 제외한 다른 회원관련 요청들(GET)은 보통 권한을 확인할 필요가 있으니 Filter를 추가해주자. 이외에도 개별적으로 요청에 따른 권한 검증이 필요한 URI가 있다면 추가해주도록 하자.
[AuthorizationHeadrFilter.java]
@Component
@Slf4j
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
Environment env;
public AuthorizationHeaderFilter(Environment env) {
super(Config.class);
this.env = env;
}
public static class Config {
//
}
@Override
public GatewayFilter apply(Config config) {
// ServerWebExchange 파라미터는 필터가 동작하는 동안 현재 요청 및 응답에 대한 정보를 제공한다.
// 비동기 서버 Netty 에서는 동기 서버(ex:tomcat)와 다르게 request/response 객체를 선언할 때 Server~ 를 사용한다.
GatewayFilter filter = (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
// 요청 헤더에 "Authorization" 헤더가 포함되어 있는지 확인한다.
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
// "Authorization" 헤더가 없는 경우, UNAUTHORIZED(401) 상태로 에러 응답을 반환.
return onError(exchange, "No Authorization header", HttpStatus.UNAUTHORIZED);
}
// "Authorization" 헤더에서 JWT 토큰을 추출.
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace("Bearer", "");
// 추출한 JWT 토큰의 유효성을 확인.
if (!isJwtValid(jwt)) {
return onError(exchange, "JWT Token is not valid", HttpStatus.UNAUTHORIZED);
}
// JWT 토큰이 유효한 경우, 다음 필터로 요청을 전달.
return chain.filter(exchange);
};
return filter;
}
private boolean isJwtValid(String jwt) {
// 반환값으로 사용할 boolean 변수를 초기값 true로 설정
boolean returnValue = true;
// JWT의 'subject'를 저장할 변수 초기화
String subject = null;
try {
// JWT 토큰을 파싱하고 검증하는 부분
subject = Jwts.parser()
.setSigningKey(env.getProperty("token.secret")) // 토큰의 비밀 키 설정
.parseClaimsJws(jwt) // JWT 토큰 파싱 및 검증
.getBody()
.getSubject(); // 토큰에서 'subject' 정보 추출
} catch (Exception e) {
// 예외가 발생하면 JWT가 유효하지 않다고 판단하고 반환값을 false로 변경
returnValue = false;
}
// 'subject' 값이 비어있으면 JWT가 유효하지 않다고 판단하고 반환값을 false로 변경
if (subject == null || subject.isEmpty()) {
returnValue = false;
}
// 최종적으로 JWT의 유효성 여부를 나타내는 반환값을 반환
return returnValue;
}
// Mono, Flux -> Spring WebFlux (기존의 SpringMVC 방식이 아니기때문에 Servlet 을 사용하지 않음)
private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
log.error(err);
return response.setComplete();
}
}
이로써 SpringSecurity를 활용한 사용자 인증 후 클라이언트에게 JWT Token의 발급과, SpringCloud의 Filter를 활용하여 클라이언트가 요청하는 JWT Token의 검증 과정에 대해 알아보았다.
참고문헌
Inflearn: Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) 강의자료
https://thalals.tistory.com/381