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.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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user