본문 바로가기

Project/Chats

Chat Project (10) - STOMP Header, JWT Token

반응형
SMALL
반응형
SMALL
간단하게 구현하는 채팅은 이번이 마지막이 될 것 같습니다.
앞으로 더 고도화를 시킬 예정이 아니라면 포스팅이 없긴 하겠지만 새로 시작하는 프로젝트에서
WebSocket을 이용한 1:1, N:M 채팅 그리고 WebRTC를 이용한 음성 및 화상 구현 등을 진행 할 예정입니다.
고도화 된 내용을 보고 싶으시면 Nuwa Project에 업로드 되는 내용을 확인 해주시면 좋을 것 같습니다.

STOMP Header에 JWT 토큰을 넣어야 되겠다고 생각이 들었습니다.

JWT 토큰을 사용하는데 채팅방에 입장을 할 땐 API로 토큰 값을 확인합니다.

그런데 구독을 하거나 메세지를 보낼 때는 따로 토큰 값을 판별하지 않고 요청을 하도록 되어 있습니다.

여기서 토큰 값이 올바르게 갱신이 되지 않거나 만료가 된 상태로 채팅이 이루어진다면

이후 다른 요청에서 문제가 생기는 상황이 될 것 같았습니다.

그래서 저는 StompInterceptor를 추가하여 문제 상황이 발생하는 것을 예방 하고자 합니다.

 

StompWebSocketConfig.java

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

 

Config 파일에 클라이언트 인바운드 채널을 추가를 해줍니다.

 

그리고 StompInterceptor.java를 생성합니다.

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

    private final JwtUtil jwtUtil;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        log.info("StompHeaderAccessor = {}", accessor);
        handleMessage(Objects.requireNonNull(accessor.getCommand()), accessor);
        return message;
    }

    private void handleMessage(StompCommand command, StompHeaderAccessor accessor) {
        switch (command) {
            case SUBSCRIBE, SEND -> verifyToken(getAccessToken(accessor));
        }
    }

    private String getAccessToken(StompHeaderAccessor accessor) {
        return accessor.getFirstNativeHeader("Authorization");
    }

    private void verifyToken(String accessToken) {
        if (!jwtUtil.verifyToken(accessToken)) {
            throw new JwtException(JWT_EXPIRATION);
        }
    }
}

 

하나씩 보겠습니다.

 

먼저 verifyToken

private void verifyToken(String accessToken) {
        if (!jwtUtil.verifyToken(accessToken)) {
            throw new JwtException(JWT_EXPIRATION);
        }
    }

 

해당 로직은 JwtUtil 에서 verifyToken 메소드를 가져와서 사용합니다.

public boolean verifyToken(String token) {
        try {
            String parseToken = token.substring(PREFIX.length());
            log.info("parseToken = {}", parseToken);

            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(signingKey)  // 비밀키를 설정하여 파싱한다.
                    .build().parseClaimsJws(parseToken); // 주어진 토큰을 파싱하여 Claims 객체를 얻는다.
            log.info("parse claims = {}", claims);
            // 토큰의 만료 시간과 현재 시간 비교
            return claims.getBody()
                    .getExpiration()
                    .after(new Date()); // 만료 시간이 현재 시간 이후인지 확인하여 유효성 검사 결과를 반환
        } catch (Exception e) {
            log.warn("token error = {}", e.getMessage());
            return false;
        }
    }

 

만약 Exception이 터지면 false를 반환합니다.

그러면 Interceptor 부분에 미리 정의해둔 예외가 발생합니다.

 

 private String getAccessToken(StompHeaderAccessor accessor) {
        return accessor.getFirstNativeHeader("Authorization");
    }

 

 

NativeMessageHeaderAccessor (Spring Framework 6.1.3 API)

Return the first value for the specified native header, or null if none.

docs.spring.io

 

위의 docs를 확인해보면 

Return the first value for the specified native header, if present.

 

지정된 네이티브 헤더가 있는 경우 해당 헤더의 첫 번째 값을 반환한다고 되어 있습니다.

