diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt new file mode 100644 index 00000000..8a9e479c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt @@ -0,0 +1,63 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import kr.co.vividnext.sodalive.jwt.JwtFilter +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.MemberAdapter +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.stereotype.Component +import org.springframework.util.StringUtils +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.server.HandshakeInterceptor + +@Component +class UserCreatorChatWebSocketAuthInterceptor( + private val tokenProvider: TokenProvider +) : HandshakeInterceptor { + override fun beforeHandshake( + request: ServerHttpRequest, + response: ServerHttpResponse, + wsHandler: WebSocketHandler, + attributes: MutableMap + ): Boolean { + val token = resolveToken(request) ?: return false + if (!tokenProvider.validateToken(token)) { + return false + } + + val authentication = try { + tokenProvider.getAuthentication(token) + } catch (e: RuntimeException) { + return false + } + val principal = authentication.principal as? MemberAdapter ?: return false + val memberId = principal.member.id ?: return false + + attributes[MEMBER_ID_ATTRIBUTE] = memberId + attributes[AUTHENTICATION_ATTRIBUTE] = authentication + return true + } + + override fun afterHandshake( + request: ServerHttpRequest, + response: ServerHttpResponse, + wsHandler: WebSocketHandler, + exception: Exception? + ) { + } + + private fun resolveToken(request: ServerHttpRequest): String? { + val bearerToken = request.headers.getFirst(JwtFilter.AUTHORIZATION_HEADER) + if (StringUtils.hasText(bearerToken) && bearerToken!!.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length) + } + + return null + } + + companion object { + const val MEMBER_ID_ATTRIBUTE = "memberId" + const val AUTHENTICATION_ATTRIBUTE = "authentication" + private const val BEARER_PREFIX = "Bearer " + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt new file mode 100644 index 00000000..1d7814f8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import org.springframework.context.annotation.Configuration +import org.springframework.web.socket.config.annotation.EnableWebSocket +import org.springframework.web.socket.config.annotation.WebSocketConfigurer +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry + +@Configuration +@EnableWebSocket +class UserCreatorChatWebSocketConfig( + private val handler: UserCreatorChatWebSocketHandler, + private val authInterceptor: UserCreatorChatWebSocketAuthInterceptor +) : WebSocketConfigurer { + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(handler, ENDPOINT) + .addInterceptors(authInterceptor) + .setAllowedOrigins( + "http://localhost:8888", + "https://creator.sodalive.net", + "https://test-creator.sodalive.net", + "https://test-admin.sodalive.net", + "https://admin.sodalive.net" + ) + } + + companion object { + const val ENDPOINT = "/ws/v2/user-creator-chat" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt new file mode 100644 index 00000000..30958ef7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import org.springframework.stereotype.Component +import org.springframework.web.socket.handler.TextWebSocketHandler + +@Component +class UserCreatorChatWebSocketHandler : TextWebSocketHandler() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt new file mode 100644 index 00000000..07c8019b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt @@ -0,0 +1,111 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.http.HttpHeaders +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.http.server.ServletServerHttpRequest +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.web.socket.WebSocketHandler + +class UserCreatorChatWebSocketAuthInterceptorTest { + private val tokenProvider = Mockito.mock(TokenProvider::class.java) + private val interceptor = UserCreatorChatWebSocketAuthInterceptor(tokenProvider) + private val response = Mockito.mock(ServerHttpResponse::class.java) + private val wsHandler = Mockito.mock(WebSocketHandler::class.java) + + @Test + @DisplayName("Authorization Bearer token이 유효하면 handshake attributes에 인증 정보를 저장한다") + fun shouldStoreAuthenticationAttributesWhenBearerTokenIsValid() { + val member = Member(email = "viewer@test.com", password = "password", nickname = "viewer").apply { id = 10L } + val authentication = UsernamePasswordAuthenticationToken(MemberAdapter(member), "valid-token") + Mockito.`when`(tokenProvider.validateToken("valid-token")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("valid-token")).thenReturn(authentication) + + val attributes = mutableMapOf() + val result = interceptor.beforeHandshake( + requestWithAuthorization("Bearer valid-token"), + response, + wsHandler, + attributes + ) + + assertTrue(result, "Expected valid Bearer token handshake to proceed") + assertEquals(10L, attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE]) + assertSame(authentication, attributes[UserCreatorChatWebSocketAuthInterceptor.AUTHENTICATION_ATTRIBUTE]) + } + + @Test + @DisplayName("Authorization header가 없으면 handshake를 거부한다") + fun shouldRejectHandshakeWithoutAuthorizationHeader() { + val attributes = mutableMapOf() + + val result = interceptor.beforeHandshake( + requestWithAuthorization(null), + response, + wsHandler, + attributes + ) + + assertFalse(result, "Expected missing Authorization header handshake to be rejected") + assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty") + } + + @Test + @DisplayName("유효하지 않은 Bearer token이면 handshake를 거부한다") + fun shouldRejectHandshakeWhenBearerTokenIsInvalid() { + Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false) + + val attributes = mutableMapOf() + val result = interceptor.beforeHandshake( + requestWithAuthorization("Bearer invalid-token"), + response, + wsHandler, + attributes + ) + + assertFalse(result, "Expected invalid Bearer token handshake to be rejected") + assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty") + } + + @Test + @DisplayName("토큰 검증 후 인증 정보 조회가 실패하면 handshake를 거부한다") + fun shouldRejectHandshakeWhenAuthenticationLookupFails() { + Mockito.`when`(tokenProvider.validateToken("logged-out-token")).thenReturn(true) + Mockito.`when`(tokenProvider.getAuthentication("logged-out-token")) + .thenThrow(IllegalStateException("token not found")) + + val attributes = mutableMapOf() + var result = true + assertDoesNotThrow { + result = interceptor.beforeHandshake( + requestWithAuthorization("Bearer logged-out-token"), + response, + wsHandler, + attributes + ) + } + + assertFalse(result, "Expected authentication lookup failure handshake to be rejected") + assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty") + } + + private fun requestWithAuthorization(authorization: String?): ServerHttpRequest { + val request = MockHttpServletRequest() + if (authorization != null) { + request.addHeader(HttpHeaders.AUTHORIZATION, authorization) + } + return ServletServerHttpRequest(request) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt new file mode 100644 index 00000000..9eb65e94 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import kr.co.vividnext.sodalive.jwt.TokenProvider +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.ApplicationContext +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping +import org.springframework.web.socket.server.support.OriginHandshakeInterceptor +import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler + +@SpringBootTest( + classes = [ + UserCreatorChatWebSocketConfig::class, + UserCreatorChatWebSocketHandler::class, + UserCreatorChatWebSocketAuthInterceptor::class + ] +) +class UserCreatorChatWebSocketConfigTest @Autowired constructor( + private val applicationContext: ApplicationContext +) { + @MockBean + private lateinit var tokenProvider: TokenProvider + + @Test + @DisplayName("유저-크리에이터 채팅 WebSocket handler를 지정 경로와 인증 interceptor, origin 제한으로 등록한다") + fun shouldRegisterUserCreatorChatWebSocketHandler() { + val handlerMappings = applicationContext.getBeansOfType(SimpleUrlHandlerMapping::class.java).values + val urlMap = handlerMappings.flatMap { mapping -> mapping.urlMap.entries } + val handler = urlMap.firstNotNullOfOrNull { (path, handler) -> + if (path == "/ws/v2/user-creator-chat") handler as? WebSocketHttpRequestHandler else null + } + + assertNotNull(handler, "Expected /ws/v2/user-creator-chat to be registered") + val interceptors = handler!!.handshakeInterceptors + assertTrue(interceptors.any { it is UserCreatorChatWebSocketAuthInterceptor }) + + val originInterceptor = interceptors.filterIsInstance().single() + assertEquals( + listOf( + "http://localhost:8888", + "https://creator.sodalive.net", + "https://test-creator.sodalive.net", + "https://test-admin.sodalive.net", + "https://admin.sodalive.net" + ), + originInterceptor.allowedOrigins.toList() + ) + } +}