이 부분은 개인적으로 이것저것 섞어서 생각해낸 인증 방법이므로 더 효율적인 방법이 아주 많을 것이라 생각된다. 일단 기록용으로 남겨두자...
소켓 구현은 잘 해놨고 이제 JWT를 사용해서 현재 메시지를 보내고 있는 사용자가 누구인지 구분하고자 했는데, 소켓 통신을 할 때는 Authorization header를 달 수 없었다. 찾아보니 웹소켓 handshake시에 다는 것이 불가능한 것 같았다.
그러면 기존에 구현해둔 인증 방식을 사용할 수 없는걸까..? 다행히 그건 아니었다.
이미 서비스 전체에서 JWT를 사용한 사용자 인증이 이뤄지고 있는데... 이왕이면 만들어져 있는 JWT를 잘 활용하고 싶었다. 만약 JWT를 쿠키에 저장 해놓는 방법을 사용한다면 이런걸 고민할 필요없이 바로 인증을 구현할 수 있었을텐데
이미 기존에 사용하고 있는 인증 방법이 로컬 스토리지에 토큰 저장 후, request마다 authorization header에 실어 보내 서버단에서 처리한다. 였기 때문에 기능 하나를 달자고 인증 방식을 통으로 바꾸긴 무리라 생각했다.
STOMP를 사용하면 연결을 요청할 때 connectHeaders
를 사용해 커스텀 헤더를 실어보낼 수 있었다.
client.current = new StompJs.Client({
brokerURL: 'ws://localhost:8787/ws',
onConnect: () => {
console.log('success');
subscribe();
},
connectHeaders: {
Authorization: window.localStorage.getItem('authorization'),
},
});
이런 식으로! 이렇게 하면 로그인 할 때 미리 저장해둔 JWT를 활용해서 사용자 인증을 진행할 수 있을 것 같았다.
이전에 만들어둔 클라이언트 설정에 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 값을 가져와서 헤더에 실어보내게끔 코드를 작성했다.이전에 작성했던 웹소켓 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
가 실행되게 설정했다. 이 인터셉터에서 JWT에 대한 처리를 진행한다.
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
annotation을 사용한다.@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor { }
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 헤더에 접근할 수 있다.
@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를 해독해서 얻은 사용자 인증 정보가 들어간다. 최초 연결 시 <연결 세션 ID, 사용자 정보>를 Hash Map에 저장해, 해당 세션 ID로 오는 메시지는 모두 매칭되는 사용자가 보낸 것으로 간주할 수 있게끔 코드를 작성했다.
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")
를 사용한다.onDisconnect()
함수를 작성한다.@EventListener(SessionDisconnectEvent.class)
public void onDisconnect(SessionDisconnectEvent event) {
sessions.remove(event.getSessionId());
}
@EventListner(SessionDisconnectEvent.class)
annotation을 사용해 소켓의 연결이 끊겼을 때의 정보를 받는다. @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
를 추가하면 해당 메시지의 헤더에 접근할 수 있다.connectHeaders
에 JWT를 실어 보낸다. (STOMP를 사용하기 때문에 가능)ChannelInterceptor
를 만들어 STOMP의 헤더에서 JWT를 얻고 해독한다.Native Header
에 넣어 넘긴다.Native Header
에서 사용자 정보를 얻어와 연결이 맺어진 session ID와 헤더에서 얻은 사용자 정보를 같이 저장해둔다.