게임방에 참가자가 입장하는 로직에서 처음엔 이렇게 작성했었다.
@MessageMapping("/game.join/{roomId}")
public void joinGame(@DestinationVariable Long roomId, String participant) {
try {
GameRoomDto gameRoom = gameRoomService.addParticipant(roomId, participant);
messagingTemplate.convertAndSend("/topic/gameRoom/" + roomId, gameRoom.getParticipants());
} catch (IllegalStateException e) {
// 게임방이 꽉 찼을 때의 예외 처리
// 적절한 예외 처리 로직 추가 예정
}
}
다음 단계에서 로그인 기능이 추가 되어 로그인한 유저의 정보를 받아와야 하는 상황이었다. 그래서
http처럼 @AuthenticationPrincipal과 UserDetailsImpl을사용하여 인증된 사용자의 정보를 불러오려 하였다.
org.springframework.messaging.converter.MessageConversionException: Could not read JSON: Cannot construct instance of 'com.service.indianfrog.global.security.UserDetailsImpl' (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)란 에러가 발생했다.
알아보니 Spring에서 사용하는 JSON 처리 라이브러리인 Jackson이 JSON 데이터를 Java 클래스의 인스턴스로 역직렬화할 수 없을 때 발생하는 문제였다. 내 경우엔 웹소켓 세션과 연관된 인증 정보의 처리 방식 때문에 메시지 핸들러에서 @AuthenticationPrincipal을 사용하려 해서 발생한 문제였다. 웹소켓에서는 @AuthenticationPrincipal 어노테이션을 직접 사용하는 것이 제한적이라고 한다.
@AuthenticationPrincipal은 주로 HTTP 요청을 처리하는 컨트롤러에서 Spring Security 컨텍스트 내의 현재 인증된 사용자의 상세 정보를 가져오기 위해 사용된다.
그래서 찾은 해결 방법은 ChannelInterceptor를 만들고 Principal를 사용하는 것이었다.
package com.service.indianfrog.global.config;
import com.service.indianfrog.domain.gameroom.dto.AuthenticatedUser;
import com.service.indianfrog.global.jwt.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
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.util.StringUtils;
import java.util.Collections;
@Slf4j
public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {
private final JwtUtil jwtUtil;
/**
* JwtUtil 객체를 주입받아 인터셉터를 생성
*
* @param jwtUtil JWT 처리
*/
public WebSocketAuthChannelInterceptor(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
/**
* 메시지 전송 전에 인증 정보를 검사하고 설정
*
* @param message 전송될 메시지
* @param channel 메시지가 전송될 채널
* @return 인증 처리 후의 메시지 객체
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// 클라이언트가 보낸 메세지에서 Authorization 추출.
// getFirstNativeHeader는 STOMP를 사용하는 웹소켓에서 헤더에 접근할수 있게 해주는 프로토콜
String token = accessor.getFirstNativeHeader("Authorization");
// 토큰 유효성 검사
if (StringUtils.hasText(token) && token.startsWith(JwtUtil.BEARER_PREFIX)) {
token = token.substring(7);
// jwt로 유효성 검사
if (jwtUtil.verifyToken(token)) {
String email = jwtUtil.getUid(token);
// 이메일을 이용해서 인증객체 생성. new AuthenticatedUser(email) 이걸로 principal 사용.
Authentication authentication = new UsernamePasswordAuthenticationToken(new AuthenticatedUser(email), null, Collections.emptyList());
//SecurityContext에 담아서 다른데서도 인증된 사용자 정보를 조회할수 있음.
SecurityContextHolder.getContext().setAuthentication(authentication);
// STOMP에 사용자 인증정보 설정해서 해당 메세지 처리하는 동안 사용자가 인증된 상태 유지.
accessor.setUser(authentication);
log.info("Authentication set for user: {}", email);
}
}
return message;
}
}
이러한 채널 인터셉터를 만든 후
@MessageMapping("/{gameRoomId}/join")
public void joinGame(@DestinationVariable Long gameRoomId, Principal principal) {
ParticipantInfo newParticipant = gameRoomService.addParticipant(gameRoomId, principal);
messagingTemplate.convertAndSend("/topic/gameRoom/" + gameRoomId + "/join", newParticipant);
}
컨트롤러를 수정했다. 수정 후 역직렬화 문제는 더 이상 발생하지 않았으나 pricipal의 값이 null이 뜨는 문제가 발생했다.
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
문제의 원인은 StompHeaderAccessor였다.
웹소켓 연결 과정에서 클라이언트로부터 전달받은 인증 토큰을 바탕으로 사용자 인증 정보(Principal)를 설정하게 되는데 만약 StompHeaderAccessor.wrap(message)를 사용한다면 이 과정에서 생성된 새로운 StompHeaderAccessor 인스턴스에는 이전 단계에서 설정된 인증 정보가 반영되지 않는다. 따라서 후속 처리 과정에서 Principal을 조회하려 할 때 null이 반환될 수 있다.
StompHeaderAccessor accessor = MessageHeaderAccessor
.getAccessor(message, StompHeaderAccessor.class);
그래서 이렇게 수정했다. 이 방식은 Message에 이미 연결된 HeaderAccessor를 탐색하여 반환한다. Message와 관련된 현재의 StompHeaderAccessor를 반환하기 때문에 이전에 설정된 Principal을 포함한 모든 컨텍스트 정보를 유지한다.
만약 Message가 이미 특정 HeaderAccessor와 연결되어 있고 해당 HeaderAccessor에 사용자 인증 정보(Principal)가 설정되어 있다면 이 정보를 그대로 유지하면서 반환한다.
'스프링' 카테고리의 다른 글
ConcurrentHashMap vs 레코드 락 (0) | 2024.10.11 |
---|---|
ConcurrentHashMap (1) | 2024.10.11 |
Mybatis와 JPA (2) | 2024.03.12 |
스프링컨테이너와 빈 (0) | 2024.03.03 |
IoC & DI (0) | 2024.02.25 |