feat(user-creator-chat): WebSocket 인증 핸드셰이크를 추가한다
This commit is contained in:
@@ -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<String, Any>
|
||||||
|
): 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 "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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<String, Any>()
|
||||||
|
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<String, Any>()
|
||||||
|
|
||||||
|
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<String, Any>()
|
||||||
|
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<String, Any>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OriginHandshakeInterceptor>().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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user