Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[OMCT-413] 실시간 채팅 기능 구현 #216

Merged
merged 10 commits into from
Feb 6, 2024
5 changes: 5 additions & 0 deletions bucketback-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// chat
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:stomp-websocket:2.3.3'

// 테스트 관련
testImplementation(testFixtures(project(':bucketback-domain')))
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.programmers.bucketback.domains.chat.api;

import java.security.Principal;
import java.time.LocalDateTime;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import com.programmers.bucketback.domains.chat.api.dto.request.ChatCreateRequest;
import com.programmers.bucketback.domains.chat.api.dto.response.ChatGetResponse;
import com.programmers.bucketback.error.ErrorCode;
import com.programmers.bucketback.global.config.security.SecurityUtils;

import io.jsonwebtoken.MalformedJwtException;
import lombok.RequiredArgsConstructor;

@Controller
@RequiredArgsConstructor
public class ChatController {

private final SimpMessagingTemplate simpMessagingTemplate;

/**
* STOMP 프로토콜을 사용하여 채팅 메시지를 처리하고 구독자들에게 메시지를 전송하는 메소드.
*
* /publish/messages 경로로 메시지를 전송하면 이 메소드가 호출됩니다.
*/
@MessageMapping("/publish/messages")
public void sendMessage(
@Payload final ChatCreateRequest request,
final Principal principal
) {
if (principal == null)
throw new MalformedJwtException(ErrorCode.BAD_SIGNATURE_JWT.getMessage());

LocalDateTime createdAt = LocalDateTime.now();
ChatGetResponse chatGetResponse = new ChatGetResponse(
request.message(),
request.userNickname(),
createdAt
);


/*
SimpMessagingTemplate를 사용하여 구독자들에게 메시지를 전송합니다.
메시지는 "/subscribe/rooms/{chatRoomId}" 주소로 전송되며,
이는 해당 채팅방을 구독하는 클라이언트들에게 전달됩니다.
*/
simpMessagingTemplate.convertAndSend("/subscribe/rooms/" + request.chatRoomId(), chatGetResponse);
}

@GetMapping("/chat/{roomId}")
public String chatPage(
@PathVariable("roomId") final int roomId,
final Model model
) {
Long currentMemberId = SecurityUtils.getCurrentMemberId();
model.addAttribute("currentMemberId", currentMemberId);

model.addAttribute("roomId", roomId);

return "chat";
}

@GetMapping("/chat-rooms")
public String chatRoomPage() {
return "chat-rooms";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.programmers.bucketback.domains.chat.api.dto.request;

public record ChatCreateRequest(
String userNickname,
Long chatRoomId,
String message
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.programmers.bucketback.domains.chat.api.dto.response;

import java.time.LocalDateTime;

public record ChatGetResponse(
String message,
String sendUserName,
LocalDateTime createdAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.programmers.bucketback.global.config.chat;

import org.springframework.messaging.Message;
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.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import com.programmers.bucketback.error.ErrorCode;
import com.programmers.bucketback.global.config.security.jwt.JwtService;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.SignatureException;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Component
public class ChatPreHandler implements ChannelInterceptor {

private static final String JWT_PREFIX = "Bearer ";
private static final String AUTH_HEADER_KEY = "Authorization";
private final JwtService jwtService;
private final UserDetailsService userDetailsService;

@Override
public Message<?> preSend(
final Message<?> message,
final org.springframework.messaging.MessageChannel channel
) {
final String authHeader;
final String jwt;
final String memberId;
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

if (accessor == null) {
throw new MalformedJwtException(ErrorCode.MEMBER_NOT_LOGIN.getMessage());
}

if (accessor.getCommand() == StompCommand.CONNECT || accessor.getCommand() == StompCommand.SEND) {
authHeader = accessor.getFirstNativeHeader(AUTH_HEADER_KEY);

if (authHeader == null || !authHeader.startsWith(JWT_PREFIX)) {
throw new MalformedJwtException(ErrorCode.MEMBER_NOT_LOGIN.getMessage());
}

jwt = authHeader.substring(JWT_PREFIX.length());

try {
memberId = jwtService.extractUsername(jwt);

if (memberId == null) {
throw new MalformedJwtException(ErrorCode.BAD_SIGNATURE_JWT.getMessage());
}

final UserDetails userDetails = this.userDetailsService.loadUserByUsername(memberId);

if (jwtService.isTokenValid(jwt, userDetails)) {
final UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
accessor.setUser(authToken);
}

return message;

} catch (SignatureException | ExpiredJwtException e) {
throw new MalformedJwtException(ErrorCode.BAD_SIGNATURE_JWT.getMessage());
}
}
return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.programmers.bucketback.global.config.chat;

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 com.programmers.bucketback.global.error.ChatErrorHandler;

import lombok.RequiredArgsConstructor;

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

private final ChatPreHandler chatPreHandler;

/**
* STOMP 프로토콜을 사용하여 클라이언트와 서버가 메시지를 주고받을 수 있도록 엔드포인트를 등록합니다.
*/
@Override
public void registerStompEndpoints(final StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp") // ws-stomp 엔드포인트를 통해 클라이언트가 서버와 연결할 수 있습니다.
.setAllowedOriginPatterns("*")
.withSockJS(); // withSockJS() 메소드는 SockJS를 사용할 수 있도록 합니다.

registry.setErrorHandler(new ChatErrorHandler());
}

/**
* 클라이언트가 메시지를 구독할 수 있도록 메시지 브로커를 등록합니다.
*/
@Override
public void configureMessageBroker(final MessageBrokerRegistry registry) {
// /subscribe로 시작하는 경로를 구독할 수 있도록 등록합니다.
registry.enableSimpleBroker("/subscribe");

// /app으로 시작하는 경로로 들어오는 메시지를 컨트롤러에서 처리할 수 있도록 등록합니다.
registry.setApplicationDestinationPrefixes("/app");
}

@Override
public void configureClientInboundChannel(final ChannelRegistration registration) {
registration.interceptors(chatPreHandler);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,14 @@ public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws E
.requestMatchers("/api/{nickname}/buckets/**").permitAll()

.requestMatchers("/api/hobbies").permitAll()
.requestMatchers("/js/**").permitAll()
.requestMatchers("/css/**").permitAll()
.requestMatchers("/images/**").permitAll()
.requestMatchers("/api/sse/subscribe").permitAll()
.requestMatchers("/chat/**").permitAll()
.requestMatchers("/chat-rooms").permitAll()

.requestMatchers("/ws-stomp/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.programmers.bucketback.global.error;

import java.nio.charset.StandardCharsets;

import org.springframework.messaging.Message;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler;

import com.programmers.bucketback.error.ErrorCode;

@Component
public class ChatErrorHandler extends StompSubProtocolErrorHandler {

private static final String ERROR_CODE_PREFIX = "errorCode: ";

public ChatErrorHandler() {
super();
}

@Override
public Message<byte[]> handleClientMessageProcessingError(final Message<byte[]> clientMessage, final Throwable ex) {
if (ex.getCause().getMessage().equals(ErrorCode.BAD_SIGNATURE_JWT.getMessage())) {
return prepareErrorMessage(ErrorCode.BAD_SIGNATURE_JWT);
}

return super.handleClientMessageProcessingError(clientMessage, ex);
}

private Message<byte[]> prepareErrorMessage(final ErrorCode errorCode) {

String retErrorCodeMessage = ERROR_CODE_PREFIX + errorCode.getCode();
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);

return MessageBuilder.createMessage(
retErrorCodeMessage.getBytes(StandardCharsets.UTF_8),
accessor.getMessageHeaders()
);
}

}
Loading
Loading