feat!: remove the shared demo account

The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
This commit is contained in:
2026-10-05 00:19:38 +02:00
parent cca3315836
commit 6d8b7be6d8
109 changed files with 487 additions and 1400 deletions
+11 -60
View File
@@ -31,7 +31,6 @@ from app.auth import (
env_admin_canonicals,
env_allowed_canonicals,
find_user_by_email,
get_or_create_demo_user,
google_enabled,
is_allowed,
log,
@@ -40,7 +39,7 @@ from app.auth import (
)
from app.config import settings
from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text
from app.models import AuthToken, Invite, OAuthToken, User, display_text
from app.ratelimit import RateLimiter, client_key
from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import canonical_email, valid_email
@@ -92,8 +91,6 @@ def _notify_suspended(background: BackgroundTasks, user: User) -> None:
)
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
router = APIRouter(prefix="/auth", tags=["auth"])
_oauth = OAuth()
@@ -161,18 +158,6 @@ def notify_admins_of_request(
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
def is_demo_allowed(db: Session, email: str) -> bool:
"""Whether this email may enter the shared demo account (no Google sign-in)."""
if not email:
return False
return (
db.execute(
select(DemoWhitelist).where(DemoWhitelist.email == email.lower())
).scalar_one_or_none()
is not None
)
class InviteRequest(NamedTuple):
"""What an access request settled on.
@@ -588,7 +573,7 @@ def _complete_link(
if link_uid != request.session.get("user_id") and link_uid not in accounts:
return RedirectResponse(url="/?link=error")
user = db.get(User, link_uid)
if user is None or user.is_demo:
if user is None:
return RedirectResponse(url="/?link=error")
other = db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none()
if other is not None:
@@ -634,36 +619,12 @@ async def logout(request: Request):
return JSONResponse({"ok": True, "switched": False})
@router.post("/demo")
def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -> dict:
"""Hidden demo entry: a whitelisted email logs straight into the shared demo account,
no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes,
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
exactly like a non-match (authenticated=false) so the field can't be hammered as an
enumeration oracle. On a match it sets the session and the client reloads into the app."""
if not _demo_limiter.allow(client_key(request)):
return {"ok": True, "authenticated": False}
email = (body.email or "").strip().lower()
if valid_email(email) and is_demo_allowed(db, email):
demo = get_or_create_demo_user(db)
# Isolate the demo: drop any real-account wallet on this tab first, so the demo session
# can't switch to / act as a previously signed-in account via the multi-account header.
request.session.clear()
request.session["user_id"] = demo.id
_remember_epoch(request, demo)
# The demo key IS an address, so it is not in the line — see the denied branch above.
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
return {"ok": True, "authenticated": True}
return {"ok": True, "authenticated": False}
def _establish_session(request: Request, user: User) -> None:
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
and password login, so both join the same account switcher and both stamp `last_login_at` (the
caller commits). Multi-session: every account that authenticated in this browser is remembered so
the user can switch between them; only accounts that actually proved themselves HERE land in that
wallet (it is the proof they may be switched to). The demo entry deliberately does not come
through here: it clears the wallet instead, and its shared account has no meaningful last login."""
wallet (it is the proof they may be switched to)."""
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
@@ -847,7 +808,7 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
# and bumps the session epoch. The residual is logged as SEC-16.
user = existing
if not user.email_verified and not user.is_demo and email_mod.email_enabled():
if not user.email_verified and email_mod.email_enabled():
send_verification_link(db, user)
return
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
@@ -981,7 +942,7 @@ def _resend_verify_if_eligible(email: str) -> None:
return
with SessionLocal() as db:
user = find_user_by_email(db, email)
if user is not None and not user.email_verified and not user.is_demo:
if user is not None and not user.email_verified:
send_verification_link(db, user)
@@ -1027,11 +988,11 @@ def password_login(
email = (body.email or "").strip().lower()
password = body.password or ""
user = find_user_by_email(db, email)
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
# Always run the KDF (against a decoy hash for an unknown / passwordless account) so the
# response time is the same whether or not the email exists — no timing-based enumeration.
candidate_hash = user.password_hash if (user and not user.is_demo) else None
candidate_hash = user.password_hash if user else None
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
if user is None or user.is_demo or not password_ok:
if user is None or not password_ok:
raise _login_refused(401, "invalid")
if user.is_suspended:
_notify_suspended(background, user)
@@ -1070,7 +1031,7 @@ def password_reset_request(
def _send_reset_if_eligible(email: str) -> None:
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
for a real (non-demo) password account. Runs after the response with its own DB session, so the
for a password account. Runs after the response with its own DB session, so the
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
can't leak into proxy/access logs or a Referer (SB3).
@@ -1081,7 +1042,7 @@ def _send_reset_if_eligible(email: str) -> None:
proves identity through the session rather than by mail."""
with SessionLocal() as db:
user = find_user_by_email(db, email)
if user is None or user.is_demo:
if user is None:
return
if user.password_hash or user.password_revoked_at:
raw = _issue_token(db, user, "reset", RESET_TTL)
@@ -1208,9 +1169,7 @@ def logout_others(
) -> dict:
"""SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
if user.is_demo:
raise HTTPException(status_code=403, detail={"code": "demo"})
in somewhere" / a suspected session compromise."""
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
# session at a newer epoch than the DB (see set_password).
bump_session_epoch(user)
@@ -1229,8 +1188,6 @@ async def link_google(
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/?link=error")
@@ -1256,8 +1213,6 @@ def set_password(
"""Set or change the signed-in account's password. Setting a first password (Google-only
account) needs no current password — the session already proves identity. Changing an
existing one requires the current password, so a hijacked session can't silently rotate it."""
if user.is_demo:
raise HTTPException(status_code=403, detail={"code": "demo"})
new_password = body.password or ""
if len(new_password) < PASSWORD_MIN_LEN:
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
@@ -1288,10 +1243,6 @@ async def upgrade(
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
the least-privileged read scope."""
# This is the browser-facing redirect entry point, so the shared demo account is bounced
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/")