diff --git a/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java b/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java index 868ae78..0ecbafb 100644 --- a/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java +++ b/src/main/java/com/rabbitmqprac/application/controller/ChatMessageController.java @@ -9,7 +9,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +28,6 @@ public class ChatMessageController implements ChatMessageApi { * Destination Queue: /pub/chat.message.{chatRoomId}를 통해 호출 후 처리 되는 로직 */ @Override - @PreAuthorize("#chatRoomAccessChecker.hasPermission(#chatRoomId, principal)") @MessageMapping("chat.room.{chatRoomId}/message") public void sendMessage(UserPrincipal principal, @DestinationVariable Long chatRoomId, diff --git a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java index 1301cc2..b8a5734 100644 --- a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java +++ b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java @@ -14,6 +14,7 @@ import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -39,6 +40,7 @@ @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity +@EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; diff --git a/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java b/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java index 0c6d8e8..284e4bc 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java +++ b/src/main/java/com/rabbitmqprac/domain/context/chatmessage/service/ChatMessageService.java @@ -16,6 +16,7 @@ import com.rabbitmqprac.domain.persistence.usersession.entity.UserStatus; import com.rabbitmqprac.global.helper.RabbitPublisher; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +35,7 @@ public class ChatMessageService { private final ChatMessageRepository chatMessageRepository; private final RabbitPublisher rabbitPublisher; + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId, #userId)") @Transactional public void sendMessage(Long userId, Long chatRoomId, ChatMessageReq req) { User user = entityFacade.readUser(userId); @@ -45,6 +47,7 @@ public void sendMessage(Long userId, Long chatRoomId, ChatMessageReq req) { sendMessage(chatMessage, unreadMemberCnt, chatRoom); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public List readChatMessagesBefore(Long chatRoomId, Long lastChatMessageId, int size) { List chatMessages = chatMessageRepository.findByChatRoomIdBefore( @@ -137,16 +140,19 @@ private void sendMessage(ChatMessage chatMessage, int unreadMemberCnt, ChatRoom rabbitPublisher.publish(chatRoom.getId(), chatMessageRes); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public Optional readLastChatMessage(Long chatRoomId) { return chatMessageRepository.findTopByChatRoomIdOrderByCreatedAtDesc(chatRoomId); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public int countUnreadMessages(Long chatRoomId, Long lastReadMessageId) { return chatMessageRepository.countByChatRoomIdAndIdGreaterThan(chatRoomId, lastReadMessageId); } + @PreAuthorize("@chatRoomMemberAuthorityValidator.isMember(#chatRoomId)") @Transactional(readOnly = true) public List readChatMessagesBetween(Long userId, Long chatRoomId, Long from, Long to) { List chatMessages = chatMessageRepository.findByChatRoomIdAndIdBetween(chatRoomId, from, to); diff --git a/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java b/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java index 1360eab..a8af4ef 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java +++ b/src/main/java/com/rabbitmqprac/domain/context/user/exception/UserErrorCode.java @@ -9,6 +9,9 @@ @RequiredArgsConstructor public enum UserErrorCode implements BaseErrorCode { + /* 403 FORBIDDEN */ + FORBIDDEN(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "사용자에게 권한이 없습니다."), + /* 404 NOT FOUND */ NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "회원을 찾을 수 없습니다."), CONFLICT_USERNAME(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 유저 아이디입니다"); diff --git a/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java b/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java index d230644..318fe2c 100644 --- a/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java +++ b/src/main/java/com/rabbitmqprac/global/advice/GlobalExceptionAdvice.java @@ -1,6 +1,7 @@ package com.rabbitmqprac.global.advice; import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; import com.rabbitmqprac.global.exception.CustomValidationException; import com.rabbitmqprac.global.exception.GlobalErrorException; import com.rabbitmqprac.global.exception.payload.CausedBy; @@ -14,6 +15,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.validation.BindingResult; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -126,6 +128,19 @@ protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) { return ErrorResponse.of(causedBy.getCode(), causedBy.getReason(), e.getMessage()); } + /** + * @PreAuthorize 에서 검증 실패 시 발생하는 AuthorizationDeniedException 처리 + */ + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler(AuthorizationDeniedException.class) + protected ErrorResponse handleAuthorizationDeniedException(AuthorizationDeniedException e) { + log.warn("handleAuthorizationDeniedException : {}", e.getMessage()); + UserErrorCode errorCode = UserErrorCode.FORBIDDEN; + CausedBy causedBy = errorCode.causedBy(); + + return ErrorResponse.of(causedBy.getCode(), causedBy.getReason(), errorCode.getExplainError()); + } + /** * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우 * diff --git a/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/AuthorityValidator.java b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/AuthorityValidator.java new file mode 100644 index 0000000..4681ab6 --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/AuthorityValidator.java @@ -0,0 +1,16 @@ +package com.rabbitmqprac.infra.security.authorityvalidator; + +import com.rabbitmqprac.domain.context.user.exception.UserErrorCode; +import com.rabbitmqprac.domain.context.user.exception.UserErrorException; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import org.springframework.security.core.Authentication; + +public abstract class AuthorityValidator { + protected SecurityUserDetails isAuthenticated(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + throw new UserErrorException(UserErrorCode.FORBIDDEN); + } + + return (SecurityUserDetails) authentication.getPrincipal(); + } +} diff --git a/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/ChatRoomMemberAuthorityValidator.java b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/ChatRoomMemberAuthorityValidator.java new file mode 100644 index 0000000..af4b7cf --- /dev/null +++ b/src/main/java/com/rabbitmqprac/infra/security/authorityvalidator/ChatRoomMemberAuthorityValidator.java @@ -0,0 +1,29 @@ +package com.rabbitmqprac.infra.security.authorityvalidator; + +import com.rabbitmqprac.domain.context.chatroommember.service.ChatRoomMemberService; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component("chatRoomMemberAuthorityValidator") +@RequiredArgsConstructor +public class ChatRoomMemberAuthorityValidator extends AuthorityValidator { + private final ChatRoomMemberService chatRoomMemberService; + + public boolean isMember(Long chatRoomId) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + SecurityUserDetails userDetails = super.isAuthenticated(authentication); + Long currentUserId = userDetails.getUserId(); + + return chatRoomMemberService.isExists(chatRoomId, currentUserId); + } + + /** + * STOMP는 HTTP와 달리 SecurityContextHolder에서 인증 정보를 가져올 수 없으므로, userId를 직접 받아서 검증한다. + */ + public boolean isMember(Long chatRoomId, Long userId) { + return chatRoomMemberService.isExists(chatRoomId, userId); + } +} diff --git a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessRegistry.java b/src/main/java/com/rabbitmqprac/infra/security/registry/ResourceCheckerRegistry.java similarity index 74% rename from src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessRegistry.java rename to src/main/java/com/rabbitmqprac/infra/security/registry/ResourceCheckerRegistry.java index ba7903e..5ff3990 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessRegistry.java +++ b/src/main/java/com/rabbitmqprac/infra/security/registry/ResourceCheckerRegistry.java @@ -1,12 +1,11 @@ -package com.rabbitmqprac.infra.security.common.registry; +package com.rabbitmqprac.infra.security.registry; -import com.rabbitmqprac.infra.stomp.exception.StompErrorCode; -import com.rabbitmqprac.infra.stomp.exception.StompErrorException; +import com.rabbitmqprac.infra.security.registry.checker.ChatRoomAccessChecker; +import com.rabbitmqprac.infra.security.registry.checker.StompAuthorityChecker; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import javax.swing.text.html.Option; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -18,8 +17,8 @@ */ @RequiredArgsConstructor @Component -public class ResourceAccessRegistry { - private final Map checkers = new HashMap<>(); +public class ResourceCheckerRegistry { + private final Map checkers = new HashMap<>(); private final ChatRoomAccessChecker chatRoomAccessChecker; @PostConstruct @@ -27,7 +26,7 @@ public void setCheckers() { registerChecker("^/exchange/chat\\.exchange/room\\.\\d+$", chatRoomAccessChecker); } - public void registerChecker(final String pathPattern, final ResourceAccessChecker checker) { + public void registerChecker(final String pathPattern, final StompAuthorityChecker checker) { checkers.put(Pattern.compile(pathPattern), checker); } @@ -38,7 +37,7 @@ public void registerChecker(final String pathPattern, final ResourceAccessChecke * @return ResourceAccessChecker : path에 대한 체커 * @throws IllegalArgumentException : 해당 경로에 대한 체커가 없는 경우 */ - public Optional getChecker(final String path) { + public Optional getChecker(final String path) { return checkers.entrySet().stream() .filter(entry -> entry.getKey().matcher(path).matches()) .map(Map.Entry::getValue) diff --git a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ChatRoomAccessChecker.java b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/ChatRoomAccessChecker.java similarity index 85% rename from src/main/java/com/rabbitmqprac/infra/security/common/registry/ChatRoomAccessChecker.java rename to src/main/java/com/rabbitmqprac/infra/security/registry/checker/ChatRoomAccessChecker.java index a1adaae..0937f6b 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ChatRoomAccessChecker.java +++ b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/ChatRoomAccessChecker.java @@ -1,4 +1,4 @@ -package com.rabbitmqprac.infra.security.common.registry; +package com.rabbitmqprac.infra.security.registry.checker; import com.rabbitmqprac.domain.context.chatroommember.service.ChatRoomMemberService; import lombok.RequiredArgsConstructor; @@ -10,7 +10,7 @@ @Slf4j @Component("chatRoomAccessChecker") @RequiredArgsConstructor -public class ChatRoomAccessChecker implements ResourceAccessChecker { +public class ChatRoomAccessChecker implements StompAuthorityChecker { private final ChatRoomMemberService chatRoomMemberService; @Override diff --git a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessChecker.java b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/StompAuthorityChecker.java similarity index 78% rename from src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessChecker.java rename to src/main/java/com/rabbitmqprac/infra/security/registry/checker/StompAuthorityChecker.java index ced40b2..c8963bf 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/common/registry/ResourceAccessChecker.java +++ b/src/main/java/com/rabbitmqprac/infra/security/registry/checker/StompAuthorityChecker.java @@ -1,11 +1,11 @@ -package com.rabbitmqprac.infra.security.common.registry; +package com.rabbitmqprac.infra.security.registry.checker; import java.security.Principal; /** * 리소스 접근 권한을 확인하는 인터페이스 */ -public interface ResourceAccessChecker { +public interface StompAuthorityChecker { /** * 리소스에 대한 접근 권한을 확인한다. * diff --git a/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java b/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java index 0debc03..8e5e960 100644 --- a/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java +++ b/src/main/java/com/rabbitmqprac/infra/stomp/handler/command/subscribe/ChatRoomAuthorizeHandler.java @@ -1,7 +1,7 @@ package com.rabbitmqprac.infra.stomp.handler.command.subscribe; import com.rabbitmqprac.domain.context.usersession.service.UserSessionService; -import com.rabbitmqprac.infra.security.common.registry.ResourceAccessRegistry; +import com.rabbitmqprac.infra.security.registry.ResourceCheckerRegistry; import com.rabbitmqprac.infra.stomp.exception.StompErrorCode; import com.rabbitmqprac.infra.stomp.exception.StompErrorException; import lombok.RequiredArgsConstructor; @@ -14,7 +14,7 @@ @Component @RequiredArgsConstructor public class ChatRoomAuthorizeHandler implements SubscribeCommandHandler { - private final ResourceAccessRegistry resourceAccessRegistry; + private final ResourceCheckerRegistry resourceCheckerRegistry; private final UserSessionService userSessionService; private static final String USER_EXCHANGE_PREFIX = "/user"; @@ -29,7 +29,7 @@ public void handle(Message message, StompHeaderAccessor accessor) { } Long chatRoomId = extractChatRoomId(destination); - resourceAccessRegistry.getChecker(destination).ifPresent(checker -> { + resourceCheckerRegistry.getChecker(destination).ifPresent(checker -> { if (checker.hasPermission(chatRoomId, accessor.getUser())) { Long userId = Long.parseLong(accessor.getUser().getName()); log.info("[Exchange 권한 검사] userId={}에 대한 {} 권한 검사 통과", userId, destination);