feat: add sliding session timeout with idle and absolute caps

This commit is contained in:
2026-04-26 18:12:37 +09:30
parent 60799bbc52
commit d48e6b2f97
14 changed files with 398 additions and 31 deletions
+47
View File
@@ -154,6 +154,7 @@ Copy `.env.example` to `.env` and configure:
- `POST /api/v1/auth/logout` - Logout - `POST /api/v1/auth/logout` - Logout
- `GET /api/v1/auth/me` - Get current user - `GET /api/v1/auth/me` - Get current user
- `GET /api/v1/auth/sessions` - Get user sessions - `GET /api/v1/auth/sessions` - Get user sessions
- `POST /api/v1/auth/sessions/refresh` - Extend session idle window
- `DELETE /api/v1/auth/sessions/:id` - Revoke session - `DELETE /api/v1/auth/sessions/:id` - Revoke session
### Users ### Users
@@ -264,6 +265,52 @@ gunicorn -w 4 -b 0.0.0.0:8000 wsgi:app
- Request ID tracking for audit trails - Request ID tracking for audit trails
## Session Management
Sessions are database-backed bearer tokens stored in PostgreSQL. Each session is created at login and validated on every authenticated request via the `login_required` decorator.
### Sliding Timeout
Sessions use a **sliding window** model with two independent limits:
| Timeout | Default | Env Var | Behaviour |
|---------|---------|---------|-----------|
| **Idle** | 15 min | `SESSION_IDLE_TIMEOUT` | Extends automatically on every request. If no request is made within this window the session expires. |
| **Absolute** | 8 h | `SESSION_ABSOLUTE_TIMEOUT` | Hard cap measured from session creation. Activity cannot extend a session beyond this point. |
Every authenticated request resets the idle clock by calling `Session.refresh()`, which sets `expires_at = now + idle_timeout` — but never past `created_at + absolute_timeout`. This means:
- An active user stays logged in indefinitely **up to** the absolute cap.
- An idle user is logged out after the idle timeout.
- No session can survive longer than the absolute timeout regardless of activity.
### Configuration
Override defaults via environment variables:
```bash
SESSION_IDLE_TIMEOUT=900 # seconds (15 min)
SESSION_ABSOLUTE_TIMEOUT=28800 # seconds (8 h)
```
### Cleanup
Expired sessions are soft-marked as `EXPIRED` by the `cleanup_sessions` job. Run it periodically via the job runner:
```bash
python manage.py cleanup_sessions
# Or via the job runner (Docker):
JOB_NAME=cleanup_sessions JOB_INTERVAL_SECONDS=300
```
### Session Endpoints
- `GET /api/v1/auth/sessions` — List active sessions for the current user
- `POST /api/v1/auth/sessions/refresh` — Extend the current session's idle window (returns new `expires_at`)
- `DELETE /api/v1/auth/sessions/:id` — Revoke a specific session
# Boostrap db # Boostrap db
python manage.py db upgrade python manage.py db upgrade
+4
View File
@@ -48,6 +48,10 @@ class BaseConfig:
seconds=int(os.getenv("MAX_SESSION_DURATION", "86400")) seconds=int(os.getenv("MAX_SESSION_DURATION", "86400"))
) )
# Session timeout policy (seconds)
SESSION_IDLE_TIMEOUT = int(os.getenv("SESSION_IDLE_TIMEOUT", "900"))
SESSION_ABSOLUTE_TIMEOUT = int(os.getenv("SESSION_ABSOLUTE_TIMEOUT", "28800"))
# CORS # CORS
CORS_ORIGINS = os.getenv( CORS_ORIGINS = os.getenv(
"CORS_ORIGINS", "CORS_ORIGINS",
+27 -1
View File
@@ -116,7 +116,7 @@ def login():
remember_me = data.get("remember_me", False) remember_me = data.get("remember_me", False)
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me) policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me)
duration = 2592000 if remember_me else 86400 duration = current_app.config.get("SESSION_ABSOLUTE_TIMEOUT", 28800) if remember_me else None
is_compliance_only = policy_result.create_compliance_only_session is_compliance_only = policy_result.create_compliance_only_session
user_session = AuthService.create_session(user, duration_seconds=duration, is_compliance_only=is_compliance_only) user_session = AuthService.create_session(user, duration_seconds=duration, is_compliance_only=is_compliance_only)
@@ -227,6 +227,32 @@ def revoke_session(session_id):
return api_response(message="Session revoked successfully") return api_response(message="Session revoked successfully")
@api_v1_bp.route("/auth/sessions/refresh", methods=["POST"])
@login_required
def refresh_session():
"""Extend the current session's idle window.
The server already refreshes the session on every authenticated
request, but this endpoint exists so the frontend can proactively
keep a session alive (e.g. a heartbeat while the user is reading
a long page with no API calls).
Returns the new ``expires_at`` so the frontend can display a
countdown or warning before the absolute cap.
"""
session = g.current_session
session.refresh()
return api_response(
data={
"expires_at": session.expires_at.isoformat() + "Z"
if session.expires_at.isoformat()[-1] != "Z"
else session.expires_at.isoformat(),
},
message="Session refreshed",
)
@api_v1_bp.route("/auth/token", methods=["GET"]) @api_v1_bp.route("/auth/token", methods=["GET"])
@login_required @login_required
def get_token(): def get_token():
+2 -2
View File
@@ -190,8 +190,8 @@ def select_organization():
if not member: if not member:
return api_response(success=False, message="You are not a member of this organization", status=403, error_type="FORBIDDEN") return api_response(success=False, message="You are not a member of this organization", status=403, error_type="FORBIDDEN")
from gatehouse_app.services.session_service import SessionService from gatehouse_app.services.auth_service import AuthService
session = SessionService.create_session(user=user, organization_id=organization_id) session = AuthService.create_session(user=user)
state_record.mark_used() state_record.mark_used()
provider_type_val = state_record.provider_type.value if isinstance(state_record.provider_type, _AuthMethodType) else state_record.provider_type provider_type_val = state_record.provider_type.value if isinstance(state_record.provider_type, _AuthMethodType) else state_record.provider_type
+55 -16
View File
@@ -1,5 +1,6 @@
"""Session model.""" """Session model."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from flask import current_app
from gatehouse_app.extensions import db from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import SessionStatus from gatehouse_app.utils.constants import SessionStatus
@@ -38,33 +39,71 @@ class Session(BaseModel):
return f"<Session user_id={self.user_id} status={self.status}>" return f"<Session user_id={self.user_id} status={self.status}>"
def is_active(self): def is_active(self):
"""Check if session is currently active.""" """Check if session is currently active.
Sessions are evaluated against two independent timeouts:
- Idle timeout: expires if no request has been made within
SESSION_IDLE_TIMEOUT seconds (default 15 min).
- Absolute timeout: expires if SESSION_ABSOLUTE_TIMEOUT seconds
have elapsed since the session was created (default 8 h),
regardless of activity.
A session must satisfy *both* constraints to remain active.
"""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expires_at = self.expires_at created_at = self.created_at
if expires_at.tzinfo is None: last_activity_at = self.last_activity_at
expires_at = expires_at.replace(tzinfo=timezone.utc)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
if last_activity_at.tzinfo is None:
last_activity_at = last_activity_at.replace(tzinfo=timezone.utc)
idle_timeout = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
absolute_timeout = current_app.config.get("SESSION_ABSOLUTE_TIMEOUT", 28800)
idle_expires_at = last_activity_at + timedelta(seconds=idle_timeout)
absolute_expires_at = created_at + timedelta(seconds=absolute_timeout)
return ( return (
self.status == SessionStatus.ACTIVE self.status == SessionStatus.ACTIVE
and expires_at > now and now < idle_expires_at
and now < absolute_expires_at
and self.deleted_at is None and self.deleted_at is None
) )
def is_expired(self): def is_expired(self):
"""Check if session has expired.""" """Check if session has expired (either idle or absolute)."""
now = datetime.now(timezone.utc) return not self.is_active() and self.status != SessionStatus.REVOKED
expires_at = self.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return now > expires_at
def refresh(self, duration_seconds: int = 86400): def refresh(self, duration_seconds: int = None):
"""Refresh session expiration. """Extend the session expiration using a sliding window.
The new ``expires_at`` is set to *now + idle timeout*, but is
capped so that the session never exceeds the absolute lifetime
(``created_at + absolute timeout``).
Args: Args:
duration_seconds: New session duration in seconds duration_seconds: Override for the idle timeout. When *None*
(the common case), the value is read from
``SESSION_IDLE_TIMEOUT`` in the Flask config.
""" """
self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=duration_seconds) now = datetime.now(timezone.utc)
self.last_activity_at = datetime.now(timezone.utc)
if duration_seconds is None:
duration_seconds = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
absolute_timeout = current_app.config.get("SESSION_ABSOLUTE_TIMEOUT", 28800)
idle_expires_at = now + timedelta(seconds=duration_seconds)
created_at = self.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
absolute_expires_at = created_at + timedelta(seconds=absolute_timeout)
self.expires_at = min(idle_expires_at, absolute_expires_at)
self.last_activity_at = now
db.session.commit() db.session.commit()
def revoke(self, reason: str = None): def revoke(self, reason: str = None):
+10 -2
View File
@@ -140,18 +140,26 @@ class AuthService:
return user return user
@staticmethod @staticmethod
def create_session(user, duration_seconds=86400, is_compliance_only=False): def create_session(user, duration_seconds=None, is_compliance_only=False):
""" """
Create a new session for the user. Create a new session for the user.
Args: Args:
user: User instance user: User instance
duration_seconds: Session duration in seconds duration_seconds: Session idle timeout in seconds.
When None, defaults to SESSION_IDLE_TIMEOUT from config.
The absolute lifetime is always enforced by Session.is_active()
regardless of this value.
is_compliance_only: Whether this is a compliance-only session (limited access) is_compliance_only: Whether this is a compliance-only session (limited access)
Returns: Returns:
Session instance Session instance
""" """
from flask import current_app
if duration_seconds is None:
duration_seconds = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
# Generate session token # Generate session token
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
@@ -263,7 +263,7 @@ def authenticate_with_provider(
state_record.mark_used() state_record.mark_used()
from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.auth_service import AuthService
session = AuthService.create_session(user=user, organization_id=organization_id) session = AuthService.create_session(user=user)
AuditService.log_external_auth_login( AuditService.log_external_auth_login(
user_id=user.id, user_id=user.id,
+4 -2
View File
@@ -10,10 +10,10 @@ class SessionService:
@staticmethod @staticmethod
def get_active_session_by_token(token): def get_active_session_by_token(token):
"""Get active session by token. """Get active session by token.
Args: Args:
token: The session token string token: The session token string
Returns: Returns:
Session object if found and active, None otherwise Session object if found and active, None otherwise
""" """
@@ -23,6 +23,8 @@ class SessionService:
token=token, token=token,
status=SessionStatus.ACTIVE, status=SessionStatus.ACTIVE,
deleted_at=None deleted_at=None
).filter(
Session.expires_at > datetime.now(timezone.utc)
).first() ).first()
@staticmethod @staticmethod
@@ -138,7 +138,7 @@ class SuperadminAuthService:
Dictionary with emergency session info Dictionary with emergency session info
""" """
from gatehouse_app.models.user.user import User from gatehouse_app.models.user.user import User
from gatehouse_app.services.session_service import SessionService from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.audit_service import AuditService from gatehouse_app.services.audit_service import AuditService
# Verify target user exists # Verify target user exists
@@ -147,7 +147,7 @@ class SuperadminAuthService:
raise ValueError(f"Target user not found: {target_user_id}") raise ValueError(f"Target user not found: {target_user_id}")
# Create emergency session for the target user # Create emergency session for the target user
emergency_session = SessionService.create_session( emergency_session = AuthService.create_session(
user=target_user, user=target_user,
duration_seconds=duration_minutes * 60, duration_seconds=duration_minutes * 60,
is_compliance_only=False is_compliance_only=False
+3 -5
View File
@@ -59,11 +59,9 @@ def login_required(f):
error_type="SESSION_INACTIVE" error_type="SESSION_INACTIVE"
) )
# Update last_activity_at timestamp # Extend session via sliding window (updates last_activity_at
from datetime import datetime, timezone # and recalculates expires_at within the idle / absolute caps).
session.last_activity_at = datetime.now(timezone.utc) session.refresh()
from gatehouse_app import db
db.session.commit()
# Set context variables # Set context variables
g.current_user = session.user g.current_user = session.user
+25
View File
@@ -153,6 +153,31 @@ def mfa_compliance_status():
print("=" * 60) print("=" * 60)
@cli.command("cleanup_sessions")
def cleanup_sessions():
"""Clean up expired user sessions.
Marks sessions as EXPIRED when they have passed their expires_at
timestamp. Safe to run frequently (e.g. every 5 minutes via job_runner).
Usage:
python manage.py cleanup_sessions
"""
from gatehouse_app.services.session_service import SessionService
print("=" * 60)
print("Session Cleanup Job")
print("=" * 60)
from datetime import datetime, timezone
print(f"Start time: {datetime.now(timezone.utc).isoformat()}")
count = SessionService.cleanup_expired_sessions()
print(f"Expired sessions marked: {count}")
print("=" * 60)
@cli.command("configure_oauth") @cli.command("configure_oauth")
@click.argument("provider", required=False) @click.argument("provider", required=False)
@click.option("--client-id", default=None, help="OAuth client ID") @click.option("--client-id", default=None, help="OAuth client ID")
+1
View File
@@ -28,6 +28,7 @@ logger = logging.getLogger(__name__)
JOB_COMMANDS = { JOB_COMMANDS = {
"zerotier_reconciliation": "python manage.py run_zerotier_reconciliation", "zerotier_reconciliation": "python manage.py run_zerotier_reconciliation",
"mfa_compliance": "python manage.py run_mfa_compliance_job", "mfa_compliance": "python manage.py run_mfa_compliance_job",
"cleanup_sessions": "python manage.py cleanup_sessions",
} }
shutdown_requested = False shutdown_requested = False
+4
View File
@@ -95,6 +95,10 @@ class AuthClient:
"""Revoke a specific session belonging to the current user.""" """Revoke a specific session belonging to the current user."""
return self._client.delete(f"/auth/sessions/{session_id}") return self._client.delete(f"/auth/sessions/{session_id}")
def refresh_session(self) -> dict:
"""Extend the current session's idle window."""
return self._client.post("/auth/sessions/refresh")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Password recovery # Password recovery
# ------------------------------------------------------------------ # ------------------------------------------------------------------
+213
View File
@@ -0,0 +1,213 @@
"""Session timeout integration tests.
Validates the sliding-window session timeout policy: idle timeout,
absolute timeout, and the interaction between activity and expiration.
Every test exercises the *public API* the only internal manipulation
is back-dating timestamps in the database, since we cannot wait minutes
inside a test run.
"""
import pytest
import uuid
from datetime import datetime, timedelta, timezone
from tests.integration.client.base import ApiError
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def assert_success(response: dict, message_contains: str = "") -> dict:
"""Assert that an api_response-wrapped payload succeeded."""
data = response.get("data", {})
assert response.get("success") is not False, (
f"Expected success but got error: {response.get('message')}"
)
if message_contains:
assert message_contains.lower() in response.get("message", "").lower(), (
f"Expected message to contain '{message_contains}' but got: {response.get('message')}"
)
return data
def _get_session_row(integration_app, token: str):
"""Look up the Session model row for a given bearer token."""
from gatehouse_app.models.user.session import Session
with integration_app.app_context():
return Session.query.filter_by(token=token).first()
def _touch_session(integration_app, session_id: str, **updates):
"""Directly update columns on a Session row.
Only use this to simulate the passage of time never to assert
internal state.
"""
from gatehouse_app.models.user.session import Session
with integration_app.app_context():
sess = Session.query.get(session_id)
for attr, value in updates.items():
setattr(sess, attr, value)
from gatehouse_app import db
db.session.commit()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def logged_in_session(integration_client, create_test_user, integration_app):
"""Register a user, log in via the API, and return the session metadata.
Returns dict with ``user``, ``token``, ``session_id``, ``session_row``.
The ``session_row`` is a detached SQLAlchemy instance re-query if
you need fresh DB state.
"""
user = create_test_user(password="TestPass123!")
integration_client.auth.login(
email=user["email"], password="TestPass123!",
)
token = integration_client._token
session_row = _get_session_row(integration_app, token)
assert session_row is not None, "Session row should exist after login"
return {
"user": user,
"token": token,
"session_id": session_row.id,
"session_row": session_row,
}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestSessionTimeouts:
"""Sliding-window timeout behavior exercised through the public API."""
def test_session_valid_before_timeout(
self, integration_client, create_test_user,
):
"""SESS-01 — Fresh session is accepted.
A session that was just created should pass all auth checks.
This is the baseline: if this fails, every other timeout test
is meaningless.
"""
user = create_test_user(password="MyPass123!")
integration_client.auth.login(email=user["email"], password="MyPass123!")
result = integration_client.auth.me()
data = assert_success(result)
assert data["user"]["email"] == user["email"]
def test_idle_timeout_rejects_token(
self, integration_client, logged_in_session, integration_app,
):
"""SESS-02 — Session rejected after idle period elapses.
Push ``last_activity_at`` far enough into the past that the
idle window has closed. The API must return 401.
"""
_touch_session(
integration_app,
logged_in_session["session_id"],
last_activity_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
with pytest.raises(ApiError) as exc_info:
integration_client.auth.me()
assert exc_info.value.status_code == 401
def test_absolute_timeout_rejects_even_active_user(
self, integration_client, logged_in_session, integration_app,
):
"""SESS-03 — Absolute cap overrides recent activity.
Push ``created_at`` into the past so the absolute window has
elapsed, but keep ``last_activity_at`` fresh. The session
must still be rejected activity cannot extend past the
absolute limit.
"""
_touch_session(
integration_app,
logged_in_session["session_id"],
created_at=datetime.now(timezone.utc) - timedelta(days=1),
last_activity_at=datetime.now(timezone.utc),
)
with pytest.raises(ApiError) as exc_info:
integration_client.auth.me()
assert exc_info.value.status_code == 401
def test_api_request_keeps_session_alive(
self, integration_client, logged_in_session, integration_app,
):
"""SESS-04 — Hitting an API endpoint extends the session.
Back-date ``last_activity_at`` to *just* inside the idle
window. A subsequent API call should succeed and the session
should remain usable the sliding window should have reset.
"""
from gatehouse_app.models.user.session import Session
from gatehouse_app import db
# Back-date to 10 seconds ago — still inside the idle window.
_touch_session(
integration_app,
logged_in_session["session_id"],
last_activity_at=datetime.now(timezone.utc) - timedelta(seconds=10),
)
# This request should succeed AND extend the session.
result = integration_client.auth.me()
assert_success(result)
# After the request, last_activity_at should be much closer to now.
with integration_app.app_context():
refreshed = Session.query.get(logged_in_session["session_id"])
now = datetime.now(timezone.utc)
# Allow for clock skew / commit latency — should be within 30s.
diff = abs((now - refreshed.last_activity_at.replace(tzinfo=timezone.utc)).total_seconds())
assert diff < 30, (
f"last_activity_at should be near-now after API call, "
f"but delta is {diff:.1f}s"
)
def test_revoked_session_rejected(
self, integration_client, logged_in_session,
):
"""SESS-05 — Revoked session is rejected regardless of timing.
Revoke via the API, then verify the token is dead. This
mirrors AUTH-12 but is included here so the timeout test
suite is self-contained.
"""
integration_client.auth.revoke_session(logged_in_session["session_id"])
with pytest.raises(ApiError) as exc_info:
integration_client.auth.me()
assert exc_info.value.status_code == 401
def test_refresh_endpoint_extends_session(
self, integration_client, logged_in_session, integration_app,
):
"""SESS-06 — POST /auth/sessions/refresh extends the session.
The refresh endpoint exists so the frontend can proactively
keep a session alive during idle UI periods. Verify it
succeeds and returns a new ``expires_at``.
"""
result = integration_client.auth.refresh_session()
data = assert_success(result, "session refreshed")
assert "expires_at" in data, "Response should include new expires_at"