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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
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.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token
|
||||||
from app.models.unlock import UnlockRecord
|
from app.models.unlock import UnlockRecord
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.rate_limit import RateLimiter, resolve_client_ip
|
||||||
from app.schemas import LoginRequest, RegisterRequest, UserOut
|
from app.schemas import LoginRequest, RegisterRequest, UserOut
|
||||||
from app.security import hash_password, verify_password
|
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")
|
_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)
|
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
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
|
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)
|
@router.post("/login", response_model=UserOut)
|
||||||
async def login(
|
async def login(
|
||||||
payload: LoginRequest,
|
payload: LoginRequest,
|
||||||
|
|||||||
60
backend/tests/test_auth_guest.py
Normal file
60
backend/tests/test_auth_guest.py
Normal file
@@ -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"
|
||||||
@@ -40,6 +40,10 @@ export function login(username: string, password: string): Promise<User> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function guest(): Promise<User> {
|
||||||
|
return request<User>('/auth/guest', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
export function logout(): Promise<void> {
|
export function logout(): Promise<void> {
|
||||||
return request<void>('/auth/logout', { method: 'POST' })
|
return request<void>('/auth/logout', { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
"switchToRegister": "no name in the registry? first contact →",
|
"switchToRegister": "no name in the registry? first contact →",
|
||||||
"switchToLogin": "already carved your name? reconnect →",
|
"switchToLogin": "already carved your name? reconnect →",
|
||||||
"failed": "the veil rejected you: {{message}}",
|
"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": {
|
"seance": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -272,6 +273,11 @@
|
|||||||
"entity": "entity",
|
"entity": "entity",
|
||||||
"jitter": "jitter"
|
"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": {
|
"codex": {
|
||||||
|
|||||||
@@ -79,7 +79,8 @@
|
|||||||
"switchToRegister": "¿sin nombre en el registro? primer contacto →",
|
"switchToRegister": "¿sin nombre en el registro? primer contacto →",
|
||||||
"switchToLogin": "¿ya grabaste tu nombre? reconectar →",
|
"switchToLogin": "¿ya grabaste tu nombre? reconectar →",
|
||||||
"failed": "el velo te rechazó: {{message}}",
|
"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": {
|
"seance": {
|
||||||
"modes": {
|
"modes": {
|
||||||
@@ -272,6 +273,11 @@
|
|||||||
"entity": "entidad",
|
"entity": "entidad",
|
||||||
"jitter": "fluctuación"
|
"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": {
|
"codex": {
|
||||||
|
|||||||
@@ -257,6 +257,38 @@
|
|||||||
cursor: wait;
|
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 {
|
.enter-smallprint {
|
||||||
margin: 1.6rem 0 0;
|
margin: 1.6rem 0 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type EnterMode = 'login' | 'register'
|
|||||||
|
|
||||||
export function EnterPage() {
|
export function EnterPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { user, login, register } = useAuth()
|
const { user, login, register, guest } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [mode, setMode] = useState<EnterMode>('login')
|
const [mode, setMode] = useState<EnterMode>('login')
|
||||||
@@ -22,8 +22,10 @@ export function EnterPage() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
// Already seated at the table — no need to knock again.
|
// Already seated at the table — no need to knock again. Wanderers are the
|
||||||
if (user) {
|
// 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 <Navigate to="/seance" replace />
|
return <Navigate to="/seance" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +120,25 @@ export function EnterPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="enter-guest"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (busy) return
|
||||||
|
setError(null)
|
||||||
|
setBusy(true)
|
||||||
|
guest()
|
||||||
|
.then(() => navigate('/seance'))
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
setBusy(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('enter.guest')}
|
||||||
|
</button>
|
||||||
|
|
||||||
<p className="enter-smallprint">
|
<p className="enter-smallprint">
|
||||||
{t('enter.smallPrint', {
|
{t('enter.smallPrint', {
|
||||||
defaultValue: 'your medium handle is only ever stored on this server',
|
defaultValue: 'your medium handle is only ever stored on this server',
|
||||||
|
|||||||
@@ -329,6 +329,50 @@
|
|||||||
|
|
||||||
/* ---------- connection banner ---------- */
|
/* ---------- 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 {
|
.seance-conn-banner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 62px;
|
top: 62px;
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
|
||||||
const [departed, setDeparted] = useState(false)
|
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 [question, setQuestion] = useState('')
|
||||||
const [dossierOpen, setDossierOpen] = useState(true)
|
const [dossierOpen, setDossierOpen] = useState(true)
|
||||||
const [view, setView] = useState<BoardView>('board')
|
const [view, setView] = useState<BoardView>('board')
|
||||||
@@ -306,6 +310,22 @@ function SeanceSession({ username }: { username: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ---- wanderer note: guests can claim a permanent name ---- */}
|
||||||
|
{isWanderer && !wandererNoteDismissed && (
|
||||||
|
<div className="seance-wanderer-note" role="note">
|
||||||
|
<span>{t('seance.wanderer.note')}</span>{' '}
|
||||||
|
<Link to="/enter">{t('seance.wanderer.claim')}</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wanderer-note-dismiss"
|
||||||
|
aria-label={t('seance.wanderer.dismiss')}
|
||||||
|
onClick={() => setWandererNoteDismissed(true)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ---- main: board + side column ---- */}
|
{/* ---- main: board + side column ---- */}
|
||||||
<main className="seance-main">
|
<main className="seance-main">
|
||||||
<section className="seance-board-wrap">
|
<section className="seance-board-wrap">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AuthApi = {
|
|||||||
checking: boolean
|
checking: boolean
|
||||||
login: (username: string, password: string) => Promise<User>
|
login: (username: string, password: string) => Promise<User>
|
||||||
register: (username: string, password: string) => Promise<User>
|
register: (username: string, password: string) => Promise<User>
|
||||||
|
guest: () => Promise<User>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +43,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
[login],
|
[login],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const guest = useCallback(async () => {
|
||||||
|
const u = await api.guest()
|
||||||
|
setUser(u)
|
||||||
|
return u
|
||||||
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = useMemo<AuthApi>(
|
const value = useMemo<AuthApi>(
|
||||||
() => ({ user, checking, login, register, logout }),
|
() => ({ user, checking, login, register, guest, logout }),
|
||||||
[user, checking, login, register, logout],
|
[user, checking, login, register, guest, logout],
|
||||||
)
|
)
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user