fix(auth): security hardening — token encryption, timing, adoption + isolation

From the auth /security-review (contained fixes; architectural SA3 proxy-trust +
SA4 session-revocation deferred to the user):
- SB1: encrypt the OAuth access_token at rest (was plaintext while refresh_token
  was encrypted) — a ~1h Google bearer credential. New security.decrypt_optional()
  falls back to a refresh for legacy plaintext tokens; column is unbounded String.
  E2E-verified: token refreshed → stored as Fernet ciphertext → 319-subscription
  YouTube sync succeeded.
- SA5: password_login is no longer a timing/enumeration oracle — it always runs
  argon2 (against a decoy hash for unknown/passwordless emails), so response time
  can't reveal whether an account exists.
- SB2: Google login only ADOPTS+activates a pre-existing password account when
  Google actually attests email_verified (defense-in-depth against takeover); and
  the email sync won't overwrite with a value another account owns (avoids a 500).
- demo_login clears the wallet first (demo can't switch to / act as a real account
  via the multi-account header); switch_account rejects a suspended target (which
  would otherwise clear the whole session on the next request).

Re-review clean; ruff clean; localdev boots; YouTube auth path E2E-verified.
This commit is contained in:
npeter83 2026-07-11 21:26:39 +02:00
parent f5fac09833
commit e92751dbce
4 changed files with 51 additions and 8 deletions

View file

@ -13,7 +13,7 @@ import httpx
from app import quota, sysconfig
from app.models import User
from app.security import decrypt
from app.security import decrypt, decrypt_optional, encrypt
log = logging.getLogger("siftlode.youtube")
@ -76,8 +76,9 @@ class YouTubeClient:
if tok is None:
raise YouTubeError("User has no stored OAuth token")
now = datetime.now(timezone.utc)
if tok.access_token and tok.expiry and tok.expiry > now + timedelta(seconds=60):
return tok.access_token
cached = decrypt_optional(tok.access_token)
if cached and tok.expiry and tok.expiry > now + timedelta(seconds=60):
return cached
refresh = decrypt(tok.refresh_token_enc)
if not refresh:
raise YouTubeError("No refresh token; user must re-authenticate")
@ -94,12 +95,13 @@ class YouTubeClient:
if resp.status_code != 200:
raise YouTubeError(f"Token refresh failed: {resp.status_code} {resp.text[:200]}")
data = resp.json()
tok.access_token = data["access_token"]
access = data["access_token"]
tok.access_token = encrypt(access)
tok.expiry = now + timedelta(seconds=int(data.get("expires_in", 3600)))
self.db.add(tok)
self.db.commit()
log.info("Refreshed access token for user %s", self.user.id)
return tok.access_token
return access
# --- core request ---
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict: