diff --git a/backend/alembic/versions/0053_user_session_epoch.py b/backend/alembic/versions/0053_user_session_epoch.py new file mode 100644 index 0000000..6f5c838 --- /dev/null +++ b/backend/alembic/versions/0053_user_session_epoch.py @@ -0,0 +1,27 @@ +"""User.session_epoch for server-side session revocation (SA4) + +A monotonic per-user counter. A signed session cookie records the epoch it was minted under; +current_user rejects any cookie whose epoch is stale. Bumped on password reset, password change, +or "log out everywhere" — so a stolen/copied client-side cookie dies on those events instead of +staying valid until it expires. Existing rows default to 0 (matching a fresh cookie's absent/0 epoch). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0053_user_session_epoch" +down_revision: Union[str, None] = "0052_plex_show_meta" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + op.drop_column("users", "session_epoch") diff --git a/backend/app/auth.py b/backend/app/auth.py index 700ff02..59d616a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -341,6 +341,7 @@ async def callback( accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) return RedirectResponse(url="/") @@ -493,6 +494,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) - # can't switch to / act as a previously signed-in account via the multi-account header. request.session.clear() request.session["user_id"] = demo.id + _remember_epoch(request, demo) log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) return {"authenticated": True} return {"authenticated": False} @@ -505,6 +507,7 @@ def _establish_session(request: Request, user: User) -> None: accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id + _remember_epoch(request, user) def _app_base() -> str: @@ -697,6 +700,7 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict if user is None: raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") user.password_hash = hash_password(new_password) + bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions. # Burn any other outstanding reset tokens for this user. for row in db.execute( select(AuthToken).where( @@ -739,6 +743,21 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]: return default_id, 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(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`.""" + user.session_epoch = (user.session_epoch or 0) + 1 + + def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id, is_default = resolved_user_id(request) if not user_id: @@ -751,6 +770,14 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: if is_default: request.session.clear() raise HTTPException(status_code=401, detail="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): + if is_default: + request.session.clear() + raise HTTPException(status_code=401, detail="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") @@ -773,6 +800,22 @@ def optional_current_user( return None +@router.post("/logout-others") +def logout_others( + request: Request, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies + are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged + in somewhere" / a suspected session compromise. The demo account (shared) is excluded.""" + if user.is_demo: + raise HTTPException(status_code=403, detail="Not available in the demo account.") + bump_session_epoch(user) + _remember_epoch(request, user) + db.commit() + log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch) + return {"ok": True} + + def require_human(user: User = Depends(current_user)) -> User: """Reject the shared demo account from actions that need a real Google/YouTube identity or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has @@ -828,7 +871,10 @@ async def link_google( @router.post("/set-password") def set_password( - payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db) + payload: dict, + request: Request, + 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 @@ -845,6 +891,10 @@ def set_password( 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) + # SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so + # the person making the change stays signed in here. + bump_session_epoch(user) + _remember_epoch(request, user) db.commit() log.info("Password set/changed: uid=%s", user.id) return {"ok": True} diff --git a/backend/app/models.py b/backend/app/models.py index d65ee87..9b31e67 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -63,6 +63,11 @@ class User(Base, TimestampMixin): is_suspended: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + # Server-side session revocation (SA4): a monotonic counter bumped on password reset, password + # change, or "log out everywhere". A signed session cookie records the epoch it was minted under; + # current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those + # events (client-side signed cookies otherwise stay valid until they expire). + session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0") display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 91c7e1e..0399ea3 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -308,11 +308,36 @@ function AccessRow({ function SignInMethods({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); + const [sessBusy, setSessBusy] = useState(false); + + // SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind + // device or a suspected compromise. + const logoutOthers = async () => { + if ( + !(await confirm({ + title: t("settings.account.sessions.confirmTitle"), + message: t("settings.account.sessions.confirmBody"), + confirmLabel: t("settings.account.sessions.action"), + danger: true, + })) + ) + return; + setSessBusy(true); + try { + await api.logoutOthers(); + notify({ level: "success", message: t("settings.account.sessions.done") }); + } catch { + notify({ level: "error", message: t("settings.account.sessions.failed") }); + } finally { + setSessBusy(false); + } + }; const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -422,6 +447,24 @@ function SignInMethods({ me }: { me: Me }) { )} + +
+
+
+
{t("settings.account.sessions.title")}
+

+ {t("settings.account.sessions.hint")} +

+
+ +
+
); } diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index c441032..3425b9f 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -97,6 +97,15 @@ "saving": "Speichern…", "saved": "Passwort für {{email}} aktualisiert.", "failed": "Das Passwort konnte nicht aktualisiert werden." + }, + "sessions": { + "title": "Aktive Sitzungen", + "hint": "Diese Kontoanmeldung überall sonst abmelden — praktisch, wenn du auf einem geteilten oder verlorenen Gerät angemeldet geblieben bist. Hier bleibst du angemeldet.", + "action": "Andere Sitzungen abmelden", + "confirmTitle": "Andere Sitzungen abmelden?", + "confirmBody": "Damit wirst du in allen anderen Browsern und auf allen anderen Geräten abgemeldet. Hier bleibst du angemeldet.", + "done": "Von allen anderen Sitzungen abgemeldet.", + "failed": "Andere Sitzungen konnten nicht abgemeldet werden. Bitte versuche es erneut." } }, "demo": { diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index cd9e2d2..c6c4ac0 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -97,6 +97,15 @@ "saving": "Saving…", "saved": "Password updated for {{email}}.", "failed": "Couldn't update the password." + }, + "sessions": { + "title": "Active sessions", + "hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.", + "action": "Log out other sessions", + "confirmTitle": "Log out other sessions?", + "confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.", + "done": "Signed out of all other sessions.", + "failed": "Couldn't sign out other sessions. Please try again." } }, "demo": { diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index c8dea12..1048ae0 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -97,6 +97,15 @@ "saving": "Mentés…", "saved": "Jelszó frissítve ehhez: {{email}}.", "failed": "Nem sikerült frissíteni a jelszót." + }, + "sessions": { + "title": "Aktív munkamenetek", + "hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.", + "action": "Kijelentkezés máshonnan", + "confirmTitle": "Kijelentkezés a többi munkamenetből?", + "confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.", + "done": "Kijelentkeztetve minden más munkamenetből.", + "failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra." } }, "demo": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8ebeb24..53e0de2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1394,6 +1394,8 @@ export const api = { // Confirm an email-verification token the SPA read from the URL fragment (SB3). verifyEmail: (token: string): Promise<{ ok: boolean }> => req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }), + // SA4: sign every OTHER session for this account out (keeps the current one). + logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }), // Set or change the signed-in account's password (errors shown inline via quiet). setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => req(