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
796eecb3ac
commit
feb41f4514
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
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ def get_me(
|
|||
"display_name": user.display_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"is_demo": user.is_demo,
|
||||
"can_read": has_read_scope(user),
|
||||
"can_write": has_write_scope(user),
|
||||
"pending_invites": pending_invites,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota, state
|
||||
from app.auth import current_user, has_read_scope
|
||||
from app.auth import current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.sync.runner import (
|
||||
|
|
@ -31,7 +31,7 @@ def _user_channels(db: Session, user: User) -> list[Channel]:
|
|||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
if not has_read_scope(user):
|
||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||
|
|
@ -49,7 +49,7 @@ def sync_subscriptions(
|
|||
|
||||
@router.post("/rss")
|
||||
def sync_rss(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
new = run_rss_poll(db, _user_channels(db, user))
|
||||
return {"new_videos": new}
|
||||
|
|
@ -57,7 +57,7 @@ def sync_rss(
|
|||
|
||||
@router.post("/backfill")
|
||||
def sync_backfill(
|
||||
user: User = Depends(current_user),
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
max_channels: int = 25,
|
||||
) -> dict:
|
||||
|
|
@ -70,7 +70,7 @@ def sync_backfill(
|
|||
|
||||
@router.post("/enrich")
|
||||
def sync_enrich(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
with quota.attribute(user.id, "enrich"):
|
||||
|
|
@ -175,7 +175,7 @@ def my_status(
|
|||
|
||||
@router.post("/deep-all")
|
||||
def deep_all(
|
||||
user: User = Depends(current_user),
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
on: bool = True,
|
||||
) -> dict:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue