feat(demo): hidden /auth/demo login + require_human guard
Whitelisted emails enter the shared demo user via /auth/demo (lazily created, no OAuth token/scope), rate-limited per IP and answering uniformly so it can't be hammered as an enumeration oracle. Add a require_human dependency that blocks the demo account from quota-spending sync endpoints and the OAuth upgrade flow, and surface is_demo on /api/me.
This commit is contained in:
parent
9cac2cd335
commit
5936436d26
3 changed files with 86 additions and 8 deletions
|
|
@ -11,11 +11,21 @@ from sqlalchemy.orm import Session
|
|||
from app import email as email_mod
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Invite, OAuthToken, User
|
||||
from app.models import DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.security import encrypt
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
# The single shared demo account's stable identity. The row is created lazily on first
|
||||
# demo login (see get_or_create_demo_user); these are just its sentinel keys.
|
||||
DEMO_GOOGLE_SUB = "__demo__"
|
||||
DEMO_EMAIL = "demo@siftlode.local"
|
||||
|
||||
# Throttle the hidden demo-login endpoint per client IP: enough for a real paste-and-wait,
|
||||
# stingy enough that the field can't be hammered as an enumeration oracle or for DoS.
|
||||
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
|
||||
|
||||
# How many recently-used accounts to keep switchable in one browser session.
|
||||
MAX_SESSION_ACCOUNTS = 6
|
||||
|
||||
|
|
@ -77,6 +87,45 @@ def is_allowed(db: Session, email: str) -> bool:
|
|||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_demo_user(db: Session) -> User:
|
||||
"""The one shared demo user, created on first use. No OAuth token / YouTube scope, so
|
||||
has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it."""
|
||||
user = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
google_sub=DEMO_GOOGLE_SUB,
|
||||
email=DEMO_EMAIL,
|
||||
display_name="Demo",
|
||||
role="user",
|
||||
is_demo=True,
|
||||
preferences={"language": "en"},
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the
|
||||
real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise."""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||
was created (so the caller can notify admins), else None for already-known emails."""
|
||||
|
|
@ -224,6 +273,24 @@ async def request_access(
|
|||
return {"status": "pending"}
|
||||
|
||||
|
||||
@router.post("/demo")
|
||||
def demo_login(payload: dict, 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_ip(request)):
|
||||
return {"authenticated": False}
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
if _EMAIL_RE.match(email) and is_demo_allowed(db, email):
|
||||
demo = get_or_create_demo_user(db)
|
||||
request.session["user_id"] = demo.id
|
||||
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
||||
return {"authenticated": True}
|
||||
return {"authenticated": False}
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
|
|
@ -241,9 +308,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|||
return user
|
||||
|
||||
|
||||
def require_human(user: User = Depends(current_user)) -> User:
|
||||
"""Reject the shared demo account from actions that need a real Google/YouTube identity
|
||||
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
|
||||
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
|
||||
where there's no scope check to lean on."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/upgrade")
|
||||
async def upgrade(
|
||||
request: Request, access: str = "read", user: User = Depends(current_user)
|
||||
request: Request, access: str = "read", user: User = Depends(require_human)
|
||||
):
|
||||
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
|
||||
read-only (enough to build the feed); `access=write` additionally grants management
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue