From 73ad8e4eb566fe682107fa7e50ef624591199e99 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 15 Sep 2026 02:16:39 +0200 Subject: feat(hub): session lifetime is an admin setting, and a browser signs out when idle Browser idle sign-out (media playback counts as activity; not the desktop app), refresh idle window and maximum session length, in hours. Sign-out now revokes on the hub, and the profile has "sign out everywhere". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm --- packages/meshbay-hub/src/meshbay_hub/api/admin.py | 25 ++++++ packages/meshbay-hub/src/meshbay_hub/api/hub.py | 4 + packages/meshbay-hub/src/meshbay_hub/api/users.py | 97 +++++++++++++++++++++-- 3 files changed, 120 insertions(+), 6 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/api') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 397b4d9..7ca1e68 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -42,6 +42,8 @@ class SettingsPatchRequest(BaseModel): mail: dict[str, int] | None = None # The sign-in lockout's two numbers, each optional, as for mail. login: dict[str, int] | None = None + # Session lifetime, in hours, each optional. + session: dict[str, int] | None = None # ── Instance settings ──────────────────────────────────────────────────────── @@ -59,6 +61,9 @@ async def _settings_payload(db: AsyncSession) -> dict: "login": await hub_settings.login_limits(db), "login_defaults": dict(hub_settings.LOGIN_DEFAULTS), "login_bounds": {k: list(v) for k, v in hub_settings.LOGIN_BOUNDS.items()}, + "session": await hub_settings.session_limits(db), + "session_defaults": dict(hub_settings.SESSION_DEFAULTS), + "session_bounds": {k: list(v) for k, v in hub_settings.SESSION_BOUNDS.items()}, } @@ -138,6 +143,26 @@ async def admin_patch_settings( )) await db.commit() + if body.session: + unknown = sorted(set(body.session) - set(hub_settings.SESSION_KEYS)) + if unknown: + raise HTTPException( + status_code=422, detail=f"Unknown session setting(s): {unknown}") + changed = [] + for key, value in body.session.items(): + clamped = hub_settings.clamp_session_value(key, value) + await hub_settings.set_raw(db, f"session.{key}", str(clamped)) + changed.append(f"{key}={clamped}") + log.info("Session lifetime changed by %s: %s", + current_user.username, ", ".join(changed)) + db.add(IPLog( + user_id=current_user.id, + event="admin_session_update", + ip_address="admin", + detail=", ".join(changed)[:255], + )) + await db.commit() + return await _settings_payload(db) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py index 5a3eb4b..94e9b3c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py @@ -40,6 +40,10 @@ async def hub_info(db: AsyncSession = Depends(get_db)): # read it without opening the source. "federation": federation.FEDERATION_ENABLED, "captcha_site_key": _cfg.captcha.site_key if _cfg and _cfg.captcha.enabled else "", + # How long a browser tab stays signed in with nobody at it. The page + # measures it (static/idle.js); the hub only says how long. + "browser_idle_hours": + (await hub_settings.session_limits(db))["browser_idle_hours"], } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 9150909..a74f6af 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -50,8 +50,28 @@ def set_config(cfg: HubConfig) -> None: def _ttl() -> int: return _cfg.jwt.access_token_ttl if _cfg else 3600 -def _refresh_ttl() -> int: - return _cfg.jwt.refresh_token_ttl if _cfg else 86400 * 30 +async def _refresh_expiry(db: AsyncSession, family_id: str | None = None) -> datetime: + """ + When a refresh token stops being accepted. + + Sliding: each renewal moves it `refresh_idle_hours` past now, so a session + in use stays open — but never past `max_hours` after the sign-in that + started its family. The idle window never drops below the access token's + own life plus an hour: shorter, and a session would lapse between two + renewals of a tab that is being used. + """ + limits = await hub_settings.session_limits(db) + now = datetime.now(timezone.utc) + idle = max(limits["refresh_idle_hours"] * 3600, _ttl() + 3600) + started = now + if family_id is not None: + first = await db.scalar( + select(func.min(RefreshToken.created_at)) + .where(RefreshToken.family_id == family_id)) + if first is not None: + started = first if first.tzinfo else first.replace(tzinfo=timezone.utc) + return min(now + timedelta(seconds=idle), + started + timedelta(hours=limits["max_hours"])) VERIFICATION_TTL = 86400 # 24 hours VERIFICATION_MAX_ATTEMPTS = 10 @@ -134,6 +154,10 @@ class RefreshRequest(BaseModel): refresh_token: str +class LogoutRequest(BaseModel): + refresh_token: str + + # ── Endpoints ───────────────────────────────────────────────────────────────── @router.post("/register", status_code=201) @@ -404,7 +428,7 @@ async def login( raw_rt, rt_hash = generate_refresh_token() family_id = str(uuid.uuid4()) - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + expires_at = await _refresh_expiry(db) db.add(RefreshToken( user_id=user.id, token_hash=rt_hash, family_id=family_id, expires_at=expires_at, @@ -586,7 +610,7 @@ async def device_auth( group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + expires_at = await _refresh_expiry(db) db.add(RefreshToken(user_id=user.id, token_hash=rt_hash, family_id=str(uuid.uuid4()), expires_at=expires_at)) db.add(IPLog(user_id=user.id, event="device_auth", @@ -633,12 +657,21 @@ async def token_refresh( if not user or user.status != "active": raise HTTPException(status_code=401, detail="User not found or suspended") + # The family's first sign-in was longer ago than any session may last. + expires_at = await _refresh_expiry(db, rt.family_id) + if expires_at <= datetime.now(timezone.utc): + await db.execute( + update(RefreshToken) + .where(RefreshToken.family_id == rt.family_id) + .values(revoked=True)) + await db.commit() + raise HTTPException(status_code=401, detail="Session expired") + # Revoke old token rt.revoked = True # Issue new refresh token in the same family new_raw_rt, new_rt_hash = generate_refresh_token() - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) db.add(RefreshToken( user_id=user.id, token_hash=new_rt_hash, family_id=rt.family_id, expires_at=expires_at, @@ -882,6 +915,58 @@ class ChangePasswordRequest(BaseModel): new_auth_key: str +@router.post("/logout") +@limiter.limit("20/minute") +async def logout( + body: LogoutRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + End this session on the hub, not only in the browser. + + The browser already forgets its tokens and keys on sign-out; this makes the + refresh token it held worthless to anyone who copied it. The token is the + credential, so no access token is asked for — it may well have expired. An + unknown or already revoked token gets the same answer. + """ + rt = await db.scalar(select(RefreshToken).where( + RefreshToken.token_hash == hash_refresh_token(body.refresh_token))) + if rt is not None: + await db.execute( + update(RefreshToken) + .where(RefreshToken.family_id == rt.family_id) + .values(revoked=True)) + db.add(IPLog(user_id=rt.user_id, event="logout", ip_address=client_ip(request))) + await db.commit() + return {"status": "signed_out"} + + +@router.post("/me/sessions/revoke") +@limiter.limit("5/minute") +async def revoke_all_sessions( + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Sign out everywhere: no refresh token of this account renews any more. + + Access tokens already issued run out on their own, within their lifetime. + A desktop device key is not a session and is left alone — removing the + device is what stops it signing back in. + """ + result = await db.execute( + update(RefreshToken) + .where(RefreshToken.user_id == current_user.id, + RefreshToken.revoked.is_(False)) + .values(revoked=True)) + db.add(IPLog(user_id=current_user.id, event="sessions_revoked", + ip_address=client_ip(request))) + await db.commit() + return {"revoked": result.rowcount} + + @router.post("/password") @limiter.limit("5/minute") async def change_password( @@ -918,7 +1003,7 @@ async def change_password( group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token(current_user.id, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() - expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl()) + expires_at = await _refresh_expiry(db) db.add(RefreshToken( user_id=current_user.id, token_hash=rt_hash, family_id=str(uuid.uuid4()), expires_at=expires_at, -- cgit v1.2.3