siftlode/backend/app/auth.py
npeter83 18e345a4de fix(security): patch cryptography CVEs, upgrade pip at build, harden /auth/upgrade
- requirements: cryptography >=46.0.7 (was pinned <46, which excluded the fix for
  the CVEs pip-audit flagged in our Fernet/crypto library). pip-audit now clean.
- Dockerfile: upgrade pip before installing deps (patches installer-level CVEs).
- auth: /auth/upgrade now defaults to the least-privileged read scope; only an
  explicit access=write requests the write scope.
2026-06-14 05:59:34 +02:00

242 lines
9.1 KiB
Python

import logging
import re
from datetime import datetime, 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.config import settings
from app.db import get_db
from app.models import Invite, OAuthToken, User
from app.security import encrypt
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# 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 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 "<no email>")
# 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"
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()
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):
request.session.clear()
return JSONResponse({"ok": True})
@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"}
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:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
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."""
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,
}