Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.27.2'
// OAuth
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client:3.5.0'
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ public enum ErrorCode {
AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.FORBIDDEN, "A-004", "AI 이미지 생성 권한이 없습니다."),
AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "A-005", "티켓 처리에 실패했습니다."),
AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION(HttpStatus.BAD_REQUEST, "AI-006", "티켓이 부족합니다."),

// Chat
CHAT_ROOM_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "CH-001", "해당 채팅방을 찾을 수 없습니다."),
CHAT_MESSAGE_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "CH-002", "해당 채팅 메시지를 찾을 수 없습니다."),
CHAT_SELF_ROOM_CREATION_EXCEPTION(HttpStatus.BAD_REQUEST, "CH-003", "자기 자신과는 채팅방을 만들 수 없습니다."),
CHAT_ROOM_ACCESS_DENIED_EXCEPTION(HttpStatus.FORBIDDEN, "CH-004", "해당 채팅방에 접근할 권한이 없습니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package hanium.modic.backend.common.websocket.config;

import org.springframework.context.annotation.Bean;
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 hanium.modic.backend.common.jwt.JwtTokenProvider;
import hanium.modic.backend.common.websocket.interceptor.StompChannelInterceptor;
import hanium.modic.backend.common.websocket.interceptor.WebSocketAuthInterceptor;
import lombok.RequiredArgsConstructor;

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

private final JwtTokenProvider jwtTokenProvider;
private final StompChannelInterceptor stompChannelInterceptor;

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic"); // 브로커가 발행한 메세지를 클라이언트가 구독
config.setApplicationDestinationPrefixes("/app"); // 클라이언트가 보낼 메세지를 Controller로 전달
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*") // TODO: 실제 도메인으로 변경 필요
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using "*" for allowed origins is a significant security risk as it allows any website to connect to your WebSocket endpoint. This can lead to Cross-Site WebSocket Hijacking (CSWH) attacks. For production environments, you should restrict this to a specific list of allowed domains for your frontend application.

.addInterceptors(webSocketAuthInterceptor()) // 핸드세이크 과정 인터셉터, 인증용
.withSockJS();
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompChannelInterceptor);
}

@Bean
public WebSocketAuthInterceptor webSocketAuthInterceptor() {
return new WebSocketAuthInterceptor(jwtTokenProvider);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package hanium.modic.backend.common.websocket.interceptor;

import static hanium.modic.backend.common.error.ErrorCode.*;

import java.security.Principal;
import java.util.Optional;

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.stereotype.Component;

import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.jwt.JwtTokenProvider;
import hanium.modic.backend.domain.user.entity.UserEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class StompChannelInterceptor implements ChannelInterceptor {

private final JwtTokenProvider jwtTokenProvider;

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

// STOMP CONNECT 명령어가 아닌 경우, 메시지를 그대로 반환
if (accessor == null || !StompCommand.CONNECT.equals(accessor.getCommand())) {
return message;
}

// Authorization 헤더에서 Bearer 토큰 추출
String token = Optional.ofNullable(accessor.getFirstNativeHeader("Authorization"))
.filter(auth -> auth.startsWith("Bearer "))
.map(auth -> auth.substring(7))
.orElse(null);

if (token == null) {
log.warn("StompChannelInterceptor - STOMP 연결 실패 - Authorization 헤더 누락 또는 형식 오류");
return null;
}
Comment on lines +45 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

인증 실패 시 더 명확한 에러 처리 필요

null을 반환하는 대신 MessageHandlingException을 던져 클라이언트에게 명확한 에러 메시지를 전달하는 것이 좋습니다.

 if (token == null) {
     log.warn("StompChannelInterceptor - STOMP 연결 실패 - Authorization 헤더 누락 또는 형식 오류");
-    return null;
+    throw new MessageHandlingException("인증 토큰이 필요합니다.");
 }
 } catch (Exception e) {
     log.warn("StompChannelInterceptor - STOMP 연결 인증 실패: {}", e.getMessage());
-    return null;
+    throw new MessageHandlingException("인증에 실패했습니다: " + e.getMessage());
 }

Also applies to: 62-65

🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/common/websocket/interceptor/StompChannelInterceptor.java
at lines 45-48 and similarly at 62-65, instead of returning null when
authentication fails due to missing or malformed Authorization header, throw a
MessageHandlingException with a clear error message. This change will provide
the client with a precise error notification rather than silently returning
null.


// 토큰 유효성 검사 및 사용자 정보 추출
try {
jwtTokenProvider.validateToken(token);

UserEntity user = jwtTokenProvider.getUser(token)
.orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION));

Principal principal = new UsernamePasswordAuthenticationToken(user.getId().toString(), null);
accessor.setUser(principal);

log.debug("StompChannelInterceptor - STOMP 연결 인증 성공 - 사용자 ID: {}", user.getId());
return message;
} catch (Exception e) {
log.warn("StompChannelInterceptor - STOMP 연결 인증 실패: {}", e.getMessage());
return null;
Comment on lines +62 to +64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching the generic Exception is too broad. It can mask underlying issues and make debugging more difficult because it catches both checked and unchecked exceptions. It's better to catch more specific exceptions that you expect from the token validation logic, such as AppException or specific exceptions from the JWT library. This will lead to more robust error handling and clearer logs.

}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package hanium.modic.backend.common.websocket.interceptor;

import static hanium.modic.backend.common.error.ErrorCode.*;

import java.security.Principal;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

사용하지 않는 import 제거

사용하지 않는 Principal import와 중복된 ErrorCode import를 제거하세요.

-import java.security.Principal;
 import java.util.Map;

 import org.springframework.http.server.ServerHttpRequest;
 import org.springframework.http.server.ServerHttpResponse;
 import org.springframework.web.socket.WebSocketHandler;
 import org.springframework.web.socket.server.HandshakeInterceptor;

-import hanium.modic.backend.common.error.ErrorCode;
 import hanium.modic.backend.common.error.exception.AppException;

Also applies to: 13-13

🤖 Prompt for AI Agents
In
src/main/java/hanium/modic/backend/common/websocket/interceptor/WebSocketAuthInterceptor.java
at lines 5 and 13, remove the unused import of java.security.Principal and the
duplicated import of ErrorCode to clean up the imports and avoid redundancy.

import java.util.Map;

import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import hanium.modic.backend.common.error.ErrorCode;
import hanium.modic.backend.common.error.exception.AppException;
import hanium.modic.backend.common.jwt.JwtTokenProvider;
import hanium.modic.backend.domain.user.entity.UserEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RequiredArgsConstructor
public class WebSocketAuthInterceptor implements HandshakeInterceptor {

private final JwtTokenProvider jwtTokenProvider;

// WebSocket 핸드세이킹 허용 여부 결정 메서드
@Override
public boolean beforeHandshake(
ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes
) throws Exception {
String token = extractTokenFromRequest(request);

if (token != null) {
try {
jwtTokenProvider.validateToken(token);

UserEntity user = jwtTokenProvider.getUser(token)
.orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION));
attributes.put("userId", user.getId());
log.debug("WebSocket 연결 인증 성공 - 사용자 ID: {}", user.getId());
return true;
} catch (Exception e) {
log.error("WebSocket 토큰 파싱 실패: {}", e.getMessage());
}
Comment on lines +45 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the StompChannelInterceptor, catching a generic Exception is too broad. This can hide unexpected runtime exceptions and makes it harder to distinguish between different types of errors (e.g., an expired token vs. a malformed token). Please catch more specific exceptions to improve error handling and logging.

}

log.warn("WebSocket 연결 인증 실패");
return false;
}

@Override
public void afterHandshake(
ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Exception exception
) {
// 핸드셰이크 후 처리 로직
}

