문제&해결

웹소켓 취약점 해결 : 인증 도입 (STOMP, Jwt, Spring Security)

mint* 2024. 2. 20. 12:26
728x90

최근에 보완해서 글을 올렸어요~ 최근 글을 보시면 문제 해결이 더 빠를거에요!
https://shout-to-my-mae.tistory.com/430

 

WebSocket에 JWT 인증 적용하기: 실시간 채팅 서비스 개발 경험담 🚀💬

서론프로젝트에서 실시간 채팅 기능을 구현하면서 WebSocket을 사용시 JWT를 인증을 처리하는 방법을 작성해보았습니다.혹시 글을 읽고 해결이 안되시는 분들은 댓글로 남겨주시면 도와드릴게요 !

shout-to-my-mae.tistory.com

 


 

웹소켓을 사용할 때 요청을 보내는 사람의 인증을 처리하는 방법은 여러 가지가 있다.

웹소켓은 HTTP 프로토콜을 업그레이드하여 연결을 시작하기 때문에, 연결 초기 단계에서 인증을 수행할 수 있는 방법을 활용할 수 있다.

 

1. 초기 HTTP 핸드셰이크에서 인증 토큰 사용

웹소켓 연결을 시작하기 전에, 클라이언트가 서버에 HTTP 요청을 보내는 초기 핸드셰이크 과정에서 인증 토큰(JWT 토큰 등)을 Authorization 헤더에 포함시키는 방법이다.

서버는 이 토큰을 검증하여 사용자를 인증한 후, 유효한 경우에만 웹소켓 연결을 수락한다.

const socket = new WebSocket('ws://example.com/ws', ['token', 'yourAuthTokenHere']);

2. 쿼리 파라미터를 통한 인증 토큰 전달

웹소켓 URL의 쿼리 파라미터에 인증 토큰을 포함하여 전달하는 방법도 있다. 이 방법은 구현이 간단하지만, URL에 토큰이 노출되므로 보안상의 리스크가 존재한다.

const socket = new WebSocket('ws://example.com/ws?token=yourAuthTokenHere');

3. 웹소켓 서브프로토콜을 사용한 인증

웹소켓 프로토콜은 여러 "서브프로토콜"을 지원할 수 있도록 설계되어 있다. 클라이언트가 연결 시도 시 인증 토큰을 서브프로토콜 중 하나로 전달하고, 서버에서는 이를 인증 메커니즘으로 사용할 수 있다.

@Override 
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
	registry.addHandler(myHandler(), "/ws")
			.setAllowedOrigins("*")
			.addInterceptors(new HttpSessionHandshakeInterceptor()) // 서브 프로토콜 전달
			.withSockJS(); 
}
			// 클라이언트 사이드 예시
			const socket = new WebSocket('ws://example.com/ws', 'yourAuthTokenHere');

4. 커넥션 열린 후의 첫 메시지로 인증

웹소켓 연결이 성공적으로 열린 직후, 클라이언트가 첫 번째 메시지로 인증 정보(예: 인증 토큰)를 서버에 전송하고, 서버는 이 메시지를 검증하여 연결을 유지하거나 끊을 수 있다. 이 방법은 연결 후 초기 통신 단계에서 추가적인 인증 로직을 처리해야 한다.

채택한 방법 : 1. 초기 HTTP 핸드셰이크에서 인증 토큰 사용

초기 웹소켓 연결시 JWT 토큰을 헤더에 추가하기

클라이언트

const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);

const headers = {
  'Authorization': 'Bearer {token}'
};

stompClient.connect(headers, function(frame) {
  // 연결 성공 시의 콜백
});

JWT를 헤더에 추가하기

서버

1. Spring Security 설정

websocket url인 /ws 은 Open url(인증X)로 설정해둔다. 초기 연결 요청시 웹소켓은 따로 인증 절차를 진행할 예정이기 때문이다.

2. 인터셉터 추가

Spring에서 JwtChannelInterceptor를 사용하여 STOMP 메시지가 처리되기 전에 헤더 내 JWT 토큰을 확인하고 검증할 수 있다.

StompCommand를 통해 웹소켓 연결, 메세지 전송, 종료 명령어에 대해 따로 작업을 지정할 수 있다.

/**
 * WebSocket 채널에 JWT 검증하는 인터셉터
 */

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtChannelInterceptor implements ChannelInterceptor {

    private final MemberFindService userUtilityService;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        // 연결 요청시 JWT 검증
        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            // Authorization 헤더 추출
            List<String> authorization = accessor.getNativeHeader(JwtProvider.HEADER);
            if (authorization != null && !authorization.isEmpty()) {
                String jwt = authorization.get(0).substring(JwtProvider.TOKEN_PREFIX.length());
                try {
                    // JWT 토큰 검증
                    DecodedJWT decodedJWT = JwtProvider.verify(jwt);
                    Long id = decodedJWT.getClaim("id").asLong();
                    // 사용자 정보 조회
                    Member member = userUtilityService.getUserById(id);

                    // 사용자 인증 정보 설정
                    CustomUserDetails userDetails = new CustomUserDetails(member);
                    UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                } catch (JWTVerificationException e) {
                    log.error("JWT Verification Failed: " + e.getMessage());
                    return null;
                } catch (Exception e) {
                    log.error("An unexpected error occurred: " + e.getMessage());
                    return null;
                }
            } else {
                // 클라이언트 측 타임아웃 처리
                log.error("Authorization header is not found");
                return null;
            }
        }
        return message;
    }
}

토큰이 오지 않을 경우 명시적으로 인터셉터에서 메세지를 보내기는 어렵기때문에 null을 반환한다. null을 반환한 경우, 실제로는 메세지를 보내지 않으며 클라이언트 측은 일정 시간 내에 메세지가 안 올 경우 에러 처리를 수행할 수 있다.

클라이언트 타임아웃 코드

var socket = new SockJS('/ws-endpoint');
var stompClient = Stomp.over(socket);

var connectionTimeout = setTimeout(() => {
    // 10초 후 연결이 성공하지 않으면 이 함수가 실행된다.
    alert('Connection timeout. Please check your connection or try again.');
    // 연결 시도를 중단하려면 socket.close()를 호출할 수 있다.
    socket.close();
}, 10000); // 10초 타임아웃

stompClient.connect({}, frame => {
    // 연결 성공 시 타임아웃 타이머를 취소한다.
    clearTimeout(connectionTimeout);
    console.log('Connected: ' + frame);
    // 연결 성공 로직
}, error => {
    // 연결 실패 처리
    console.log('Connection error: ' + error);
});

3. 웹소켓 설정에 추가

configureClientInboundChannel 메서드를 오버라이드하여 인터셉터를 추가한다.

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final JwtChannelInterceptor jwtChannelInterceptor;

    // 메시지 브로커 설정
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); // 메시지 브로커가 /topic으로 시작하는 메시지를 클라이언트로 브로드캐스팅 (1:N 통신)
        config.setApplicationDestinationPrefixes("/app"); // 핸들러 메소드가 /app으로 시작하는 메시지를 처리
    }

    // 웹소켓 연결을 위한 endpoint 설정
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
            .setAllowedOriginPatterns("*"); // 모든 도메인에서 접근 허용
//            .withSockJS(); // /ws로 접속하면 SockJS를 통해 웹소켓 연결
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(jwtChannelInterceptor);
    }
}

APIC으로 테스트할 수 있다.

 

WithSockJS를 주석처리한 이유는 SockJS 라이브러리를 사용할 경우 정상적으로 웹소켓 연결이 수행되지 않았기 때문이다.

 

 

 

728x90