현재 Ficket 프로젝트는 user
, admin
, event
, queue
, ticketing
, face
총 6개의 마이크로서비스로 구성되어 있습니다.
초기에는 각 마이크로서비스에 개별적으로 인증/인가 로직을 적용하는 방식을 고려했으나, 이로 인해 코드의 중복과 관리의 복잡성이 증가할 수 있다는 문제가 있었습니다. 특히, 서비스마다 인증 로직을 변경할 때마다 모든 서비스를 수정하고 배포해야 하는 어려움이 예상되었습니다.
이러한 고민 끝에, Spring Cloud Gateway를 사용해 인증과 인가를 중앙에서 처리하는 구조를 시작하기로 결정했습니다. Gateway에서 모든 서비스의 진입점에서 인증과 인가를 수행함으로써, 각 마이크로서비스는 비즈니스 로직에만 집중할 수 있습니다.
필터 클래스 생성: AbstractGatewayFilterFactory
를 상속하여 필터 클래스를 생성합니다. 필터의 apply
메서드를 구현하여 요청을 처리하는 로직을 정의합니다.
@Slf4j
@Component
public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> {
public CustomFilter() {
super(Config.class);
}
public static class Config {
// 커스텀 필터에 필요한 설정 속성을 정의합니다.
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
// 요청 처리 로직 구현
log.info("커스텀 필터 적용됨");
return chain.filter(exchange);
};
}
}
필터 등록: 생성한 필터를 Spring의 @Component
로 등록합니다. 이렇게 하면 Spring Boot가 해당 필터를 관리하고 사용할 수 있게 됩니다.
YML 설정 파일에 필터 적용: application.yml
파일에서 라우트 설정 시 filters
항목에 커스텀 필터를 추가합니다.
spring:
cloud:
gateway:
routes:
- id: custom-route
uri: lb://some-service
predicates:
- Path=/api/v1/custom/**
filters:
- CustomFilter
위와 같은 방법으로 커스텀 필터를 사용하여 특정 경로에 대해 원하는 전처리나 후처리 로직을 적용할 수 있습니다.
JWT 토큰의 유효성을 검증하는 AuthorizationHeaderFilter
를 정의합니다. 이 필터는 요청에 포함된 JWT 토큰을 검증하고, 유효하지 않은 경우 요청을 차단합니다.
@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private static final String BEARER_TYPE = "Bearer ";
private final Key key;
public AuthorizationHeaderFilter(@Value("${jwt.secret}") String secretKey) {
super(Config.class);
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public static class Config {
// 필터에 필요한 설정 속성을 여기에 정의할 수 있습니다.
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "Authorization 헤더가 없습니다.");
}
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace(BEARER_TYPE, "");
if (!validateToken(jwt)) {
return onError(exchange, "JWT 토큰이 유효하지 않습니다.");
}
return chain.filter(exchange);
};
}
private Mono<Void> onError(ServerWebExchange exchange, String err) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
log.error(err);
return response.setComplete();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (Exception e) {
log.info("유효하지 않은 JWT 토큰입니다.", e);
}
return false;
}
}
관리자만 접근할 수 있는 경로에 대해 추가적인 역할(Role) 검증을 수행하는 AdminRoleCheckFilter
도 구현되어 있습니다. 이 필터는 JWT 토큰의 클레임에서 역할 정보를 확인하고, "MANAGER" 권한이 있는지 검증합니다.
@Slf4j
@Component
public class AdminRoleCheckFilter extends AbstractGatewayFilterFactory<AdminRoleCheckFilter.Config> {
private final Key key;
public AdminRoleCheckFilter(@Value("${jwt.secret}") String secretKey) {
super(Config.class);
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "Authorization 헤더가 없습니다.");
}
String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
String jwt = authorizationHeader.replace(BEARER_TYPE, "");
if (!validateToken(jwt)) {
return onError(exchange, "JWT 토큰이 유효하지 않습니다.");
}
return chain.filter(exchange);
};
}
public boolean validateToken(String token) {
try {
Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
String role = claims.get("role").toString();
return role.equals("MANAGER");
} catch (Exception e) {
log.info("유효하지 않은 JWT 토큰입니다.", e);
}
return false;
}
}
추가적으로 마이크로서비스에서 JWT 토큰의 사용자 정보가 필요한 경우가 있었습니다. JWT 토큰에서 userId
를 추출하여 헤더에 담아 사용하는 필터도 구현했습니다.
/**
* JWT에서 값 추출 후 헤더에 추가하는 필터입니다.
*/
@Slf4j
@Component
public class UserTokenExtractionFilter extends AbstractGatewayFilterFactory<UserTokenExtractionFilter.Config> {
private static final String BEARER_TYPE = "Bearer ";
private static final String USER_ID_HEADER = "X-User-Id"; // 추가할 헤더 이름
private final Key key;
public UserTokenExtractionFilter(@Value("${jwt.secret}") String secretKey) {
super(Config.class);
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public static class Config {
// 필터에 필요한 설정 속성을 여기에 정의할 수 있습니다.
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
// Authorization 헤더 확인
String authorizationHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_TYPE)) {
log.error("Authorization 헤더가 없거나 Bearer 타입이 아닙니다.");
return chain.filter(exchange);
}
// JWT 토큰 추출
String jwt = authorizationHeader.replace(BEARER_TYPE, "");
// JWT에서 userId 추출 및 변환
String userId = extractUserIdAsString(jwt);
if (userId != null) {
// userId를 헤더에 추가
ServerHttpRequest modifiedRequest = request.mutate()
.header(USER_ID_HEADER, userId) // Long 값을 문자열로 변환하여 추가
.build();
return chain.filter(exchange.mutate().request(modifiedRequest).build());
}
log.error("JWT에서 userId를 추출하지 못했습니다.");
return chain.filter(exchange);
};
}
/**
* JWT에서 userId를 추출하고 Long 타입으로 변환합니다.
*
* @param token JWT 토큰
* @return userId를 Long으로 반환, 실패 시 null
*/
private String extractUserIdAsString(String token) {
try {
Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
Long userId = claims.get("userId", Long.class);
return String.valueOf(userId);
} catch (NumberFormatException e) {
log.error("userId를 Long으로 변환할 수 없습니다.", e);
} catch (Exception e) {
log.error("JWT에서 userId 추출 실패", e);
}
return null;
}
}
Gateway에서 각 서비스로의 라우팅과 필터 적용을 위해 application.yml
파일에 설정을 추가합니다. 여기서는 서비스별로 필요한 필터를 적용하여 인증과 인가를 처리합니다.
spring:
cloud:
gateway:
routes:
- id: event-service
uri: lb://event-service
predicates:
- Path=/api/v1/events/seat/{action}
filters:
- AuthorizationHeaderFilter
- UserTokenExtractionFilter
- RemoveRequestHeader=Cookie
- id: event-service
uri: lb://event-service
predicates:
- Path=/api/v1/events/admins/**
filters:
- AdminRoleCheckFilter
- AdminTokenExtractionFilter
- RewritePath=/api/v1/events/admins/(?<segment>.*), /api/v1/events/admins/${segment}
- RemoveRequestHeader=Cookie
- id: face-service
uri: lb://face-service
predicates:
- Path=/api/v1/faces/**
filters:
- name: AuthorizationHeaderFilter
위와 같이 설정함으로써, 각 서비스로 들어오는 요청은 Gateway에서 먼저 JWT 토큰의 유효성을 검증하거나 관리자의 역할을 확인하며, 필요한 경우 사용자 정보를 헤더에 추가하여 전달합니다. 이를 통해 각 마이크로서비스는 인증/인가 로직에서 벗어나 비즈니스 로직에 집중할 수 있게 되었습니다.
Refrence