import hashlib import logging import re import secrets from datetime import datetime, timedelta, timezone from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from sqlalchemy import select from sqlalchemy.orm import Session 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 AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.ratelimit import RateLimiter from app.security import encrypt, hash_password, verify_password # Email+password auth tuning. PASSWORD_MIN_LEN = 10 VERIFY_TTL = timedelta(hours=24) RESET_TTL = timedelta(hours=1) _register_limiter = RateLimiter(max_events=5, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300) _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 # Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do # NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new # user gets a clean, familiar consent. YouTube access is granted later, one step at a # time, by the onboarding wizard via /auth/upgrade (incremental authorization): # - read -> youtube.readonly (read subscriptions, build the feed) # - write -> youtube (full: unsubscribe / playlist export) # Both YouTube scopes are "sensitive"; the wizard explains the Google warning before # sending the user there, so the extra permission never feels like a surprise. WRITE_SCOPE = "https://www.googleapis.com/auth/youtube" READ_SCOPE = f"{WRITE_SCOPE}.readonly" BASE_SCOPES = "openid email profile" READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}" WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}" log = logging.getLogger("subfeed.auth") router = APIRouter(prefix="/auth", tags=["auth"]) oauth = OAuth() oauth.register( name="google", client_id=settings.google_client_id, client_secret=settings.google_client_secret, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_kwargs={"scope": BASE_SCOPES}, ) 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 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.""" email = (email or "").lower() if not _EMAIL_RE.match(email): return None inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() if inv is not None: # Let a previously-denied person ask again; don't disturb approved/pending rows. if inv.status == "denied": inv.status = "pending" inv.requested_at = datetime.now(timezone.utc) inv.decided_at = None inv.decided_by = None db.commit() return inv return None inv = Invite(email=email, status="pending") db.add(inv) db.commit() return inv @router.get("/login") async def login(request: Request): # access_type=offline ensures a refresh_token on first authorization. We avoid # prompt=consent so returning users get a quick sign-in; the stored refresh token # is kept when Google doesn't re-issue one (see the callback). return await oauth.google.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", prompt="select_account", include_granted_scopes="true", scope=BASE_SCOPES, ) @router.get("/callback") async def callback( request: Request, background: BackgroundTasks, db: Session = Depends(get_db), ): try: token = await oauth.google.authorize_access_token(request) except OAuthError as exc: raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}") userinfo = token.get("userinfo") if not userinfo or not userinfo.get("sub"): raise HTTPException(status_code=400, detail="No user info returned by Google") email = (userinfo.get("email") or "").lower() if not is_allowed(db, email): log.warning("Login denied (not approved): %s", email or "") # A denied Google login doubles as an access request: record it for the admin and # land the user on a friendly "request received" screen instead of a raw 403. if email: inv = upsert_pending_invite(db, email) if inv is not None and settings.admin_email_set: background.add_task( email_mod.send_admin_new_request, sorted(settings.admin_email_set), email, ) return RedirectResponse(url="/?access=requested") return RedirectResponse(url="/?access=denied") user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none() if user is None: user = User(google_sub=userinfo["sub"], email=email) db.add(user) user.email = email user.display_name = userinfo.get("name") user.avatar_url = userinfo.get("picture") user.role = "admin" if email in settings.admin_email_set else "user" # Default UI language from the Google-reported locale on first login (if the user hasn't # picked one yet); unsupported locales fall back to English. prefs = dict(user.preferences or {}) if not prefs.get("language"): loc = (userinfo.get("locale") or "").split("-")[0].lower() prefs["language"] = loc if loc in {"en", "hu", "de"} else "en" user.preferences = prefs db.flush() tok = user.token or OAuthToken(user=user) # Google only returns a refresh_token on (re)consent; keep the previous one otherwise. if token.get("refresh_token"): tok.refresh_token_enc = encrypt(token["refresh_token"]) tok.access_token = token.get("access_token") expires_at = token.get("expires_at") tok.expiry = ( datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None ) # include_granted_scopes=true means Google returns the union of all scopes the user # has ever granted this app, so this correctly reflects read/write upgrades too. tok.scopes = token.get("scope") or BASE_SCOPES db.add(tok) db.commit() # Multi-session: remember every account that has authenticated in this browser so the # user can switch between them without going through Google again. Only accounts that # actually completed OAuth here land in this list (it's 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:] request.session["user_id"] = user.id log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) return RedirectResponse(url="/") @router.post("/logout") async def logout(request: Request): """Sign out the active account. If other accounts have authenticated in this browser, switch to the most recent one instead of fully logging out.""" active = request.session.get("user_id") remaining = [a for a in (request.session.get("account_ids") or []) if a != active] if remaining: request.session["account_ids"] = remaining request.session["user_id"] = remaining[-1] return JSONResponse({"ok": True, "switched": True}) request.session.clear() return JSONResponse({"ok": True, "switched": False}) @router.post("/request-access") async def request_access( payload: dict, background: BackgroundTasks, db: Session = Depends(get_db), ) -> dict: """Public: ask for access. Idempotent — repeat requests for the same email don't duplicate or re-spam admins (upsert returns None for an already-pending row).""" email = (payload.get("email") or "").strip().lower() if not _EMAIL_RE.match(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") if is_allowed(db, email): return {"status": "approved"} # already allowed — just sign in inv = upsert_pending_invite(db, email) if inv is not None and settings.admin_email_set: background.add_task( email_mod.send_admin_new_request, sorted(settings.admin_email_set), email ) 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 _establish_session(request: Request, user: User) -> None: """Sign `user` in for this browser session, mirroring the Google callback's multi-account handling so password sign-in joins the same account switcher.""" 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:] request.session["user_id"] = user.id def _app_base() -> str: """Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback).""" u = settings.oauth_redirect_url return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/") def _hash_token(raw: str) -> str: return hashlib.sha256(raw.encode()).hexdigest() def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str: """Create a single-use token of `kind`; only its hash is stored. Returns the raw token (goes only into the emailed link).""" raw = secrets.token_urlsafe(32) db.add( AuthToken( user_id=user.id, kind=kind, token_hash=_hash_token(raw), expires_at=datetime.now(timezone.utc) + ttl, ) ) db.commit() return raw def _consume_token(db: Session, raw: str | None, kind: str) -> User | None: """Validate + burn a token. Returns its user, or None if missing/expired/used/wrong kind.""" if not raw: return None row = db.execute( select(AuthToken).where( AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind ) ).scalar_one_or_none() if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc): return None row.used_at = datetime.now(timezone.utc) db.commit() return db.get(User, row.user_id) @router.post("/register") def register( payload: dict, request: Request, background: BackgroundTasks, db: Session = Depends(get_db), ) -> dict: """Email+password registration. Creates a pending (inactive, unverified) account, sends a verification link, and records an access request for admin approval. Two gates before sign-in: verified email AND admin approval. Responses are uniform once input is valid, so the endpoint can't be used to discover which emails are registered.""" email = (payload.get("email") or "").strip().lower() password = payload.get("password") or "" # Input validation is safe to reveal (independent of whether the email exists). if not _EMAIL_RE.match(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") if len(password) < PASSWORD_MIN_LEN: raise HTTPException( status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.", ) if not sysconfig.get_bool(db, "allow_registration"): raise HTTPException(status_code=403, detail="Registration is currently closed.") if not _register_limiter.allow(_client_ip(request)): return {"status": "ok"} # silently throttle; uniform response existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if existing is None: user = User( email=email, password_hash=hash_password(password), is_active=False, email_verified=False, ) db.add(user) db.flush() upsert_pending_invite(db, email) # admin-approval gate raw = _issue_token(db, user, "verify", VERIFY_TTL) background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}") if settings.admin_email_set: background.add_task( email_mod.send_admin_new_request, sorted(settings.admin_email_set), email ) # Existing email → do nothing visible (no enumeration). Uniform success either way. return {"status": "ok"} @router.get("/verify") def verify_email(token: str, db: Session = Depends(get_db)): """Confirm an email-verification link, then bounce to a friendly status on the app.""" user = _consume_token(db, token, "verify") if user is None: return RedirectResponse(url="/?verify=invalid") user.email_verified = True db.commit() return RedirectResponse(url="/?verify=ok") @router.post("/password-login") def password_login( payload: dict, request: Request, db: Session = Depends(get_db) ) -> dict: """Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration). Once the password is proven correct, the owner gets a specific reason if their account is still pending — that's not an enumeration leak (they already hold the password).""" if not _login_limiter.allow(_client_ip(request)): raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") email = (payload.get("email") or "").strip().lower() password = payload.get("password") or "" user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if user is None or user.is_demo or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid email or password.") if not user.email_verified: raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).") if not user.is_active: raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.") _establish_session(request, user) log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role) return {"ok": True} @router.post("/password-reset/request") def password_reset_request( payload: dict, request: Request, background: BackgroundTasks, db: Session = Depends(get_db), ) -> dict: """Request a password-reset link. Uniform response regardless of whether the email has a password account, so it can't probe for registered emails.""" if not _reset_limiter.allow(_client_ip(request)): return {"status": "ok"} email = (payload.get("email") or "").strip().lower() if _EMAIL_RE.match(email): user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if user is not None and user.password_hash and not user.is_demo: raw = _issue_token(db, user, "reset", RESET_TTL) background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}") return {"status": "ok"} @router.post("/password-reset/confirm") def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict: """Set a new password from a valid reset token, then invalidate any other reset tokens.""" token = payload.get("token") new_password = payload.get("password") or "" if len(new_password) < PASSWORD_MIN_LEN: raise HTTPException( status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.", ) user = _consume_token(db, token, "reset") if user is None: raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") user.password_hash = hash_password(new_password) # Burn any other outstanding reset tokens for this user. for row in db.execute( select(AuthToken).where( AuthToken.user_id == user.id, AuthToken.kind == "reset", AuthToken.used_at.is_(None) ) ).scalars(): row.used_at = datetime.now(timezone.utc) db.commit() return {"ok": True} def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id = request.session.get("user_id") 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: request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") # Always keep the active account in the switchable list — covers sessions created before # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. accounts = request.session.get("account_ids") or [] if user_id not in accounts: accounts.append(user_id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] 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) ): """Incremental consent for the onboarding wizard. `access=read` grants YouTube read-only (enough to build the feed); `access=write` additionally grants management (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="/") scope = WRITE_SCOPES if access == "write" else READ_SCOPES return await oauth.google.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", prompt="consent", include_granted_scopes="true", scope=scope, ) @router.get("/me") async def me(user: User = Depends(current_user)) -> dict: return { "id": user.id, "email": user.email, "display_name": user.display_name, "avatar_url": user.avatar_url, "role": user.role, }