Private
Public Access
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:
@@ -0,0 +1,100 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Session
|
||||
|
||||
# #69: how stale last_seen_at has to be before a request bothers updating
|
||||
# it. get_current_user resolves a session on *every* authenticated
|
||||
# request (dozens per minute per active browser tab, between message
|
||||
# polling, presence, etc.) -- writing+committing on every single one would
|
||||
# turn a read into a write storm for no real benefit, since "active
|
||||
# sessions" only needs last-seen accurate to within a few minutes, not to
|
||||
# the second.
|
||||
LAST_SEEN_THROTTLE = timedelta(minutes=5)
|
||||
|
||||
|
||||
class SessionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_client_ip(request_or_websocket) -> str | None:
|
||||
# X-Forwarded-For's first entry is the original client -- everything
|
||||
# after it was appended by intermediate proxies. Production runs
|
||||
# behind Nginx Proxy Manager (see backend/README.md's "Admin portal"
|
||||
# section preamble), which sets this; local dev has nothing in front
|
||||
# of the app, so this falls back to the direct peer address.
|
||||
forwarded = request_or_websocket.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
client = request_or_websocket.client
|
||||
return client.host if client else None
|
||||
|
||||
|
||||
async def create_session(
|
||||
db: AsyncSession, user_id: uuid.UUID, ip_address: str | None, user_agent: str | None
|
||||
) -> Session:
|
||||
session = Session(user_id=user_id, ip_address=ip_address, user_agent=user_agent)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
async def start_session(request: Request, db: AsyncSession, user_id: uuid.UUID) -> Session:
|
||||
"""Every "log this browser in" call site (login, reset-password
|
||||
completion, signup completion) needs the exact same three steps --
|
||||
read the request's IP/UA, create the row, stash its id in the signed
|
||||
cookie -- so this is the one place that combination lives."""
|
||||
session = await create_session(db, user_id, get_client_ip(request), request.headers.get("user-agent"))
|
||||
request.session["session_id"] = str(session.id)
|
||||
return session
|
||||
|
||||
|
||||
async def resolve_session(db: AsyncSession, session_id: uuid.UUID) -> Session | None:
|
||||
"""Returns the session iff it exists and hasn't been revoked -- the
|
||||
single choke point get_current_user and the WS handshake both go
|
||||
through, so revoking a session (this endpoint or another device's
|
||||
"sign out") takes effect on that session's very next request rather
|
||||
than only once its signed cookie happens to expire."""
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.revoked_at is not None:
|
||||
return None
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if now - session.last_seen_at > LAST_SEEN_THROTTLE:
|
||||
session.last_seen_at = now
|
||||
await db.commit()
|
||||
return session
|
||||
|
||||
|
||||
async def list_sessions(db: AsyncSession, user_id: uuid.UUID) -> list[Session]:
|
||||
result = await db.execute(
|
||||
select(Session)
|
||||
.where(Session.user_id == user_id, Session.revoked_at.is_(None))
|
||||
.order_by(Session.last_seen_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def revoke_session(db: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID) -> None:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.user_id != user_id or session.revoked_at is not None:
|
||||
raise SessionNotFoundError()
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def revoke_session_unchecked(db: AsyncSession, session_id: uuid.UUID) -> None:
|
||||
"""Logout's own path -- no ownership check needed (a session can only
|
||||
ever log itself out) and silently does nothing for a session that's
|
||||
missing or already revoked, since "sign this browser out" should
|
||||
never itself fail."""
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None or session.revoked_at is not None:
|
||||
return
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
Reference in New Issue
Block a user