diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 2704f55..85e232a 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,3 +1,4 @@ +import secrets from datetime import datetime, timezone from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status @@ -9,6 +10,7 @@ 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 @@ -16,6 +18,15 @@ 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)): @@ -34,6 +45,63 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)) 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, diff --git a/backend/tests/test_auth_guest.py b/backend/tests/test_auth_guest.py new file mode 100644 index 0000000..c186697 --- /dev/null +++ b/backend/tests/test_auth_guest.py @@ -0,0 +1,60 @@ +import pytest + +import app.routes.auth as auth_module + + +@pytest.fixture(autouse=True) +def _reset_guest_limiter(): + # The limiter is module-level state; a previous test's hits would bleed + # into the next one's per-IP budget. + auth_module.guest_limiter._hits.clear() + yield + + +@pytest.mark.asyncio +async def test_guest_creates_wanderer_and_cookie_works_on_me(client): + response = await client.post("/auth/guest") + assert response.status_code == 201 + body = response.json() + assert body["username"].startswith("wanderer-") + assert "password" not in body + assert "qm_session" in response.cookies + + me_resp = await client.get("/auth/me") + assert me_resp.status_code == 200 + assert me_resp.json()["username"] == body["username"] + + +@pytest.mark.asyncio +async def test_guest_cannot_login_with_any_password(client): + response = await client.post("/auth/guest") + username = response.json()["username"] + login_resp = await client.post( + "/auth/login", json={"username": username, "password": "anythingatall"} + ) + assert login_resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_guest_rate_limit_fires_per_ip(client): + for _ in range(5): + response = await client.post("/auth/guest") + assert response.status_code == 201 + response = await client.post("/auth/guest") + assert response.status_code == 429 + + +@pytest.mark.asyncio +async def test_guest_username_collision_retries(client, monkeypatch): + taken_resp = await client.post("/auth/guest") + taken = taken_resp.json()["username"].removeprefix("wanderer-") + + # First attempt collides with the existing wanderer; the retry must land + # on the fresh suffix instead of erroring out. + suffixes = iter([taken, "f4ee"]) + monkeypatch.setattr( + auth_module.secrets, "token_hex", lambda n: next(suffixes) + ) + response = await client.post("/auth/guest") + assert response.status_code == 201 + assert response.json()["username"] == "wanderer-f4ee" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ecdfae0..585b068 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -40,6 +40,10 @@ export function login(username: string, password: string): Promise { }) } +export function guest(): Promise { + return request('/auth/guest', { method: 'POST' }) +} + export function logout(): Promise { return request('/auth/logout', { method: 'POST' }) } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 50ae8fa..ff6db2f 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -79,7 +79,8 @@ "switchToRegister": "no name in the registry? first contact →", "switchToLogin": "already carved your name? reconnect →", "failed": "the veil rejected you: {{message}}", - "smallPrint": "your medium handle is only ever stored on this server" + "smallPrint": "your medium handle is only ever stored on this server", + "guest": "slip through as a wanderer" }, "seance": { "modes": { @@ -272,6 +273,11 @@ "entity": "entity", "jitter": "jitter" } + }, + "wanderer": { + "note": "your contacts fade with the mist —", + "claim": "claim a name to keep your codex", + "dismiss": "let the mist take it" } }, "codex": { diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index f8788d8..0a79fa7 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -79,7 +79,8 @@ "switchToRegister": "¿sin nombre en el registro? primer contacto →", "switchToLogin": "¿ya grabaste tu nombre? reconectar →", "failed": "el velo te rechazó: {{message}}", - "smallPrint": "tu nombre de médium solo se guarda en este servidor" + "smallPrint": "tu nombre de médium solo se guarda en este servidor", + "guest": "escabúllete como errante" }, "seance": { "modes": { @@ -272,6 +273,11 @@ "entity": "entidad", "jitter": "fluctuación" } + }, + "wanderer": { + "note": "tus contactos se desvanecen con la niebla —", + "claim": "reclama un nombre para conservar tu códice", + "dismiss": "deja que la niebla se lo lleve" } }, "codex": { diff --git a/frontend/src/pages/EnterPage.css b/frontend/src/pages/EnterPage.css index 289fdf5..32d8559 100644 --- a/frontend/src/pages/EnterPage.css +++ b/frontend/src/pages/EnterPage.css @@ -257,6 +257,38 @@ cursor: wait; } +.enter-guest { + display: block; + width: 100%; + margin-top: 0.9rem; + font-family: 'Cinzel', serif; + font-size: 0.72rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: rgba(216, 230, 221, 0.6); + background: transparent; + border: 1px dashed rgba(216, 230, 221, 0.25); + border-radius: 0; + padding: 0.65rem 1rem; + cursor: pointer; + transition: color 0.25s ease, border-color 0.25s ease; +} + +.enter-guest:hover:not(:disabled) { + color: #8dffc0; + border-color: rgba(124, 255, 178, 0.55); +} + +.enter-guest:focus-visible { + outline: 2px solid #b26bff; + outline-offset: 3px; +} + +.enter-guest:disabled { + opacity: 0.55; + cursor: wait; +} + .enter-smallprint { margin: 1.6rem 0 0; text-align: center; diff --git a/frontend/src/pages/EnterPage.tsx b/frontend/src/pages/EnterPage.tsx index d409529..b8b93da 100644 --- a/frontend/src/pages/EnterPage.tsx +++ b/frontend/src/pages/EnterPage.tsx @@ -13,7 +13,7 @@ type EnterMode = 'login' | 'register' export function EnterPage() { const { t } = useTranslation() - const { user, login, register } = useAuth() + const { user, login, register, guest } = useAuth() const navigate = useNavigate() const [mode, setMode] = useState('login') @@ -22,8 +22,10 @@ export function EnterPage() { const [error, setError] = useState(null) const [busy, setBusy] = useState(false) - // Already seated at the table — no need to knock again. - if (user) { + // Already seated at the table — no need to knock again. Wanderers are the + // exception: the séance's claim-a-name note sends them here to register, + // so they must be allowed to stand at the threshold while still seated. + if (user && !user.username.startsWith('wanderer-')) { return } @@ -118,6 +120,25 @@ export function EnterPage() { + +

