-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge/prod dev pr : 채팅방 재입장 적용한 prod merge PR (#268)
* 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
1 parent
47cc7c9
commit bdfebb0
Showing
17 changed files
with
418 additions
and
54 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
37 changes: 37 additions & 0 deletions
37
src/main/java/com/dife/api/config/SecurityWebSocketConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() {}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
110 changes: 110 additions & 0 deletions
110
src/main/java/com/dife/api/config/WebSocketInterceptor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
78 changes: 78 additions & 0 deletions
78
src/main/java/com/dife/api/exception/StompExceptionHandler.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,5 +2,7 @@ | |
|
||
public enum ChatroomType { | ||
GROUP, | ||
SINGLE | ||
SINGLE, | ||
EXITED, | ||
MANAGER | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.