새소식

반응형
Spring

[스프링 부트 + 시큐리티 + REST] STOMP를 통한 채팅 구현(JWT를 통한 사용자 인증)

  • -
반응형

 

 

개요

스프링 부트를 활용하여 채팅 기능이 포함된 프로젝트를 구현 하였다. STOMP를 활용했고, 스프링 시큐티리와 JWT를 통해 채팅 구현 내용과 사용자 인증 과정 및 코드를 공유한다.

 

 

STOMP란?

웹 소켓, TCP와 같이 양방향 네트워크 프로토콜 위에서 사용되는 서브 프로토콜이다. websocket을 통해 직접 구현하기 보다 메시지를 편리하게 사용하도록 구현되어 있는 STOMP를 활용하였다.

 

주요 특징 및 개념

  1. 텍스트 기반 프로토콜:
    • STOMP는 텍스트 기반 프로토콜로, 프레임(frame)이라고 불리는 명령어를 텍스트 형식으로 전달한다. 각 프레임은 명령어, 헤더, 본문으로 구성된다.
  2. 프레임 구조:
    • 명령어(Command): CONNECT, SEND, SUBSCRIBE, UNSUBSCRIBE, BEGIN, COMMIT, ABORT, ACK, NACK, DISCONNECT, MESSAGE, RECEIPT, ERROR 등이 있다.
    • 헤더(Headers): 각 명령어에 따라 다양한 헤더가 포함되며, key-value 형식으로 전달된다.
    • 본문(Body): 메시지의 내용이 포함된다. 본문은 선택 사항이며, 없을 수도 있다.
  3. 구독 및 전송:
    • 클라이언트는 특정 목적지(destination)에 대한 메시지를 구독(subscribe)할 수 있다.
    • 메시지를 전송(send)할 때는 목적지(destination)를 지정하여 메시지를 보낸다.
  4. 메시지 브로커:
    • STOMP는 메시지 브로커와 통신하기 위해 설계되었다. 메시지 브로커는 메시지를 받아서 구독한 클라이언트에게 전달하는 역할을 한다.
    • 대표적인 메시지 브로커로는 Apache ActiveMQ, RabbitMQ 등이 있다.

 

요구사항

소켓 통신을 활용한 단체 채팅 구현

JWT 토큰을 활용한 메시지 송신 시 사용자 인증

 

 

프레임워크 및 라이브러리 버전

자바 17

스프링부트 3.2.5

롬복

jjwt

시큐리티

웹소켓

 

 

구현 코드

 

TokenProvider.java

@Slf4j
@Component
public class JwtTokenProvider {

    private final UserMapper userMapper;

    private final Key SECRET_KEY;
    private static final String AUTHORITIES_KEY = "role";
    private static final String EMAIL_KEY = "email";

    public JwtTokenProvider(UserMapper userMapper, @Value("${app.auth.token.secret-key}") String secretKey) {
        this.userMapper = userMapper;
        this.SECRET_KEY = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
    }
    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new).toList();
        log.info(claims.getSubject());


        log.info("EmailKey" + claims.get(EMAIL_KEY, String.class ) + "Role" + claims.get("role", String.class).substring(5));

        String email = claims.get(EMAIL_KEY, String.class);

        User user = userMapper.selectByEmail(email);

        CustomUserDetails principal = new CustomUserDetails(user, authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(SECRET_KEY).build().parseClaimsJws(accessToken).getBody();
        }catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • JWT토큰 관련 클래스이다. 토큰 내 유저 정보를 활용하여 인증 객체를 생성하기 위한 클래스이다.

 

ChatMessage.java

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessage {

    public enum MessageType {
        ENTER, TALK
    }

    private MessageType type;
    @Setter
    private String message;
    private int userId;
    private String nickname;
    private LocalDateTime createdAt;

    public void setUser(User user) {
        this.userId = user.getUserId();
        this.nickname = user.getNickname();
        this.createdAt = LocalDateTime.now();
    }
}
  • 실제 메시지를 주고받을 때 활용할 메시지 객체이다. type필드는 처음 채팅 방 입장 시 ENTER로 입장을 알리는 역할을 한다.
  • 채팅 메시지를 보낼 때 인증 객체로 해당 유저 정보가 매핑되어 보내 질 수 있도록 setUser()메서드를 구현하였다.

 

WebSocketConfig.java

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

    private final ChatHandler chatHandler;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");
        registry.setApplicationDestinationPrefixes("/app");
    }

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

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(chatHandler);
    }
}
  • WebSocketMessageBrokerconfigurer 을 구현하여 기본적인 소켓 정보를 설정한다.
  • STOMP를 활용하기 위해 @EnableWebSocketMessageBroker 어노테이션을 붙여준다.
  • enableSimpleBroker() 메서드를 통해 메모리 기반의 간단한 메시지 브로커를 활성화한다. 이를 통해 클라이언트는 /topic 경로에 메시지를 발행하거나 구독할 수 있다.
  • setApplicationDestinationPrefixes("/app") 은 클라이언트가 **/app**으로 시작하는 경로로 메시지를 보내면, 이는 컨트롤러의 @MessageMapping 어노테이션이 붙은 메서드로 라우팅된다.
  • JWT토큰을 통한 사용자 인증을 위해 ChannelInterceptor를 직접 구현한 ChatHandler클래스를 등록한다.

 

ChatHandler.java

@Order(Ordered.HIGHEST_PRECEDENCE + 99)
@RequiredArgsConstructor
public class ChatHandler implements ChannelInterceptor {

