이 연재글은 웹소켓(websocket)으로 채팅서버 만들기의 4번째 글입니다.

이번 장에서는 SpringSecurity와 Jwt를 이용하여 Web 및 Websocket의 보안을 좀 더 강화하고. 기존의 복잡한 로직을 간소화하는 작업을 진행해 보겠습니다. 크게 아래의 3가지 작업을 진행하겠습니다. 

  • SpringSecurity를 통한 로그인 및 간단한 회원 정보 연동
  • Jwt Token을 이용하여 websocket 통신 보안 강화
  • Redis Topic 공유를 통한 메시지 전송 프로세스 간소화

간단하게 요약하면 채팅과 관련된 웹페이지의 접근권한은 SpringSecurity를 통해 통제합니다. 즉 로그인한 회원만 채팅 화면에 접근 가능하도록 처리합니다. 그리고 WebSocket 연결 및 메시지 전송은 Jwt 토큰을 통해 통제합니다. Websocket 접속이나 메시지 전송 시엔 헤더에 유효한 Jwt Token을 보내야 하며, 유효하지 않은 token에 대해서는 요청 내용을 처리하지 않습니다. 

SpringSecurity 및 Jwt토큰을 적용하여도 서비스에 사용하기에는 보안이 취약합니다. 서비스에서 사용하기 위해서는 위 내용은 기본으로 적용하고 서버에 HTTPS(SSL) 프로토콜을 적용하여 요청과 응답 데이터가 네트워크 단에서 암호화되도록 하는 것을 추천합니다. 추가로 가능하다면 서버-클라이언트 간에 주고받는 메시지 자체를 암호화하는 것도 보안에 좋은 방법입니다. 

이전 장에서 많은 내용이 수정되었습니다. 아래 GitHub 내용을 참고하면서 내용을 보는 것이 수월할 수 있습니다. 
https://github.com/codej99/websocket-chat-server/tree/feature/security

라이브러리 추가

build.gradle에 SpringSecurity 및 Jwt 적용을 위해 다음 라이브러리를 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

SpringSecurity를 통한 로그인 및 간단한 회원 정보 연동

아래와 같이 SpringSecurity 설정을 추가합니다. formLogin()을 설정할 경우 인증이 필요한 페이지 접근 시 로그인 화면으로 이동되도록 처리됩니다. 아래에서는 /chat으로 시작하는 웹페이지 접근 시 USER 권한이 필요하며 비로그인으로 접근할 경우 로그인 화면으로 이동됩니다. 

로그인 화면은 Security에서 기본으로 제공합니다. 물론 로그인 화면의 디자인은 커스터마이징 가능합니다. 여기서는 기본 화면을 사용하겠습니다. 

회원 정보는 따로 DB구축을 해서 데이터를 불러와야 하지만, 이 장에서 설명해야 하는 부분에서 내용이 많이 벗어나게 되므로, Security에서 제공하는 inMemory저장 방식을 사용하겠습니다. 총 3개의 계정을 세팅하였는데 1,2번은 두 명이 채팅을 해야 하므로 USER권한을 설정하였고, 3번 계정은 USER권한으로 된 페이지에 접근 불가능함을 테스트하기 위해 GUEST로 권한을 설정하였습니다. 

WebSecurityConfig 생성

package com.websocket.chat.config;

import ... 생략

