Files
vikunja/pkg/websocket/hub.go
kolaente 46ae983554 fix(websocket): check the session and close sockets on revocation
The socket only checked the token's signature and expiry. A token of a
deleted session or of a disabled user still authenticated, and an open
socket kept receiving notifications after logout, session revocation,
password change or account deletion.

Auth now requires the token's session to exist for that user and the
user to be active; the connection registers first and is checked once,
so a concurrent revocation can't slip in between. Transient errors close
with try-again-later instead of invalid_token. Each socket closes when
its token expires, which bounds exposure even on nodes that miss a
revocation event.

Deleting sessions dispatches SessionsRevokedEvent on commit, every caller
flushes it after committing, and the hub closes the matching sockets.
Disabling or locking a user and deleting a user revoke their sessions,
also from the CLI.
2026-10-02 19:08:02 +02:00

104 lines
2.9 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package websocket
import (
"sync"
"code.vikunja.io/api/pkg/log"
)
// Hub maintains the set of active connections and delivers messages to them.
type Hub struct {
mu sync.RWMutex
connections map[int64][]*Connection // userID -> connections
}
// NewHub creates a new Hub.
func NewHub() *Hub {
return &Hub{
connections: make(map[int64][]*Connection),
}
}
// Register adds a connection to the hub.
func (h *Hub) Register(conn *Connection) {
h.mu.Lock()
defer h.mu.Unlock()
h.connections[conn.userID] = append(h.connections[conn.userID], conn)
log.Debugf("WebSocket: registered connection for user %d (total: %d)", conn.userID, len(h.connections[conn.userID]))
}
// Unregister removes a connection from the hub.
func (h *Hub) Unregister(conn *Connection) {
h.mu.Lock()
defer h.mu.Unlock()
conns := h.connections[conn.userID]
for i, c := range conns {
if c == conn {
h.connections[conn.userID] = append(conns[:i], conns[i+1:]...)
break
}
}
remaining := len(h.connections[conn.userID])
if remaining == 0 {
delete(h.connections, conn.userID)
}
log.Debugf("WebSocket: unregistered connection for user %d (remaining: %d)", conn.userID, remaining)
}
func (h *Hub) DisconnectSession(userID int64, sessionID string) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, conn := range h.connections[userID] {
if conn.SessionID() == sessionID {
conn.close("session revoked")
}
}
}
func (h *Hub) DisconnectUser(userID int64) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, conn := range h.connections[userID] {
conn.close("session revoked")
}
}
// PublishForUser sends an event to all connections of a specific user that are subscribed to the given event.
func (h *Hub) PublishForUser(userID int64, event string, data any) {
h.mu.RLock()
defer h.mu.RUnlock()
conns := h.connections[userID]
msg := OutgoingMessage{
Event: event,
Data: data,
}
for _, conn := range conns {
if !conn.IsSubscribed(event) {
continue
}
select {
case conn.send <- msg:
default:
log.Warningf("WebSocket: send buffer full for user %d, dropping message", userID)
}
}
}