    private final JwtTokenProvider tokenProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {

        StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            String token = String.valueOf(accessor.getNativeHeader("Authorization"));
            log.info(token.substring(1, token.length() - 1));

            Authentication auth = tokenProvider.getAuthentication(token.substring(1, token.length() - 1));

            accessor.setUser(auth);
        }
        return message;
    }
}

 

 

 

Token Authentication :: Spring Framework

Spring Security OAuth provides support for token based security, including JSON Web Token (JWT). You can use this as the authentication mechanism in Web applications, including STOMP over WebSocket interactions, as described in the previous section (that i

docs.spring.io

 

토큰 인증을 위해 위의 공식문서를 참고했다.

먼저 @Order(Ordered.HIGHEST_PRECEDENCE + 99) 어노테이션을 통해 가장 먼저 해당 인터셉터가 작동하도록 한다.

메시지가 메시지 채널을 통해 송수신될 때 가로채는 역할을 하는 ChannelInterceptor를 구현하여 메시지가 채널에 들어오거나 나갈 때 특정 작업을 수행할 수 있다는 점을 활용했다.

아래의 주요 메서드 중 메시지를 보내기 전 초기 연결인 경우에 인증 객체를 헤더에 넣기 위해 preSend() 메서드를 구현하였다.

  • 주요 메서드:
    • preSend(): 메시지가 실제로 전송되기 전에 호출된다.
    • postSend(): 메시지가 전송된 후에 호출된다.
    • preReceive(): 메시지가 수신 되기 전에 호출된다.
    • postReceive(): 메시지가 수신 된 후에 호출된다.
    • afterReceiveCompletion(): 메시지 수신이 완료된 후 호출된다.

메시지를 보내기 전에 요청을 가로채어 로직을 실행하는 preSend() 메서드를 통해 Stomp헤더에 있는 jwt토큰을 가져오고 초기 연결 시에만 stomp 메시지 헤더에 유저 인증 객체를 저장한다.

 

MessageController.java

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/messages")
public class MessageController {

    private final SimpMessageSendingOperations sendingOperations;

    //채팅 메세지 보내기
    @MessageMapping("/{roomId}")
    public void enter(ChatMessage message, Authentication authentication, @DestinationVariable int roomId) {

        User user = ((CustomUserDetails)authentication.getPrincipal()).getUser();
        log.info("User: {}", user);
        log.info("Room: {}", roomId);
        message.setUser(user);

        if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
            message.setMessage( message.getNickname() + "님이 입장하였습니다.");
        }
        sendingOperations.convertAndSend("/topic/" + roomId , message);
    }
}

실제 메시지가 받아 처리하는 컨트롤러이다. 채팅 메시지를 특정 채팅 방으로 보내는 역할을 하는 클래스이다.

@MessageMapping 을 통해 특정 URL에 메시지 요청을 받았을 때, 해당 메서드가 실행된다.

아래 공식문서를 통해 관련 파라미터를 확인할 수 있다.

 

 

Annotated Controllers :: Spring Framework

@SubscribeMapping is similar to @MessageMapping but narrows the mapping to subscription messages only. It supports the same method arguments as @MessageMapping. However for the return value, by default, a message is sent directly to the client (through cli

docs.spring.io

 

위의 문서를 보면 @DestinationVariable 을 통해 일반 컨트롤러의 @PathVariable과 같이 동적으로 채팅방 아이디를 받아 추출할 수 있다.

또한 Principal객체를 받는데 이는 기존에 StompHeadAccessorsetUser()메서드를 통해 넣어두었던 유저 인증 객체를 꺼내 매핑 시켜준다.

 

로그를 확인해 보면 인증 객체 내에 있는 유저 정보를 성공적으로 불러온 것을 볼 수 있다.

초기 메시지 타입이 “ENTER”일 때에 입장 메시지로 변경하고 간단한 메시지 전송 로직이 구현된 SimpMessageSendingOperations를 주입받아 convertAndSend() 메서드를 통해 메시지를 전달한다.

 

 

CustomUserDetails.java, CustomUserDetailsService.java

public class CustomUserDetails implements UserDetails, OAuth2User {

    @Getter
    private User user;
    private Collection<? extends GrantedAuthority> authorities;
    private Map<String, Object> attributes;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    public CustomUserDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    public CustomUserDetails(User user, Collection<? extends GrantedAuthority> authorities) {
        this.user = user;
        this.authorities = authorities;
    }

    public String getEmail() {
        return user.getEmail();
    }

    @Override
    public String getName() {
        return user.getName();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return String.valueOf(user.getUserId());
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        User user = userMapper.selectByLoginId(userId);
        return new CustomUserDetails(user);
    }
}

CustomUserDetails UserDetails OAuth2User 인터페이스를 구현하여 사용자 인증에 필요한 정보를 제공한다. 여기에는 사용자의 이메일, 이름, 권한 등의 정보가 포함된다. CustomUserDetailsService UserDetailsService를 구현하여 사용자 정보를 불러오는 로직을 구현한다. 이 서비스는 사용자 로그인 시 사용자의 정보를 데이터베이스에서 불러와 인증 과정을 수행한다.

두 클래스 모두 기존 시큐리티 인증 인가를 위해 구현된 코드이다.

 

추후 추가 구현 및 공부 내용

 

  • 읽지 않은 메시지 구현
  • 메시지 예외 처리 방법 등
반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.