fix(auth): a tab acts only as the account it was built for, and the review's other findings
A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
This commit is contained in:
@@ -25,6 +25,7 @@ from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
BASE_SCOPES,
|
||||
EXPECTED_ACCOUNT_HEADER,
|
||||
PASSWORD_MIN_LEN,
|
||||
READ_SCOPES,
|
||||
WRITE_SCOPES,
|
||||
@@ -36,6 +37,7 @@ from app.auth import (
|
||||
is_allowed,
|
||||
log,
|
||||
operator_contact,
|
||||
optional_current_user,
|
||||
session_user_id,
|
||||
stamp_session,
|
||||
)
|
||||
@@ -330,6 +332,11 @@ async def login(request: Request, db: Session = Depends(get_db)):
|
||||
# Google sign-in isn't configured on this instance — land back on the login page (the UI
|
||||
# hides the button, so this is just a safety net for a stale/direct hit).
|
||||
return RedirectResponse(url="/")
|
||||
# A sign-in, never a link: a connect abandoned at Google (Back, a closed tab) never reached the
|
||||
# callback that pops its markers, and left behind they would attach the identity signing in
|
||||
# here to the account still signed into this browser.
|
||||
request.session.pop("oauth_link_uid", None)
|
||||
request.session.pop("oauth_link_explicit", None)
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
_oauth_redirect_uri(request),
|
||||
@@ -354,7 +361,7 @@ async def callback(
|
||||
# /auth/google/connect; when present we attach to the current user instead of identifying by sub.
|
||||
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
|
||||
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
|
||||
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
|
||||
# attempt. A connect that never comes back here at all is cleared by /auth/login instead.
|
||||
link_uid = request.session.pop("oauth_link_uid", None)
|
||||
link_explicit = request.session.pop("oauth_link_explicit", False)
|
||||
try:
|
||||
@@ -584,12 +591,12 @@ 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 grant — the caller pops it up front so an aborted flow leaves nothing behind."""
|
||||
# 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:
|
||||
# The marker must name the account still signed into THIS browser, and that session must still
|
||||
# hold: a sign-in as someone else between the start and the callback replaced it (the identity
|
||||
# must not land on the newcomer), and a session revoked meanwhile ("log out everywhere", a
|
||||
# password change, a suspension) must not finish what it started.
|
||||
user = optional_current_user(request, db)
|
||||
if user is None or user.id != link_uid:
|
||||
return RedirectResponse(url="/?link=error")
|
||||
other = db.execute(
|
||||
select(User).where(User.google_sub == sub, User.id != user.id)
|
||||
@@ -616,7 +623,13 @@ def _complete_link(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Sign this browser out: the session holds one account, and it ends."""
|
||||
"""Sign this browser out: the session holds one account, and it ends — unless the tab asking was
|
||||
built for another account than the one signed in now (a sign-in as someone else in another tab):
|
||||
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
|
||||
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
|
||||
signed_in = session_user_id(request)
|
||||
if expected is not None and signed_in is not None and expected != str(signed_in):
|
||||
raise refused(401, "account_changed")
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@@ -136,7 +136,10 @@ def update_preferences(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
# Merge so partial updates don't wipe other keys.
|
||||
# Merge so partial updates don't wipe other keys — over the stored row read again under a row
|
||||
# lock: a client saves keys one at a time (several at once on a boot that moves this browser's
|
||||
# copies to the account), and two merges over the same earlier read would keep only the last.
|
||||
db.refresh(user, with_for_update=True)
|
||||
merged = dict(user.preferences or {})
|
||||
merged.update(preferences.root)
|
||||
user.preferences = merged
|
||||
|
||||
Reference in New Issue
Block a user