/**
 * Web Security 설정
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable() // 기본값이 on인 csrf 취약점 보안을 해제한다. on으로 설정해도 되나 설정할경우 웹페이지에서 추가처리가 필요함.
            .headers()
                .frameOptions().sameOrigin() // SockJS는 기본적으로 HTML iframe 요소를 통한 전송을 허용하지 않도록 설정되는데 해당 내용을 해제한다.
            .and()
                .formLogin() // 권한없이 페이지 접근하면 로그인 페이지로 이동한다.
            .and()
                .authorizeRequests()
                    .antMatchers("/chat/**").hasRole("USER") // chat으로 시작하는 리소스에 대한 접근 권한 설정
                    .anyRequest().permitAll(); // 나머지 리소스에 대한 접근 설정
    }

    /**
     * 테스트를 위해 In-Memory에 계정을 임의로 생성한다.
     * 서비스에 사용시에는 DB데이터를 이용하도록 수정이 필요하다.
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                    .withUser("happydaddy")
                    .password("{noop}1234")
                    .roles("USER")
                .and()
                    .withUser("angrydaddy")
                    .password("{noop}1234")
                    .roles("USER")
                .and()
                    .withUser("guest")
                    .password("{noop}1234")
                    .roles("GUEST");
    }
}

테스트

위 내용을 설정하고 서버를 실행한 다음 채팅방 페이지에 접근합니다. Security설정에 의해 다음과 같이 로그인 화면으로 이동됩니다.

http://localhost:8080/chat/room

Security에서 설정한 1,2번 계정으로 로그인을 하면 다음과 같이 채팅방 리스트 화면으로 이동됩니다. 하지만 GUEST계정으로 로그인할 경우 페이지 접근 권한이 없으므로 다음과 같이 Forbidden페이지로 이동됨을 확인할 수 있습니다. 

User 권한이 있는 아이디로 로그인했을 경우
User 권한이 없는 아이디로 로그인했을 경우

Jwt Token을 이용하여 websocket 통신 보안 강화

이번에는 Websocket 통신 시 token정보를 활용하여 보안을 강화해 보도록 하겠습니다. 기존엔 Stomp를 이용한 websocket통신 시 header에 아무런 정보를 세팅하지 않았지만, 아래 작업을 통해 jwt token을 세팅하여 검증된 token을 보낸 클라이언트만 통신이 가능하도록 설정해보겠습니다. 

JwtTokenProvider 생성

Jwt를 생성하고, 검증하는 기능을 제공하는 컴포넌트를 생성합니다. 토큰 생성 시엔 로그인한 회원의 id정보로 Jwt토큰을 만들도록 메서드를 구현합니다. 그리고 검증된 토큰으로 회원의 id를 알아낼 수 있는 메서드도 추가합니다. 그리고 마지막으로 Jwt토큰의 유효성을 검증할 수 있는 메서드를 추가합니다. 

package com.websocket.chat.service;

//import ... 생략

import java.util.Date;

@Slf4j
@Component
public class JwtTokenProvider {

    @Value("${spring.jwt.secret}")
    private String secretKey;

    private long tokenValidMilisecond = 1000L * 60 * 60; // 1시간만 토큰 유효

    /**
     * 이름으로 Jwt Token을 생성한다.
     */
    public String generateToken(String name) {
        Date now = new Date();
        return Jwts.builder()
                .setId(name)
                .setIssuedAt(now) // 토큰 발행일자
                .setExpiration(new Date(now.getTime() + tokenValidMilisecond)) // 유효시간 설정
                .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret값 세팅
                .compact();
    }

    /**
     * Jwt Token을 복호화 하여 이름을 얻는다.
     */
    public String getUserNameFromJwt(String jwt) {
        return getClaims(jwt).getBody().getId();
    }

    /**
     * Jwt Token의 유효성을 체크한다.
     */
    public boolean validateToken(String jwt) {
        return this.getClaims(jwt) != null;
    }

    private Jws<Claims> getClaims(String jwt) {
        try {
            return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt);
        } catch (SignatureException ex) {
            log.error("Invalid JWT signature");
            throw ex;
        } catch (MalformedJwtException ex) {
            log.error("Invalid JWT token");
            throw ex;
        } catch (ExpiredJwtException ex) {
            log.error("Expired JWT token");
            throw ex;
        } catch (UnsupportedJwtException ex) {
            log.error("Unsupported JWT token");
            throw ex;
        } catch (IllegalArgumentException ex) {
            log.error("JWT claims string is empty.");
            throw ex;
        }
    }
}

application.yml에 jwt.secret 추가

spring:
  jwt:
    secret: govlepel@$&

LoginInfo.java 생성

로그인 정보(id 및 jwt토큰)를 전달할 DTO를 생성합니다. 

@Getter
public class LoginInfo {
    private String name;
    private String token;

    @Builder
    public LoginInfo(String name, String token) {
        this.name = name;
        this.token = token;
    }
}

