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:
2026-08-28 20:22:50 -06:00
co-authored by Claude Sonnet 5
parent 278f8bb995
commit b26643527d
15 changed files with 554 additions and 14 deletions
+49 -3
View File
@@ -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")
+2 -1
View File
@@ -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