diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c2d01..ca7ae28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,13 @@ The format is inspired by Keep a Changelog, and this project uses semantic versi ### Changed -- Nothing yet. +- Changed Web sessions from a fixed lifetime to renewable inactivity-based + expiration, with throttled renewal and a configurable absolute lifetime. ### Fixed -- Nothing yet. +- Fixed active Web users being forced to sign in again exactly 24 hours after + authentication. ## [1.0.7] - 2026-07-19 diff --git a/compose.yaml b/compose.yaml index 87e3f82..1540450 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,7 +10,9 @@ services: FLUXTUNER_DATA_DIR: /data FLUXTUNER_WEB_SETUP_TOKEN: "${FLUXTUNER_WEB_SETUP_TOKEN:-}" FLUXTUNER_WEB_SECURE_COOKIES: "false" - FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS: "86400" + FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS: "2592000" + FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS: "7776000" + FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS: "86400" volumes: - fluxtuner-data:/data restart: unless-stopped diff --git a/docs/container.md b/docs/container.md index e00f7cd..813baca 100644 --- a/docs/container.md +++ b/docs/container.md @@ -60,7 +60,9 @@ podman run --rm \ -e FLUXTUNER_DATA_DIR=/data \ -e FLUXTUNER_WEB_SETUP_TOKEN="$FLUXTUNER_WEB_SETUP_TOKEN" \ -e FLUXTUNER_WEB_SECURE_COOKIES=false \ - -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=86400 \ + -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=2592000 \ + -e FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS=7776000 \ + -e FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS=86400 \ -v fluxtuner-data:/data \ fluxtuner-web:dev ``` @@ -88,7 +90,9 @@ docker run --rm \ -e FLUXTUNER_DATA_DIR=/data \ -e FLUXTUNER_WEB_SETUP_TOKEN="$FLUXTUNER_WEB_SETUP_TOKEN" \ -e FLUXTUNER_WEB_SECURE_COOKIES=false \ - -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=86400 \ + -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=2592000 \ + -e FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS=7776000 \ + -e FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS=86400 \ -v fluxtuner-data:/data \ fluxtuner-web:dev ``` @@ -117,7 +121,9 @@ podman run --rm \ -e FLUXTUNER_DATA_DIR=/data \ -e FLUXTUNER_WEB_SETUP_TOKEN="$FLUXTUNER_WEB_SETUP_TOKEN" \ -e FLUXTUNER_WEB_SECURE_COOKIES=true \ - -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=86400 \ + -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=2592000 \ + -e FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS=7776000 \ + -e FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS=86400 \ -v fluxtuner-data:/data \ fluxtuner-web:dev ``` diff --git a/docs/multiuser.md b/docs/multiuser.md index 80774f1..9b5a848 100644 --- a/docs/multiuser.md +++ b/docs/multiuser.md @@ -337,7 +337,9 @@ Relevant environment variables: FLUXTUNER_WEB_SETUP_TOKEN FLUXTUNER_WEB_SECURE_COOKIES=true - FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=86400 + FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=2592000 + FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS=7776000 + FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS=86400 FLUXTUNER_DATA_DIR=/data ## Testing requirements diff --git a/docs/secure-web-deployment.md b/docs/secure-web-deployment.md index e47d848..9296068 100644 --- a/docs/secure-web-deployment.md +++ b/docs/secure-web-deployment.md @@ -82,16 +82,40 @@ that setting for shared LAN or internet deployments. ### `FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS` -Controls the maximum session lifetime in seconds. +Controls the session inactivity timeout in seconds. Active sessions are renewed +before this timeout is reached. The default is 30 days. Example: ```bash -FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=86400 +FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=2592000 ``` -Choose a value that matches your deployment risk. Shorter sessions reduce the -impact of stolen cookies; longer sessions are more convenient. +Existing deployments that explicitly use `86400` remain compatible: sessions +expire after one day of inactivity but no longer expire after one day of +continuous use. + +### `FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS` + +Controls the maximum lifetime from the original login regardless of activity. +The default is 90 days: + +```bash +FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS=7776000 +``` + +### `FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS` + +Controls how often an active session may be renewed. The default is 24 hours: + +```bash +FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS=86400 +``` + +The effective renewal interval is capped at half the inactivity timeout so +shorter configured sessions can renew before they expire. Renewal is throttled +to avoid a SQLite write on every authenticated request. Logout, administrative +revocation and user deactivation remain immediate. ## First-run setup checklist @@ -123,7 +147,9 @@ Run the app bound to localhost: export FLUXTUNER_DATA_DIR=/var/lib/fluxtuner export FLUXTUNER_WEB_SETUP_TOKEN="$(openssl rand -hex 32)" export FLUXTUNER_WEB_SECURE_COOKIES=true -export FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=86400 +export FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=2592000 +export FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS=7776000 +export FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS=86400 fluxtuner-web --host 127.0.0.1 --port 8080 ``` @@ -145,7 +171,9 @@ podman run --rm \ -e FLUXTUNER_DATA_DIR=/data \ -e FLUXTUNER_WEB_SETUP_TOKEN="$FLUXTUNER_WEB_SETUP_TOKEN" \ -e FLUXTUNER_WEB_SECURE_COOKIES=true \ - -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=86400 \ + -e FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS=2592000 \ + -e FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS=7776000 \ + -e FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS=86400 \ -v fluxtuner-data:/data \ fluxtuner-web:dev ``` diff --git a/fluxtuner/web/app.py b/fluxtuner/web/app.py index d4f23a8..0b4eca1 100644 --- a/fluxtuner/web/app.py +++ b/fluxtuner/web/app.py @@ -22,7 +22,12 @@ from fluxtuner.web.metadata import MetadataCoordinator, SystemStreamTargetResolver from fluxtuner.web.payloads import public_user_payload from fluxtuner.web.security import ( + SESSION_COOKIE_NAME, csrf_token_for_session_token, + session_absolute_max_age, + session_cookie_max_age, + session_initial_max_age, + session_renewal_interval, set_session_cookie, ) @@ -56,6 +61,13 @@ ACCOUNT_CHANGE_NOT_PENDING_DETAIL = password_change_actions.ACCOUNT_CHANGE_NOT_PENDING_DETAIL ACCOUNT_CHANGE_PENDING_DETAIL = password_change_actions.ACCOUNT_CHANGE_PENDING_DETAIL ACCOUNT_CHANGE_EXPIRED_DETAIL = password_change_actions.ACCOUNT_CHANGE_EXPIRED_DETAIL +SESSION_COOKIE_MUTATION_PATHS = frozenset( + { + "/api/auth/login", + "/api/auth/logout", + "/api/setup/create-admin", + } +) def _missing_web_dependency_message() -> str: @@ -125,6 +137,47 @@ async def lifespan(app_instance: FastAPI): lifespan=lifespan, ) + @app.middleware("http") + async def resolve_and_renew_session(request: Request, call_next: Any) -> Response: + token = request.cookies.get(SESSION_COOKIE_NAME) + renewed_cookie_max_age: int | None = None + + if token and request.url.path.startswith("/api/"): + with db.connect() as conn: + web_context.ensure_web_schema(conn) + session = auth.get_session( + conn, + token, + absolute_max_age_seconds=session_absolute_max_age(), + ) + user = auth.get_user_for_session(conn, session) if session is not None else None + setattr( + request.state, + web_context.AUTHENTICATED_USER_STATE_KEY, + user, + ) + + if ( + session is not None + and user is not None + and request.url.path not in SESSION_COOKIE_MUTATION_PATHS + ): + max_age = session_cookie_max_age() + renewed_cookie_max_age = auth.renew_session( + conn, + session, + max_age_seconds=max_age, + absolute_max_age_seconds=session_absolute_max_age(), + renewal_interval_seconds=session_renewal_interval(max_age), + ) + if renewed_cookie_max_age is not None: + conn.commit() + + response = await call_next(request) + if renewed_cookie_max_age is not None: + set_session_cookie(response, token, max_age=renewed_cookie_max_age) + return response + @app.middleware("http") async def add_static_cache_headers(request: Request, call_next: Any) -> Response: response = await call_next(request) @@ -260,7 +313,12 @@ def setup_create_admin( ) db.ensure_default_profile(conn, user_id=user_id) - token = auth.create_session(conn, user_id) + initial_max_age = session_initial_max_age() + token = auth.create_session( + conn, + user_id, + max_age_seconds=initial_max_age, + ) auth.record_login_attempt( conn, SETUP_RATE_LIMIT_USERNAME, @@ -274,7 +332,7 @@ def setup_create_admin( if user is None: raise HTTPException(status_code=500, detail="Could not create setup session.") - set_session_cookie(response, token) + set_session_cookie(response, token, max_age=initial_max_age) return { "authenticated": True, diff --git a/fluxtuner/web/auth.py b/fluxtuner/web/auth.py index 88b83d3..923ca4c 100644 --- a/fluxtuner/web/auth.py +++ b/fluxtuner/web/auth.py @@ -23,7 +23,9 @@ MAX_PASSWORD_BYTES = 1024 SESSION_TOKEN_BYTES = 32 -DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 +DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 +DEFAULT_SESSION_ABSOLUTE_MAX_AGE_SECONDS = 60 * 60 * 24 * 90 +DEFAULT_SESSION_RENEWAL_INTERVAL_SECONDS = 60 * 60 * 24 MAX_FAILED_LOGIN_ATTEMPTS = 5 LOGIN_RATE_LIMIT_WINDOW_SECONDS = 60 * 5 @@ -187,6 +189,7 @@ def get_session( token: str | None, *, now: datetime | None = None, + absolute_max_age_seconds: int | None = None, ) -> dict[str, Any] | None: if not token: return None @@ -223,6 +226,15 @@ def get_session( if parse_datetime(str(session["expires_at"])) <= current_time: return None + if absolute_max_age_seconds is not None: + if absolute_max_age_seconds <= 0: + raise ValueError("Session absolute max age must be positive.") + absolute_expires_at = parse_datetime(str(session["created_at"])) + timedelta( + seconds=absolute_max_age_seconds + ) + if absolute_expires_at <= current_time: + return None + return session @@ -231,11 +243,26 @@ def get_session_user( token: str | None, *, now: datetime | None = None, + absolute_max_age_seconds: int | None = None, ) -> dict[str, Any] | None: - session = get_session(conn, token, now=now) + session = get_session( + conn, + token, + now=now, + absolute_max_age_seconds=absolute_max_age_seconds, + ) if session is None: return None + return get_user_for_session(conn, session) + + +def get_user_for_session( + conn: Any, + session: dict[str, Any], +) -> dict[str, Any] | None: + """Return the active approved user associated with a resolved session.""" + row = conn.execute( """ SELECT @@ -254,7 +281,7 @@ def get_session_user( FROM users WHERE id = ? AND is_active = 1 AND approval_status = 'approved' """, - (session["user_id"],), + (int(session["user_id"]),), ).fetchone() if row is None: @@ -263,6 +290,65 @@ def get_session_user( return db.user_from_row(row) +def renew_session( + conn: Any, + session: dict[str, Any], + *, + max_age_seconds: int, + absolute_max_age_seconds: int, + renewal_interval_seconds: int, + now: datetime | None = None, +) -> int | None: + """Renew an active session and return the cookie lifetime, if renewed.""" + if max_age_seconds <= 0: + raise ValueError("Session max age must be positive.") + if absolute_max_age_seconds <= 0: + raise ValueError("Session absolute max age must be positive.") + if renewal_interval_seconds <= 0: + raise ValueError("Session renewal interval must be positive.") + if session.get("revoked_at") is not None: + return None + + current_time = now or utc_now() + created_at = parse_datetime(str(session["created_at"])) + last_seen_at = parse_datetime(str(session["last_seen_at"])) + expires_at = parse_datetime(str(session["expires_at"])) + absolute_expires_at = created_at + timedelta(seconds=absolute_max_age_seconds) + + if expires_at <= current_time or absolute_expires_at <= current_time: + return None + if last_seen_at + timedelta(seconds=renewal_interval_seconds) > current_time: + return None + + renewed_expires_at = min( + current_time + timedelta(seconds=max_age_seconds), + absolute_expires_at, + ) + cookie_max_age = int((renewed_expires_at - current_time).total_seconds()) + if cookie_max_age <= 0: + return None + + cursor = conn.execute( + """ + UPDATE web_sessions + SET last_seen_at = ?, expires_at = ? + WHERE id = ? AND revoked_at IS NULL AND expires_at > ? + """, + ( + encode_datetime(current_time), + encode_datetime(renewed_expires_at), + int(session["id"]), + encode_datetime(current_time), + ), + ) + if cursor.rowcount <= 0: + return None + + session["last_seen_at"] = encode_datetime(current_time) + session["expires_at"] = encode_datetime(renewed_expires_at) + return cookie_max_age + + def revoke_session( conn: Any, token: str | None, diff --git a/fluxtuner/web/context.py b/fluxtuner/web/context.py index 6196b65..398dab3 100644 --- a/fluxtuner/web/context.py +++ b/fluxtuner/web/context.py @@ -7,7 +7,12 @@ from fluxtuner.core import db from fluxtuner.core.profiles import resolve_effective_profile_name from fluxtuner.web import auth -from fluxtuner.web.security import SESSION_COOKIE_NAME +from fluxtuner.web.security import ( + SESSION_COOKIE_NAME, + session_absolute_max_age, +) + +AUTHENTICATED_USER_STATE_KEY = "fluxtuner_authenticated_user" def ensure_web_schema(conn: Any) -> None: @@ -19,10 +24,18 @@ def ensure_web_schema(conn: Any) -> None: def authenticated_user(request: Any) -> dict[str, Any] | None: """Return the Web user associated with the request session cookie, if any.""" + state = getattr(request, "state", None) + if state is not None and hasattr(state, AUTHENTICATED_USER_STATE_KEY): + return getattr(state, AUTHENTICATED_USER_STATE_KEY) + token = request.cookies.get(SESSION_COOKIE_NAME) with db.connect() as conn: ensure_web_schema(conn) - return auth.get_session_user(conn, token) + return auth.get_session_user( + conn, + token, + absolute_max_age_seconds=session_absolute_max_age(), + ) def effective_profile_name(profile: str | None = None) -> str | None: diff --git a/fluxtuner/web/routes/auth.py b/fluxtuner/web/routes/auth.py index b1e69bc..08d4dd2 100644 --- a/fluxtuner/web/routes/auth.py +++ b/fluxtuner/web/routes/auth.py @@ -16,7 +16,7 @@ SESSION_COOKIE_NAME, csrf_token_for_session_token, delete_session_cookie, - session_cookie_max_age, + session_initial_max_age, set_session_cookie, ) @@ -164,10 +164,11 @@ def login( raise HTTPException(status_code=401, detail=AUTH_ERROR_DETAIL) authenticated_user = user + initial_max_age = session_initial_max_age() token = auth.create_session( conn, int(authenticated_user["id"]), - max_age_seconds=session_cookie_max_age(), + max_age_seconds=initial_max_age, ) auth.record_login_attempt( conn, @@ -177,7 +178,7 @@ def login( ) conn.commit() - set_session_cookie(response, token) + set_session_cookie(response, token, max_age=initial_max_age) return { "authenticated": True, "user": public_user_payload(authenticated_user), @@ -200,9 +201,7 @@ def logout(request: Request, response: Response) -> dict[str, Any]: @router.get("/api/auth/me") def me(request: Request) -> dict[str, Any]: token = request.cookies.get(SESSION_COOKIE_NAME) - with db.connect() as conn: - web_context.ensure_web_schema(conn) - user = auth.get_session_user(conn, token) + user = web_context.authenticated_user(request) if user is None: raise HTTPException(status_code=401, detail=AUTH_REQUIRED_DETAIL) diff --git a/fluxtuner/web/security.py b/fluxtuner/web/security.py index 8bf9e1b..9422c28 100644 --- a/fluxtuner/web/security.py +++ b/fluxtuner/web/security.py @@ -29,6 +29,32 @@ def session_cookie_max_age() -> int: return max_age if max_age > 0 else auth.DEFAULT_SESSION_MAX_AGE_SECONDS +def session_absolute_max_age() -> int: + value = os.getenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "") + try: + max_age = int(value) + except ValueError: + return auth.DEFAULT_SESSION_ABSOLUTE_MAX_AGE_SECONDS + return max_age if max_age > 0 else auth.DEFAULT_SESSION_ABSOLUTE_MAX_AGE_SECONDS + + +def session_initial_max_age() -> int: + return min(session_cookie_max_age(), session_absolute_max_age()) + + +def session_renewal_interval(max_age_seconds: int | None = None) -> int: + value = os.getenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", "") + try: + interval = int(value) + except ValueError: + interval = auth.DEFAULT_SESSION_RENEWAL_INTERVAL_SECONDS + if interval <= 0: + interval = auth.DEFAULT_SESSION_RENEWAL_INTERVAL_SECONDS + + session_max_age = max_age_seconds or session_cookie_max_age() + return min(interval, max(1, session_max_age // 2)) + + def csrf_token_for_session_token(token: str | None) -> str: """Return a CSRF token derived from the opaque session token.""" if not token: @@ -41,11 +67,11 @@ def csrf_token_for_session_token(token: str | None) -> str: ).hexdigest() -def set_session_cookie(response: Any, token: str) -> None: +def set_session_cookie(response: Any, token: str, *, max_age: int | None = None) -> None: response.set_cookie( key=SESSION_COOKIE_NAME, value=token, - max_age=session_cookie_max_age(), + max_age=max_age if max_age is not None else session_cookie_max_age(), httponly=True, secure=web_secure_cookies(), samesite="lax", diff --git a/tests/test_web_api_auth.py b/tests/test_web_api_auth.py index 0ac0347..45b14a1 100644 --- a/tests/test_web_api_auth.py +++ b/tests/test_web_api_auth.py @@ -1,5 +1,7 @@ # SPDX-License-Identifier: LicenseRef-FluxTuner-Web-NC +from datetime import UTC, datetime, timedelta + from fastapi.testclient import TestClient from fluxtuner.core import db @@ -66,6 +68,134 @@ def test_login_sets_http_only_session_cookie_and_me_returns_user(tmp_path, monke assert me.json()["csrf_token"] == payload["csrf_token"] +def test_active_session_is_renewed_without_changing_csrf_token(tmp_path, monkeypatch) -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + clock = {"now": now} + monkeypatch.setattr(auth, "utc_now", lambda: clock["now"]) + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "1000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "2000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", "100") + client = make_client(tmp_path, monkeypatch) + create_user("alice") + + login = client.post( + "/api/auth/login", + json={"username": "alice", "password": VALID_PASSWORD}, + ) + csrf_token = login.json()["csrf_token"] + clock["now"] = now + timedelta(seconds=100) + + me = client.get("/api/auth/me") + + assert me.status_code == 200 + assert me.json()["csrf_token"] == csrf_token + assert "Max-Age=1000" in me.headers["set-cookie"] + with db.connect() as conn: + session_row = conn.execute("SELECT last_seen_at, expires_at FROM web_sessions").fetchone() + assert session_row is not None + assert auth.parse_datetime(session_row["last_seen_at"]) == clock["now"] + assert auth.parse_datetime(session_row["expires_at"]) == now + timedelta(seconds=1100) + + +def test_active_session_is_not_written_before_renewal_interval(tmp_path, monkeypatch) -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + clock = {"now": now} + monkeypatch.setattr(auth, "utc_now", lambda: clock["now"]) + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "1000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "2000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", "100") + client = make_client(tmp_path, monkeypatch) + create_user("alice") + login = client.post( + "/api/auth/login", + json={"username": "alice", "password": VALID_PASSWORD}, + ) + assert login.status_code == 200 + clock["now"] = now + timedelta(seconds=99) + + me = client.get("/api/auth/me") + + assert me.status_code == 200 + assert "set-cookie" not in me.headers + with db.connect() as conn: + last_seen_at = conn.execute("SELECT last_seen_at FROM web_sessions").fetchone()[0] + assert auth.parse_datetime(last_seen_at) == now + + +def test_session_is_rejected_at_absolute_lifetime(tmp_path, monkeypatch) -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + clock = {"now": now} + monkeypatch.setattr(auth, "utc_now", lambda: clock["now"]) + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "1000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "200") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", "50") + client = make_client(tmp_path, monkeypatch) + create_user("alice") + login = client.post( + "/api/auth/login", + json={"username": "alice", "password": VALID_PASSWORD}, + ) + assert login.status_code == 200 + clock["now"] = now + timedelta(seconds=200) + + me = client.get("/api/auth/me") + + assert me.status_code == 401 + + +def test_login_cookie_and_database_expiry_are_capped_by_absolute_lifetime( + tmp_path, monkeypatch +) -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + monkeypatch.setattr(auth, "utc_now", lambda: now) + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "1000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "200") + client = make_client(tmp_path, monkeypatch) + create_user("alice") + + login = client.post( + "/api/auth/login", + json={"username": "alice", "password": VALID_PASSWORD}, + ) + + assert login.status_code == 200 + assert "Max-Age=200" in login.headers["set-cookie"] + with db.connect() as conn: + expires_at = conn.execute("SELECT expires_at FROM web_sessions").fetchone()[0] + assert auth.parse_datetime(expires_at) == now + timedelta(seconds=200) + + +def test_login_does_not_renew_or_restore_previous_session_cookie(tmp_path, monkeypatch) -> None: + now = datetime(2026, 1, 1, tzinfo=UTC) + clock = {"now": now} + monkeypatch.setattr(auth, "utc_now", lambda: clock["now"]) + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "1000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "2000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", "100") + client = make_client(tmp_path, monkeypatch) + create_user("alice") + first_login = client.post( + "/api/auth/login", + json={"username": "alice", "password": VALID_PASSWORD}, + ) + first_cookie = first_login.cookies[SESSION_COOKIE_NAME] + clock["now"] = now + timedelta(seconds=100) + + second_login = client.post( + "/api/auth/login", + json={"username": "alice", "password": VALID_PASSWORD}, + ) + + assert second_login.status_code == 200 + assert second_login.cookies[SESSION_COOKIE_NAME] != first_cookie + assert client.cookies[SESSION_COOKIE_NAME] == second_login.cookies[SESSION_COOKIE_NAME] + with db.connect() as conn: + previous_last_seen = conn.execute( + "SELECT last_seen_at FROM web_sessions ORDER BY id LIMIT 1" + ).fetchone()[0] + assert auth.parse_datetime(previous_last_seen) == now + + def test_login_rejects_wrong_password_with_generic_error(tmp_path, monkeypatch) -> None: client = make_client(tmp_path, monkeypatch) create_user("alice") diff --git a/tests/test_web_auth.py b/tests/test_web_auth.py index 509612e..701e5af 100644 --- a/tests/test_web_auth.py +++ b/tests/test_web_auth.py @@ -162,6 +162,125 @@ def test_get_session_rejects_expired_session( assert auth.get_session_user(conn, token, now=now + timedelta(seconds=11)) is None +def test_get_session_rejects_absolute_lifetime_boundary(tmp_path, monkeypatch) -> None: + db_file = tmp_path / "sessions.db" + monkeypatch.setattr(db, "DB_FILE", db_file) + db.init_db() + now = datetime(2026, 1, 1, tzinfo=UTC) + + with db.connect() as conn: + user_id = db.get_or_create_user(conn, "alice") + token = auth.create_session(conn, user_id, max_age_seconds=1000, now=now) + conn.commit() + + with db.connect() as conn: + assert ( + auth.get_session( + conn, + token, + now=now + timedelta(seconds=99), + absolute_max_age_seconds=100, + ) + is not None + ) + assert ( + auth.get_session( + conn, + token, + now=now + timedelta(seconds=100), + absolute_max_age_seconds=100, + ) + is None + ) + + +def test_renew_session_updates_activity_and_idle_expiry(tmp_path, monkeypatch) -> None: + db_file = tmp_path / "sessions.db" + monkeypatch.setattr(db, "DB_FILE", db_file) + db.init_db() + now = datetime(2026, 1, 1, tzinfo=UTC) + + with db.connect() as conn: + user_id = db.get_or_create_user(conn, "alice") + token = auth.create_session(conn, user_id, max_age_seconds=100, now=now) + session = auth.get_session(conn, token, now=now) + assert session is not None + + cookie_max_age = auth.renew_session( + conn, + session, + max_age_seconds=100, + absolute_max_age_seconds=1000, + renewal_interval_seconds=25, + now=now + timedelta(seconds=25), + ) + row = conn.execute( + "SELECT last_seen_at, expires_at FROM web_sessions WHERE id = ?", + (session["id"],), + ).fetchone() + + assert cookie_max_age == 100 + assert row is not None + assert auth.parse_datetime(row["last_seen_at"]) == now + timedelta(seconds=25) + assert auth.parse_datetime(row["expires_at"]) == now + timedelta(seconds=125) + + +def test_renew_session_skips_write_before_interval(tmp_path, monkeypatch) -> None: + db_file = tmp_path / "sessions.db" + monkeypatch.setattr(db, "DB_FILE", db_file) + db.init_db() + now = datetime(2026, 1, 1, tzinfo=UTC) + + with db.connect() as conn: + user_id = db.get_or_create_user(conn, "alice") + token = auth.create_session(conn, user_id, max_age_seconds=100, now=now) + session = auth.get_session(conn, token, now=now) + assert session is not None + + cookie_max_age = auth.renew_session( + conn, + session, + max_age_seconds=100, + absolute_max_age_seconds=1000, + renewal_interval_seconds=25, + now=now + timedelta(seconds=24), + ) + row = conn.execute( + "SELECT last_seen_at, expires_at FROM web_sessions WHERE id = ?", + (session["id"],), + ).fetchone() + + assert cookie_max_age is None + assert row is not None + assert auth.parse_datetime(row["last_seen_at"]) == now + assert auth.parse_datetime(row["expires_at"]) == now + timedelta(seconds=100) + + +def test_renew_session_is_capped_by_absolute_lifetime(tmp_path, monkeypatch) -> None: + db_file = tmp_path / "sessions.db" + monkeypatch.setattr(db, "DB_FILE", db_file) + db.init_db() + now = datetime(2026, 1, 1, tzinfo=UTC) + + with db.connect() as conn: + user_id = db.get_or_create_user(conn, "alice") + token = auth.create_session(conn, user_id, max_age_seconds=100, now=now) + session = auth.get_session(conn, token, now=now) + assert session is not None + + cookie_max_age = auth.renew_session( + conn, + session, + max_age_seconds=100, + absolute_max_age_seconds=120, + renewal_interval_seconds=20, + now=now + timedelta(seconds=30), + ) + + assert cookie_max_age == 90 + assert auth.parse_datetime(session["expires_at"]) == now + timedelta(seconds=120) + + def test_invalid_session_tokens_are_rejected( tmp_path, monkeypatch, diff --git a/tests/test_web_security.py b/tests/test_web_security.py index f048175..3514cc0 100644 --- a/tests/test_web_security.py +++ b/tests/test_web_security.py @@ -8,7 +8,10 @@ SESSION_COOKIE_NAME, csrf_token_for_session_token, delete_session_cookie, + session_absolute_max_age, session_cookie_max_age, + session_initial_max_age, + session_renewal_interval, set_session_cookie, web_secure_cookies, ) @@ -64,6 +67,36 @@ def test_session_cookie_max_age_falls_back_for_invalid_values(monkeypatch) -> No assert session_cookie_max_age() == auth.DEFAULT_SESSION_MAX_AGE_SECONDS +def test_session_lifetime_settings_use_positive_overrides(monkeypatch) -> None: + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "456") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", "123") + + assert session_absolute_max_age() == 456 + assert session_renewal_interval(1000) == 123 + + +def test_initial_session_lifetime_is_capped_by_absolute_limit(monkeypatch) -> None: + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "1000") + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", "200") + + assert session_initial_max_age() == 200 + + +def test_session_lifetime_settings_fall_back_for_invalid_values(monkeypatch) -> None: + for value in ["", "abc", "0", "-1"]: + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_ABSOLUTE_MAX_AGE_SECONDS", value) + assert session_absolute_max_age() == auth.DEFAULT_SESSION_ABSOLUTE_MAX_AGE_SECONDS + + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", value) + assert session_renewal_interval() == auth.DEFAULT_SESSION_RENEWAL_INTERVAL_SECONDS + + +def test_session_renewal_interval_is_capped_at_half_idle_timeout(monkeypatch) -> None: + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_RENEWAL_INTERVAL_SECONDS", "100") + + assert session_renewal_interval(40) == 20 + + def test_set_session_cookie_uses_web_session_settings(monkeypatch) -> None: monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "42") monkeypatch.setenv("FLUXTUNER_WEB_SECURE_COOKIES", "false") @@ -84,6 +117,15 @@ def test_set_session_cookie_uses_web_session_settings(monkeypatch) -> None: ] +def test_set_session_cookie_accepts_renewed_lifetime(monkeypatch) -> None: + monkeypatch.setenv("FLUXTUNER_WEB_SESSION_MAX_AGE_SECONDS", "42") + response = DummyResponse() + + set_session_cookie(response, "session-token", max_age=17) + + assert response.set_calls[0]["max_age"] == 17 + + def test_delete_session_cookie_uses_web_session_settings(monkeypatch) -> None: monkeypatch.setenv("FLUXTUNER_WEB_SECURE_COOKIES", "false") response = DummyResponse()