StompHandler 생성

Websocket 연결 시 요청 header의 jwt token 유효성을 검증하는 코드를 다음과 같이 추가합니다. 유효하지 않은 Jwt토큰이 세팅될 경우 websocket 연결을 하지 않고 예외 처리됩니다. 

package com.websocket.chat.config.handler;

// import ... 생략

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

    private final JwtTokenProvider jwtTokenProvider;

    // websocket을 통해 들어온 요청이 처리 되기전 실행된다.
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        // websocket 연결시 헤더의 jwt token 검증
        if (StompCommand.CONNECT == accessor.getCommand()) {
            jwtTokenProvider.validateToken(accessor.getFirstNativeHeader("token"));
        }
        return message;
    }
}

WebSockConfig에 StompHandler 인터셉터 설정

위에서 생성한 StompHandler가 Websocket 앞단에서 token을 체크할 수 있도록 다음과 같이 인터셉터로 설정합니다.

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

    private final StompHandler stompHandler;

    // 내용 생략 .......

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

ChatController 내용 수정

Websocket을 통해 서버에 메시지가 Send(/pub/chat/message) 되었을 때도 Jwt token 유효성 검증이 필요합니다. 다음과 같이 회원 대화명(id)을 조회하는 코드를 삽입하여 유효성이 체크될 수 있도록 합니다. 
String nickname = jwtTokenProvider.getUserNameFromJwt(token); 
유효하지 않은 Jwt토큰이 세팅될 경우 websocket을 통해 보낸 메시지는 무시됩니다. 

@MessageMapping("/chat/message")
public void message(ChatMessage message, @Header("token") String token) {
    String nickname = jwtTokenProvider.getUserNameFromJwt(token);

    if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
    chatRoomRepository.enterChatRoom(message.getRoomId());
    message.setMessage(nickname + "님이 입장하셨습니다.");
    }
    // Websocket에 발행된 메시지를 redis로 발행한다(publish)
    redisPublisher.publish(chatRoomRepository.getTopic(message.getRoomId()), message);
}

ChatRoomController에 로그인 유저 정보 조회하는 api 추가

로그인한 회원의 id및 Jwt토큰 정보를 조회할 수 있도록 다음과 같이 추가합니다.

private final JwtTokenProvider jwtTokenProvider;

@GetMapping("/user")
@ResponseBody
public LoginInfo getUserInfo() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    String name = auth.getName();
    return LoginInfo.builder().name(name).token(jwtTokenProvider.generateToken(name)).build();
}

Redis Topic 공유를 통한 pub/sub 프로세스 간소화

기존에는 신규 채팅방 생성 시 무조건 새로운 ChannelTopic을 생성하고 새로운 redisListener와 연동하는 작업이 있었는데 이 방법은 불필요하게 자원을 낭비하는 단점이 있습니다. 다음과 같이 ChannelTopic과 redisListener를 하나로 일원화하는 작업을 진행합니다. 

RedisConfig 내용 추가

  • ChannelTopic 단일화 – channelTopic()
  • 메시지 리스너 단일화 – public RedisMessageListenerContainer redisMessageListener(…)
  • 메시지를 구독자에게 보내는 역할을 하는 Bean 추가 – public MessageListenerAdapter listenerAdapter(RedisSubscriber subscriber)
package com.websocket.chat.config;

// import ... 내용 생략

@RequiredArgsConstructor
@Configuration
public class RedisConfig {

    /**
     * 단일 Topic 사용을 위한 Bean 설정
     */
    @Bean
    public ChannelTopic channelTopic() {
        return new ChannelTopic("chatroom");
    }

    /**
     * redis에 발행(publish)된 메시지 처리를 위한 리스너 설정
     */
    @Bean
    public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory,
                                                              MessageListenerAdapter listenerAdapter,
                                                              ChannelTopic channelTopic) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, channelTopic);
        return container;
    }

    /**
     * 실제 메시지를 처리하는 subscriber 설정 추가
     */
    @Bean
    public MessageListenerAdapter listenerAdapter(RedisSubscriber subscriber) {
        return new MessageListenerAdapter(subscriber, "sendMessage");
    }

   ............. 내용 생략
}

