L-03: Session 7-day sliding window with 30-day hard ceiling
Reduce session expiry from 30 days to 7 days of inactivity while preserving a 30-day absolute token lifetime for itsdangerous: - SESSION_MAX_AGE_DAYS=7: sliding window for DB expires_at + cookie - SESSION_TOKEN_HARD_CEILING_DAYS=30: itsdangerous max_age (prevents rejecting renewed tokens whose creation timestamp exceeds 7 days) - get_current_user: silently extends expires_at and re-issues cookie when >1 day has elapsed since last renewal - Active users never notice; 7 days of inactivity forces re-login; 30-day absolute ceiling forces re-login regardless of activity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e27f2920b
commit
1ebc41b9d7
@ -9,8 +9,9 @@ class Settings(BaseSettings):
|
|||||||
COOKIE_SECURE: bool = False
|
COOKIE_SECURE: bool = False
|
||||||
OPENWEATHERMAP_API_KEY: str = ""
|
OPENWEATHERMAP_API_KEY: str = ""
|
||||||
|
|
||||||
# Session config
|
# Session config — sliding window
|
||||||
SESSION_MAX_AGE_DAYS: int = 30
|
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 config (short-lived token bridging password OK → TOTP verification)
|
||||||
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes
|
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes
|
||||||
|
|||||||
@ -75,11 +75,16 @@ def _set_session_cookie(response: Response, token: str) -> None:
|
|||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
response: Response,
|
||||||
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Dependency that verifies the session cookie and returns the authenticated 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:
|
if not session_cookie:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
@ -113,6 +118,17 @@ async def get_current_user(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
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.
|
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:
|
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:
|
try:
|
||||||
return _serializer.loads(token, max_age=max_age)
|
return _serializer.loads(token, max_age=max_age)
|
||||||
except (BadSignature, SignatureExpired):
|
except (BadSignature, SignatureExpired):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user