security: WebSocket Auth-Token aus Query-Parameter in Authorization-Header verschieben

- Client: Token als 'Authorization: Bearer' Header statt ?token= Query-Parameter senden
- Server: Token aus Authorization-Header statt Query-Parameter lesen
- Tests: Alle 8 WebSocket-Tests auf Header-Auth umgestellt
- Integration-Tests: WebSocket-Verbindung mit Header aktualisiert

Closes #97
This commit is contained in:
Jens Reinemann 2026-05-18 08:23:10 +02:00
parent 75f46de05e
commit dad15b9e94
5 changed files with 19 additions and 12 deletions

View file

@ -5,6 +5,8 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -59,7 +61,10 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
_connectionState.value = ConnectionState.Connecting _connectionState.value = ConnectionState.Connecting
Log.d(TAG, "WebSocket: Verbindungsversuch #${consecutiveFailures + 1} (Backoff: ${backoffMs}ms)") Log.d(TAG, "WebSocket: Verbindungsversuch #${consecutiveFailures + 1} (Backoff: ${backoffMs}ms)")
try { try {
wsHttpClient.webSocket("$wsUrl/ws/sync?token=$accessToken") { wsHttpClient.webSocket(
urlString = "$wsUrl/ws/sync",
request = { header(HttpHeaders.Authorization, "Bearer $accessToken") }
) {
backoffMs = INITIAL_BACKOFF_MS backoffMs = INITIAL_BACKOFF_MS
consecutiveFailures = 0 consecutiveFailures = 0
_connectionState.value = ConnectionState.Connected _connectionState.value = ConnectionState.Connected

View file

@ -87,7 +87,8 @@ function Invoke-Api {
# WebSocket-Verbindung oeffnen (gibt ClientWebSocket zurueck) # WebSocket-Verbindung oeffnen (gibt ClientWebSocket zurueck)
function Open-WebSocket([string]$token) { function Open-WebSocket([string]$token) {
$ws = New-Object System.Net.WebSockets.ClientWebSocket $ws = New-Object System.Net.WebSockets.ClientWebSocket
$uri = [Uri]"$WsUrl/ws/sync?token=$token" $ws.Options.SetRequestHeader("Authorization", "Bearer $token")
$uri = [Uri]"$WsUrl/ws/sync"
$ws.ConnectAsync($uri, [System.Threading.CancellationToken]::None).Wait() $ws.ConnectAsync($uri, [System.Threading.CancellationToken]::None).Wait()
return $ws return $ws
} }

View file

@ -88,7 +88,7 @@ internal fun Application.configureRouting(
userRoutes(userRepository, wsManager) userRoutes(userRepository, wsManager)
} }
// WebSocket auth via query param ?token= // WebSocket auth via Authorization: Bearer header
webSocketRoutes(wsManager, jwtService, messageRepository) webSocketRoutes(wsManager, jwtService, messageRepository)
// Admin web UI (static) // Admin web UI (static)

View file

@ -17,8 +17,9 @@ internal fun Route.webSocketRoutes(
messageRepository: MessageRepository messageRepository: MessageRepository
) { ) {
webSocket("/ws/sync") { webSocket("/ws/sync") {
val token = call.request.queryParameters["token"] val authHeader = call.request.headers["Authorization"]
if (token == null) { val token = authHeader?.removePrefix("Bearer ")?.takeIf { authHeader.startsWith("Bearer ") }
if (token.isNullOrBlank()) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Missing token")) close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Missing token"))
return@webSocket return@webSocket
} }

View file

@ -66,7 +66,7 @@ class WebSocketTest {
install(ClientWebSockets) install(ClientWebSockets)
} }
wsClient.webSocket("/ws/sync?token=$token") { wsClient.webSocket("/ws/sync", { header("Authorization", "Bearer $token") }) {
// Connection established send close // Connection established send close
close(CloseReason(CloseReason.Codes.NORMAL, "Test done")) close(CloseReason(CloseReason.Codes.NORMAL, "Test done"))
} }
@ -94,7 +94,7 @@ class WebSocketTest {
install(ClientWebSockets) install(ClientWebSockets)
} }
wsClient.webSocket("/ws/sync?token=invalid-jwt-token") { wsClient.webSocket("/ws/sync", { header("Authorization", "Bearer invalid-jwt-token") }) {
val reason = closeReason.await() val reason = closeReason.await()
assertNotNull(reason) assertNotNull(reason)
assertEquals(CloseReason.Codes.VIOLATED_POLICY.code, reason!!.code) assertEquals(CloseReason.Codes.VIOLATED_POLICY.code, reason!!.code)
@ -109,7 +109,7 @@ class WebSocketTest {
install(ClientWebSockets) install(ClientWebSockets)
} }
wsClient.webSocket("/ws/sync?token=$expiredToken") { wsClient.webSocket("/ws/sync", { header("Authorization", "Bearer $expiredToken") }) {
val reason = closeReason.await() val reason = closeReason.await()
assertNotNull(reason) assertNotNull(reason)
assertEquals(CloseReason.Codes.VIOLATED_POLICY.code, reason!!.code) assertEquals(CloseReason.Codes.VIOLATED_POLICY.code, reason!!.code)
@ -125,7 +125,7 @@ class WebSocketTest {
install(ClientWebSockets) install(ClientWebSockets)
} }
wsClient.webSocket("/ws/sync?token=$token") { wsClient.webSocket("/ws/sync", { header("Authorization", "Bearer $token") }) {
// Trigger an inventory update from outside via PUT // Trigger an inventory update from outside via PUT
val httpClient = this@testApp.client val httpClient = this@testApp.client
val inventory = """{"categories":[],"locations":[],"items":[],"settings":[]}""" val inventory = """{"categories":[],"locations":[],"items":[],"settings":[]}"""
@ -160,7 +160,7 @@ class WebSocketTest {
} }
// Bob connects WebSocket // Bob connects WebSocket
wsClient.webSocket("/ws/sync?token=$bobToken") { wsClient.webSocket("/ws/sync", { header("Authorization", "Bearer $bobToken") }) {
// Alice sends a message to Bob via HTTP // Alice sends a message to Bob via HTTP
val httpClient = this@testApp.client val httpClient = this@testApp.client
httpClient.post("/api/messages") { httpClient.post("/api/messages") {
@ -192,7 +192,7 @@ class WebSocketTest {
} }
// Connect and immediately disconnect // Connect and immediately disconnect
wsClient.webSocket("/ws/sync?token=$token") { wsClient.webSocket("/ws/sync", { header("Authorization", "Bearer $token") }) {
close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting"))
} }
@ -221,7 +221,7 @@ class WebSocketTest {
install(ClientWebSockets) install(ClientWebSockets)
} }
wsClient.webSocket("/ws/sync?token=$bobToken") { wsClient.webSocket("/ws/sync", { header("Authorization", "Bearer $bobToken") }) {
// Then Bob should receive the pending message on connect // Then Bob should receive the pending message on connect
val frame = withTimeout(5_000) { incoming.receive() } val frame = withTimeout(5_000) { incoming.receive() }
assertTrue(frame is Frame.Text) assertTrue(frame is Frame.Text)