Files
ds-chat/backend/app/models/session.py
T
ksmithandClaude Sonnet 5 b26643527d 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>
2026-08-28 20:22:50 -06:00

44 lines
2.1 KiB
Python

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")