보안 강화: JWT는 클라이언트와 서버 간에 인증을 위해 사용되는 토큰입니다. 클라이언트가 발급한 JWT를 서버에 전달하면, 서버는 해당 JWT의 유효성을 검사하여 해당 클라이언트가 인증된 사용자인지 확인합니다. 게이트웨이에서 유효성을 검사하여 정상적인 토큰만 허용하고, 위조된 토큰이나 만료된 토큰 등을 거부하여 보안을 강화할 수 있습니다.
접근 제어 및 인가: JWT는 사용자의 역할(Role)이나 권한(Authority) 정보를 포함할 수 있습니다. 게이트웨이에서 JWT를 검사하여 해당 사용자가 특정 리소스에 접근할 권한이 있는지 확인할 수 있습니다. 이를 통해 인가된 사용자만이 특정 기능이나 리소스에 접근할 수 있도록 제어할 수 있습니다.
단일 인증 지점: 여러 마이크로서비스로 구성된 MSA(Microservices Architecture) 환경에서 각 서비스마다 별도의 인증 로직을 구현하는 것은 복잡하고 비효율적일 수 있습니다. 게이트웨이에서 JWT 유효성 검사를 하면, 모든 인증과 인가 관련 로직을 중앙 집중화하여 관리할 수 있습니다. 이를 통해 단일 인증 지점(Single Sign-On)을 구현하거나 중복된 인증 로직을 방지할 수 있습니다.
클라이언트 단순화: 클라이언트 애플리케이션에서 JWT를 관리하고 유효성 검사를 직접 수행하는 것은 복잡할 수 있습니다. 게이트웨이에서 JWT 유효성 검사를 대신해주면 클라이언트는 단순히 JWT를 요청 헤더에 추가하기만 하면 됩니다. 클라이언트 애플리케이션을 더 단순화할 수 있습니다.
중앙화된 관리: 게이트웨이에서 JWT 유효성 검사를 관리하면, 변경이나 업데이트가 필요한 경우 한 곳에서 처리할 수 있습니다. 여러 서비스를 수정하지 않아도 되므로 유지보수가 편리해집니다.
게이트웨이를 클라이언트와 여러 백엔드 서버들 중간에 두어서 보안과, 불필요한 호출을 줄이고 클라이언트에게는 어떤 서버가 해당 요청에 대한 응답을 주는지에 대한 불필요한 정보를 노출하지 않는 등의 이점으로 도입했음을 생각하면 위의 이유들이 전부 이해 된다.
그러니까 왜 jwt 유효성을 검사를 게이트웨이에서 하는가아?
간단하게 해당 작업을 위한 필터를 구성해서 등록하고 사용하기만 하면 되니까 유지보수
도 편해지고 클라이언트 측에서는 해당 jwt에 대한 검사가 필요 없어
지며 게이트웨이에서 Jwt 유효성을 확인해주니 필요할때마다 해당 작업을 진행할 필요가 없어 코드의 중복이 줄어들 것
이고 결과적으로 게이트웨이가 이 작업을 중앙에서 진행해주니 위조, 만료된 토큰 등의 경우를 거절해주어 보안이 강화
될 것이다.
우리가 JWT를 도입한 이유는 사실상 남들이 좋다니까 보안에도 좋고 stateless하게 사용이 가능하고 분산된 서버에서는 세션처럼 클러스터링을 굳이 머리아프게 고민하지 않아도 된다는 장점이 있기 때문이라고는 알고 있을 것이다.
쿠키
- 조작 가능성이 높음
- 보안이 구려서 개인정보 저장엔 지양하는 것이 맞음
세션
- 메모리에 저장해서 저장 공간에 한계가 존재 (서버 자원을 사용)
- 로드 밸런스와 같은 기술을 적용할 때 세션 클러스터링 등을 고려해야 함 머리가 아프다.
- 쿠키에 비해서 느리다. (그러나 쿠키에 비해서 보안이 괜찮다.)
jwt
[장점]
- 데이터의 위변조를 방지한다.
- JWT는 인증에 필요한 모든 정보를 담고 있기 때문에 인증을 위한 별도의 저장소가 없어도 된다.
- 세션(Stateful)과 다르게 서버는 무상태(StateLess)가 된다.
- 확장성이 우수하다.
[단점]
- 쿠키/세션과 다르게 토큰의 길이가 길어, 인증 요청이 많아질수록 네트워크 부하가 심해진다.
- Payload 자체는 암호화가 되지 않아 중요한 정보는 담을 수 없다.
- 토큰을 탈취당한다면 대처가 매우 어렵다.
사실 위의 장단점만 봤을 땐 딱히 JWT가 그다지 ;; 매력적으로 보이진 않는다.
여러 이유로 JWT를 도입했고 해당 서비스 아키텍쳐에 맞춰서 이를 하나씩 구현하고자 했다.
1-1. 매번 들어오는 인증 작업을 어떻게 진행할 것?
1-2. 그래서 유효성 검사를 중앙에 존재하는 게이트웨이를 이용하기로 했다.
1-3. 게이트웨이에 무엇을 할건데?
1-4. request header로 넘어온 jwt의 유효성 검사 필터 도입
gateway url host
로 들어온 요청의 정보가 해당 Predicates에 적용된 Path와 일치할 때 해당 필터는 동작한다.스프링 클라우드 게이트웨이를 사용한 라우터 작업은 사실 두가지로 가능하다
RouteLocator
빈(bean) 등록import com.yaloostore.gateway.filter.CustomAuthorizationFilter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.GatewayFilterSpec;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.gateway.route.builder.UriSpec;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.util.function.Function;
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "yaloostore")
public class GatewayConfig {
private String shopUrl;
private String tokenUrlPattern;
private String authUrl;
@Bean
public RouteLocator routeLocator(CustomAuthorizationFilter authorizationFilter,
RestTemplate restTemplate,
RouteLocatorBuilder builder) {
return builder.routes()
.route("token", r-> r.path(tokenUrlPattern)
.filters(tokenFilter(authorizationFilter, restTemplate))
.uri(shopUrl))
.build();
}
private Function<GatewayFilterSpec, UriSpec> tokenFilter(CustomAuthorizationFilter filter,
RestTemplate restTemplate) {
return f -> f.filter(
filter.apply(
new CustomAuthorizationFilter.Config(restTemplate, authUrl)
)
);
}
public String getTokenUrlPattern() {
return tokenUrlPattern;
}
public String getAuthUrl() {
return authUrl;
}
public String getShopUrl() {
return shopUrl;
}
public void setShopUrl(String shopUrl) {
this.shopUrl = shopUrl;
}
public void setTokenUrlPattern(String tokenUrlPattern) {
this.tokenUrlPattern = tokenUrlPattern;
}
public void setAuthUrl(String authUrl) {
this.authUrl = authUrl;
}
}
[application.yml]
yaloostore:
token-url-pattern: /token/**
shop-url: http://localhost:8081
auth-url: http://localhost:8083
spring:
cloud:
gateway:
routes:
- id: yalooStore-shop
uri: http://localhost:8081
predicates:
- Path= /api/**
- id: yalooStore-front
uri: http://localhost:8082
predicates:
- Path=/members/**, /, /error, /products/** , /product/**, /auth-login, /static/**, /assets/**, /css/**, /js/**, /img/**, /fonts/**
- id: yalooStore-auth
uri: http://localhost:8083
predicates:
- Path= /auth/**
- id: 해당 라우터 이름
- uri: 조건을 먼저 살펴보고 조건에 맞게 설정해둔 uri로 해당 요청을 게이트웨이는 보내준다. (3)
- predicates(1) -> 필수
- 해당 조건으로 들어온 작업의 경우엔 설정해준 uri로 작업을 보내준다.
- 이때 gateway uri로 들어오는 작업의 조건에 한해서만 진행됨 (작성된 uri로 들어온 조건은 상관 없음 적용 안 됨)
- 또한 predicates에 들어가는 Path 같은 경우엔 대문자로 시작해야 한다.
- filter: 작성한 게이트웨이 필터를 적용 (2)
import com.yalooStore.common_utils.dto.ResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bouncycastle.asn1.ocsp.ResponseData;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.util.Objects;
/**
* jwt를 사용한 토큰 유효성 검사에 사용되는 필터 클래스입니다.
* */
@Component
@Slf4j
public class CustomAuthorizationFilter extends AbstractGatewayFilterFactory<CustomAuthorizationFilter.Config> {
@RequiredArgsConstructor
public static class Config{
// 필요 설정을 여기에서 진행
//private final RedisTemplate<String, Object> redisTemplate;
private final RestTemplate restTemplate;
private final String authUrl;
}
public CustomAuthorizationFilter(){
super(Config.class);
}
/**
* 해당 조건문에 맞는 요청어오면 jwt이 있는지 유효한지를 확인하는 작업을 진행합니다.
*
* @param config 필터에서 사용할 설정
* @return 토큰 유효성 검사를 진행하는 gatewayFilter
* */
@Override
public GatewayFilter apply(Config config) {
return (((exchange, chain) -> {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
//넘어온 Authorization 헤더가 없으면 UNAUTHORIZATION를 날림
if (Objects.isNull(token)){
log.info("Authorization header not exist");
return unAuthorizedHandler(exchange);
}
// Authorization: Bearer 로 시작되기 때문에 이 역시 확인 작업을 진행
String accessToken = prefixRemoveToken(token);
if (Objects.isNull(accessToken)){
log.info("해당 토큰이 Bearer로 시작하지 않습니다.");
return unAuthorizedHandler(exchange);
}
boolean isValidToken = checkIsValidToken(accessToken,config);
if (!isValidToken){
log.info("is not Valid Token");
return unAuthorizedHandler(exchange);
}
return chain.filter(exchange);
}));
}
private boolean checkIsValidToken(String accessToken, Config config) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
log.info("jwt header!!! === {} ", headers.get("Authorization").toString());
HttpEntity entity = new HttpEntity(headers);
URI uri = UriComponentsBuilder.fromUriString(config.authUrl)
.pathSegment("authorizations", "isValidToken").build().toUri();
ResponseEntity<ResponseDto<Void>> response = config.restTemplate.exchange(uri, HttpMethod.GET, entity, new ParameterizedTypeReference<>() {
});
log.info(String.valueOf(response.getBody().isSuccess()));
if(!response.getBody().isSuccess()){
return false;
}
return true;
}
private String prefixRemoveToken(String token) {
if (!token.startsWith("Bearer ")){
return null;
}
return token.substring(7);
}
private Mono<Void> unAuthorizedHandler(ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
}
인증/인가
를 담당하는 서버에서 해당 토큰을 확인해서 유효성 여부를 넘겨준다.