{t('enter.smallPrint', { defaultValue: 'your medium handle is only ever stored on this server', diff --git a/frontend/src/pages/SeancePage.css b/frontend/src/pages/SeancePage.css index 674245c..5d4578b 100644 --- a/frontend/src/pages/SeancePage.css +++ b/frontend/src/pages/SeancePage.css @@ -329,6 +329,50 @@ /* ---------- connection banner ---------- */ +.seance-wanderer-note { + position: absolute; + top: 62px; + left: 50%; + transform: translateX(-50%); + z-index: 29; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.9rem; + background: rgba(16, 16, 26, 0.92); + border: 1px dashed rgba(216, 230, 221, 0.3); + border-radius: 3px; + color: rgba(216, 230, 221, 0.65); + font-size: 0.72rem; + letter-spacing: 0.06em; + white-space: nowrap; +} + +.seance-wanderer-note a { + color: #8dffc0; + text-decoration: none; + border-bottom: 1px dotted rgba(141, 255, 192, 0.5); +} + +.seance-wanderer-note a:hover { + border-bottom-style: solid; +} + +.wanderer-note-dismiss { + margin-left: 0.2rem; + background: transparent; + border: none; + color: rgba(216, 230, 221, 0.45); + font-size: 0.9rem; + line-height: 1; + cursor: pointer; + padding: 0 0.2rem; +} + +.wanderer-note-dismiss:hover { + color: #d8e6dd; +} + .seance-conn-banner { position: absolute; top: 62px; diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index fc7cb51..f682280 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -108,6 +108,10 @@ function SeanceSession({ username }: { username: string }) { const { t, i18n } = useTranslation() const [departed, setDeparted] = useState(false) + // Guests are recognized by their generated name prefix — no backend flag. + // Dismissal is per-visit on purpose: the nudge should return next séance. + const isWanderer = username.startsWith('wanderer-') + const [wandererNoteDismissed, setWandererNoteDismissed] = useState(false) const [question, setQuestion] = useState('') const [dossierOpen, setDossierOpen] = useState(true) const [view, setView] = useState('board') @@ -306,6 +310,22 @@ function SeanceSession({ username }: { username: string }) { )} + {/* ---- wanderer note: guests can claim a permanent name ---- */} + {isWanderer && !wandererNoteDismissed && ( +

+ {t('seance.wanderer.note')}{' '} + {t('seance.wanderer.claim')} + +
+ )} + {/* ---- main: board + side column ---- */}
diff --git a/frontend/src/state/auth.tsx b/frontend/src/state/auth.tsx index 76fce0e..697f0cf 100644 --- a/frontend/src/state/auth.tsx +++ b/frontend/src/state/auth.tsx @@ -11,6 +11,7 @@ export type AuthApi = { checking: boolean login: (username: string, password: string) => Promise register: (username: string, password: string) => Promise + guest: () => Promise logout: () => Promise } @@ -42,14 +43,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { [login], ) + const guest = useCallback(async () => { + const u = await api.guest() + setUser(u) + return u + }, []) + const logout = useCallback(async () => { await api.logout() setUser(null) }, []) const value = useMemo( - () => ({ user, checking, login, register, logout }), - [user, checking, login, register, logout], + () => ({ user, checking, login, register, guest, logout }), + [user, checking, login, register, guest, logout], ) return {children} }