해당 메소드를 사용하여 Header에서 Authorization이라는 이름의 value를 가져옵니다.

 

private void handleMessage(StompCommand command, StompHeaderAccessor accessor) {
    switch (command) {
        case SUBSCRIBE, SEND -> verifyToken(getAccessToken(accessor));
    }
}

 

그리고 저는 StompCommand를 사용하여 구독과 전송에서 토큰 값을 확인 하도록 했습니다.

 

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
    StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
    log.info("StompHeaderAccessor = {}", accessor);
    handleMessage(Objects.requireNonNull(accessor.getCommand()), accessor);
    return message;
}

 

StompHeaderAccessor.wrap은 docs를 확인해보면

 

StompHeaderAccessor (Spring Framework 6.1.3 API)

setContentLength public void setContentLength(int contentLength)

docs.spring.io

Create an instance from the payload and headers of the given Message.

 

지정된 메세지에서 페이로드와 헤더 인스턴스를 만든다고 되어 있습니다.

 wrap으로 인스턴스를 가져와 필요한 정보를 handleMessage에 넣어줍니다.

그리고 메세지를 반환합니다.

 

이렇게 코드를 작성하면 Subscribe와 send 요청이 클라이언트에게 들어오면 토큰 값을 확인하는 과정을 거치게 됩니다.

이제 자바스크립트 쪽 코드를 작성해보도록 하겠습니다.

 

chat.js

document.addEventListener("DOMContentLoaded", function () {

    const urlParams = new URLSearchParams(window.location.search);
    const type = urlParams.get('type');
    const roomId = urlParams.get('roomId');
    const roomName = urlParams.get('roomName');
    const userName = urlParams.get('userName');
    const headers = {Authorization: localStorage.getItem("accessToken")}

    document.getElementById("roomName").textContent = roomName;

    // 저장된 메시지 불러오기
    fetch(`/chat/${roomId}`, {
        method: 'GET',
        headers: {
            'Content-Type': 'application/json',
        },
    })
        .then(response => response.json())
        .then(data => {
            const messages = data.data; // 메시지 배열
            messages.forEach(msg => {
                const str = `<div class='col-6'><div class='alert alert-info'><b>${msg.sender} : ${msg.message}</b> </div></div>`;
                const msgArea = document.getElementById("msgArea");
                msgArea.innerHTML += str;
            });
        })
        .catch(error => console.error('Error:', error));

    const sockJs = new SockJS("/stomp/chat");
    const stomp = Stomp.over(sockJs);


    stomp.connect({}, function () {
        console.log("STOMP Connection")
        stomp.subscribe("/sub/chat/" + roomId, function (chat) {
            const content = JSON.parse(chat.body);
            const sender = content.sender;
            const message = content.message; // 추가된 부분
            let str;

            if (sender === userName) {
                str = "<div class='col-6'><div class='alert alert-secondary'><b>" + sender + " : " + message + "</b></div></div>";
            } else {
                str = "<div class='col-6'><div class='alert alert-warning'><b>" + sender + " : " + message + "</b></div></div>";
            }

            const msgArea = document.getElementById("msgArea");
            msgArea.innerHTML += str;
        }, headers);

        if (type === 'enter') {
            stomp.send('/pub/chat/enter', headers, JSON.stringify({
                roomId: roomId,
                sender: userName
            }));
        }
    });

    const sendButton = document.getElementById("button-send");
    sendButton.addEventListener("click", function (e) {
        const msgInput = document.getElementById("msg");
        stomp.send('/pub/chat/send', headers, JSON.stringify({
            roomId: roomId,
            message: msgInput.value,
            sender: userName
        }));
        msgInput.value = '';
    });

    // 채팅방 목록으로 돌아가는 버튼 이벤트 리스너 추가
    document.getElementById("backToList").addEventListener("click", function () {
        stomp.disconnect(function () {
            console.log("STOMP DISCONNECT")
            fetch(`/chat/redis/${roomId}`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': localStorage.getItem("accessToken")
                },
            }).then(response => response.json())
                .then(data => {
                    const success = data.status;
                    stomp.send('/pub/chat/quit', headers, JSON.stringify({
                        roomId: roomId,
                        sender: userName
                    }));
                    window.location.href = "/html/chats.html";
                    console.log(success);
                });
        }).catch(error => console.error('Error:', error));
    })
});

 

 

