feat(auth): account lifecycle — Google linking, passwords, suspension & deletion plumbing

- Link a Google account to a password account, and adopt the Google identity onto a matching
  email account instead of 500ing on a duplicate; set or change a password from Settings.
- Expose has_google/has_password on /api/me for the Sign-in methods UI.
- Mark Google logins email-verified (backfill existing rows, migration 0024); stop a routine
  login from clobbering an admin-assigned role (env ADMIN_EMAILS stays the bootstrap admin).
- Suspension login-gates (password + Google callback + current_user) with a rate-limited
  'suspended' notice; shared purge_user (cascade delete + access-request cleanup + Google-grant
  revoke) behind self- and admin-deletion; single app_base source for user-facing email links.
This commit is contained in:
npeter83 2026-06-19 19:52:02 +02:00
parent 72cd8e6d65
commit 8c727dd99e
9 changed files with 507 additions and 43 deletions

View file

@ -4,10 +4,11 @@ import re
import secrets
from datetime import datetime, timedelta, timezone
import httpx
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 import delete, select
from sqlalchemy.orm import Session
from app import email as email_mod
@ -16,7 +17,7 @@ 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
from app.security import decrypt, encrypt, hash_password, verify_password
# Email+password auth tuning.
PASSWORD_MIN_LEN = 10
@ -25,6 +26,22 @@ 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)
# At most one "your account is suspended" email per address per hour, so a suspended user who
# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them.
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
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)
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
triggered by someone who doesn't control the account."""
if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact())
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@ -190,7 +207,16 @@ async def callback(
if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google")
sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower()
# Linking mode: an already-authenticated account is attaching this Google identity (a
# password account enabling YouTube or connecting SSO). The marker is set by /auth/link and
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
link_uid = request.session.pop("oauth_link_uid", None)
if link_uid is not None:
return _complete_link(request, db, link_uid, sub, userinfo, token)
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
@ -206,14 +232,49 @@ async def callback(
return RedirectResponse(url="/?access=requested")
return RedirectResponse(url="/?access=denied")
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none()
user = db.query(User).filter(User.google_sub == sub).one_or_none()
if user is None:
user = User(google_sub=userinfo["sub"], email=email)
db.add(user)
# No account carries this Google identity yet. If one already exists for this email —
# e.g. an email+password registration — adopt the Google identity onto it instead of
# inserting a duplicate (which would collide on the unique email and 500). Google has
# verified the address, so this safely unifies password + Google sign-in on one account.
existing = db.query(User).filter(User.email == email).one_or_none()
if existing is not None:
if existing.google_sub is not None and existing.google_sub != sub:
# A different Google identity is already bound to this email — refuse rather than
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email)
return RedirectResponse(url="/?access=denied")
user = existing
user.google_sub = sub
# Google verified the email and is_allowed granted access, so finish activating a
# previously pending password registration (otherwise current_user would bounce it).
user.email_verified = True
user.is_active = True
else:
user = User(google_sub=sub, email=email)
db.add(user)
# A suspended account can't sign in by any method. Block before establishing the session and
# tell the (verified) owner why — a successful Google auth proves they control this account.
if user.is_suspended:
log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email)
return RedirectResponse(url="/?login=suspended")
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"
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
# default for a brand-new account (its role attribute is still unset before flush).
if email in settings.admin_email_set:
user.role = "admin"
elif not user.role:
user.role = "user"
# Google attests the email, so a Google sign-in implicitly verifies it. Setting this also lets
# a Google user who later adds a password sign in (password-login gates on email_verified).
if userinfo.get("email_verified", True):
user.email_verified = True
# 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 {})
@ -223,19 +284,7 @@ async def callback(
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)
_store_token(db, user, token)
db.commit()
# Multi-session: remember every account that has authenticated in this browser so the
@ -249,6 +298,94 @@ async def callback(
return RedirectResponse(url="/")
def _store_token(db: Session, user: User, token: dict) -> None:
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
include_granted_scopes=true means `scope` is the union of everything granted, so this
reflects read/write upgrades correctly."""
tok = user.token or OAuthToken(user=user)
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
tok.scopes = token.get("scope") or BASE_SCOPES
db.add(tok)
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
google_token = (decrypt(tok.refresh_token_enc) or 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())
def _complete_link(
request: Request, db: Session, link_uid: int, sub: str, userinfo: dict, token: dict
):
"""Attach the just-authorized Google identity to the already-signed-in account that started
the link/upgrade. No is_allowed gate they're already an active user. Refuses to hijack a
Google identity owned by another account, or to silently swap a different linked identity."""
# Consume the companion marker up front so it never lingers across the early returns below.
explicit = request.session.pop("oauth_link_explicit", False)
# The marker must match the live session user (guards a stale/forged marker).
if link_uid != request.session.get("user_id"):
return RedirectResponse(url="/?link=error")
user = db.get(User, link_uid)
if user is None or user.is_demo:
return RedirectResponse(url="/?link=error")
other = (
db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none()
)
if other is not None:
return RedirectResponse(url="/?link=conflict")
if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub
if not user.display_name:
user.display_name = userinfo.get("name")
if not user.avatar_url:
user.avatar_url = userinfo.get("picture")
_store_token(db, user, token)
db.commit()
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
return RedirectResponse(url="/?link=ok" if explicit else "/")
@router.post("/logout")
async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser,
@ -313,8 +450,7 @@ def _establish_session(request: Request, user: User) -> None:
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("/")
return settings.app_base
def _hash_token(raw: str) -> str:
@ -381,17 +517,24 @@ def register(
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if existing is None:
# Without working SMTP we can't deliver a verification link, so email ownership can't
# gate sign-in. Admin approval stays the real gate (is_active=False); mark the account
# verified so the flow still completes on a no-SMTP self-host. See email.email_enabled().
email_ok = email_mod.email_enabled()
user = User(
email=email,
password_hash=hash_password(password),
is_active=False,
email_verified=False,
email_verified=not email_ok,
)
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 email_ok:
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
@ -413,11 +556,14 @@ def verify_email(token: str, db: Session = Depends(get_db)):
@router.post("/password-login")
def password_login(
payload: dict, request: Request, db: Session = Depends(get_db)
payload: dict,
request: Request,
background: BackgroundTasks,
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)."""
still pending or suspended 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()
@ -425,6 +571,13 @@ def password_login(
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 user.is_suspended:
_notify_suspended(background, user.email)
op = operator_contact()
detail = "Your account has been suspended."
if op:
detail += f" If you have questions, contact the operator at {op}."
raise HTTPException(status_code=403, detail=detail)
if not user.email_verified:
raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).")
if not user.is_active:
@ -484,7 +637,8 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
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:
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.
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the active account in the switchable list — covers sessions created before
@ -506,6 +660,49 @@ def require_human(user: User = Depends(current_user)) -> User:
return user
@router.get("/link")
async def link_google(request: Request, user: User = Depends(current_user)):
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
the identity scopes YouTube access is granted separately via /auth/upgrade. The callback
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
if user.is_demo:
return RedirectResponse(url="/")
request.session["oauth_link_uid"] = user.id
request.session["oauth_link_explicit"] = True
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.post("/set-password")
def set_password(
payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Set or change the signed-in account's password. Setting a first password (Google-only
account) needs no current password the session already proves identity. Changing an
existing one requires the current password, so a hijacked session can't silently rotate it."""
if user.is_demo:
raise HTTPException(status_code=403, detail="Not available in the demo account.")
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.",
)
if user.password_hash:
if not verify_password(payload.get("current_password") or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.")
user.password_hash = hash_password(new_password)
db.commit()
log.info("Password set/changed: uid=%s", user.id)
return {"ok": True}
@router.get("/upgrade")
async def upgrade(
request: Request, access: str = "read", user: User = Depends(current_user)
@ -519,6 +716,9 @@ async def upgrade(
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo:
return RedirectResponse(url="/")
# Attach the grant to THIS account in the callback. Without it a password account (no
# google_sub) would get a brand-new, separate Google user created instead of being linked.
request.session["oauth_link_uid"] = user.id
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await oauth.google.authorize_redirect(
request,