[Spring boot + React] STOMP로 실시간 채팅 구현하기 (3) - 사용자 인증 구현하기

클롱바·2022년 9월 8일
2

이 부분은 개인적으로 이것저것 섞어서 생각해낸 인증 방법이므로 더 효율적인 방법이 아주 많을 것이라 생각된다. 일단 기록용으로 남겨두자...

문제점 인식

발단

소켓 구현은 잘 해놨고 이제 JWT를 사용해서 현재 메시지를 보내고 있는 사용자가 누구인지 구분하고자 했는데, 소켓 통신을 할 때는 Authorization header를 달 수 없었다. 찾아보니 웹소켓 handshake시에 다는 것이 불가능한 것 같았다.

그러면 기존에 구현해둔 인증 방식을 사용할 수 없는걸까..? 다행히 그건 아니었다.

해결책 생각

다른 인증 방법 생각?

이미 서비스 전체에서 JWT를 사용한 사용자 인증이 이뤄지고 있는데... 이왕이면 만들어져 있는 JWT를 잘 활용하고 싶었다. 만약 JWT를 쿠키에 저장 해놓는 방법을 사용한다면 이런걸 고민할 필요없이 바로 인증을 구현할 수 있었을텐데
이미 기존에 사용하고 있는 인증 방법이 로컬 스토리지에 토큰 저장 후, request마다 authorization header에 실어 보내 서버단에서 처리한다. 였기 때문에 기능 하나를 달자고 인증 방식을 통으로 바꾸긴 무리라 생각했다.

헤더에 JWT를 실어보내는 방안

STOMP를 사용하면 연결을 요청할 때 connectHeaders를 사용해 커스텀 헤더를 실어보낼 수 있었다.

    client.current = new StompJs.Client({
      brokerURL: 'ws://localhost:8787/ws',
      onConnect: () => {
        console.log('success');
        subscribe();
      },
      connectHeaders: {
        Authorization: window.localStorage.getItem('authorization'),
      },
    });

이런 식으로! 이렇게 하면 로그인 할 때 미리 저장해둔 JWT를 활용해서 사용자 인증을 진행할 수 있을 것 같았다.

사용자 인증 구현

React 클라이언트에 커스텀 헤더 달기

이전에 만들어둔 클라이언트 설정에 connectHeadders 옵션만 따로 주면 쉽게 구현할 수 있다.

  const connect = () => {
    client.current = new StompJs.Client({
      brokerURL: 'ws://localhost:8787/ws',
      onConnect: () => {
        console.log('success');
        subscribe();
      },
      connectHeaders: { // 이 부분 새로 추가
        Authorization: window.localStorage.getItem('authorization'),
      },
    });
    client.current.activate();
  };
  • 헤더 이름은 자유롭게 설정할 수 있다. 나는 Authorzation으로 설정했고, 로컬 스토리지에 미리 저장해둔 JWT 값을 가져와서 헤더에 실어보내게끔 코드를 작성했다.

Spring boot 서버에서 헤더 받아 처리하기

0. Channel Interceptor 등록하기

이전에 작성했던 웹소켓 config 파일에 configureClientInboundChannel을 오버라이드하고, 인터셉터를 등록한다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("*");
    }

	// 새로 추가
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new FilterChannelInterceptor());
    }
}
  • configureClientInboundChannel은 STOMP 연결 시도 시 호출되는 메소드다.
    • 인터셉터를 등록해서 연결을 시도하면 FilterChannelInterceptor가 실행되게 설정했다.

1. ChannelInterceptor 작성

