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:
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database import get_db
|
||||
from app.models import RoomMembership, RoomRole, User
|
||||
from app.services.bot_service import resolve_token
|
||||
from app.services.session_service import resolve_session
|
||||
|
||||
_ROLE_RANK = {RoomRole.member: 0, RoomRole.admin: 1, RoomRole.owner: 2}
|
||||
|
||||
@@ -29,16 +30,26 @@ async def get_current_user(
|
||||
request.state.api_token = token
|
||||
return user
|
||||
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
session_id = request.session.get("session_id")
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# #69: a session row, not a bare user_id -- resolve_session is also
|
||||
# where a revoked session (this endpoint's own DELETE, or another
|
||||
# device's "sign out") actually takes effect, since there's no other
|
||||
# per-request check of that state.
|
||||
session = await resolve_session(db, uuid.UUID(session_id))
|
||||
if session is None:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
request.state.session_id = session.id
|
||||
|
||||
# Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
|
||||
# be read without a MissingGreenlet -- selectinload skips the second
|
||||
# query entirely when active_custom_theme_id is null (the common case),
|
||||
# so this costs nothing for users who've never set a custom theme.
|
||||
user = await db.get(
|
||||
User, uuid.UUID(user_id), options=[selectinload(User.active_custom_theme)]
|
||||
User, session.user_id, options=[selectinload(User.active_custom_theme)]
|
||||
)
|
||||
if user is None or not user.is_active:
|
||||
request.session.clear()
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.models.message_room_reference import MessageRoomReference
|
||||
from app.models.password_reset import PasswordReset
|
||||
from app.models.push_subscription import PushSubscription
|
||||
from app.models.room import Room
|
||||
from app.models.session import Session
|
||||
from app.models.site_invite import SiteInvite
|
||||
from app.models.smtp_settings import SmtpSettings
|
||||
from app.models.upload_settings import UploadSettings
|
||||
@@ -35,6 +36,7 @@ __all__ = [
|
||||
"MessageRoomReference",
|
||||
"InviteStatus",
|
||||
"PasswordReset",
|
||||
"Session",
|
||||
"SiteInvite",
|
||||
"SmtpSettings",
|
||||
"UploadSettings",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
# #69: the row's own id doubles as the opaque value stored in the
|
||||
# signed session cookie (see app/dependencies.py) -- no separate
|
||||
# generate_token()/hash_token() pair like ApiToken needs. A bearer API
|
||||
# token has to be looked up *by itself* from a plaintext string a bot
|
||||
# pastes into an Authorization header (real leak risk, hence hashing
|
||||
# it at rest); this id only ever travels inside itsdangerous's signed,
|
||||
# tamper-proof cookie payload, so a plain UUID primary key carries the
|
||||
# same security properties the stateless cookie already had before
|
||||
# this table existed.
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
# 45 chars fits the longest possible IPv6 text representation.
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45))
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# Bumped (throttled, not on every request -- see session_service.py) so
|
||||
# "active sessions" can be sorted/labeled by actual recent use, not just
|
||||
# login time -- a session opened once a week ago and used constantly
|
||||
# since should not look identical to one opened once and abandoned.
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
# Null while active. Set on explicit logout or a deliberate "sign out
|
||||
# this device" from another session -- never deleted outright, so a
|
||||
# revoked row still means something if anyone ever needs to ask "was
|
||||
# this session valid at time X."
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user = relationship("User")
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.auth import LoginRequest
|
||||
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
|
||||
from app.schemas.session import SessionRead
|
||||
from app.schemas.user import ProfileUpdate, UserRead
|
||||
from app.services.auth_service import (
|
||||
AccountDeactivatedError,
|
||||
@@ -22,7 +25,15 @@ from app.services.password_service import (
|
||||
request_password_reset,
|
||||
validate_reset_token,
|
||||
)
|
||||
from app.services.session_service import (
|
||||
SessionNotFoundError,
|
||||
list_sessions,
|
||||
revoke_session,
|
||||
revoke_session_unchecked,
|
||||
start_session,
|
||||
)
|
||||
from app.services.upload_settings_service import format_mb, get_upload_settings
|
||||
from app.services.user_agent_service import describe_user_agent
|
||||
from app.storage import (
|
||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||
InvalidImageError,
|
||||
@@ -55,12 +66,15 @@ async def login(
|
||||
except AccountDeactivatedError:
|
||||
raise HTTPException(status_code=401, detail="Account is deactivated")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(request: Request) -> Response:
|
||||
async def logout(request: Request, db: AsyncSession = Depends(get_db)) -> Response:
|
||||
session_id = request.session.get("session_id")
|
||||
if session_id:
|
||||
await revoke_session_unchecked(db, uuid.UUID(session_id))
|
||||
request.session.clear()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -218,5 +232,37 @@ async def complete_reset_password_endpoint(
|
||||
except PasswordResetInvalidError:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionRead])
|
||||
async def list_sessions_endpoint(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sessions = await list_sessions(db, current_user.id)
|
||||
return [
|
||||
SessionRead(
|
||||
id=s.id,
|
||||
ip_address=s.ip_address,
|
||||
device_label=describe_user_agent(s.user_agent),
|
||||
created_at=s.created_at,
|
||||
last_seen_at=s.last_seen_at,
|
||||
is_current=s.id == request.state.session_id,
|
||||
)
|
||||
for s in sessions
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
async def revoke_session_endpoint(
|
||||
session_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
await revoke_session(db, current_user.id, session_id)
|
||||
except SessionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.database import get_db
|
||||
from app.schemas.site_invite import SignupComplete, SignupValidateRead
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.auth_service import DuplicateUserError
|
||||
from app.services.session_service import start_session
|
||||
from app.services.site_invite_service import (
|
||||
SiteInviteInvalidError,
|
||||
complete_signup,
|
||||
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
|
||||
except DuplicateUserError:
|
||||
raise HTTPException(status_code=409, detail="That username or email is already taken")
|
||||
|
||||
request.session["user_id"] = str(user.id)
|
||||
await start_session(request, db, user.id)
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SessionRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
ip_address: str | None
|
||||
# Parsed from the stored user_agent by the router (see
|
||||
# user_agent_service.describe_user_agent) -- not a stored column, so a
|
||||
# future improvement to the parser applies retroactively to old rows
|
||||
# too.
|
||||
device_label: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
# Whether this is the session the request making this call is itself
|
||||
# authenticated with -- lets the UI mark "this device" and treat
|
||||
# revoking it as a self-logout rather than just another row.
|
||||
is_current: bool
|
||||
@@ -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()
|
||||
@@ -0,0 +1,46 @@
|
||||
def describe_user_agent(user_agent: str | None) -> str:
|
||||
"""A short, human-readable "Browser on OS" label for the sessions list
|
||||
-- no dependency pulled in for this (the app has stayed on a
|
||||
zero-subdependency-when-possible diet, see MessageContent.tsx's
|
||||
markdown-to-jsx choice), just substring checks against the handful of
|
||||
tokens that actually distinguish the browsers/platforms this app's
|
||||
users run. Order matters: Electron and Edge both also contain
|
||||
"Chrome/", and Chrome-on-iOS/Safari-on-iOS both contain "Safari/", so
|
||||
the more specific token has to be checked first.
|
||||
"""
|
||||
if not user_agent:
|
||||
return "Unknown device"
|
||||
|
||||
if "Electron/" in user_agent:
|
||||
browser = "DS Chat Desktop"
|
||||
elif "Edg/" in user_agent:
|
||||
browser = "Edge"
|
||||
elif "OPR/" in user_agent:
|
||||
browser = "Opera"
|
||||
elif "Firefox/" in user_agent:
|
||||
browser = "Firefox"
|
||||
elif "Chrome/" in user_agent:
|
||||
browser = "Chrome"
|
||||
elif "CriOS/" in user_agent:
|
||||
browser = "Chrome"
|
||||
elif "Safari/" in user_agent:
|
||||
browser = "Safari"
|
||||
else:
|
||||
browser = "Unknown browser"
|
||||
|
||||
if "Windows" in user_agent:
|
||||
os_name = "Windows"
|
||||
elif "Mac OS X" in user_agent and ("iPhone" in user_agent or "iPad" in user_agent):
|
||||
os_name = "iOS"
|
||||
elif "Mac OS X" in user_agent:
|
||||
os_name = "macOS"
|
||||
elif "Android" in user_agent:
|
||||
os_name = "Android"
|
||||
elif "Linux" in user_agent:
|
||||
os_name = "Linux"
|
||||
else:
|
||||
os_name = "Unknown OS"
|
||||
|
||||
if browser == "DS Chat Desktop":
|
||||
return f"{browser} ({os_name})"
|
||||
return f"{browser} on {os_name}"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user