import logging import httpx from fastapi import BackgroundTasks, Depends, HTTPException, Request from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from starlette.requests import HTTPConnection from app import email as email_mod from app import sysconfig from app.config import settings from app.db import get_db from app.models import Invite, User from app.security import decrypt PASSWORD_MIN_LEN = 10 def operator_contact() -> str | None: """Who a blocked user should contact — the first configured admin email, if any.""" return next(iter(sorted(settings.admin_email_set)), None) DEMO_GOOGLE_SUB = "__demo__" DEMO_EMAIL = "demo@siftlode.local" MAX_SESSION_ACCOUNTS = 6 WRITE_SCOPE = "https://www.googleapis.com/auth/youtube" READ_SCOPE = f"{WRITE_SCOPE}.readonly" log = logging.getLogger("siftlode.auth") def google_enabled(db: Session) -> bool: """Whether Sign in with Google is available (both client id + secret resolve to non-empty).""" return bool( sysconfig.get_str(db, "google_client_id") and sysconfig.get_str(db, "google_client_secret") ) def has_read_scope(user: User) -> bool: """Whether the user's stored grant lets us read their YouTube data (build the feed). The full write scope is a superset, so it implies read.""" tok = user.token if tok is None or not tok.scopes: return False granted = tok.scopes.split() return READ_SCOPE in granted or WRITE_SCOPE in granted def has_write_scope(user: User) -> bool: """Whether the user's stored grant includes YouTube write (unsubscribe / export).""" tok = user.token if tok is None or not tok.scopes: return False return WRITE_SCOPE in tok.scopes.split() def is_allowed(db: Session, email: str) -> bool: """May this email sign in? An approved Invite is the source of truth; the env ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).""" if not email: return False email = email.lower() inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() if inv is not None: return inv.status == "approved" return email in settings.allowed_email_set or email in settings.admin_email_set 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 revoke_google_token(token: str | None) -> None: """Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes silently carried over via include_granted_scopes). Fail-soft: never blocks the caller — used from account deletion, where our own data is already gone regardless of Google's response.""" if not token: return try: resp = httpx.post( "https://oauth2.googleapis.com/revoke", data={"token": token}, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=10, ) log.info("Google token revoke: status=%s", resp.status_code) except Exception: log.warning("Google token revoke failed", exc_info=True) def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: """Hard-delete a user and ALL their personal data (subscriptions, tags, video states, playlists, notifications, tokens — all cascade on the users row), erase their access-request row, and revoke their Google grant. Shared by GDPR self-deletion and admin deletion. Does NOT touch any browser session — each caller handles its own.""" email = user.email.lower() user_id = user.id tok = user.token # Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing # ciphertext, so a token row with no refresh token silently failed to revoke). google_token = ( (decrypt(tok.refresh_token_enc) or decrypt(tok.access_token)) if tok else None ) # Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table. db.execute(delete(User).where(User.id == user_id)) db.execute(delete(Invite).where(Invite.email == email)) db.commit() if google_token: background.add_task(revoke_google_token, google_token) # Confirm the erasure to the (now former) user — GDPR good practice, and covers both the # self-service and admin deletion paths since both funnel through here. background.add_task(email_mod.send_account_deleted, email, operator_contact()) ACTIVE_ACCOUNT_HEADER = "x-siftlode-account" def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]: """Which account this connection acts as, and whether that's the session's default account. Takes any HTTPConnection — an HTTP Request OR a WebSocket — so the WS push channel shares this exact per-tab resolution instead of re-implementing it. The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser) plus a default `user_id`. A caller may override the default with the X-Siftlode-Account header, but ONLY for an account already in the wallet — so a tab can't impersonate an account that never authenticated here. Everything else (WebSocket, plain navigations) keeps using the cookie default. """ default_id = conn.session.get("user_id") wallet = conn.session.get("account_ids") or [] # Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a # plain file download). Both are wallet-gated below, so neither can impersonate an # account that never signed into this browser. hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account") if hdr: try: hid = int(hdr) except ValueError: hid = None if hid is not None and hid in wallet: return hid, hid == default_id return default_id, True def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id, is_default = resolved_user_id(request) if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") user = db.get(User, user_id) if user is None or not user.is_active or user.is_suspended: # Suspended/deactivated mid-session → drop the session so the block takes effect at once. # But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale # per-tab header account just 401s that one tab (other tabs' default may still be valid). if is_default: request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") # SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a # password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an # account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too. epochs = request.session.get("epochs") or {} if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0): if is_default: request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") # Always keep the session default in the switchable list — covers sessions created before # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. default_id = request.session.get("user_id") accounts = request.session.get("account_ids") or [] if default_id and default_id not in accounts: accounts.append(default_id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] return user def optional_current_user( request: Request, db: Session = Depends(get_db) ) -> User | None: """Like `current_user` but returns None instead of raising 401 when there's no valid session. Used by the bootstrap /api/me probe so the logged-out landing doesn't log a failed request to the browser console (a non-2xx fetch is a console error no matter how the app handles it).""" try: return current_user(request, db) except HTTPException: return None 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 def require_write_scope(user: User = Depends(require_human)) -> User: """Dependency: require the YouTube write scope (subscribe/unsubscribe, playlist export/edit). Single source of truth for the write gate — replaces the inline `if not has_write_scope(user)` checks that had drifted into three different 403 messages across channels/playlists. Layers on `require_human` because writing to YouTube needs a real, non-demo identity.""" if not has_write_scope(user): raise HTTPException( status_code=403, detail="Enable YouTube editing in Settings first.", ) return user def admin_user(user: User = Depends(current_user)) -> User: """Dependency: require the signed-in user to be an admin. Single source of truth for the admin gate — every admin route/action depends on this rather than re-checking the role.""" if user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") return user def count_admins(db: Session, *, active_only: bool = False) -> int: """How many admins exist (optionally only un-suspended ones). Used by the 'can't remove / suspend / delete the last admin' guards so they don't each re-spell the count query.""" q = select(func.count()).select_from(User).where(User.role == "admin") if active_only: q = q.where(User.is_suspended.is_(False)) return db.scalar(q) or 0