이 인터셉터에서 JWT에 대한 처리를 진행한다.

  1. 우선 Spring Security보다 인터셉터의 우선 순위를 올리기 위해 @Order(Ordered.HIGHEST_PRECEDENCE + 99) annotation을 사용한다.
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor { }
  1. preSend 메소드를 오버라이드하고 StompHeaerAccessor를 생성한다.
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor {
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        assert headerAccessor != null;
}
  • preSend는 메시지가 채널로 전송되기 전에 호출되는 메소드다.

  • StompHeaderAccessor를 사용해서 STOMP 헤더에 접근할 수 있다.

    • 클라이언트에서 커스텀 헤더에 JWT를 실어 보냈으므로 Accessor로 접근해야 한다.
  1. JWT를 검증하고 메시지에 인증 정보를 추가해 채널로 넘긴다.
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor {
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        assert headerAccessor != null;
        if (headerAccessor.getCommand() == StompCommand.CONNECT) { // 연결 시에한 header 확인
            String token = String.valueOf(headerAccessor.getNativeHeader("Authorization").get(0));
            token = token.replace(JwtProperties.TOKEN_PREFIX, "");

            try {
                Integer userId = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(token)
                        .getClaim("id").asInt();

                headerAccessor.addNativeHeader("User", String.valueOf(userId));
            } catch (TokenExpiredException e) {
                e.printStackTrace();
            } catch (JWTVerificationException e) {
                e.printStackTrace();
            }
        }
        return message;
    }
}
  • 첫 연결 시도에만 사용자를 인증하고 이후엔 저장해둔 정보를 사용할 것이므로 getCommand()로 연결 시도인지 확인한다.

  • addNativeHeader()를 사용해 User 라는 네이티브 헤더를 메시지에 추가한다.

    • User 헤더에는 JWT를 해독해서 얻은 사용자 인증 정보가 들어간다.

2. Controller에서 연결 세션과 사용자 정보 저장하기

최초 연결 시 <연결 세션 ID, 사용자 정보>를 Hash Map에 저장해, 해당 세션 ID로 오는 메시지는 모두 매칭되는 사용자가 보낸 것으로 간주할 수 있게끔 코드를 작성했다.

  1. 컨트롤러에 연결 시 실행되는 onConnect() 함수를 작성해 정보를 저장한다.
    @EventListener(SessionConnectEvent.class)
    public void onConnect(SessionConnectEvent event){
        String sessionId = event.getMessage().getHeaders().get("simpSessionId").toString();
        String userId = event.getMessage().getHeaders().get("nativeHeaders").toString().split("User=\\[")[1].split("]")[0];

        sessions.put(sessionId, Integer.valueOf(userId));
    }
  • @EventListener(SessionConnectEvent.class) annotation을 사용해 소켓이 연결됐을 때의 정보를 받는다.
  • 인터셉터에서 넣어둔 사용자 정보를 얻기 위해 get("nativeHeaders")를 사용한다.
    • native header가 통채로 나오기 때문에 파싱해서 사용한다.
  1. 연결이 끊겼을 시 정보를 삭제할 수 있게 onDisconnect() 함수를 작성한다.
@EventListener(SessionDisconnectEvent.class)
    public void onDisconnect(SessionDisconnectEvent event) {
        sessions.remove(event.getSessionId());
    }
  • @EventListner(SessionDisconnectEvent.class) annotation을 사용해 소켓의 연결이 끊겼을 때의 정보를 받는다.
  • 연결이 끊긴 세션의 ID를 토대로 매칭해둔 정보를 삭제한다.
  1. 이후 오는 요청은 세션의 ID를 토대로 사용자를 식별한다.
    @MessageMapping("/chat")
    public void sendMessage(ChatDto chatDto, SimpMessageHeaderAccessor accessor) {
        Integer writerId = sessions.get(accessor.getSessionId());
        chatDto.setWriterId(writerId);
        
        simpMessagingTemplate.convertAndSend("/sub/chat/" + chatDto.getApplyId(), chatDto);
    }
  • 인자에 SimpMessageHeaderAccessor를 추가하면 해당 메시지의 헤더에 접근할 수 있다.

정리

  1. 클라이언트는 connectHeaders에 JWT를 실어 보낸다. (STOMP를 사용하기 때문에 가능)
  2. 서버는 ChannelInterceptor를 만들어 STOMP의 헤더에서 JWT를 얻고 해독한다.
  3. 해독된 JWT의 사용자 정보를 메시지의 Native Header에 넣어 넘긴다.
  4. 소켓 connect를 할 때, Native Header에서 사용자 정보를 얻어와 연결이 맺어진 session ID와 헤더에서 얻은 사용자 정보를 같이 저장해둔다.
  5. 이후 오는 요청은 모두 session ID를 통해 사용자를 구분한다.

참고

0개의 댓글