RedisPublisher 삭제

메시지 리스너 단일화 및 redisTemplate을 이용하여 기능 대체가 가능하므로 삭제합니다.

RedisSubscriber 수정

메시지 리스너에 메시지가 수신되면 아래 RedisSubscriber.sendMessage가 수행됩니다. 수신된 메시지는 /sub/chat/room/{roomId}를 구독한 websocket 클라이언트에게 메시지가 발송됩니다.

package com.websocket.chat.pubsub;

// import ... 생략

@Slf4j
@RequiredArgsConstructor
@Service
public class RedisSubscriber {

    private final ObjectMapper objectMapper;
    private final SimpMessageSendingOperations messagingTemplate;

    /**
     * Redis에서 메시지가 발행(publish)되면 대기하고 있던 Redis Subscriber가 해당 메시지를 받아 처리한다.
     */
    public void sendMessage(String publishMessage) {
        try {
            // ChatMessage 객채로 맵핑
            ChatMessage chatMessage = objectMapper.readValue(publishMessage, ChatMessage.class);
            // 채팅방을 구독한 클라이언트에게 메시지 발송
            messagingTemplate.convertAndSend("/sub/chat/room/" + chatMessage.getRoomId(), chatMessage);
        } catch (Exception e) {
            log.error("Exception {}", e);
        }
    }
}

ChatController 내용 수정

헤더에서 token을 읽어 대화명을 세팅하도록 내용을 변경합니다. 또한 redisPublisher가 삭제되었으므로. redisTemplate을 통해 바로 ChannelTopic으로 메시지를 발행하도록 내용을 수정합니다.

@MessageMapping("/chat/message")
public void message(ChatMessage message, @Header("token") String token) {
    String nickname = jwtTokenProvider.getUserNameFromJwt(token);
    // 로그인 회원 정보로 대화명 설정
    message.setSender(nickname);
    // 채팅방 입장시에는 대화명과 메시지를 자동으로 세팅한다.
    if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
    message.setSender("[알림]");
    message.setMessage(nickname + "님이 입장하셨습니다.");
    }
    // Websocket에 발행된 메시지를 redis로 발행(publish)
    redisTemplate.convertAndSend(channelTopic.getTopic(), message);
}

ChatRoomRepository 수정

채팅방 입장시 Topic을 신규로 생성하고, redisMessageListner와 연동시키던 작업이 모두 필요없게 되어 다음과 같이 코드가 간소화 됩니다.

@RequiredArgsConstructor
@Service
public class ChatRoomRepository {
    // Redis
    private static final String CHAT_ROOMS = "CHAT_ROOM";
    private final RedisTemplate<String, Object> redisTemplate;
    private HashOperations<String, String, ChatRoom> opsHashChatRoom;

    @PostConstruct
    private void init() {
        opsHashChatRoom = redisTemplate.opsForHash();
    }

    // 모든 채팅방 조회
    public List<ChatRoom> findAllRoom() {
        return opsHashChatRoom.values(CHAT_ROOMS);
    }

    // 특정 채팅방 조회
    public ChatRoom findRoomById(String id) {
        return opsHashChatRoom.get(CHAT_ROOMS, id);
    }

    // 채팅방 생성 : 서버간 채팅방 공유를 위해 redis hash에 저장한다.
    public ChatRoom createChatRoom(String name) {
        ChatRoom chatRoom = ChatRoom.create(name);
        opsHashChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom);
        return chatRoom;
    }
}

room.ftl 수정

로그아웃 버튼이 추가되었습니다. 별다른 설정을 하지 않아도 /logout 주소를 호출하면 Security가 로그아웃 처리를 해줍니다. 로그인 기능이 추가되었으므로 대화명 입력 부분이 삭제되었습니다. 채팅방 입장 시 채팅방 정보를 별도 조회할 필요 없도록 LocalStorage에 저장하고 채팅방에 입장하도록 수정합니다. 