localStorage에 저장된 토큰 값을 가져와서 header를 만들어줍니다.

    const headers = {Authorization: localStorage.getItem("accessToken")}

 

그리고 각각 위치에 맞게 header를 넣어줍니다.

저는 connect에는 넣지 않았고 subscribe와 send에만 넣어줬습니다.

본인 코드에 맞게 넣어서 구현하시면 될 것 같습니다.

 

이후 크롬 개발자 도구로 콘솔에 찍어 확인을 해보면 정상적으로 토큰 값이 헤더에 담겨서 보내진 것을 확인 할 수 있습니다.

 

서버에서도

INFO org.project.chats.config.websocket.StompInterceptor - StompHeaderAccessor = StompHeaderAccessor [headers={simpMessageType=SUBSCRIBE, stompCommand=SUBSCRIBE, nativeHeaders={Authorization=[Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbHNndXI5OTAxMDRAZ21haWwuY29tIiwicm9sZSI6IlJPTEVfVVNFUiIsImlhdCI6MTcwNzExMTExMCwiZXhwIjoxNzA3MTEyOTEwfQ.1fg-HWyZMmJrKplpBxe2-xSvKz0-GfqY9ah9ealLMXI], id=[sub-0], destination=[/sub/chat/c2a5e58f-bf9c-44cb-ab98-e9ae2c8213e2]}, simpSessionAttributes={}, simpHeartbeat=[J@2b82ca04, simpSubscriptionId=sub-0, simpSessionId=zotqgqe2, simpDestination=/sub/chat/c2a5e58f-bf9c-44cb-ab98-e9ae2c8213e2}]
INFO org.project.chats.config.websocket.StompInterceptor - StompHeaderAccessor = StompHeaderAccessor [headers={simpMessageType=MESSAGE, stompCommand=SEND, nativeHeaders={Authorization=[Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbHNndXI5OTAxMDRAZ21haWwuY29tIiwicm9sZSI6IlJPTEVfVVNFUiIsImlhdCI6MTcwNzExMTExMCwiZXhwIjoxNzA3MTEyOTEwfQ.1fg-HWyZMmJrKplpBxe2-xSvKz0-GfqY9ah9ealLMXI], id=[sub-0], destination=[/pub/chat/send], content-length=[81]}, simpSessionAttributes={}, simpHeartbeat=[J@5d6d5ae3, simpSessionId=zotqgqe2, simpDestination=/pub/chat/send}]

 

정상적으로 토큰 값이 넘어온 것을 확인이 되었습니다.

이렇게 header에 토큰 값을 넣어 보내는 것까지 채팅 구현이 완료 되었습니다.


2024.01.24 - [Project/Chats] - Chats 프로젝트 (9) - STOMP, MongoDB

2024.01.18 - [Project/Chats] - Chats 프로젝트 (8) - WebSocket

2024.01.16 - [Project/Chats] - Chats 프로젝트 (7) - OAuth2.0, Login

2024.01.15 - [Project/Chats] - Chats 프로젝트 (6) - JWT, Login, Redis

2024.01.11 - [Project/Chats] - Chats 프로젝트 (5) - Spring Security, Login

2024.01.09 - [Project/Chats] - Chats 프로젝트 (4) - Spring Security, Signup

2024.01.09 - [Project] - Chats 프로젝트 (3) - logack, Signup

2024.01.08 - [Project/Chats] - Chats 프로젝트 (2) - Signup

2024.01.08 - [Project] - Chats 프로젝트 (1)

 

 

GitHub - Llimy1/Chats

Contribute to Llimy1/Chats development by creating an account on GitHub.

github.com

 

반응형
LIST