diff --git a/backend/src/ws-server.js b/backend/src/ws-server.js index 43ab4c2..9da1a37 100644 --- a/backend/src/ws-server.js +++ b/backend/src/ws-server.js @@ -279,27 +279,21 @@ function attach(httpServer) { const wss = new WebSocketServer({ server: httpServer, path: '/ws' }); _wss = wss; - wss.on('connection', (ws, req) => { - /* ── Auth ── */ - let user = null; - try { - const url = new URL(req.url, 'http://localhost'); - const token = url.searchParams.get('token') || ''; - user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); - } catch { - ws.close(4001, 'Unauthorized'); - return; - } - - ws.userId = user.id; - ws.userName = user.name || user.email || ''; + wss.on('connection', (ws) => { + ws.userId = null; + ws.userName = ''; ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); - ws._msgCount = 0; + ws._msgCount = 0; ws._msgWindowStart = Date.now(); + // Close unauthenticated connections after 5s + ws._authTimer = setTimeout(() => { + if (!ws.userId) { try { ws.close(4001, 'Auth timeout'); } catch {} } + }, 5000); + ws.on('message', raw => { // Rate-limit: max 120 messages per second per connection const now = Date.now(); @@ -308,13 +302,37 @@ function attach(httpServer) { let msg; try { msg = JSON.parse(raw); } catch { return; } + + // Pre-auth: only {type:'auth', token} accepted + if (!ws.userId) { + if (msg.type !== 'auth' || !msg.token) { + try { ws.close(4001, 'Auth required'); } catch {} + return; + } + try { + const user = jwt.verify(msg.token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + ws.userId = user.id; + ws.userName = user.name || user.email || ''; + clearTimeout(ws._authTimer); + ws._authTimer = null; + try { ws.send(JSON.stringify({ type: 'auth_ok' })); } catch {} + } catch { + try { ws.close(4001, 'Invalid token'); } catch {} + } + return; + } + + // Post-auth: normal handler try { _handleMessage(ws, msg); } catch (e) { // swallow — never crash the WS server on bad input } }); ws.on('error', () => {}); - ws.on('close', () => { _unregisterUser(ws); }); + ws.on('close', () => { + if (ws._authTimer) { clearTimeout(ws._authTimer); ws._authTimer = null; } + if (ws.userId) _unregisterUser(ws); + }); }); /* ── Ping/pong keepalive (30s) ── */ diff --git a/frontend/classroom.html b/frontend/classroom.html index a6cab77..59d4567 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -4330,12 +4330,11 @@ function _crWsConnect() { if (_crWs && (_crWs.readyState === WebSocket.OPEN || _crWs.readyState === WebSocket.CONNECTING)) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const token = LS.getToken() || ''; - _crWs = new WebSocket(`${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`); + _crWs = new WebSocket(`${proto}//${location.host}/ws`); _crWs.onopen = () => { - _crWsReady = true; - // Register in session so server delivers events via WS instead of SSE - if (_sessionId) _crWs.send(JSON.stringify({ type: 'classroom_join', sessionId: _sessionId })); + // Authenticate via first message — token never appears in URL or proxy logs + const token = LS.getToken() || ''; + _crWs.send(JSON.stringify({ type: 'auth', token })); }; _crWs.onclose = () => { _crWsReady = false; @@ -4343,9 +4342,17 @@ if (_sessionId) setTimeout(() => { if (_sessionId) _crWsConnect(); }, 2000); }; _crWs.onerror = () => { _crWsReady = false; }; - // All classroom server→client events arrive here (WS replaces SSE for classroom) _crWs.onmessage = e => { - try { handleSSE(JSON.parse(e.data), true); } catch {} + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + if (msg.type === 'auth_ok') { + _crWsReady = true; + // Register in session so server delivers events via WS instead of SSE + if (_sessionId) _crWs.send(JSON.stringify({ type: 'classroom_join', sessionId: _sessionId })); + return; + } + // All other classroom server→client events + try { handleSSE(msg, true); } catch {} }; }