Files
qtalker---/backend/app/routes/auth.py
Indiana f5320fcdec feat: guest passage — slip through as a wanderer
POST /auth/guest mints a real user row (wanderer-<4 hex>, collision
retry, unusable random password) and issues the normal session cookie,
per-IP rate limited at 5/hour. EnterPage gains the guest action;
the séance shows a dismissible claim-a-name note for wanderer- users.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:18:16 +00:00

174 lines
5.9 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
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
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
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")
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-{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
)