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,44 @@
|
|||||||
|
"""add sessions table for active-sessions feature
|
||||||
|
|
||||||
|
Revision ID: 319c30e24cd9
|
||||||
|
Revises: 05b28b0a2261
|
||||||
|
Create Date: 2026-08-28 19:59:51.475384
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '319c30e24cd9'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '05b28b0a2261'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('sessions',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||||
|
sa.Column('user_agent', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('last_seen_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
||||||
|
op.drop_table('sessions')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import RoomMembership, RoomRole, User
|
from app.models import RoomMembership, RoomRole, User
|
||||||
from app.services.bot_service import resolve_token
|
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}
|
_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
|
request.state.api_token = token
|
||||||
return user
|
return user
|
||||||
|
|
||||||
user_id = request.session.get("user_id")
|
session_id = request.session.get("session_id")
|
||||||
if not user_id:
|
if not session_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
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
|
# Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
|
||||||
# be read without a MissingGreenlet -- selectinload skips the second
|
# be read without a MissingGreenlet -- selectinload skips the second
|
||||||
# query entirely when active_custom_theme_id is null (the common case),
|
# 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.
|
# so this costs nothing for users who've never set a custom theme.
|
||||||
user = await db.get(
|
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:
|
if user is None or not user.is_active:
|
||||||
request.session.clear()
|
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.password_reset import PasswordReset
|
||||||
from app.models.push_subscription import PushSubscription
|
from app.models.push_subscription import PushSubscription
|
||||||
from app.models.room import Room
|
from app.models.room import Room
|
||||||
|
from app.models.session import Session
|
||||||
from app.models.site_invite import SiteInvite
|
from app.models.site_invite import SiteInvite
|
||||||
from app.models.smtp_settings import SmtpSettings
|
from app.models.smtp_settings import SmtpSettings
|
||||||
from app.models.upload_settings import UploadSettings
|
from app.models.upload_settings import UploadSettings
|
||||||
@@ -35,6 +36,7 @@ __all__ = [
|
|||||||
"MessageRoomReference",
|
"MessageRoomReference",
|
||||||
"InviteStatus",
|
"InviteStatus",
|
||||||
"PasswordReset",
|
"PasswordReset",
|
||||||
|
"Session",
|
||||||
"SiteInvite",
|
"SiteInvite",
|
||||||
"SmtpSettings",
|
"SmtpSettings",
|
||||||
"UploadSettings",
|
"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 fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -7,6 +9,7 @@ from app.dependencies import get_current_user
|
|||||||
from app.models import User
|
from app.models import User
|
||||||
from app.schemas.auth import LoginRequest
|
from app.schemas.auth import LoginRequest
|
||||||
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
|
from app.schemas.password import ForgotPasswordRequest, PasswordChange, ResetPasswordComplete
|
||||||
|
from app.schemas.session import SessionRead
|
||||||
from app.schemas.user import ProfileUpdate, UserRead
|
from app.schemas.user import ProfileUpdate, UserRead
|
||||||
from app.services.auth_service import (
|
from app.services.auth_service import (
|
||||||
AccountDeactivatedError,
|
AccountDeactivatedError,
|
||||||
@@ -22,7 +25,15 @@ from app.services.password_service import (
|
|||||||
request_password_reset,
|
request_password_reset,
|
||||||
validate_reset_token,
|
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.upload_settings_service import format_mb, get_upload_settings
|
||||||
|
from app.services.user_agent_service import describe_user_agent
|
||||||
from app.storage import (
|
from app.storage import (
|
||||||
ALLOWED_IMAGE_CONTENT_TYPES,
|
ALLOWED_IMAGE_CONTENT_TYPES,
|
||||||
InvalidImageError,
|
InvalidImageError,
|
||||||
@@ -55,12 +66,15 @@ async def login(
|
|||||||
except AccountDeactivatedError:
|
except AccountDeactivatedError:
|
||||||
raise HTTPException(status_code=401, detail="Account is deactivated")
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout", status_code=204)
|
@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()
|
request.session.clear()
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
@@ -218,5 +232,37 @@ async def complete_reset_password_endpoint(
|
|||||||
except PasswordResetInvalidError:
|
except PasswordResetInvalidError:
|
||||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired")
|
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
|
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.site_invite import SignupComplete, SignupValidateRead
|
||||||
from app.schemas.user import UserRead
|
from app.schemas.user import UserRead
|
||||||
from app.services.auth_service import DuplicateUserError
|
from app.services.auth_service import DuplicateUserError
|
||||||
|
from app.services.session_service import start_session
|
||||||
from app.services.site_invite_service import (
|
from app.services.site_invite_service import (
|
||||||
SiteInviteInvalidError,
|
SiteInviteInvalidError,
|
||||||
complete_signup,
|
complete_signup,
|
||||||
@@ -39,5 +40,5 @@ async def complete_signup_endpoint(
|
|||||||
except DuplicateUserError:
|
except DuplicateUserError:
|
||||||
raise HTTPException(status_code=409, detail="That username or email is already taken")
|
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
|
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,
|
toggle_reaction,
|
||||||
)
|
)
|
||||||
from app.services.room_service import mark_room_read
|
from app.services.room_service import mark_room_read
|
||||||
|
from app.services.session_service import resolve_session
|
||||||
|
|
||||||
router = APIRouter(tags=["ws"])
|
router = APIRouter(tags=["ws"])
|
||||||
|
|
||||||
@@ -75,11 +76,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
|
|||||||
return
|
return
|
||||||
user, api_token = resolved
|
user, api_token = resolved
|
||||||
else:
|
else:
|
||||||
user_id_raw = websocket.session.get("user_id")
|
session_id_raw = websocket.session.get("session_id")
|
||||||
if not user_id_raw:
|
if not session_id_raw:
|
||||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||||
return
|
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:
|
if user is None or not user.is_active:
|
||||||
await websocket.close(code=WS_UNAUTHENTICATED)
|
await websocket.close(code=WS_UNAUTHENTICATED)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.schemas.user import UserCreate
|
||||||
|
from app.services.auth_service import register_user
|
||||||
|
from tests.conftest import login_as, register_and_login
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(prefix: str) -> str:
|
||||||
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_creates_a_session(client, db_session):
|
||||||
|
username = _unique("alice")
|
||||||
|
await register_user(
|
||||||
|
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username_or_email": username, "password": "password123"},
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
sessions = (await client.get("/api/auth/sessions")).json()
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0]["is_current"] is True
|
||||||
|
assert sessions[0]["device_label"] == "Chrome on Windows"
|
||||||
|
assert sessions[0]["ip_address"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_revokes_the_session(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username=_unique("alice"))
|
||||||
|
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
||||||
|
|
||||||
|
await client.post("/api/auth/logout")
|
||||||
|
# The important part is server-side: the session row itself is gone
|
||||||
|
# from the active list (this request also 401s since this client's own
|
||||||
|
# cookie was cleared, but that alone wouldn't prove the *row* is dead).
|
||||||
|
resp = await client.get("/api/auth/sessions")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_x_forwarded_for_takes_priority_over_direct_peer(client, db_session):
|
||||||
|
username = _unique("alice")
|
||||||
|
await register_user(
|
||||||
|
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username_or_email": username, "password": "password123"},
|
||||||
|
headers={"X-Forwarded-For": "203.0.113.7, 10.0.0.1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions = (await client.get("/api/auth/sessions")).json()
|
||||||
|
assert sessions[0]["ip_address"] == "203.0.113.7"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoking_another_session_logs_it_out(client, app, db_session):
|
||||||
|
username = _unique("alice")
|
||||||
|
await register_and_login(client, db_session, username=username)
|
||||||
|
|
||||||
|
# A second "device" -- a separate client hitting the same app instance
|
||||||
|
# (so it shares the DB-override wiring), its own independent cookie.
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as other_device:
|
||||||
|
await login_as(other_device, username)
|
||||||
|
assert len((await other_device.get("/api/auth/sessions")).json()) == 2
|
||||||
|
|
||||||
|
mine = (await client.get("/api/auth/sessions")).json()
|
||||||
|
assert len(mine) == 2
|
||||||
|
not_current = next(s for s in mine if not s["is_current"])
|
||||||
|
|
||||||
|
revoke = await client.delete(f"/api/auth/sessions/{not_current['id']}")
|
||||||
|
assert revoke.status_code == 204
|
||||||
|
|
||||||
|
# The other device's own cookie is now dead.
|
||||||
|
resp = await other_device.get("/api/auth/sessions")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
# And the revoked session no longer shows up for the account at all.
|
||||||
|
remaining = (await client.get("/api/auth/sessions")).json()
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0]["is_current"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cannot_revoke_another_users_session(client, app, db_session):
|
||||||
|
await register_and_login(client, db_session, username=_unique("alice"))
|
||||||
|
my_session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
|
||||||
|
await register_and_login(bob_client, db_session, username=_unique("bob"))
|
||||||
|
resp = await bob_client.delete(f"/api/auth/sessions/{my_session_id}")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
# Untouched -- still exactly one active session for alice.
|
||||||
|
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_revoking_own_current_session_works_then_logs_this_client_out(client, db_session):
|
||||||
|
await register_and_login(client, db_session, username=_unique("alice"))
|
||||||
|
session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
||||||
|
|
||||||
|
# Revoking your own *current* session is allowed (a remote sign-out of
|
||||||
|
# this same device is a legitimate, if odd, thing to do).
|
||||||
|
first = await client.delete(f"/api/auth/sessions/{session_id}")
|
||||||
|
assert first.status_code == 204
|
||||||
|
|
||||||
|
# A second call with the same (now-dead) cookie can't even reach the
|
||||||
|
# revoke check -- get_current_user itself already 401s.
|
||||||
|
second = await client.delete(f"/api/auth/sessions/{session_id}")
|
||||||
|
assert second.status_code == 401
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiFetch, ApiError, NetworkError } from './client'
|
import { apiFetch, ApiError, NetworkError } from './client'
|
||||||
import type { User } from '../types'
|
import type { User, UserSession } from '../types'
|
||||||
|
|
||||||
// No register() here: this is an invite-only site. Accounts are created by
|
// No register() here: this is an invite-only site. Accounts are created by
|
||||||
// an operator via the backend CLI (`python -m app.cli create-user`), not
|
// an operator via the backend CLI (`python -m app.cli create-user`), not
|
||||||
@@ -16,6 +16,16 @@ export function logout(): Promise<void> {
|
|||||||
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
|
return apiFetch<void>('/api/auth/logout', { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #69: every device/browser currently logged into this account, newest
|
||||||
|
// last-seen first -- see backend's app/schemas/session.py.
|
||||||
|
export function listSessions(): Promise<UserSession[]> {
|
||||||
|
return apiFetch<UserSession[]>('/api/auth/sessions')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeSession(sessionId: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/api/auth/sessions/${sessionId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
export function me(): Promise<User> {
|
export function me(): Promise<User> {
|
||||||
return apiFetch<User>('/api/auth/me')
|
return apiFetch<User>('/api/auth/me')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,6 +429,25 @@
|
|||||||
color: var(--ds-muted);
|
color: var(--ds-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-list-row-action {
|
||||||
|
flex: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ds-danger);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-list-row-action:hover:not(:disabled) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-list-row-action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-modal-avatar-row {
|
.profile-modal-avatar-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||||
import { changePassword, me, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth'
|
import {
|
||||||
|
changePassword,
|
||||||
|
listSessions,
|
||||||
|
me,
|
||||||
|
removeAvatar,
|
||||||
|
revokeSession,
|
||||||
|
updateProfile,
|
||||||
|
updateTheme,
|
||||||
|
uploadAvatar,
|
||||||
|
} from '../api/auth'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import {
|
import {
|
||||||
activateCustomTheme,
|
activateCustomTheme,
|
||||||
@@ -12,7 +21,7 @@ import { getUserAvatarUrl } from '../api/users'
|
|||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { hashIndex } from '../lib/avatar'
|
import { hashIndex } from '../lib/avatar'
|
||||||
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
|
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
|
||||||
import type { CustomTheme, CustomThemeColors } from '../types'
|
import type { CustomTheme, CustomThemeColors, UserSession } from '../types'
|
||||||
import { ThemeBuilderModal } from './ThemeBuilderModal'
|
import { ThemeBuilderModal } from './ThemeBuilderModal'
|
||||||
import { UserAvatar } from './UserAvatar'
|
import { UserAvatar } from './UserAvatar'
|
||||||
import './Modal.css'
|
import './Modal.css'
|
||||||
@@ -44,7 +53,7 @@ interface ProfileModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileModal({ onClose }: ProfileModalProps) {
|
export function ProfileModal({ onClose }: ProfileModalProps) {
|
||||||
const { user, updateUser } = useAuth()
|
const { user, updateUser, logout } = useAuth()
|
||||||
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
|
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [savingName, setSavingName] = useState(false)
|
const [savingName, setSavingName] = useState(false)
|
||||||
@@ -66,6 +75,10 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
|||||||
const [passwordSuccess, setPasswordSuccess] = useState(false)
|
const [passwordSuccess, setPasswordSuccess] = useState(false)
|
||||||
const [savingPassword, setSavingPassword] = useState(false)
|
const [savingPassword, setSavingPassword] = useState(false)
|
||||||
|
|
||||||
|
const [sessions, setSessions] = useState<UserSession[]>([])
|
||||||
|
const [sessionsError, setSessionsError] = useState<string | null>(null)
|
||||||
|
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listCustomThemes()
|
listCustomThemes()
|
||||||
.then(setCustomThemes)
|
.then(setCustomThemes)
|
||||||
@@ -73,6 +86,12 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
|||||||
// Non-critical -- the saved-themes list just stays empty; presets
|
// Non-critical -- the saved-themes list just stays empty; presets
|
||||||
// and everything else in this modal still work fine.
|
// and everything else in this modal still work fine.
|
||||||
})
|
})
|
||||||
|
listSessions()
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(() => {
|
||||||
|
// Same non-critical treatment -- an empty list just means this
|
||||||
|
// section renders no rows rather than failing the whole modal.
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
@@ -252,6 +271,27 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRevokeSession(session: UserSession) {
|
||||||
|
setSessionsError(null)
|
||||||
|
setRevokingSessionId(session.id)
|
||||||
|
try {
|
||||||
|
if (session.is_current) {
|
||||||
|
// Revoking your own current session is really just "sign out" --
|
||||||
|
// go through the normal logout path so local auth state (and the
|
||||||
|
// rest of the app) clears immediately, instead of waiting for the
|
||||||
|
// next request to organically 401.
|
||||||
|
await logout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await revokeSession(session.id)
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== session.id))
|
||||||
|
} catch (err) {
|
||||||
|
setSessionsError(err instanceof ApiError ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setRevokingSessionId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
|
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
|
||||||
const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null
|
const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null
|
||||||
|
|
||||||
@@ -464,6 +504,37 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<hr className="modal-divider" />
|
||||||
|
|
||||||
|
<div className="modal-field-label">Active sessions</div>
|
||||||
|
{sessionsError && <p className="modal-error">{sessionsError}</p>}
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<p className="modal-empty">No active sessions.</p>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<div key={session.id} className="modal-list-row">
|
||||||
|
<div className="modal-list-row-body">
|
||||||
|
<div className="modal-list-row-title">
|
||||||
|
{session.device_label}
|
||||||
|
{session.is_current && ' · This device'}
|
||||||
|
</div>
|
||||||
|
<div className="modal-list-row-sub">
|
||||||
|
{session.ip_address ?? 'Unknown location'} · last active{' '}
|
||||||
|
{new Date(session.last_seen_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-list-row-action"
|
||||||
|
disabled={revokingSessionId === session.id}
|
||||||
|
onClick={() => handleRevokeSession(session)}
|
||||||
|
>
|
||||||
|
{session.is_current ? 'Sign out' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export interface User {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #69: one row per logged-in device/browser -- see backend's app/models/session.py.
|
||||||
|
export interface UserSession {
|
||||||
|
id: string
|
||||||
|
ip_address: string | null
|
||||||
|
device_label: string
|
||||||
|
created_at: string
|
||||||
|
last_seen_at: string
|
||||||
|
is_current: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserDirectoryEntry {
|
export interface UserDirectoryEntry {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
Reference in New Issue
Block a user