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>
207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
import secrets
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db import get_db
|
|
from app.deps import SESSION_COOKIE_NAME, get_current_user, get_optional_current_user
|
|
from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token
|
|
from app.models.unlock import UnlockRecord
|
|
from app.models.user import User
|
|
from app.rate_limit import RateLimiter, resolve_client_ip
|
|
from app.schemas import LoginRequest, RegisterRequest, UserOut
|
|
from app.security import hash_password, verify_password
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
_DUMMY_PASSWORD_HASH = hash_password("dummy-password-for-timing-safety")
|
|
|
|
# Guest creation writes a real user row per call — without a per-IP cap a
|
|
# single client could fill the users table. resolve_client_ip (not the raw
|
|
# socket peer) because internet traffic arrives via the Cloudflare Tunnel.
|
|
guest_limiter = RateLimiter(max_requests=5, window_seconds=3600)
|
|
|
|
# 4 hex chars = 65k names; a full retry budget failing means the wanderer
|
|
# namespace is effectively exhausted, not that we got unlucky.
|
|
_GUEST_NAME_ATTEMPTS = 8
|
|
|
|
# Shared with the guest-provisioning endpoint below, and with the frontend's
|
|
# own username-prefix check (SeancePage's claim-a-name nudge) — a wanderer
|
|
# is any user row whose username starts with this.
|
|
WANDERER_PREFIX = "wanderer-"
|
|
|
|
|
|
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
|
async def register(
|
|
payload: RegisterRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User | None = Depends(get_optional_current_user),
|
|
):
|
|
existing = await db.scalar(select(User).where(User.username == payload.username))
|
|
if existing is not None:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username taken")
|
|
|
|
# A *wanderer* hitting /register is claiming a name for the account they
|
|
# already have, not opening a new one — this is what makes the séance's
|
|
# "claim a name to keep your codex" nudge true rather than a lie.
|
|
# Previously register() always created a brand-new User row, so a
|
|
# guest's essence, discovered entities, ritual/judgment history and
|
|
# Ghost Log (all foreign-keyed to the guest's user_id) were silently
|
|
# abandoned the moment they registered — the exact opposite of what the
|
|
# UI promises. Renaming the SAME row in place keeps every one of those
|
|
# relationships intact, and the existing AuthSession stays valid (same
|
|
# user_id), so claiming a name doesn't log you out.
|
|
#
|
|
# Deliberately scoped to wanderers only: a caller who already has a real
|
|
# name is registering a SECOND account (a legitimate thing to do while
|
|
# logged in — shared computer, alt account), so their cookie is ignored
|
|
# and the normal create-a-new-row path runs. An earlier version of this
|
|
# rejected that case outright and broke exactly that flow.
|
|
if current_user is not None and current_user.username.startswith(WANDERER_PREFIX):
|
|
current_user.username = payload.username
|
|
current_user.password_hash = hash_password(payload.password)
|
|
current_user.email = payload.email
|
|
await db.commit()
|
|
await db.refresh(current_user)
|
|
return current_user
|
|
|
|
user = User(
|
|
username=payload.username,
|
|
password_hash=hash_password(payload.password),
|
|
email=payload.email,
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.post("/guest", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
|
async def guest(
|
|
request: Request,
|
|
response: Response,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
client_ip = resolve_client_ip(
|
|
request.headers, request.client.host if request.client else None
|
|
)
|
|
if not guest_limiter.allow(client_ip):
|
|
raise HTTPException(
|
|
status.HTTP_429_TOO_MANY_REQUESTS,
|
|
"the veil admits only so many wanderers — return later",
|
|
)
|
|
|
|
for _ in range(_GUEST_NAME_ATTEMPTS):
|
|
username = f"{WANDERER_PREFIX}{secrets.token_hex(2)}"
|
|
existing = await db.scalar(select(User).where(User.username == username))
|
|
if existing is None:
|
|
break
|
|
else:
|
|
raise HTTPException(
|
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
"the mist is too crowded — try again",
|
|
)
|
|
|
|
# A guest is a real user: the password is random and never disclosed, so
|
|
# the row is unreachable via /auth/login but works everywhere else.
|
|
user = User(
|
|
username=username,
|
|
password_hash=hash_password(secrets.token_urlsafe(32)),
|
|
)
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
|
|
raw_token, token_hash = generate_session_token()
|
|
session = AuthSession(
|
|
user_id=user.id,
|
|
token_hash=token_hash,
|
|
expires_at=datetime.now(timezone.utc) + SESSION_TTL,
|
|
)
|
|
db.add(session)
|
|
await db.commit()
|
|
|
|
# Same dual-scheme cookie rule as /auth/login (https tunnel vs LAN http).
|
|
response.set_cookie(
|
|
SESSION_COOKIE_NAME,
|
|
raw_token,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=request.url.scheme == "https",
|
|
max_age=int(SESSION_TTL.total_seconds()),
|
|
)
|
|
return user
|
|
|
|
|
|
@router.post("/login", response_model=UserOut)
|
|
async def login(
|
|
payload: LoginRequest,
|
|
request: Request,
|
|
response: Response,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
user = await db.scalar(select(User).where(User.username == payload.username))
|
|
if user is None:
|
|
verify_password(payload.password, _DUMMY_PASSWORD_HASH)
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
|
if not verify_password(payload.password, user.password_hash):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
|
|
|
raw_token, token_hash = generate_session_token()
|
|
session = AuthSession(
|
|
user_id=user.id,
|
|
token_hash=token_hash,
|
|
expires_at=datetime.now(timezone.utc) + SESSION_TTL,
|
|
)
|
|
db.add(session)
|
|
await db.commit()
|
|
|
|
# The app is reached two ways: https via the Cloudflare Tunnel (Secure
|
|
# required) and plain http on the LAN (a Secure cookie would be dropped
|
|
# by the browser entirely, silently breaking the séance socket).
|
|
response.set_cookie(
|
|
SESSION_COOKIE_NAME,
|
|
raw_token,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=request.url.scheme == "https",
|
|
max_age=int(SESSION_TTL.total_seconds()),
|
|
)
|
|
return user
|
|
|
|
|
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def logout(
|
|
request: Request,
|
|
response: Response,
|
|
qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if qm_session is not None:
|
|
token_hash = hash_token(qm_session)
|
|
session = await db.scalar(select(AuthSession).where(AuthSession.token_hash == token_hash))
|
|
if session is not None:
|
|
await db.delete(session)
|
|
await db.commit()
|
|
response.delete_cookie(
|
|
SESSION_COOKIE_NAME,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=request.url.scheme == "https",
|
|
)
|
|
|
|
|
|
@router.get("/me", response_model=UserOut)
|
|
async def me(
|
|
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
|
):
|
|
result = await db.execute(
|
|
select(UnlockRecord.unlock_key).where(UnlockRecord.user_id == user.id)
|
|
)
|
|
unlock_keys = [row[0] for row in result.all()]
|
|
return UserOut(
|
|
id=user.id, username=user.username, essence=user.essence, unlocks=unlock_keys
|
|
)
|