STOMP 메세지를 처리하는 채널 인터셉터를 정의하여 명시적으로 Principal을 만들어 저장하고 싶었음.
왜냐하면, SocketJS()를 통한 웹 소켓 연결시도가 별도의 헤더 설정을 하기 어려웠고, 이후 CONNECT 메세지는 웹 소켓 상의 STOMP 메세지였기 때문에 Spring Security Filter Chain에서 토큰 정보를 읽을 수가 없었음. (HTTP 헤더가 아니라)
package com.e104_2.reciplaywebsocket.common.config;
import com.e104_2.reciplaywebsocket.room.config.StompAuthenticationChannelInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
@Configuration
@EnableWebSocketMessageBroker // WebSocket 메시지 브로커를 활성화합니다.
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Value("${-}")
private String URI_PREFIX;
private final StompAuthenticationChannelInterceptor stompAuthenticationChannelInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// /ws 경로로 WebSocket 연결을 허용하고, 모든 도메인에서의 접근을 허용합니다.
registry.addEndpoint(URI_PREFIX + "/-----")
.setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker(URI_PREFIX + "/--", URI_PREFIX + "/--");
registry.setApplicationDestinationPrefixes(URI_PREFIX + "/--");
registry.setUserDestinationPrefix(URI_PREFIX + "/--");
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.setMessageSizeLimit(4 * 8192); // 메세지 최대 길이 = 32KB
registry.setTimeToFirstMessage(30000); // 30초 동안 최초 메세지 대기
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompAuthenticationChannelInterceptor);
}
}
configureClientInboundChannel 메서드를 통해 인터셉터를 등록했다.
@Component
@Slf4j
@RequiredArgsConstructor
public class StompAuthenticationChannelInterceptor implements ChannelInterceptor {
private final JWTUtil jwtUtil;
private final TokenQueryService tokenQueryService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if(StompCommand.CONNECT.equals(accessor.getCommand())) {
String auth = accessor.getFirstNativeHeader("Authorization");
if(!auth.startsWith("Bearer")) throw new InvalidTokenFormatException("유효하지 않은 토큰 길이/Prefix");
String token = auth.split(" ")[1];
if(tokenQueryService.isInvalidToken(token)) {
throw new InvalidTokenException("만료된 토큰 입니다.");
}
if(jwtUtil.isExpired(token)) {
throw new InvalidTokenException("만료된 토큰 입니다.");
}
CustomUserDetails userDetails = new CustomUserDetails();
userDetails.setUsername(jwtUtil.getUsername(token));
userDetails.setRole(jwtUtil.getRole(token));
log.debug("웹 소켓 핸드쉐이크. 유저 정보 입력됨 : {}", userDetails);
Principal principal = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
accessor.setUser(principal);
}
return message;
}
}
accessor.setUser() 를 통해 유저 정보를 세션에 유지하려 했으나, 다음 컨트롤러의 핸들러 메서드에서 NullPointerExeption이 발생했다.
@MessageMapping("/----")
public void issueNextChapter(@Payload ChapterIssueRequest message,
Principal principal
) {
if(!message.getType().equals("chapter-issue")) return;
Integer sequence = message.getChapterSequence();
Long lectureId = message.getLectureId();
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken)principal;
CustomUserDetails userDetails = (CustomUserDetails)token.getPrincipal();
if(!principal.getName().equals(message.getIssuer()) || !userDetails.getAuthorities().iterator().next().getAuthority().equals("ROLE_INSTRUCTOR")) {
messagingTemplate.convertAndSendToUser(message.getIssuer(), URL_PREFIX+"/queue/"+message.getRoomId(), Map.of("status", "refused", "message", "라이브룸 강사 권한이 없습니다."));
return;
}
ChapterTodoResponse response = todoQueryService.queryTodoOfChapter(lectureId, sequence);
messagingTemplate.convertAndSend(URL_PREFIX+"/topic/room/"+message.getRoomId(), response);
}
메시지 헤더를 수정한 후에도 원래 Message 객체를 그대로 반환할 수 있도록 해주는 설정이다.
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
위에서 생성한 accessor는 현재 도착한 stomp 메세지의 헤더를 조작하기 위하여 메세지를 랩핑한다.
setUser() 메서드는 message의 simpUser 헤더 부분을 세팅하는 메서드이므로, 이 메서드 호출 후, 반드시 변경 사항이 반영되도록 조치해야한다.
그래도 여전히 에러가 발생했다.
메세지를 새로 만들어 전송한 방법.
이렇게 해도 여전히 NPE가 발생했다.
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtHandshakeInterceptor implements HandshakeInterceptor {
private final JWTUtil jwtUtil;
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
String token = UriComponentsBuilder.fromUri(request.getURI()).build().getQueryParams().getFirst("token");
log.debug("token 파싱 {}", token);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new CustomUserDetails(jwtUtil.getUsername(token), jwtUtil.getRole(token)), null, List.of(new SimpleGrantedAuthority(jwtUtil.getRole(token)))
));
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// /ws 경로로 WebSocket 연결을 허용하고, 모든 도메인에서의 접근을 허용합니다.
registry.addEndpoint(URI_PREFIX + "/---")
.addInterceptors(jwtHandshakeInterceptor)
.setAllowedOriginPatterns("*").withSockJS();
}
위와같이 HandshakeInterceptor를 등록해봤지만,
이 메서드에서 정확히 동작하더라도, 이후 404 에러가 발생했다.
@Component
@Slf4j
@RequiredArgsConstructor
public class StompAuthenticationChannelInterceptor implements ChannelInterceptor {
private final JWTUtil jwtUtil;
private final TokenQueryService tokenQueryService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if(StompCommand.CONNECT.equals(accessor.getCommand())) {
String auth = accessor.getFirstNativeHeader("Authorization");
if(!auth.startsWith("Bearer")) throw new InvalidTokenFormatException("유효하지 않은 토큰 길이/Prefix");
String token = auth.split(" ")[1];
if(tokenQueryService.isInvalidToken(token)) {
throw new InvalidTokenException("만료된 토큰 입니다.");
}
if(jwtUtil.isExpired(token)) {
throw new InvalidTokenException("만료된 토큰 입니다.");
}
CustomUserDetails userDetails = new CustomUserDetails();
userDetails.setUsername(jwtUtil.getUsername(token));
userDetails.setRole(jwtUtil.getRole(token));
log.debug("웹 소켓 핸드쉐이크. 유저 정보 입력됨 : {}", userDetails);
Authentication principal = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(principal);
return MessageBuilder.createMessage(message.getPayload(), accessor.getMessageHeaders());
}
return message;
}
}
위와 같은 처리는 유효하지 않다고 한다. 왜냐하면, SecurityContext가 ThreadLocal이기 때문에 beforeHandshake 등 단계에서 초기화하더라도 그 순간에만 유지되고, 이후 스레드에선 사라지기 때문이다.
따라서 SecurityContext를 직접 주입하는 방식은 적합하지 않다.
무엇보다, 개인 채널에 대한 구독과 전송도 반드시 필요한 기능기 때문에 Principal을 유지하지 못한다면, 기존 로직들의 대폭 수정해야 한다.
https://cyyyummy.tistory.com/53
위 글을 참조하여 조치해보았다.
조치한 사항은 기존과 크게 다르지 않다.
기존에는 wrap으로 처리한 것을 이렇게 변경했다.
1번의 조치로 기존의 accessor 존재하면, 이걸 반환받아 사용해야 한다.
아니라면, Connect 이외의 요청에선 Principal이 존재하지 않게 된다.
import com.e104_2.reciplaywebsocket.room.exception.InvalidTokenException;
import com.e104_2.reciplaywebsocket.room.exception.InvalidTokenFormatException;
import com.e104_2.reciplaywebsocket.security.dto.CustomUserDetails;
import com.e104_2.reciplaywebsocket.security.jwt.JWTUtil;
import com.e104_2.reciplaywebsocket.security.service.TokenQueryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component
@Slf4j
@RequiredArgsConstructor
public class StompAuthenticationChannelInterceptor implements ChannelInterceptor {
private final JWTUtil jwtUtil;
private final TokenQueryService tokenQueryService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if(accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
String auth = accessor.getFirstNativeHeader("Authorization");
if(!auth.startsWith("Bearer")) throw new InvalidTokenFormatException("유효하지 않은 토큰 길이/Prefix");
String token = auth.split(" ")[1];
if(tokenQueryService.isInvalidToken(token)) {
throw new InvalidTokenException("만료된 토큰 입니다.");
}
if(jwtUtil.isExpired(token)) {
throw new InvalidTokenException("만료된 토큰 입니다.");
}
CustomUserDetails userDetails = new CustomUserDetails();
userDetails.setUsername(jwtUtil.getUsername(token));
userDetails.setRole(jwtUtil.getRole(token));
log.debug("웹 소켓 핸드쉐이크. 유저 정보 입력됨 : {}", userDetails);
Authentication principal = new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(principal);
accessor.setUser(principal);
}
return message;
}
}
컨트롤러에서 Principal을 전달받는데 성공했다.