feat(auth): per-tab account — different account per browser tab
Two tabs in one browser can now run two different signed-in accounts at once. - The signed session cookie stays the browser's account WALLET (account_ids). Which account a given tab acts as is a per-tab choice held in sessionStorage and sent per-request via the X-Siftlode-Account header; current_user honours it only for an account already in the wallet, without mutating the cookie's default account. Switching accounts sets the header + reloads THIS tab only, instead of the old cookie-wide switch that changed every tab. - WebSocket can't send headers, so the per-tab account rides in the ?account= query param (validated against the wallet). - Logout is per-tab aware: it signs the requesting tab's active account out of the wallet (promoting a new default only if the removed one was the default), and the tab drops its override. A stale per-tab header account 401s just that tab instead of clearing the session. - Serve index.html with Cache-Control: no-cache so a deploy's new hashed bundle is picked up immediately instead of the browser running a heuristically-cached stale index.html.
This commit is contained in:
parent
c0487101fe
commit
5cd807ec51
6 changed files with 146 additions and 36 deletions
|
|
@ -419,15 +419,22 @@ def _complete_link(
|
|||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Sign out the active account. If other accounts have authenticated in this browser,
|
||||
switch to the most recent one instead of fully logging out."""
|
||||
active = request.session.get("user_id")
|
||||
"""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)."""
|
||||
active, is_default = resolved_user_id(request)
|
||||
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
|
||||
if remaining:
|
||||
request.session["account_ids"] = remaining
|
||||
request.session["user_id"] = remaining[-1]
|
||||
return JSONResponse({"ok": True, "switched": True})
|
||||
request.session.clear()
|
||||
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})
|
||||
|
||||
|
||||
|
|
@ -659,20 +666,52 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
# A tab can act as a different signed-in account than the session default by sending this header
|
||||
# (per-tab identity: two tabs in one browser, two accounts). Lowercased — Starlette header lookup
|
||||
# is case-insensitive, but we compare explicitly below.
|
||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
|
||||
|
||||
def resolved_user_id(request: Request) -> tuple[int | None, bool]:
|
||||
"""Which account this request acts as, and whether that's the session's default account.
|
||||
|
||||
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
|
||||
plus a default `user_id`. A request may override the default with the X-Siftlode-Account
|
||||
header, but ONLY for an account already in the wallet — so a tab can't impersonate an account
|
||||
that never authenticated here. Everything else (WebSocket, plain navigations) keeps using the
|
||||
cookie default.
|
||||
"""
|
||||
default_id = request.session.get("user_id")
|
||||
wallet = request.session.get("account_ids") or []
|
||||
hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER)
|
||||
if hdr:
|
||||
try:
|
||||
hid = int(hdr)
|
||||
except ValueError:
|
||||
hid = None
|
||||
if hid is not None and hid in wallet:
|
||||
return hid, hid == default_id
|
||||
return default_id, True
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
user_id, is_default = resolved_user_id(request)
|
||||
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 or user.is_suspended:
|
||||
# Suspended/deactivated mid-session → drop the session so the block takes effect at once.
|
||||
request.session.clear()
|
||||
# But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale
|
||||
# per-tab header account just 401s that one tab (other tabs' default may still be valid).
|
||||
if is_default:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
# Always keep the active account in the switchable list — covers sessions created before
|
||||
# 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 user_id not in accounts:
|
||||
accounts.append(user_id)
|
||||
if default_id and default_id not in accounts:
|
||||
accounts.append(default_id)
|
||||
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
|
||||
return user
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue