Skip to content

Commit

Permalink
Merge/prod dev pr : 채팅방 재입장 적용한 prod merge PR (#268)
Browse files Browse the repository at this point in the history
* Feat/chat authorization : 일대일 채팅 JWT Authorization 넣기  (#254)

* feat/chat-authorization-1: STOMP 시큐리티 작업

- SecurityWebSockConfig:
- SecurityConfig로 보호되지 않았던 /pub, /sub 보안 잠그기
- WebSockConfig:
- 채팅 발행하기에 앞서 Client에서 보내준 Authorization 값 STOMP Session에 포함시키기 위한 Interceptor 주입

* feat/chat-authorization-2: WebSocketInterceptor 작업

- WebSocketInterceptor:
- CONNCECT -> SEND 모두 authorization을 받아 JWTFilter의 doFilterChain 메서드가 하는 일을 수행함
- 자격증명이 마친다면 StompHeaderAccessor로 회원 이메일을 전달함

* hotfix/chatroom-exit-unsubscribe-1: 기존에 DISCONNECT 했던 채팅방을 UNSUBSCRIBE로 퇴장을 구현함 (#258)

* hotfix/chatroom-exit-unsubscribe-1: 기존에 DISCONNECT 했던 채팅방을 UNSUBSCIRBE로 퇴장을 수정함

* hotfix/chatroom-exit-unsubscribe-2: spotlessapply

* feat(채팅): 더 세밀한 채팅 관련 인증/인가 조절 구현 (#259)

개요

- 현재 SecurityWebSocketConfig에서 HttpSecurity를 사용하고 있는데, 이를
authorizationManager로 바꾸어 STOMP 명령어별로 인증/인가를 설정할 수
있도록 한다.

수정사항

- 누구나 접속해도 되는 CONNECT, UNSUB, DISCONNECT, HEARTBEAT는 풀어놓고
- MESSAGE, SUB은 인증이 될 경우에만 호출할 수 있도록 한다.
- 추가로, SEND /pub, SUBSCRIBE /sub으로의 destination은 무조건 인증된
경우만 접근할 수 있도록
- 이외의 호출은 모두 거절한다.

- csrfChannelInterceptor는 csrf 활성화를 제거하기 위한 작업. (브라우저는
어플에서 사용하지 않으므로 csrf 비활성화)

- Interceptor의 로직 같은 경우, 초기 CONNECT하는 경우에만 JWT로 해당
유저의 인증상태를 확인한다. (만료된 상황에서 확인하는 과정은 다음 PR에서
진행하도록 하겠습니다.)

- authenticated()를 AuthorizationManager에서 사용하려면, 현재 컨텍스트에
setAuthentication을 해주고, 이후의 활용을 위해 accessor.setUser까지
추가해줘야한다. 그래야만 인증이 되었을 때, 문제없이 인증을 할 수 있다.

- 참고로, Websocket 자체에서 인증 인가를, Header에서 simpUser로 진행하기
때문에, 기존에 header에 넣어줬던 userEmail을 제거하고
SocketController의 UserDetails 주입을 통해 해당 인증 정보를 가져올 수
있도록 한다. 기존의 userEmail을 이렇게 변경한 이유는, 웹소켓이 초기에
연결이 성립되고 이후에 메시지보내기 등을 사용하면 기존의 Auth 정보는
header에 저장이 되지만, 우리가 직접 넣어줬던 userEmail은 요청마다 새로
다시 넣어줘야하기 때문이다.

* fix(error): 웹소켓 에러 시에 이유 함께 보낼 수 있도록 처리 (#260)

개요

- 현재 웹소켓의 연결이 끊길 때 원인이 잘 나오지 않는 문제가 있다.
- 에러 발생 시, 원인을 전달하기 위해 StompSubProtocolHandler를 직접
작성한다.
- Interceptor에서 에러 체킹을 추가한다.

수정 사항

- 에러 발생 시, 원인을 전달하기 위해 StompSubProtocolHandler를 직접
  - 빠른 구현을 위해 링크 참고해서 일부만 수정함: https://velog.io/@jkijki12/%EC%B1%84%ED%8C%85-STOMP-JWT
- WebsocketInterceptor에서, JWT가 필요한 Command와 그렇지 않은
Command를 구분해서 알맞게 상황처리를 해준다.
  - CONNECT, SEND, MESSAGE, SUBSCRIBE는 JWT 필요
  - CONNECT의 경우에는 초기 auth 설정해줌
  - SUBSCRIBE의 경우에 destination 체킹 진행
  - SUBSCRIBE 때, 구독 권한이 있는지 확인한다.
- chatroom.getMembers().contains(member) 이 부분에서 Member 인스턴스가
달라도, id로 멤버가 포함되어있는지 체킹이 필요하기 때문에, equals와
hashcode 함께 override (Convention 따라, o 변수 사용)

* refactor/chat-exit-auth-1: 개선된 채팅 인증/인가 적용한 채팅방 퇴장 수정 (#261)

- 기존에 채팅방 퇴장하면 DISCONNECT 했던 것을 UNSUBSCRIBE로 수정
-  messagingTemplate 이용해 UNSUBSCRIBE 메시지 직접 서버에서 전송

chore
- 이전에 DISCONNECT 하지 않았어야 하는 enter 부분 코드 Exception 던지는 것으로 수정
- 불필요한 퇴장 시 빈 채팅방 확인 코드 삭제

* Revert "Feat/chatroom re enter (#264)" (#267)

This reverts commit c75de60.

* Refactor/chatroom re enter (#269)

* feat/chatroom-reEnter-1: 재입장 가능하도록
- 중간 테이블 chatroom_exited_member 생성

재입장 로직:
- EXIT을 하게 되면 chatroom_member에서 member이 삭제되는 것이 아닌 chatroom_exited_member에 member가 추가되는 것임

- 응답 DTO에 퇴장한 회원 정보 표시 (그룹 채팅방에 접속한 회원 수 표시 목적)

* feat/chatroom-reEnter-2: 채팅방 조회 코드 수정 및 chore

- ChatService:
- EXIT:
- 퇴장을 하게 되면 chatroom의 members에서 삭제되는 것이 아닌 exitedMembers에 추가되는 것임
- CHAT:
- 퇴장을 한 회원에게는 채팅 알림이 가지 않아야 하기 때문에 제외 코드를 추가함
- ENTER:
- 그 후 재 입장을 하게 되면 원래 있던 채팅방에 입장해야 하기 때문에 exitedMembers에서 삭제를 진행하며 입장 알림이 가도록 함

- ChatroomService:
- GET 채팅방 조회시에 퇴장한 채팅방은 포함되지 않아야 하기 때문에 filter 코드 추가함
- 퇴장한 채팅방 조회도 가능하도록 추가
- GET : /api/chatrooms?type=EXITED

chore
- 방장인 채팅방 조회:
- GET : /api/chatrooms?type=MANAGER
- Test 통과를 위한 JVM 크기 2GB로 늘림
- SWAGGER 반영 완료

* feat/chatroom-reEnter-3: 퇴장한 채팅방 접근 강화

* feat/chatroom-reEnter-4: build messaging 추가

---------

Co-authored-by: Seungho Lee <[email protected]>
  • Loading branch information
KooSuYeon and seungholee-dev authored Oct 18, 2024
1 parent 47cc7c9 commit bdfebb0
Show file tree
Hide file tree
Showing 17 changed files with 418 additions and 54 deletions.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dependencies {
testImplementation 'org.testcontainers:localstack'
testImplementation 'org.testcontainers:junit-jupiter'
implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.19.0'
implementation 'org.springframework.security:spring-security-messaging'

implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
Expand All @@ -75,5 +76,6 @@ dependencies {

tasks.named('test') {
useJUnitPlatform()
jvmArgs '-Xmx2048m'
exclude '**/MemberControllerTest.*'
}
37 changes: 37 additions & 0 deletions src/main/java/com/dife/api/config/SecurityWebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.dife.api.config;

import static org.springframework.messaging.simp.SimpMessageType.*;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity;
import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;

@Configuration
@EnableWebSocketSecurity
public class SecurityWebSocketConfig {
@Bean
AuthorizationManager<Message<?>> authorizationManager(
MessageMatcherDelegatingAuthorizationManager.Builder messages) {
return messages
.simpTypeMatchers(CONNECT, UNSUBSCRIBE, DISCONNECT, HEARTBEAT)
.permitAll()
.simpMessageDestMatchers("/pub/**")
.authenticated()
.simpSubscribeDestMatchers("/sub/**")
.authenticated()
.simpTypeMatchers(MESSAGE, SUBSCRIBE)
.authenticated()
.anyMessage()
.denyAll()
.build();
}

@Bean("csrfChannelInterceptor")
ChannelInterceptor csrfChannelInterceptor() {
return new ChannelInterceptor() {};
}
}
12 changes: 11 additions & 1 deletion src/main/java/com/dife/api/config/WebSockConfig.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.dife.api.config;

import com.dife.api.exception.StompExceptionHandler;
import lombok.RequiredArgsConstructor;
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.*;

Expand All @@ -10,14 +12,22 @@
@EnableWebSocketMessageBroker
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {

private final StompExceptionHandler stompExceptionHandler;
private final WebSocketInterceptor webSocketInterceptor;

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*");
registry.setErrorHandler(stompExceptionHandler).addEndpoint("/ws").setAllowedOrigins("*");
}

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/sub");
config.setApplicationDestinationPrefixes("/pub");
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketInterceptor);
}
}
110 changes: 110 additions & 0 deletions src/main/java/com/dife/api/config/WebSocketInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.dife.api.config;

import static org.springframework.messaging.simp.stomp.StompCommand.*;

import com.dife.api.jwt.JWTUtil;
import com.dife.api.model.Chatroom;
import com.dife.api.model.Member;
import com.dife.api.model.dto.CustomUserDetails;
import com.dife.api.repository.MemberRepository;
import com.dife.api.service.ChatroomService;
import com.dife.api.service.MemberService;
import jakarta.security.auth.message.AuthException;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
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;

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

private final JWTUtil jwtUtil;
private final MemberRepository memberRepository;
private final MemberService memberService;
private final ChatroomService chatroomService;

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

Set<StompCommand> jwtExpiredCheckCommands = Set.of(CONNECT, SEND, MESSAGE, SUBSCRIBE);
StompCommand currentCommand = accessor.getCommand();

if (jwtExpiredCheckCommands.contains(currentCommand)) {
String authToken = accessor.getFirstNativeHeader("authorization");
String jwtToken = extractJwtToken(authToken);

if (!isValidJwtToken(jwtToken)) {
throw new AuthException("손상된 토큰입니다! 다시 로그인 하세요!");
}

Long memberId = jwtUtil.getId(jwtToken);
Member member = memberService.getMemberEntityById(memberId);
if (!memberService.isValidMember(member)) {
throw new AuthException("인증이 필요한 회원입니다!");
}
if (currentCommand.equals(CONNECT)) {
setAuthentication(member, accessor);
}

String destination = accessor.getDestination();

if (currentCommand.equals(SUBSCRIBE)) {
if (!isValidSubscriptionDestination(destination)) {
throw new AuthException(
"Unauthorized subscription to " + destination + ". Only chatrooms are allowed.");
}
Long chatroomId = parseChatroomIdFromDestination(destination);
Chatroom chatroom = chatroomService.getChatroomById(chatroomId);
if (!isMemberAllowedToSubscribe(member, chatroom)) {
throw new AuthException("채팅방 메시지 Subscribe 권한이 없습니다.!");
}
}
}
return message;
}

private boolean isValidSubscriptionDestination(String destination) {
return destination != null && destination.startsWith("/sub/chatroom/");
}

private boolean isMemberAllowedToSubscribe(Member member, Chatroom chatroom) {
return chatroomService.isMemberInChatroom(member, chatroom);
}

private Long parseChatroomIdFromDestination(String destination) {
return Long.parseLong(destination.split("/")[3]);
}

private String extractJwtToken(String authToken) {
return authToken.split(" ")[1];
}

private boolean isValidJwtToken(String jwtToken) {
return jwtToken != null && !jwtUtil.isExpired(jwtToken);
}

private void setAuthentication(Member member, StompHeaderAccessor accessor) {
CustomUserDetails customUserDetails = new CustomUserDetails(member);
Authentication authentication =
new UsernamePasswordAuthenticationToken(
customUserDetails, null, customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
accessor.setUser(authentication);
}
}
9 changes: 7 additions & 2 deletions src/main/java/com/dife/api/controller/SocketController.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.*;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.RestController;

@RestController
Expand All @@ -17,8 +19,11 @@ public class SocketController {
private final ChatService chatService;

@MessageMapping("/chatroom/chat")
public void sendMessage(ChatRequestDto dto, SimpMessageHeaderAccessor headerAccessor)
public void sendMessage(
ChatRequestDto dto,
SimpMessageHeaderAccessor headerAccessor,
@AuthenticationPrincipal UserDetails userDetails)
throws JsonProcessingException {
chatService.sendMessage(dto, headerAccessor);
chatService.sendMessage(dto, headerAccessor, userDetails);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ ResponseEntity<Void> kickoutMember(
@Operation(
summary = "채팅방 전체 조회 API",
description =
"조회하고자 하는 채팅방 타입(그룹/싱글)을 입력해 속한 채팅방들을 조회하는 API입니다. 그룹의 경우 그냥 GROUP만 입력값으로 넣고 싱글의 경우 SINGLE과 더불어 조회하고자 하는 상대방의 id를 입력값으로 넣어야 합니다.")
"조회하고자 하는 채팅방 타입(GROUP/SINGLE/EXITED/MANAGER)을 입력해 속한 채팅방들을 조회하는 API입니다. 그룹의 경우 그냥 GROUP만 입력값으로 넣고 싱글의 경우 SINGLE과 더불어 조회하고자 하는 상대방의 id를 입력값으로 넣어야 합니다.")
@ApiResponse(
responseCode = "200",
description = "채팅방 조회 성공 예시",
Expand Down
78 changes: 78 additions & 0 deletions src/main/java/com/dife/api/exception/StompExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.dife.api.exception;

import java.nio.charset.StandardCharsets;
import java.util.Objects;
import org.springframework.http.HttpStatus;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageDeliveryException;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler;

@Component
public class StompExceptionHandler extends StompSubProtocolErrorHandler {
private static final byte[] EMPTY_PAYLOAD = new byte[0];

public StompExceptionHandler() {
super();
}

@Override
public Message<byte[]> handleClientMessageProcessingError(
Message<byte[]> clientMessage, Throwable ex) {
final Throwable exception = converterThrowException(ex);
if (exception instanceof Exception) {
return handleUnauthorizedException(clientMessage, exception);
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}

private Throwable converterThrowException(final Throwable exception) {
if (exception instanceof MessageDeliveryException) {
return exception.getCause();
}
return exception;
}

private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage, Throwable ex) {
return prepareErrorMessage(clientMessage, ex.getMessage(), HttpStatus.UNAUTHORIZED.name());
}

private Message<byte[]> prepareErrorMessage(
final Message<byte[]> clientMessage, final String message, final String errorCode) {
final StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(errorCode);
accessor.setLeaveMutable(true);
setReceiptIdForClient(clientMessage, accessor);
return MessageBuilder.createMessage(
message != null ? message.getBytes(StandardCharsets.UTF_8) : EMPTY_PAYLOAD,
accessor.getMessageHeaders());
}

private void setReceiptIdForClient(
final Message<byte[]> clientMessage, final StompHeaderAccessor accessor) {
if (Objects.isNull(clientMessage)) {
return;
}

final StompHeaderAccessor clientHeaderAccessor =
MessageHeaderAccessor.getAccessor(clientMessage, StompHeaderAccessor.class);
final String receiptId =
Objects.isNull(clientHeaderAccessor) ? null : clientHeaderAccessor.getReceipt();
if (receiptId != null) {
accessor.setReceiptId(receiptId);
}
}

@Override
protected Message<byte[]> handleInternal(
StompHeaderAccessor errorHeaderAccessor,
byte[] errorPayload,
Throwable cause,
StompHeaderAccessor clientHeaderAccessor) {
return MessageBuilder.createMessage(errorPayload, errorHeaderAccessor.getMessageHeaders());
}
}
9 changes: 0 additions & 9 deletions src/main/java/com/dife/api/handler/DisconnectHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,6 @@ public boolean canEnterChatroom(
return true;
}

public boolean isExitDisconnectChecked(Chatroom chatroom, String sessionId) {

if (isEmpty(chatroom)) {
disconnect(chatroom.getId(), sessionId);
return false;
}
return true;
}

private boolean isValidGroupChatroom(Chatroom chatroom, String password) {
return isGroupChatroom(chatroom) && !isRestrictedGroupChatroom(chatroom, password);
}
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/com/dife/api/model/Chatroom.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public class Chatroom extends BaseTimeEntity {
@JsonIgnore
private Set<Member> members = new HashSet<>();

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "chatroom_exited_members",
joinColumns = @JoinColumn(name = "chatroom_id"),
inverseJoinColumns = @JoinColumn(name = "member_id"))
private Set<Member> exitedMembers = new HashSet<>();

@OneToMany(mappedBy = "chatroom", fetch = FetchType.EAGER, cascade = CascadeType.ALL)
@JsonIgnore
private Set<Chat> chats = new HashSet<>();
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/dife/api/model/ChatroomType.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@

public enum ChatroomType {
GROUP,
SINGLE
SINGLE,
EXITED,
MANAGER
}
17 changes: 17 additions & 0 deletions src/main/java/com/dife/api/model/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ public class Member extends BaseTimeEntity {
@JsonIgnore
private Set<Chatroom> chatrooms = new HashSet<>();

@ManyToMany(mappedBy = "exitedMembers") // Chatroom 엔티티의 exitedMembers와 일치시킴
@JsonIgnore
private Set<Chatroom> exitedChatrooms = new HashSet<>();

@OneToMany(mappedBy = "manager")
@JsonIgnore
private Set<Chatroom> managingChatrooms = new HashSet<>();
Expand Down Expand Up @@ -119,4 +123,17 @@ public class Member extends BaseTimeEntity {
inverseJoinColumns = @JoinColumn(name = "likelisted_member_id"))
@JsonIgnore
private List<Member> likeList = new ArrayList<>();

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Member member = (Member) o;
return Objects.equals(id, member.id);
}

@Override
public int hashCode() {
return Objects.hash(id); // Use ID for hashing
}
}
2 changes: 2 additions & 0 deletions src/main/java/com/dife/api/model/dto/ChatroomResponseDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface ChatroomResponseDto {
ChatroomType getChatroomType();

Set<MemberRestrictedResponseDto> getMembers();

Set<MemberRestrictedResponseDto> getExitedMembers();
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public class GroupChatroomResponseDto implements ChatroomResponseDto {

private Set<MemberRestrictedResponseDto> members = new HashSet<>();

private Set<MemberRestrictedResponseDto> exitedMembers = new HashSet<>();

private MemberRestrictedResponseDto manager;

private String name;
Expand Down
Loading

0 comments on commit bdfebb0

Please sign in to comment.