<!doctype html>
<html lang="en">
  <head>
    <title>Websocket Chat</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <!-- CSS -->
    <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
    <style>
      [v-cloak] {
          display: none;
      }
    </style>
  </head>
  <body>
    <div class="container" id="app" v-cloak>
        <div class="row">
            <div class="col-md-6">
                <h3>채팅방 리스트</h3>
            </div>
            <div class="col-md-6 text-right">
                <a class="btn btn-primary btn-sm" href="/logout">로그아웃</a>
            </div>
        </div>
        <div class="input-group">
            <div class="input-group-prepend">
                <label class="input-group-text">방제목</label>
            </div>
            <input type="text" class="form-control" v-model="room_name" v-on:keyup.enter="createRoom">
            <div class="input-group-append">
                <button class="btn btn-primary" type="button" @click="createRoom">채팅방 개설</button>
            </div>
        </div>
        <ul class="list-group">
            <li class="list-group-item list-group-item-action" v-for="item in chatrooms" v-bind:key="item.roomId" v-on:click="enterRoom(item.roomId, item.name)">
                {{item.name}}
            </li>
        </ul>
    </div>
    <!-- JavaScript -->
    <script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
    <script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
    <script>
        var vm = new Vue({
            el: '#app',
            data: {
                room_name : '',
                chatrooms: [
                ]
            },
            created() {
                this.findAllRoom();
            },
            methods: {
                findAllRoom: function() {
                    axios.get('/chat/rooms').then(response => { this.chatrooms = response.data; });
                },
                createRoom: function() {
                    if("" === this.room_name) {
                        alert("방 제목을 입력해 주십시요.");
                        return;
                    } else {
                        var params = new URLSearchParams();
                        params.append("name",this.room_name);
                        axios.post('/chat/room', params)
                        .then(
                            response => {
                                alert(response.data.name+"방 개설에 성공하였습니다.")
                                this.room_name = '';
                                this.findAllRoom();
                            }
                        )
                        .catch( response => { alert("채팅방 개설에 실패하였습니다."); } );
                    }
                },
                enterRoom: function(roomId, roomName) {
                    localStorage.setItem('wschat.roomId',roomId);
                    localStorage.setItem('wschat.roomName',roomName);
                    location.href="/chat/room/enter/"+roomId;
                }
            }
        });
    </script>
  </body>
</html>

roomDetail.ftl 수정

채팅방 정보는 LocalStorage에서 조회하고, 대화명이나 Token정보는 서버에서 실시간으로 조회하도록 수정합니다. 그리고 websocket 접속, 메시지 발송시엔 header에 토큰 정보를 추가로 세팅하도록 수정합니다.

ws.connect({"token":_this.token} ...................});
ws.send("/pub/chat/message", {"token":this.token}.................});
<!doctype html>
<html lang="en">
  <head>
    <title>Websocket ChatRoom</title>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
    <style>
      [v-cloak] {
          display: none;
      }
    </style>
  </head>
  <body>
    <div class="container" id="app" v-cloak>
        <div class="row">
            <div class="col-md-6">
                <h3>{{roomName}}</h3>
            </div>
            <div class="col-md-6 text-right">
                <a class="btn btn-primary btn-sm" href="/logout">로그아웃</a>
            </div>
        </div>
        <div class="input-group">
            <div class="input-group-prepend">
                <label class="input-group-text">내용</label>
            </div>
            <input type="text" class="form-control" v-model="message" v-on:keypress.enter="sendMessage('TALK')">
            <div class="input-group-append">
                <button class="btn btn-primary" type="button" @click="sendMessage('TALK')">보내기</button>
            </div>
        </div>
        <ul class="list-group">
            <li class="list-group-item" v-for="message in messages">
                {{message.sender}} - {{message.message}}</a>
            </li>
        </ul>
    </div>
    <!-- JavaScript -->
    <script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
    <script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
    <script src="/webjars/sockjs-client/1.1.2/sockjs.min.js"></script>
    <script src="/webjars/stomp-websocket/2.3.3-1/stomp.min.js"></script>
    <script>
        // websocket & stomp initialize
        var sock = new SockJS("/ws-stomp");
        var ws = Stomp.over(sock);
        var reconnect = 0;
        // vue.js
        var vm = new Vue({
            el: '#app',
            data: {
                roomId: '',
                roomName: '',
                message: '',
                messages: [],
                token: ''
            },
            created() {
                this.roomId = localStorage.getItem('wschat.roomId');
                this.roomName = localStorage.getItem('wschat.roomName');
                var _this = this;
                axios.get('/chat/user').then(response => {
                    _this.token = response.data.token;
                    ws.connect({"token":_this.token}, function(frame) {
                        ws.subscribe("/sub/chat/room/"+_this.roomId, function(message) {
                            var recv = JSON.parse(message.body);
                            _this.recvMessage(recv);
                        });
                        _this.sendMessage('ENTER');
                    }, function(error) {
                        alert("서버 연결에 실패 하였습니다. 다시 접속해 주십시요.");
                        location.href="/chat/room";
                    });
                });
            },
            methods: {
                sendMessage: function(type) {
                    ws.send("/pub/chat/message", {"token":this.token}, JSON.stringify({type:type, roomId:this.roomId, message:this.message}));
                    this.message = '';
                },
                recvMessage: function(recv) {
                    this.messages.unshift({"type":recv.type,"sender":recv.sender,"message":recv.message})
                }
            }
        });
    </script>
  </body>
