diff --git a/backend/app/config.py b/backend/app/config.py index ee2da79..ca01e9c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,8 +9,9 @@ class Settings(BaseSettings): COOKIE_SECURE: bool = False OPENWEATHERMAP_API_KEY: str = "" - # Session config - SESSION_MAX_AGE_DAYS: int = 30 + # Session config — sliding window + SESSION_MAX_AGE_DAYS: int = 7 # Sliding window: inactive sessions expire after 7 days + SESSION_TOKEN_HARD_CEILING_DAYS: int = 30 # Absolute token lifetime for itsdangerous max_age # MFA token config (short-lived token bridging password OK → TOTP verification) MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a8b0266..16e8ed5 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -75,11 +75,16 @@ def _set_session_cookie(response: Response, token: str) -> None: async def get_current_user( request: Request, + response: Response, session_cookie: Optional[str] = Cookie(None, alias="session"), db: AsyncSession = Depends(get_db), ) -> User: """ Dependency that verifies the session cookie and returns the authenticated User. + + L-03 sliding window: if the session has less than + (SESSION_MAX_AGE_DAYS - 1) days remaining, silently extend expires_at + and re-issue the cookie so active users never hit expiration. """ if not session_cookie: raise HTTPException(status_code=401, detail="Not authenticated") @@ -113,6 +118,17 @@ async def get_current_user( if not user: raise HTTPException(status_code=401, detail="User not found or inactive") + # L-03: Sliding window renewal — extend session if >1 day has elapsed since + # last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day). + now = datetime.now() + renewal_threshold = timedelta(days=app_settings.SESSION_MAX_AGE_DAYS - 1) + if db_session.expires_at - now < renewal_threshold: + db_session.expires_at = now + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS) + await db.flush() + # Re-issue cookie with fresh signed token to reset browser max_age timer + fresh_token = create_session_token(user_id, session_id) + _set_session_cookie(response, fresh_token) + return user diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index adf2323..c6ef16a 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -88,10 +88,14 @@ def create_session_token(user_id: int, session_id: str) -> str: def verify_session_token(token: str, max_age: int | None = None) -> dict | None: """ Verify a session cookie and return its payload dict, or None if invalid/expired. - max_age defaults to SESSION_MAX_AGE_DAYS from config. + + max_age defaults to SESSION_TOKEN_HARD_CEILING_DAYS (absolute token lifetime). + The sliding window (SESSION_MAX_AGE_DAYS) is enforced via DB expires_at checks, + not by itsdangerous — this decoupling prevents the serializer from rejecting + renewed tokens that were created more than SESSION_MAX_AGE_DAYS ago. """ if max_age is None: - max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400 + max_age = app_settings.SESSION_TOKEN_HARD_CEILING_DAYS * 86400 try: return _serializer.loads(token, max_age=max_age) except (BadSignature, SignatureExpired):