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