// 추출된 토큰을 요청에서 가져오는 메서드
private String extractTokenFromRequest(ServerHttpRequest request) {
// 헤더에서 Bearer 토큰 추출 시도
String token = request.getHeaders().getFirst("Authorization");
if (token != null && token.startsWith("Bearer ")) {
return token.substring(7);
}

// 실패 시 쿼리 파라미터에서 token 추출
String queryToken = request.getURI().getQuery();
if (queryToken != null && queryToken.contains("token=")) {
String[] params = queryToken.split("&");
for (String param : params) {
if (param.startsWith("token=")) {
return param.substring(6);
}
}
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package hanium.modic.backend.domain.chat.entity;

import hanium.modic.backend.common.entity.BaseEntity;
import hanium.modic.backend.domain.user.entity.UserEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(name = "chat_messages")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatMessageEntity extends BaseEntity {

@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "message_id", unique = true, nullable = false)
private String messageId;

@Column(name = "chatRoom_id", nullable = false)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

컬럼명 네이밍 일관성 문제

chatRoom_id는 카멜케이스와 스네이크케이스가 혼재되어 있습니다. 다른 컬럼들과 일관성을 유지하기 위해 chat_room_id로 수정하세요.

-	@Column(name = "chatRoom_id", nullable = false)
+	@Column(name = "chat_room_id", nullable = false)
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/chat/entity/ChatMessageEntity.java
at line 34, the column name "chatRoom_id" mixes camelCase and snake_case,
causing inconsistency. Rename the column to "chat_room_id" to maintain
consistent snake_case naming with other columns.

private Long chatRoomId;

@Column(name = "sender_id", nullable = false)
private Long senderId;

@Lob
@Column(name = "message", nullable = false)
private String message;

@Column(name = "is_read", nullable = false)
private Boolean isRead = false;

@Builder
private ChatMessageEntity(String messageId, ChatRoomEntity chatRoom, UserEntity sender, String message) {
this.messageId = messageId;
this.chatRoomId = chatRoom.getId();
this.senderId = sender.getId();
this.message = message;
this.isRead = false;
}

// 메시지 읽음 처리
public void markAsRead() {
this.isRead = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package hanium.modic.backend.domain.chat.entity;

import hanium.modic.backend.common.entity.BaseEntity;
import hanium.modic.backend.domain.user.entity.UserEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(name = "chat_rooms")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoomEntity extends BaseEntity {

@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "user1_id", nullable = false)
private Long user1Id;

@Column(name = "user2_id", nullable = false)
private Long user2Id;

@Column(name = "user1_deleted", nullable = false)
private Boolean user1Deleted = false;

@Column(name = "user2_deleted", nullable = false)
private Boolean user2Deleted = false;

@Builder
private ChatRoomEntity(UserEntity user1, UserEntity user2) {
this.user1Id = user1.getId();
this.user2Id = user2.getId();
this.user1Deleted = false;
this.user2Deleted = false;
}
Comment on lines +39 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

자기 자신과의 채팅방 생성 방지 필요

Builder에서 같은 사용자끼리 채팅방을 만드는 것을 방지하는 검증이 필요합니다.

 @Builder
 private ChatRoomEntity(UserEntity user1, UserEntity user2) {
+    if (user1.getId().equals(user2.getId())) {
+        throw new IllegalArgumentException("같은 사용자끼리 채팅방을 만들 수 없습니다.");
+    }
     this.user1Id = user1.getId();
     this.user2Id = user2.getId();
     this.user1Deleted = false;
     this.user2Deleted = false;
 }
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/chat/entity/ChatRoomEntity.java
between lines 39 and 45, add validation in the builder constructor to check if
user1 and user2 are the same user by comparing their IDs. If they are the same,
throw an IllegalArgumentException to prevent creating a chat room with the same
user.


// 유저 소프트 삭제
public void deleteForUser(Long userId) {
if (user1Id.equals(userId)) {
this.user1Deleted = true;
} else if (user2Id.equals(userId)) {
this.user2Deleted = true;
}
}

// 상대방 유저 반환
public Long getOpponent(Long userId) {
if (user1Id.equals(userId)) {
return user2Id;
}
return user1Id;
}
Comment on lines +56 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

getOpponent 메서드 개선 필요

userId가 채팅방 참여자가 아닌 경우에 대한 처리가 필요합니다.

 public Long getOpponent(Long userId) {
     if (user1Id.equals(userId)) {
         return user2Id;
+    } else if (user2Id.equals(userId)) {
+        return user1Id;
+    } else {
+        throw new IllegalArgumentException("사용자가 이 채팅방의 참여자가 아닙니다.");
     }
-    return user1Id;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 상대방 유저 반환
public Long getOpponent(Long userId) {
if (user1Id.equals(userId)) {
return user2Id;
}
return user1Id;
}
// 상대방 유저 반환
public Long getOpponent(Long userId) {
if (user1Id.equals(userId)) {
return user2Id;
} else if (user2Id.equals(userId)) {
return user1Id;
} else {
throw new IllegalArgumentException("사용자가 이 채팅방의 참여자가 아닙니다.");
}
}
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/chat/entity/ChatRoomEntity.java
between lines 56 and 62, the getOpponent method lacks handling for cases where
the provided userId is not a participant in the chat room. Modify the method to
check if userId matches either user1Id or user2Id; if it does not, throw an
appropriate exception or return a null/optional value to indicate invalid input,
ensuring the method handles non-participant userIds safely.


// 채팅방을 모든 유저가 삭제했는지 확인
public boolean isDeleted() {
return user1Deleted && user2Deleted;
}

// 특정 유저가 채팅방을 삭제했는지 확인
public boolean isDeletedForUser(Long userId) {
if (user1Id.equals(userId)) {
return user1Deleted;
} else if (user2Id.equals(userId)) {
return user2Deleted;
}
return false;
}
}
Loading