API GATEWAY에서의 JWT 인증 기반의 프로젝트에서 Socket 인증?
현재 FitTrip 프로젝트에서는 클라이언트가 요청 헤더에 JWT를 담아서 보내고 api gateway 쪽에서 헤더에 있는 JWT를 파싱 후 해당 JWT 유효성을 검증하고 있다.
그런데 WebSocket의 경우 헤더의 토큰을 담아서 보내던 HTTP 프로토콜과는 완전히 달라 인증 처리를 어떻게 하면 좋을지 고민이었다.
api gateway는 웹소켓 연결 요청 라우팅이 가능한가?
처음에는 HTTP 요청에 대해서는 api gateway에서 다른 서비스로 라우팅 처리가 가능한 걸 봤지만 웹소켓 연결 요청은 채팅 서비스로 라우팅이 가능할까 라는 의문을 가졌다. 그리고 자료를 찾아보면서 아래와 같은 사실을 찾았다.
아래 이미지는 토리맘의 한글라이즈 프로젝트 Spring Cloud Gateway Global Filters 일부분을 캡처한 부분이다.
웹소켓 라우팅 필터 설정 예시
spring:
cloud:
gateway:
routes:
# SockJS route
- id: websocket_sockjs_route
uri: http://localhost:3001
predicates:
- Path=/websocket/info/**
# Normal Websocket route
- id: websocket_route
uri: ws://localhost:3001
predicates:
- Path=/websocket/**
위 사실을 통해 api gateway는 웹소켓 연결 요청 라우팅이 가능하다는 것을 확인했다.
WebSocket 연결 과정
클라이언트/ 서버 웹소켓 연결과정
우선 api gateway가 없을 때 클라이언트와 서버 간에 웹소켓 연결 과정을 알아보자.
WebSocket은 커넥션을 맺기 위해 HTTP 요청을 보내는데, 아래와 같이 HTTP 요청 헤더에 Upgrade 헤더와 Connection 포함한다. 맨 처음 HTTP 요청과 응답이 HTTP 프로토콜에서 웹소켓으로 전환하기 위한 HandShake이다.
# Upgrade
- 이미 생성된 커넥션을 다른 프로토콜로 업그레이드/변경
- 클라이언트가 Upgrade 헤더 값에 나열한 프로토콜 리스트를 서버가 선택한다.
- 앞쪽에 배치할수록 우선순위가 높음
- 서버는 Upgrade 하기로 선택한 프로토콜을 응답 Uprade 헤더에 추가해 전달한다.
GET ws://localhost:3000/sockjs-node HTTP/1.1
Host: localhost:3000
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36
Upgrade: websocket
Origin: http://localhost:3000
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6
Sec-WebSocket-Key: xwGnajy+I6YJ/AW7pTKioA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
서버는 아래와 같이 101 Switching Protocols 상태 코드로 응답을 하는데, Handshask 이후에도 TCP 커넥션은 지속적으로 유지된다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 6Ux2cxOp2HhzP9SLCuADGUKiLbU=
클라이언트/ api gateway/서버 웹소켓 연결 과정
이제 클라이언트와 api gateway 그리고 채팅 서비스 간에 웹소켓 연결 과정을 알아보자
위 그림을 보고 든 생각은 웹소켓으로 전환하기 위한 HandShake 단계에서 맨 처음 HTTP 요청 헤더에 JWT를 담아서 보내 api gateway에서 JWT 유효성을 검증하여 계속 라우팅을 진행할지 아니면 연결을 거부하게 하고 싶었다.
그래서 api gateway에서 웹소켓 전환을 위한 HandShake 단계에서의 맨 처음 HTTP 요청을 받아서 JWT를 파싱 할 수 있는지 알아보기 위해 자료 조사를 했지만 찾아봐도 비슷한 예시조차 찾을 수 없었다
현재 api gateway 쪽에서 exchange.getRequest().getHeaders().get("Authorization").get(0).substring(7); 형태로 HTTP 기반의 Request 헤더에서 JWT를 파싱 후 유효성을 검증했지만 웹소켓과 HTTP는 아예 다르고 HandShake 단계에서 HTTP 요청을 처리할 수 있나 찾아봤지만 찾을 수 없어 api gateway에서 웹소켓 연결 요청에 대한 JWT 검증은 사용할 수 없는 것으로 결정했다.
그래서 다른 방법들을 생각해 봤다.
1. 요청 url 쪽에 JWT를 넣어서 보내기
2. api gateway를 거치지 않고 바로 채팅 서비스로 연결하기
3. 웹소켓 연결 요청하기 전 따로 HTTP 요청을 보내 JWT 유효성을 검증받은 후 이상 없으면 웹소켓 연결 요청하기
1번은 url에 JWT를 넣어서 보내는 거라 굉장히 보안에 취약한 방식이다.
2번은 api gateway를 거치지 않고 바로 채팅 서비스로 보낸다면 채팅 서비스 스케일 아웃 시 라우팅 처리는 어떻게 할지에 대한 문제가 있다. 또한 채팅 서비스에서도 어떻게 JWT를 검사해야 할지가 문제이다.
3번은 웹소켓 연결 요청 전에 HTTP 요청도 보내니 트래픽 리소스 사용이 추가로 발생하는 문제가 있다.
3가지 방식 모두 단점들이 많아 굉장히 맘에 들지 않는 상황이다.
분명 웹소켓 연결 시 발생하는 보안문제가 있을 테고 JWT 처리에 대한 방법도 있을 거라고 생각을 했다
그래서 Spring WebSocket 공식 문서에서 인증과 보안에 관련된 부분을 샅샅이 뒤져봤다.
인증
WebSocket을 통한 모든 STOMP 메시징 세션은 HTTP 요청으로 시작하고, WebSocket HandShake 과정으로 HTTP 요청은 WebScoket으로 Upgrade 한다.(SockJS 경우에는 일련의 SockJS HTTP transport 요청을 보낸다.) 많은 웹 애플리케이션은 이미 HTTP 요청을 보호하기 위해 인증 및 권한을 제공하고 있다.
전형적으로, Spring Security는 사용자가 login 페이지, HTTP basic 인증 등을 제공하여 사용자를 인증한다.
인증된 사용자에 대한 보안 컨텍스트는 HTTP 세션에 저장되고, 그다음 요청부터는 쿠키 기반으로 동일하게 연결된다. 따라서 WebSocket handshake 또는 SockJS HTTP transport 요청의 경우,
일반적으로 HttpServletRequest#getUserPrincipal()으로 접근 가능한 인증된 사용자가 이미 존재한다.
결국, 스프링은 자동적으로 WebSocket 또는 SockJS 세션을 갖는 사용자와 해당 세션으로 전달되는 모든 STOMP 메시지를 연관 짖는다.
요약해 보면, 일반적인 웹 애플리케이션은 이미 보안을 위해 이미 많은 작업을 수행하고 있기 때문에 추가적으로 무엇을 할 필요는 없다.
즉, 사용자는 쿠키 기반의 HTTP 세션으로 유지되는 보안 컨텍스트를 사용해서 HTTP 요청 수준에서 인증된다.
이후 애플리케이션을 통과하는 모든 메시지는 사용자 헤더를 가지고 인증 확인 과정을 거친다.
물론 STOMP 프로토콜도 CONNECT 프레임에 login, passcode 헤더를 가지고 있다.
이러한 헤더들은 STOMP TCP 기반으로 동작하는 경우에 필요하지만, WebSocket 기반인 경우에는 스프링에서 STOMP 프로토콜 수준의 인증 헤더를 무시한다.
그 이유는 사용자가 HTTP 전송 수준에서 이미 인증되었다고 가정하기 때문이다.
따라서, WebSocket, SockJS 세션은 서버에 메시지를 보내기 위해 반드시 이미 인증된 사용자를 포함하고 있어야만 한다.
-> 위 내용은 쿠키 기반의 세션 수준에서 인증/인가를 얘기하고 있고 FitTrip 프로젝트는 JWT 기반 인증/인가여서 우리 프로젝트와는 상관이 없는 얘기였다.
Token 인증
Spring Security OAuth는 JWT 같은 Token 기반의 보안을 제공한다.
따라서, WebSocket 기반 STOMP 프로토콜을 비롯한 웹 애플리케이션에서 Token 기반 보안을 사용할 수 있다.
Token 기반 보안을 사용하는 이유는 항상 쿠키 기반의 세션이 모든 상황에서 적합할 수 없기 때문이다.
예를 들어, 서버 애플리케이션에서 세션을 지원하지 않을 수도 있고 모바일 애플리케이션에서는 일반적으로 인증 헤더를 선호한다. The WebSocket protocol, RFC 6455은 WebSocket Handshake 과정에서 서버가 클라이언트를 인증하는 방법을 규정하고 있지 않다.
또한 브라우저 클라이언트는 오직 표준 인증 헤더 또는 쿠키만 사용할 수 있으며, 커스텀한 사용자 지정 헤더를 사용할 수 없다. 뿐만 아니라, SockJS JavaScript client는 SockJS transport 요청과 함께 HTTP 헤더를 전달할 방법을 제공하고 있지 않다. 대신에, 클라이언트는 Token을 Query Parameter로 전송할 수 있으나 이 또한 몇 가지 단점을 가지고 있다.
예를 들어, 토큰이 서버 로그에 URL과 함께 실수로 기록될 수 있다.
위에서 말했다시피, 서버 애플리케이션은 HTTP 수준에서 쿠키 사용 없이 인증할 마땅한 대안이 없다.
따라서 쿠키 사용 대신에, STOMP Messaging 프로토콜 수준의 헤더를 이용해서 인증하는 것을 생각해 볼 수 있다.
그렇게 하려면, 아래와 같은 두 단계가 필요하다.
- STOMP 클라이언트는 CONNECT 프레임에 pass 인증 헤더를 추가해야 한다.
- ChannelInterceptor 사용해서 인증 헤더를 처리한다.
→ FitTrip 프로젝트에서는 JWT를 사용하므로 위 내용에서 얘기한 STOMP Messaging 프로토콜 수준의 헤더를 이용해서 JWT 인증 처리를 구현하려고 한다.
그럼 실제 FitTrip 프로젝트에 적용한 코드를 살펴보자.
서버 측 적용 코드
ChannelInterceptor를 구현해서 STOMP의 헤더에 직접 접근해 헤더에 담긴 JWT을 파싱 후 JWT의 유효성을 검증해서 유효하지 않다면 연결을 끊을 것이다.
ChannelInterceptor 구현하기
ChannelInterceptor를 사용하기 위해 우선 WebSocketMessageBrokerConfigurer 의 configureClientInboundChannel 메서드를 오버라이드해 Registration에 인터셉터를 추가해야 한다.
서버 측에서 커스텀하게 인증 처리하는 인터셉터를 등록하는 과정이다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketConnectionHandler webSocketConnectionHandler;
...
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketConnectionHandler);
}
}
StompHeaderAccessor로 message를 감싸주면 STOMP의 헤더에 직접 접근할 수 있다.
조건에 따라 소켓 연결을 못하게 하려면 exception을 던지거나 Message 객체 대신 null을 반환해 주면 된다.
FitTrip 프로젝트에서는 STOMP 헤더에 접근 후 JWT 파싱 후 JWT에 대한 유효성을 검증하여 유효하지 않으면 exception을 던지고 있다. JWT validator는 api gateway에서 사용 중인걸 가져왔다.
@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketConnectionHandler implements ChannelInterceptor {
...
private final JwtTokenHandler jwtTokenHandler;
private static final String AUTH_PREFIX = "Authorization";
private static final String USER_ID = "userId";
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(headerAccessor.getCommand())) {
if (!jwtTokenHandler.validateToken(
Objects.requireNonNull(headerAccessor.getFirstNativeHeader(AUTH_PREFIX)))) {
throw new GlobalException(Code.UNAUTHORIZED);
}
}
return message;
}
...
}
api gateway에서 사용하고 있는 JWT validator
@Slf4j
@Component
public class JwtTokenHandler {
@Value("${jwt.secret}")
private String secretKey;
public static final String BEARER_PREFIX = "Bearer ";
public boolean validateToken(String token) {
String jwtToken = parseBearerToken(token);
try {
parseClaims(jwtToken);
} catch (MalformedJwtException e) {
log.info("Invalid JWT token");
log.trace("Invalid JWT token trace = {}", e);
return false;
} catch (ExpiredJwtException e) {
log.info("Expired JWT token");
log.trace("Expired JWT token trace = {}", e);
return false;
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token");
log.trace("Unsupported JWT token trace = {}", e);
return false;
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty");
log.trace("JWT claims string is empty trace = {}", e);
return false;
} catch (SecurityException e) {
log.info("Security Error");
log.trace("Security Error = {}", e);
return false;
}
return true;
}
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
private String parseBearerToken(String token) {
if (token.startsWith(BEARER_PREFIX)) {
return token.substring(7);
}
return null;
}
}
클라이언트 측 적용 코드
그렇다면 클라이언트는 socket의 어디에 언제 JWT를 보내야 할까?
@stomp/stompjs 라이브러리에서 아주 편하게 구현할 수 있었다
connectHeaders
connectHeaders를 사용하여 헤더에 원하는 값을 담아서 보내면 서버단에서는 message에서 확인할 수 있다.
const useWebSocketStore = create((set, get) => ({
client: null,
isConnected: false,
connect: (userId) => {
const stompClient = new StompJs.Client({
webSocketFactory: () => new SockJS("https://..."),
debug: (str) => console.log(str),
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
connectHeaders: {
userId: userId,
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
},
});
...
}
참고
https://docs.spring.io/spring-framework/reference/web/websocket.html
https://dev-gorany.tistory.com/212
https://sjh836.tistory.com/166
https://velog.io/@tlatldms/Spring-Boot-STOMP-JWT-Socket-%EC%9D%B8%EC%A6%9D%ED%95%98%EA%B8%B0
'프로젝트 > FitTrip' 카테고리의 다른 글
개발 기록 - MongoDB auto-incremented sequence 적용 (0) | 2024.07.03 |
---|---|
트러블 슈팅 - IN 연산자를 활용하여 채팅 목록 조회 92% 성능 최적화 (0) | 2024.07.02 |
개발 기록 - MongoDB 트랜잭션 도입 (0) | 2024.06.29 |
개발 기록 - 채팅 서비스 몽고DB 데이터 모델링 (0) | 2024.06.28 |
트러블 슈팅 - 채팅 서비스 scalue out 문제 (0) | 2024.06.27 |