The séance tells guests "claim a name to keep your codex". It was a lie. register() unconditionally created a brand-new User row with a fresh UUID, so a wanderer's essence, discovered entities, sightings, ritual/judgment history and Ghost Log — every one of them foreign-keyed to the guest's user_id — were silently orphaned the moment they registered. Now that every visitor starts as a guest, that hit essentially everyone who ever signed up. A wanderer hitting /register now renames that same row in place, keeping all relationships intact. The existing AuthSession stays valid (same user_id), so claiming a name doesn't even log you out. Scoped deliberately to wanderers. My first attempt rejected ANY authenticated caller with a 409, which broke registering a second account while logged in — a legitimate flow (shared computer, alt account) that tests/test_device.py::test_device_feed_only_broadcasts_to_the_owning_user caught immediately: its second register 409'd, its login then failed, and "user B's" device got paired to user A, silently defeating a cross-user-isolation assertion. A named caller's cookie is now ignored and the normal create-a-new-row path runs. Adds get_optional_current_user (None instead of 401) for endpoints that behave differently for anonymous vs. authenticated callers but must stay reachable without auth. This was one of eight findings from an adversarial audit whose verifier agents all died on session limits, so nothing was machine-verified — I confirmed this one by reading the code and then proving it end-to-end. The other seven remain unchecked. 331 backend tests pass. Verified live: guest 23846555 -> livehunter1, same id, same session still valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
49 lines
1.9 KiB
Python
49 lines
1.9 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from fastapi import Cookie, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db import get_db
|
|
from app.models.auth_session import AuthSession, hash_token
|
|
from app.models.user import User
|
|
|
|
SESSION_COOKIE_NAME = "qm_session"
|
|
|
|
|
|
async def get_current_user(
|
|
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
if qm_session is None:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
|
|
|
token_hash = hash_token(qm_session)
|
|
result = await db.execute(select(AuthSession).where(AuthSession.token_hash == token_hash))
|
|
session = result.scalar_one_or_none()
|
|
if session is None or session.expires_at < datetime.now(timezone.utc):
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session expired")
|
|
|
|
user = await db.get(User, session.user_id)
|
|
if user is None:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
|
|
return user
|
|
|
|
|
|
async def get_optional_current_user(
|
|
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User | None:
|
|
"""Same lookup as get_current_user, but None instead of a 401 when
|
|
there is no valid session — for endpoints (like /auth/register) that
|
|
behave differently for an anonymous caller vs. an already-authenticated
|
|
one, but must not require auth to be reachable at all."""
|
|
if qm_session is None:
|
|
return None
|
|
token_hash = hash_token(qm_session)
|
|
result = await db.execute(select(AuthSession).where(AuthSession.token_hash == token_hash))
|
|
session = result.scalar_one_or_none()
|
|
if session is None or session.expires_at < datetime.now(timezone.utc):
|
|
return None
|
|
return await db.get(User, session.user_id)
|