refactor(auth): one account per browser session
The session held a wallet of up to six accounts, and a request picked one by a header or an ?account= parameter. It now holds one: user_id and its epoch. A sign-in replaces whoever was signed in, sign-out and account deletion end the session, and an account refused mid-session (revoked, suspended, deactivated) clears it. /api/me/accounts and /api/me/switch are gone, and with them their refusal codes and the UI copy for them. A cookie the multi-account build wrote carries no single epoch and is refused: its holder signs in once more. A Google link finishing in a browser now signed in as someone else is refused, as before for an account outside the wallet. A test holds that no app code reads the wallet keys or the account header again.
This commit is contained in:
+25
-92
@@ -25,8 +25,6 @@ def operator_contact() -> str | None:
|
||||
return next(iter(sorted(settings.admin_email_set)), None)
|
||||
|
||||
|
||||
MAX_SESSION_ACCOUNTS = 6
|
||||
|
||||
# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check
|
||||
# the atoms against a user's stored grant; the *_SCOPES bundles are what the login/callback endpoints
|
||||
# request (imported by app/routes/auth.py).
|
||||
@@ -253,94 +251,37 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
|
||||
|
||||
|
||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
def session_user_id(conn: HTTPConnection) -> int | None:
|
||||
"""The account this browser session signed in as, read from the signed cookie alone (no DB).
|
||||
One account per session: signing in as another replaces it (`routes.auth._establish_session`)."""
|
||||
return conn.session.get("user_id")
|
||||
|
||||
|
||||
def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int | None, bool]:
|
||||
"""Which account this connection acts as, and whether that's the session's default account.
|
||||
Takes an HTTPConnection rather than a Request: it reads only the session and the headers.
|
||||
|
||||
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
||||
plus a default `user_id`. A caller may name its account with the X-Siftlode-Account header (or
|
||||
`?account=`), but ONLY an account in the wallet — so a tab can't impersonate an account that
|
||||
never authenticated here. A connection that names no account acts as the cookie default.
|
||||
|
||||
A connection naming an account NOT in the wallet acts as nobody (`None`) — typically a tab still
|
||||
pinned to an account that was signed out in another tab. It must not fall back to the default:
|
||||
that tab would go on acting as an account it never showed, its player checkpoints and pref saves
|
||||
landing there and its Sign out signing THAT account out. Only the bootstrap /api/me probe passes
|
||||
`strict=False` and gets the default, which is how such a tab learns who it now is (the frontend
|
||||
re-pins it and reloads). An account whose session ended while it stayed in the wallet (deleted,
|
||||
suspended, revoked) leaves the wallet when it is refused (`_session_user`), so a tab pinned to it
|
||||
takes the same way back.
|
||||
"""
|
||||
default_id = conn.session.get("user_id")
|
||||
wallet = conn.session.get("account_ids") or []
|
||||
# Header for normal XHR; ?account= for a context that can't set headers (a plain <a> file
|
||||
# download). Both are wallet-gated below.
|
||||
hdr = conn.headers.get(ACTIVE_ACCOUNT_HEADER) or conn.query_params.get("account")
|
||||
if hdr:
|
||||
try:
|
||||
hid = int(hdr)
|
||||
except ValueError:
|
||||
# Under `strict` this becomes a 401 and the tab authenticates as nobody, which reads
|
||||
# in the browser exactly like a session that expired. The value itself stays out —
|
||||
# it came from a request, and `wallet` says which accounts were actually on offer.
|
||||
log.debug(
|
||||
"the active-account header could not be read as an id",
|
||||
extra=logs.event("auth.account_header.invalid", accounts=len(wallet)),
|
||||
)
|
||||
hid = None
|
||||
if hid is not None and hid in wallet:
|
||||
return hid, hid == default_id
|
||||
if strict:
|
||||
return None, False
|
||||
return default_id, True
|
||||
|
||||
|
||||
def drop_from_wallet(session: dict, user_id: int) -> None:
|
||||
"""Take one account out of this browser's wallet (`account_ids`); the session default is untouched."""
|
||||
session["account_ids"] = [a for a in (session.get("account_ids") or []) if a != user_id]
|
||||
|
||||
|
||||
def _end_account_session(request: Request, user_id: int, is_default: bool) -> None:
|
||||
"""An account whose session here is no longer valid. Only nuke the whole session when it is the
|
||||
SESSION DEFAULT; a per-tab account just leaves the wallet (other tabs' default may still be valid).
|
||||
Leaving is what lets a tab pinned to it recover: its next /api/me names an account no longer signed
|
||||
in here and gets the default. Left in, it answered "signed out" — the landing page, with the default
|
||||
still signed in, and no way off it but closing the tab."""
|
||||
if is_default:
|
||||
request.session.clear()
|
||||
else:
|
||||
drop_from_wallet(request.session, user_id)
|
||||
def stamp_session(request: Request, user: User) -> None:
|
||||
"""Record the account's current `session_epoch` in the signed cookie, so `current_user` can later
|
||||
reject the cookie once the epoch moves (password reset/change, or "log out everywhere")."""
|
||||
request.session["epoch"] = user.session_epoch or 0
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
return _session_user(request, db, *resolved_user_id(request))
|
||||
return _session_user(request, db, session_user_id(request))
|
||||
|
||||
|
||||
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
||||
def _session_user(request: Request, db: Session, user_id: int | None) -> User:
|
||||
if not user_id:
|
||||
raise refused(401, "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 → end it so the block takes effect at once.
|
||||
_end_account_session(request, user_id, is_default)
|
||||
request.session.clear()
|
||||
raise refused(401, "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):
|
||||
_end_account_session(request, user_id, is_default)
|
||||
# A cookie whose epoch is behind the account's (revoked by a password reset/change or "log out
|
||||
# everywhere") is refused — and so is one with no epoch at all, which only a build before the
|
||||
# one-account session wrote: its holder signs in once more.
|
||||
epoch = request.session.get("epoch")
|
||||
if epoch is None or int(epoch) != (user.session_epoch or 0):
|
||||
request.session.clear()
|
||||
raise refused(401, "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:]
|
||||
# Attribute this request's log lines to the account. Here, not in the request middleware:
|
||||
# this is the one place a session becomes an account, and the middleware runs outside both the
|
||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||
@@ -358,25 +299,17 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
|
||||
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).
|
||||
The one lenient resolution (`strict=False`): a tab naming an account no longer signed in here gets
|
||||
the default account, which is how it learns who it now is — see `resolved_user_id`. That includes an
|
||||
account refused right here, which `_session_user` has just taken out of the wallet."""
|
||||
user_id, is_default = resolved_user_id(request, strict=False)
|
||||
the browser console (a non-2xx fetch is a console error no matter how the app handles it)."""
|
||||
user_id = session_user_id(request)
|
||||
if user_id is None:
|
||||
return None
|
||||
try:
|
||||
return _session_user(request, db, user_id, is_default)
|
||||
# silent-ok: this is the anonymous case as much as the refused one — every landing page hit
|
||||
# comes through here. The second attempt below is the handling, and it reports.
|
||||
return _session_user(request, db, user_id)
|
||||
except HTTPException:
|
||||
if is_default:
|
||||
return None
|
||||
try:
|
||||
return _session_user(request, db, *resolved_user_id(request, strict=False))
|
||||
except HTTPException:
|
||||
# Both the pinned account AND the default one were refused, so the tab is signed out
|
||||
# without being told why — and it got here from a session that named a real account.
|
||||
# A session that named an account, refused: the browser is signed out without being told
|
||||
# why, so say it here.
|
||||
log.info(
|
||||
"a session was resolved to nobody: neither the named nor the default account held",
|
||||
"a session was refused: its account no longer holds",
|
||||
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
|
||||
)
|
||||
return None
|
||||
|
||||
+19
-52
@@ -25,7 +25,6 @@ from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
BASE_SCOPES,
|
||||
MAX_SESSION_ACCOUNTS,
|
||||
PASSWORD_MIN_LEN,
|
||||
READ_SCOPES,
|
||||
WRITE_SCOPES,
|
||||
@@ -37,7 +36,8 @@ from app.auth import (
|
||||
is_allowed,
|
||||
log,
|
||||
operator_contact,
|
||||
resolved_user_id,
|
||||
session_user_id,
|
||||
stamp_session,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal, get_db
|
||||
@@ -361,13 +361,13 @@ async def callback(
|
||||
token = await client.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
# The user cancelled or denied consent at Google — land back with a friendly notice instead
|
||||
# of a raw 400 JSON error page. An already-signed-in user (adding another account via
|
||||
# /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login
|
||||
# banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on
|
||||
# the login page with the oauth_cancelled banner instead. Keyed on the live session, not on
|
||||
# link_uid, so the add-account path (no marker) is covered too.
|
||||
# of a raw 400 JSON error page. An already-signed-in user (signing in again via /auth/login,
|
||||
# or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login banner, so
|
||||
# surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on the login page
|
||||
# with the oauth_cancelled banner instead. Keyed on the live session, not on link_uid, so a
|
||||
# sign-in started while signed in (no marker) is covered too.
|
||||
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
||||
if request.session.get("user_id") is not None:
|
||||
if session_user_id(request) is not None:
|
||||
return RedirectResponse(url="/?oauth=cancelled")
|
||||
return RedirectResponse(url="/?login=oauth_cancelled")
|
||||
|
||||
@@ -584,12 +584,9 @@ def _complete_link(
|
||||
Google identity owned by another account, or to silently swap a different linked identity.
|
||||
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
||||
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
|
||||
# SEC-11: the marker must name an account signed into THIS browser — the session default OR any
|
||||
# account in the wallet — not only the default. A tab acting as a secondary account links Google to
|
||||
# ITSELF (the marker was set from current_user, which resolves the ?account= it navigated with), so
|
||||
# accepting wallet membership stops the identity/token landing on the default account A instead of B.
|
||||
accounts = request.session.get("account_ids") or []
|
||||
if link_uid != request.session.get("user_id") and link_uid not in accounts:
|
||||
# The marker must name the account still signed into THIS browser: a sign-in as someone else
|
||||
# between the start and the callback replaced it, and the identity must not land on the newcomer.
|
||||
if link_uid != session_user_id(request):
|
||||
return RedirectResponse(url="/?link=error")
|
||||
user = db.get(User, link_uid)
|
||||
if user is None:
|
||||
@@ -619,38 +616,17 @@ def _complete_link(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Sign the requesting tab's active account out of this browser (removing it from the wallet).
|
||||
Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
|
||||
out in one tab doesn't disturb another tab running a different account. If the account being
|
||||
removed was the session default, promote the most recent remaining account (or clear the
|
||||
session when none remain)."""
|
||||
# A tab still pinned to an account already signed out in another tab resolves to nobody, so it
|
||||
# signs nobody out — it used to fall back to the default and sign THAT account out in its place.
|
||||
active, is_default = resolved_user_id(request)
|
||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
|
||||
request.session["account_ids"] = remaining
|
||||
if is_default:
|
||||
if remaining:
|
||||
request.session["user_id"] = remaining[-1]
|
||||
return JSONResponse({"ok": True, "switched": True})
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
# A non-default (per-tab) account signed out: it's gone from the wallet and the session default
|
||||
# is untouched. The tab drops its own override and falls back to the default on reload.
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
"""Sign this browser out: the session holds one account, and it ends."""
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def _establish_session(request: Request, user: User) -> None:
|
||||
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
|
||||
and password login, so both join the same account switcher and both stamp `last_login_at` (the
|
||||
caller commits). Multi-session: every account that authenticated in this browser is remembered so
|
||||
the user can switch between them; only accounts that actually proved themselves HERE land in that
|
||||
wallet (it is 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:]
|
||||
and password login, so both stamp `last_login_at` (the caller commits). One account per session:
|
||||
the sign-in replaces whoever was signed in here before."""
|
||||
request.session["user_id"] = user.id
|
||||
_remember_epoch(request, user)
|
||||
stamp_session(request, user)
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -1135,15 +1111,6 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _remember_epoch(request: Request, user: User) -> None:
|
||||
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
|
||||
can later reject the cookie if the account's epoch was bumped (password reset/change, or
|
||||
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
|
||||
epochs = dict(request.session.get("epochs") or {})
|
||||
epochs[str(user.id)] = user.session_epoch
|
||||
request.session["epochs"] = epochs
|
||||
|
||||
|
||||
def bump_session_epoch(db: Session, user: User) -> None:
|
||||
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
|
||||
minted before the bump carry a now-stale epoch and are rejected by `current_user`. The account's
|
||||
@@ -1220,7 +1187,7 @@ def logout_others(
|
||||
# session at a newer epoch than the DB (see set_password).
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
stamp_session(request, user)
|
||||
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1272,7 +1239,7 @@ def set_password(
|
||||
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||
bump_session_epoch(db, user)
|
||||
db.commit()
|
||||
_remember_epoch(request, user)
|
||||
stamp_session(request, user)
|
||||
log.info("Password set/changed: uid=%s", user.id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@ from app.auth import (
|
||||
count_admins,
|
||||
count_pending_activations,
|
||||
current_user,
|
||||
drop_from_wallet,
|
||||
google_enabled,
|
||||
has_read_scope,
|
||||
has_write_scope,
|
||||
is_allowed,
|
||||
optional_current_user,
|
||||
purge_user,
|
||||
)
|
||||
@@ -26,79 +24,10 @@ from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.utils import iso
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
|
||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
def my_accounts(
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""The accounts that have authenticated in this browser session (switchable without a
|
||||
new Google sign-in). The active one is flagged."""
|
||||
ids = request.session.get("account_ids") or [user.id]
|
||||
rows = {u.id: u for u in db.execute(select(User).where(User.id.in_(ids))).scalars()}
|
||||
out = []
|
||||
for uid in ids: # preserve recency order from the session
|
||||
u = rows.get(uid)
|
||||
if u is not None:
|
||||
out.append(
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"avatar_url": u.avatar_url,
|
||||
"active": u.id == user.id,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class SwitchAccountIn(BaseModel):
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.post("/switch")
|
||||
def switch_account(
|
||||
body: SwitchAccountIn,
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Switch the active account to another one already authenticated in this browser. No
|
||||
Google round-trip — but only accounts present in the session list (proof they signed in
|
||||
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
||||
target = body.user_id
|
||||
if target not in (request.session.get("account_ids") or []):
|
||||
raise refused(403, "not_your_account")
|
||||
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
|
||||
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
|
||||
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
|
||||
# wallet session, logging out every account here. (B-core-9)
|
||||
u = db.get(User, target)
|
||||
if u is None:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise not_found("account")
|
||||
if not is_allowed(db, u.email):
|
||||
raise refused(403, "account_no_access")
|
||||
if not u.is_active:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise refused(403, "account_no_access")
|
||||
if u.is_suspended:
|
||||
drop_from_wallet(request.session, target)
|
||||
raise refused(403, "account_suspended")
|
||||
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
||||
epochs = request.session.get("epochs") or {}
|
||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||
drop_from_wallet(request.session, target)
|
||||
raise refused(403, "account_signed_out")
|
||||
request.session["user_id"] = target
|
||||
return {"ok": True, "user_id": target}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_me(
|
||||
user: User | None = Depends(optional_current_user), db: Session = Depends(get_db)
|
||||
@@ -187,17 +116,11 @@ def delete_my_account(
|
||||
surfaces)."""
|
||||
if user.role == "admin" and count_admins(db) <= 1:
|
||||
raise refused(400, "only_admin_delete")
|
||||
user_id = user.id
|
||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
||||
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
||||
# admin delete path.
|
||||
purge_user(db, user, background)
|
||||
# Drop this account from the browser session; switch to another signed-in account if any.
|
||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id]
|
||||
if remaining:
|
||||
request.session["account_ids"] = remaining
|
||||
request.session["user_id"] = remaining[-1]
|
||||
else:
|
||||
request.session.clear()
|
||||
# The session's one account is gone: so is the session.
|
||||
request.session.clear()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user, resolved_user_id
|
||||
from app.auth import current_user, session_user_id
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import User
|
||||
from app.plex import imgcache, queries, service, subtitles
|
||||
@@ -42,7 +42,7 @@ def _require_session(request: Request) -> int:
|
||||
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
|
||||
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
|
||||
low-sensitivity; a valid session is enough."""
|
||||
uid, _ = resolved_user_id(request)
|
||||
uid = session_user_id(request)
|
||||
if not uid:
|
||||
raise refused(401, "not_authenticated")
|
||||
return uid
|
||||
|
||||
Reference in New Issue
Block a user