해당 포스트 (1) ~ (7) 과정의 코드를 리펙토링 한 것이다!
Spring Boot에서 JWT 프로젝트 세팅하기 / Filter 테스트 (1)
VERSION springboot : '3.2.3' java : '17' 1. build.gradle에 Dependecy 추가 plugins { id 'java' id 'org.springframework.boot' version '3.2.3' id 'io.spring.dependency-management' version '1.1.4' } group = 'com.example' version = '0.0.1-SNAPSHOT' java { s
jinhos-devlog.tistory.com
수정한 부분만 설명하겠다!
0. 프로젝트 설명
해당 구조로 이루어지며,
- config-service에서 원격저장소에 있는 YML 파일을 가져올 것이다.
- username -> email로 대체되었다.
정도만 알고 시작해보자.
1. ApiGateway 패턴의 필요성
ApiGateway의 본래 목적은
클라이언트 애플리케이션과 마이크로서비스들 사이에 위치하여 요청을 서비스로 라우팅하는 역방향 프록시로써 사용되는 것.
내부 마이크로서비스의 엔드포인트로 리디렉션!또한 인가, SSL 종료 및 캐시와 같은 공통 관심사를 처리하는 기능을 제공할 수 있다.
그래서 우리는 "인가" 과정만 API Gateway에서 처리해주도록 결정했다!
2. ApiGateway 구현 방식 : WebFlux와 Reactor
자바로 웹 기반 애플리케이션을 개발하기 위해서는 서블릿(Servlet)과 서블릿 컨테이너(Servlet container)를 사용한다!
(우리가 지금까지 했다 Java-Spring이다!)
기존 Spring MVC 모델에 비동기(asynchronous)와 넌블럭킹 I/O(non-blocking I/O) 처리를 맡기려면 너무 큰 변화가 필요했기 때문에
리액티브 프로그래밍(Reactive programming)을 지원하는 새로운 웹 프레임워크를 제공했다!
Spring Cloud Gateway는 WebFlux와 Reactor 프로젝트를 기반으로 만들어진 비동기적인 API Gateway이다.
기존의 Spring MVC와는 아예 다른 개념, Servlet 기반의 프로젝트와는 차이가 있다.
netty 서버를 기반으로 동작하며, 이를 통해 비동기적이고 빠른 HTTP 통신을 처리할 수 있다.
따라서 다른 프레임워크를 사용하더라도 비동기적으로 동작하는 마이크로서비스를 구현할 수 있다.
이러한 전제 하에 Spring Cloud Gateway는 스프링에 의존적이지 않고, 다양한 프레임워크와의 통합을 지원한다.
2-1. 🤔고민...
그동안 사용하던 Spring MVC에서의 servlet 기반 프로젝트와는 아예 다른 개념이다...
아직 Java-Spring도 잘 못하는데... 이게 무슨 난관인가 싶었다ㅠ
마이크로서비스를 위한 API Gateway는 늘어난 Request를 빨리빨리 처리하기위해
nonblocking & aysnchronus 하게 돌아갈 필요가 있다.
그래서.. 다른 차선책을 발견했는데..
Spring Cloud Gateway MVC라는 것도 있더라!
허나, 우리는 SPOF의 문제도 고려하여 Api Gateway의 성능 개선을 놓칠 수 없었다..
또한, 우리 뒷단 MS 들이 MVC 단의 서비스가 아닌 다른 것이 들어올 수도 있다는 확장성을 고려해서
... Spring Cloud Gateway를 사용하기로 결정했다!
3. yml 파일 설정!
3-1. 원격 저장소에 있는 yml 파일
- database-apiGateway-dev.yml
spring:
cloud:
gateway:
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Global Filter
preLogger: true
postLogger: true
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user/signup
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user/(?<segment>.*), /$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user/login
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user/(?<segment>.*), /$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user/reissue
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user/(?<segment>.*), /$\{segment}
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user/logout
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user/(?<segment>.*), /$\{segment}
- AuthorizationHeaderFilter
- CustomLogoutFilter
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user/**
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user/(?<segment>.*), /$\{segment}
- AuthorizationHeaderFilter
RewritePath=/api/(?<segment>.*), /$\{segment}
/api/test 로 요청이 들어올 경우
/api/를 빼고 /test 만 전달
로드밸런서는 기본적으로 라운드 로빈을 사용한다.
- jwt-dev.yml
spring:
jwt:
# HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용
secret: testSecretKey20240316testSecretKey20240316testSecretKey20240316
token:
access-expiration-time: 3600000
refresh-expiration-time: 86400000
해당 jwt.yml은 "user"와, "apiGateway" 서비스 단에만 불러올 것이다.
- redis-dev.yml
spring:
data:
redis:
host: localhost
port: 6379
3-2. 로컬에 있는 yml 파일
- apiGateway-service 단의 bootstrap.yml
server:
port: 8000
spring:
application:
name: apiGateway-service
profiles:
active: dev
cloud:
config:
uri: http://localhost:8888
name: database-apiGateway, redis, jwt
kafka:
bootstrap-servers: "localhost:9092"
consumer:
group-id: "GroupId"
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
- user-service 단의 bootstrap.yml
server:
port: 8080
spring:
application:
name: user-service # micro service unique ID
profiles:
active: dev
cloud:
config:
uri: http://localhost:8888
name: database-user,redis, jwt
kafka:
bootstrap-servers: "localhost:9092"
consumer:
group-id: "GroupId"
eureka:
client:
register-with-eureka: true # register to eureka server
fetch-registry: true # Getting instances' information from eureka server
service-url:
defaultZone : http://127.0.0.1:8761/eureka #Eureka Server's address
4. 전체 로직
4-1. 로직 고민
API Gateway에서 사용자 인증 및 권한 부여와 같은 보안 기능을 구현.
우리는 권한은 따로 설정하지 않았다.
jwt 토큰에 권한은 인코딩 해두었으니, 필요한 경우 꺼내서 처리하면 될 것 같다.
API Gateway로 완전히 옮기기보다는,
API Gateway에서 필요한 요청 전처리(인가)와 백엔드 서비스로의 전달을 담당하는 것이 적절하다고 판단
어차피 endpoint가 User- Service 와야 하며,
User-DB가 필요한
/signup, /login, /reissue 등은 User-Service에서 처리 하기로 했다!
4-2. 전체적인 로직 도식화
엑세스토큰을 "신분증"이라고 예를 들면,
어차피 "발급", "재발급", "폐지" 등은 신원확인을 해야하는 [동사무소(User-Service)]에 가서 해야한다.
고로, 해당 로직에 관련된 필터는 [동사무소(User-Service)] 앞 단에 두었다.
- 저, CustomLogoutFilter는 뭐야....???
- 왜, LofoutFilter가 두 개로 나눠져있어?..
- 등등은 아래에서 설명하겠다 ㅠㅠ
5. 로그아웃/토큰 재발급 처리에 대한 고민
해당 글을 쓸 때, [이미 발급된 토큰의 상태 변화] 에 대해 매우 고민을 많이 했다...
JWT 토큰 서버 구축하기 (인가/인증) (6) - 로그아웃 처리
JWT 토큰 서버 구축하기 (인가/인증) (5) - RefreshToken으로 토큰 재발급 JWT 토큰 서버 구축하기 (인가/인증) (4) - 인가 처리 필터 JWT 토큰 서버 구축하기 (인가/인증) (3) - 엑세스토큰 발급하기 JWT 토큰
jinhos-devlog.tistory.com
그런데, MSA 구조에서..? 이 상태변화를 도대체 어떻게 처리해??????ㅠㅠㅠㅠㅠㅠ 너무 복잡하다..
5-1. 결론
1. 로그아웃 처리
기존 JwtLogoutFilter에서 해당 역할을 하였다.
- AccessToken을 블랙리스트 처리하여 redis에 저장한다.
- AccessToken에서 추출한 username을 key로 가지고 있는 value(RefreshToken)을 삭제한다.
여기에서
AccessToken을 블랙리스트 처리하여 redis에 저장한다.
부분을 apiGateway로 위임함으로써 이를 해결했다!
2. 토큰 재발급
어차피, /reissue에서 토큰 valid 검사는 해주므로, 필터 단의 인가 과정은 모두 풀어주기로 결정했다!
6. 권한 처리에 대한 고민
해당 경로별 인가 작업은 삭제했다. (우리 서비스단에는 ROLE_USER만 쓰이기 때문)
만약에 필요하다면,
filter를 하나 만들고 apiGateway단에 달아주자!
해당 filter에서 jwt토큰 내의 권한을 꺼내서 작업해주자.
apiGateway-service의 router설정에서 AuthorizationHeaderFilter(인가 필터)를 활용하여
대부분 처리할 수 있었다.
인가 처리가 필요한 경로에는 AuthorizationHeaderFilter를 달아주고,
필요없는 경로는 달아주지 않았다.
(전체 로직 도식화 참고)
7. 개발 전 환경 설정
7-1. [apiGateway-service]: GlobalFilter
위의 yml을 보면
default-filters:
- name: GlobalFilter
부분이 있다.
@Component
@Slf4j
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {
public GlobalFilter() {
super(Config.class);
}
public static class Config {
//Configuration 정보
}
@Override
public GatewayFilter apply(Config config) {
return ((exchange, chain) -> {
//Global PRE Filter Start ===========================================================
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
log.info("Global PRE Filter : request id : {}", request.getId());
//Global PRE Filter End =============================================================
//Global POST Filter Start ==========================================================
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
//Mono : WebFlux 비동기 방식 서버 단일값 전달
log.info("Global POST Filter : Response Code : {}",
response.getStatusCode());
})
//Global POST Filter End =============================================================
);
});
}
}
- 요청과 관련된 작업/ 응답과 관련된 작업이다.
- CustomFilter 클래스를 사용하여 Spring Cloud Gateway에서 요청과 응답을 조작하고 필터링해준다.
- (뒤로 요청 보내주고, 앞으로 응답 보내주고!)
- (큰 역할은 없다..ㅎ)
7-2. [apiGateway-service]: SecurityConfig
@Configuration // IoC 빈(bean)을 등록
@EnableWebFluxSecurity // 필터 체인 관리 시작 어노테이션
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.csrf(ServerHttpSecurity.CsrfSpec::disable);
return http.build();
}
}
- CORS 문제가 뜬다면 해당 블로그 참고 : https://yoo-dev.tistory.com/4
- API-Gateway에서 처리해주면, 뒷 단 MS에서는 CORS 설정 지워줘야 한다.
7-3. [apiGateway-service]: RedisConfig, RedisUtil
- RedisConfig와 RedisUtil을 추가하여 RedisTemplete를 사용할 수 있도록 해주자.
7-4. MS단 : CorsConfig 수정
이 또한, API-Gateway로 위임하는 것이 사실 맞다... (보류)
- CorsConfig에 allowedOriginPatterns.add("http://localhost:8080"); 추가
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class CorsConfig implements WebMvcConfigurer {
public static CorsConfigurationSource apiConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
ArrayList<String> allowedOriginPatterns = new ArrayList<>();
allowedOriginPatterns.add("http://localhost:8000");
allowedOriginPatterns.add("http://localhost:8080");
allowedOriginPatterns.add("http://localhost:3000");
ArrayList<String> allowedHttpMethods = new ArrayList<>();
allowedHttpMethods.add("GET");
allowedHttpMethods.add("POST");
allowedHttpMethods.add("PUT");
allowedHttpMethods.add("DELETE");
configuration.setAllowedOrigins(allowedOriginPatterns);
configuration.setAllowedMethods(allowedHttpMethods);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
8. [user-service] : 회원가입 로직, 로그인 로직, 토큰 재발급 로직
기존과 동일하다! (상단 포스팅 참고)
9. [apiGateway-service] : AccessToken 인가 로직
9-1. [apiGateway-service] : JwtUtil 작성
@Slf4j
@Component
public class JwtUtil {
private final SecretKey secretKey;
public JwtUtil(
@Value("${spring.jwt.secret}") String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),
Jwts.SIG.HS256.key().build().getAlgorithm());
}
// JWT 토큰을 입력으로 받아 토큰의 subject에서 사용자 이메일(email)을 추출
public String getEmail(String token) throws io.jsonwebtoken.security.SignatureException {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public Long getExpTime(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration()
.getTime();
}
// AccessToken 유효성 검사
public boolean validateAccessToken(String token) {
try {
// 구문 분석 시스템의 시계가 JWT를 생성한 시스템의 시계 오차 고려
// 약 3분 허용.
long seconds = 3 *60;
boolean isExpired = Jwts
.parser()
.clockSkewSeconds(seconds)
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration()
.before(new Date());
if (isExpired) {
log.info("만료된 JWT 토큰입니다.");
}
// Jwt 통과
log.info("[*] Token Valid");
return !isExpired;
} catch (SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
9-2. [apiGateway-service] : AuthorizationHeaderFilter
@Slf4j
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
public AuthorizationHeaderFilter(JwtUtil jwtUtil, RedisUtil redisUtil) {
super(Config.class);
this.jwtUtil = jwtUtil;
this.redisUtil = redisUtil;
}
// GatewayFilter 설정을 위한 Config 클래스
public static class Config {
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
// HTTP 요청 헤더에서 Authorization 헤더를 가져옴
HttpHeaders headers = request.getHeaders();
if (!headers.containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "HTTP 요청 헤더에 Authorization 헤더가 포함되어 있지 않습니다.", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = Objects.requireNonNull(headers.get(HttpHeaders.AUTHORIZATION)).get(0);
// JWT 토큰 가져오기
String accessToken = authorizationHeader.replace("Bearer ", "");
log.info("[*] Token exists");
// JWT 토큰 유효성 검사
jwtUtil.validateAccessToken(accessToken);
// logout 처리된 accessToken
if (redisUtil.get(accessToken) != null && redisUtil.get(accessToken).equals("logout")) {
log.info("[*] Logout accessToken");
return onError(exchange, "로그아웃된 토큰입니다.", HttpStatus.UNAUTHORIZED);
}
// JWT 토큰에서 사용자 email 추출
String subject = jwtUtil.getEmail(accessToken);
// 사용자 email를 HTTP 요청 헤더에 추가하여 전달
ServerHttpRequest newRequest = request.mutate()
.header("email", subject)
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
};
}
// Mono(단일 값), Flux(다중 값) -> Spring WebFlux
private Mono<Void> onError(ServerWebExchange exchange, String errorMsg, HttpStatus httpStatus) {
log.error(errorMsg);
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
return response.setComplete();
}
}
- 그저... 똑같은 코드를 옮긴 것 뿐
- Mono로 응답하는 부분만 조금 찾아보자!
- 인가처리가 필요한 경로에 yml에 Filter를 추가해주자!
9-3. Test
10. [apiGateway-service / user-service] : 로그아웃 로직
10-1. [apiGateway-service.yml] : router 설정하기
- apiGateway-service단에서 AuthorizationHeaderFilter와 CustomLogoutFilter를 추가
10-2. 로직 복습
- AccessToken을 블랙리스트 처리하여 redis에 저장한다.
-> CustomLogoutFilter에서 처리
- AccessToken에서 추출한 username을 key로 가지고 있는 value(RefreshToken)을 삭제한다.
-> 기존 JwtLogoutFilter에서 여전히 처리
10-3. [apiGateway-service] : CustomLogoutFilter
@Slf4j
@Component
public class CustomLogoutFilter extends AbstractGatewayFilterFactory<CustomLogoutFilter.Config> {
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
public CustomLogoutFilter(JwtUtil jwtUtil, RedisUtil redisUtil) {
super(CustomLogoutFilter.Config.class);
this.jwtUtil = jwtUtil;
this.redisUtil =redisUtil;
}
// GatewayFilter 설정을 위한 Config 클래스
public static class Config {
}
@Override
public GatewayFilter apply(CustomLogoutFilter.Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
// HTTP 요청 헤더에서 Authorization 헤더를 가져옴
HttpHeaders headers = request.getHeaders();
if (!headers.containsKey(HttpHeaders.AUTHORIZATION)) {
return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
}
String authorizationHeader = Objects.requireNonNull(headers.get(HttpHeaders.AUTHORIZATION)).get(0);
// JWT 토큰 판별
String accessToken = authorizationHeader.replace("Bearer ", "");
log.info("[*] Token exists");
// Logout 블랙리스트 - Redis에 저장
redisUtil.save(
accessToken,
"logout",
jwtUtil.getExpTime(accessToken),
TimeUnit.MILLISECONDS
);
// 사용자 ID를 HTTP 요청 헤더에 추가하여 전달
ServerHttpRequest newRequest = request.mutate()
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
};
}
// Mono(단일 값), Flux(다중 값) -> Spring WebFlux
private Mono<Void> onError(ServerWebExchange exchange, String errorMsg, HttpStatus httpStatus) {
log.error(errorMsg);
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
return response.setComplete();
}
}
10-4. [user-service] : JwtLogoutFilter 수정
@RequiredArgsConstructor
@Slf4j
public class JwtLogoutFilter implements LogoutHandler {
private final RedisUtil redisUtil;
private final JwtUtil jwtUtil;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
try {
log.info("[*] Logout Filter");
String accessToken = jwtUtil.resolveAccessToken(request);
String email = jwtUtil.getEmail(accessToken);
// RefreshToken 삭제
redisUtil.delete(email);
} catch (ExpiredJwtException e) {
log.warn("[*] case : accessToken expired");
try {
HttpResponseUtil.setErrorResponse(response, HttpStatus.UNAUTHORIZED, "세션이 만료되었습니다. 다시 로그인하세요");
} catch (IOException ex) {
log.error("IOException occurred while setting error response: {}", ex.getMessage());
}
}
}
}
- 마저 RefreshToken 삭제하는 부분을 수행해주자.
10-5. Test
아직, 에러핸들러나 응답을 통일화하지 못했다. 해당 부분을 나중에 수정하겠다!
참고 자료 :
https://imprint.tistory.com/216