plugins {
id 'java'
id 'org.springframework.boot' version '3.0.1'
id 'io.spring.dependency-management' version '1.1.0'
// querysl
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
group = 'com.example.woong99'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'mysql:mysql-connector-java'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// ====================stomp======================
implementation group: 'org.webjars', name: 'stomp-websocket', version: '2.3.3-1'
// ====================stomp======================
// ====================jwt========================
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// ====================jwt========================
// ==================querydsl=====================
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// ==================querydsl=====================
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
}
// ========================querydsl=======================
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
querydsl.extendsFrom compileClasspath
}
// ========================querydsl=======================
package com.example.woong99.stomp.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
@EnableWebSocketMessageBroker // stomp를 사용하기 위해 선언하는 어노테이션
@Configuration
@RequiredArgsConstructor
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final FilterChannelInterceptor filterChannelInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000", "ws://localhost:3000");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/pub"); // client에서 SEND 요청을 처리
registry.enableSimpleBroker("/sub"); // 해당 경로로 SimpleBroker를 등록. SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 Client에게 메세지를 전달하는 간단한 작업을 수행
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(filterChannelInterceptor);
}
}
package com.example.woong99.stomp.dto;
import com.example.woong99.stomp.entity.ChatMessage;
import lombok.*;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ChatMessageDto {
private String roomId;
private String writer;
private String receiver;
private String message;
private String command;
private String isRead;
public static ChatMessageDto of(ChatMessage chatMessage) {
return ChatMessageDto.builder()
.roomId(chatMessage.getPrivateChatRoom().getId())
.writer(chatMessage.getSender())
.message(chatMessage.getMessage())
.isRead(chatMessage.getIsRead())
.build();
}
}
package com.example.woong99.stomp.dto;
import com.example.woong99.stomp.entity.PublicChatRoom;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.UUID;
@Data
@NoArgsConstructor
@Builder
public class PublicChatRoomDto {
private String roomId;
private String name;
public PublicChatRoomDto(String roomId, String name) {
this.roomId = roomId == null ? UUID.randomUUID().toString() : roomId;
this.name = name;
}
public static PublicChatRoomDto from(PublicChatRoom entity) {
return new PublicChatRoomDto(entity.getId(), entity.getName());
}
public PublicChatRoom toEntity(PublicChatRoomDto publicChatRoomDto) {
return PublicChatRoom.builder()
.id(publicChatRoomDto.getRoomId() == null ? UUID.randomUUID().toString() : publicChatRoomDto.getRoomId())
.name(publicChatRoomDto.getName())
.build();
}
}
UUID.randomUUID().toString()
을 이용하여 자동으로 랜덤한 방 번호를 생성되게 하였다.
package com.example.woong99.stomp.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity(name = "stomp_public_chat_room")
public class PublicChatRoom {
@Id
@Column(nullable = false, name = "stomp_public_chat_room_id")
private String id;
@Column(nullable = false, name = "stomp_public_chat_room_name")
private String name;
}
package com.example.woong99.stomp.repository;
import com.example.woong99.stomp.entity.PublicChatRoom;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PublicChatRoomRepository extends JpaRepository<PublicChatRoom, String> {
}
package com.example.woong99.stomp.service;
import com.example.woong99.stomp.dto.PublicChatRoomDto;
import com.example.woong99.stomp.repository.PublicChatRoomRepository;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class PublicChatRoomService {
private final PublicChatRoomRepository publicChatRoomRepository;
public List<PublicChatRoomDto> getChatRooms() {
return publicChatRoomRepository.findAll().stream().map(PublicChatRoomDto::from)
.collect(Collectors.toList());
}
public void saveChatRoom(PublicChatRoomDto publicChatRoomDto) {
publicChatRoomRepository.save(publicChatRoomDto.toEntity(publicChatRoomDto));
}
}
package com.example.woong99.stomp.controller;
import com.example.woong99.stomp.dto.PublicChatRoomDto;
import com.example.woong99.stomp.service.PublicChatRoomService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/public-room")
public class PublicChatRoomController {
private final PublicChatRoomService publicChatRoomService;
@GetMapping()
public ResponseEntity<List<PublicChatRoomDto>> getRooms() {
return ResponseEntity.ok(publicChatRoomService.getChatRooms());
}
@PostMapping()
public ResponseEntity<Void> saveRoom(@RequestBody PublicChatRoomDto publicChatRoomDto) {
publicChatRoomService.saveChatRoom(publicChatRoomDto);
return ResponseEntity.ok().build();
}
}
package com.example.woong99.stomp.controller;
import com.example.woong99.stomp.dto.ChatMessageDto;
import com.example.woong99.stomp.entity.ChatMessage;
import com.example.woong99.stomp.entity.PrivateChatRoom;
import com.example.woong99.stomp.repository.ChatMessageRepository;
import com.example.woong99.stomp.repository.MemberRepository;
import com.example.woong99.stomp.repository.PrivateChatRoomRepository;
import com.example.woong99.stomp.service.ChatMessageService;
import com.example.woong99.stomp.service.PrivateChatRoomService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.MessageDeliveryException;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
import java.security.Principal;
import java.util.HashMap;
@Controller
@RequiredArgsConstructor
public class StompChatController {
private final SimpMessagingTemplate template;
private final HashMap<String, String> simpSessionIdMap = new HashMap<>(); // stomp에 CONNECT한 유저 정보
private final String noticeDestination = "/sub/notice";
private final PrivateChatRoomRepository privateChatRoomRepository;
@MessageMapping(value = "/chat/enter")
public void enter(ChatMessageDto message, Principal principal) {
message.setWriter(principal.getName());
message.setMessage(principal.getName() + "님이 채팅방에 참여하였습니다.");
template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
}
@MessageMapping(value = "/chat/message")
public void message(ChatMessageDto message, Principal principal) {
message.setWriter(principal.getName());
template.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
}
@MessageMapping("/notice")
public void noticeMessage(ChatMessageDto chatMessageDto) {
if (chatMessageDto.getCommand().equals("SUBSCRIBE")) {
template.convertAndSend(noticeDestination, simpSessionIdMap.values());
}
}
@EventListener
public void handleSessionConnected(SessionConnectedEvent event) {
String simpSessionId = (String) event.getMessage().getHeaders().get("simpSessionId");
if (event.getUser() != null) {
Principal user = event.getUser();
if (user != null) {
try {
String username = user.getName();
simpSessionIdMap.put(simpSessionId, username);
} catch (Exception e) {
throw new MessageDeliveryException("인증 정보가 올바르지 않습니다. 다시 로그인 후 이용해주세요.");
}
}
}
}
@EventListener
public void handleSessionSubscribe(SessionSubscribeEvent event) {
String destination = (String) event.getMessage().getHeaders().get("simpDestination");
assert destination != null;
if (destination.equals(noticeDestination)) {
template.convertAndSend(noticeDestination, simpSessionIdMap.values());
}
}
@EventListener
public void handleSessionDisconnect(SessionDisconnectEvent event) {
String simpSessionId = (String) event.getMessage().getHeaders().get("simpSessionId");
simpSessionIdMap.remove(simpSessionId);
template.convertAndSend(noticeDestination, simpSessionIdMap.values());
}
}
@MessageMapping
을 통해 WebSocket으로 들어오는 메세지 발행을 처리한다. @EventListener
를 통해 이벤트를 캐치할 수 있다.SessionConnectEvent
: 세션에 연결하는 경우SessionConnectedEvent
: 세션에 연결한 경우SessionSubscribeEvent
: 구독을 하는 경우SessionUnsubscribeEvent
: 구독을 취소하는 경우SessionDisconnectEvent
: 종료하는 경우SimpMessagingTemplate
을 이용하여 메세지를 보낼 수 있지만, @SendTo
나 @SendToUser
등과 같은 어노테이션을 통해서도 메세지를 보낼 수 있다. @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.authorizeHttpRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.requestMatchers("/members/login").permitAll()
.requestMatchers("/members/signup").permitAll()
.requestMatchers("/members/me").authenticated()
.requestMatchers("/public-room").authenticated()
.requestMatchers("/ws/**").permitAll()
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfig(jwtTokenProvider));
return http.build();
}
package com.example.woong99.stomp.config;
import com.example.woong99.stomp.security.JwtTokenProvider;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SecurityException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.MessageDeliveryException;
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.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Objects;
@RequiredArgsConstructor
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class FilterChannelInterceptor implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
assert headerAccessor != null;
if (!StompCommand.UNSUBSCRIBE.equals(headerAccessor.getCommand()) && !StompCommand.DISCONNECT.equals(headerAccessor.getCommand())) {
String authorizationHeader = String.valueOf(headerAccessor.getNativeHeader("Authorization"));
if (authorizationHeader == null || authorizationHeader.equals("null")) {
throw new MessageDeliveryException("로그인 후 이용해주세요.");
}
String token = Objects.requireNonNull(headerAccessor.getNativeHeader("Authorization")).get(0)
.replace("Bearer ", "");
try {
jwtTokenProvider.validateToken(token);
Authentication user = jwtTokenProvider.getAuthentication(token);
headerAccessor.setUser(user);
} catch (MessageDeliveryException e) {
throw new MessageDeliveryException("메세지 에러");
} catch (SecurityException | MalformedJwtException | ExpiredJwtException | UnsupportedJwtException |
IllegalArgumentException e) {
throw new MessageDeliveryException("인증 정보가 올바르지 않습니다. 다시 로그인 후 이용해주세요.");
}
}
return message;
}
}
ChannelInterceptor
를 implements 해주고, @Order(Ordered.HIGHEST_PRECEDENCE + 99)
를 통해 Spring Security의 인증보다 앞에 오게 했다.JwtTokenProvider
를 통해 인증했고, 인증 정보가 없거나 올바르지 않은 경우에는 MessageDeliveryException
을 통해 클라이언트로 에러 메세지를 전송했다. @Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(filterChannelInterceptor);
}
지금까지 구현한 인증 과정을 위 메소드를 통해 등록한다.