Add per-device active sessions with revocation (#69)

Replaces the stateless signed-cookie session (bare user_id) with a
real server-side sessions table -- the cookie now just carries an
opaque session id, resolved against the DB on every request. Each
session records IP address (respects X-Forwarded-For), a parsed
device label, and last-seen time (throttled updates, not written on
every request).

New GET/DELETE /api/auth/sessions endpoints and an "Active sessions"
section in Profile settings let a user see every device they're
logged in from and revoke one they don't recognize -- including their
own current session, which just signs them out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 20:22:50 -06:00
co-authored by Claude Sonnet 5
parent 278f8bb995
commit b26643527d
15 changed files with 554 additions and 14 deletions
+8 -3
View File
@@ -25,6 +25,7 @@ from app.services.message_service import (
toggle_reaction,
)
from app.services.room_service import mark_room_read
from app.services.session_service import resolve_session
router = APIRouter(tags=["ws"])
@@ -75,11 +76,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
return
user, api_token = resolved
else:
user_id_raw = websocket.session.get("user_id")
if not user_id_raw:
session_id_raw = websocket.session.get("session_id")
if not session_id_raw:
await websocket.close(code=WS_UNAUTHENTICATED)
return
user = await db.get(User, uuid.UUID(user_id_raw))
session = await resolve_session(db, uuid.UUID(session_id_raw))
if session is None:
await websocket.close(code=WS_UNAUTHENTICATED)
return
user = await db.get(User, session.user_id)
if user is None or not user.is_active:
await websocket.close(code=WS_UNAUTHENTICATED)
return