</html>

Jwt Token 테스트

채팅 서버를 구동합니다. 로그인 기능이 추가되어 동일한 브라우저에서는 1개의 계정만 로그인 할 수 있습니다. 서로 다른 브라우저(ex. 크롬, 익스플로러 or 파이어폭스)에서 로그인을 진행합니다.

유효한 token 세팅

happydaddy와 angrydaddy로 로그인하여 대화를 진행한 화면입니다. 문제없이 채팅이 진행되는것을 확인 할 수 있습니다.

Websocket Connect시 Jwt token 누락

이번에는 connect시 token을 세팅하지 않도록 처리하고 테스트해보겠습니다. roomdetail.ftl에서 connect부분을 다음과 같이 변경합니다. 

ws.connect({"token":_this.token}, .....  -> ws.connect({}, .......

변경 후 다시 채팅방에 입장하면 다음과 같은 메시지가 나오며 접속 불가한 것을 확인할 수 있습니다. 닫기를 누르면 채팅방 리스트로 이동합니다. 

Message Send시 Jwt token 누락

이번에는 message send시 token을 세팅하지 않도록 처리하고 테스트 해보겠습니다. ws.connect부분은 원래대로 token을 세팅하도록 원복합니다. 그리고 ws.send에서는 token을 전달하지 않도록 수정합니다.

ws.connect({}, ....... -> ws.connect({"token":_this.token}, .....
ws.send("/pub/chat/message", {"token":this.token}..... -> ws.send("/pub/chat/message", {}......

이번에는 채팅방에는 접속 가능하지만, 메시지를 입력하면 메시지가 전달이 안되는것을 확인 할수 있습니다.

채팅 서버의 로그를 보면 다음과 같이 jwt 토큰이 유효하지 않다는 에러 로그를 확인 할 수 있습니다.

2019-06-17 17:38:41.702 ERROR 63987 --- [boundChannel-24]
 .WebSocketAnnotationMethodMessageHandler : Unhandled exception from message handler method

org.springframework.messaging.MessageHandlingException: 
Missing header 'token' for method parameter type [class java.lang.String]

이번장에서는 SpringSecurity와 Jwt를 이용하여 인증받은 사용자만 채팅을 할 수 있도록 고도화 해보았습니다. 차후 실습에서는 ssl 인증서를 websocket에 적용하여 보안을 좀더 높히는 방법에 대해서도 실습해 보겠습니다.

실습한 최신 소스는 아래 GitHub에서 확인할 수 있습니다.
https://github.com/codej99/websocket-chat-server/tree/feature/security

연재글 이동[이전글] Spring websocket chatting server(3) – 여러대의 채팅서버간에 메시지 공유하기 by Redis pub/sub
[다음글] Spring websocket chatting server(5) – 채팅방 입장/퇴장 이벤트 처리, 인원수 표시