Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions docs/container.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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
```
Expand Down
4 changes: 3 additions & 1 deletion docs/multiuser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 34 additions & 6 deletions docs/secure-web-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
```
Expand All @@ -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
```
Expand Down
62 changes: 60 additions & 2 deletions fluxtuner/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
92 changes: 89 additions & 3 deletions fluxtuner/web/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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,
Expand Down
Loading