feat(auth): make session TTL configurable via env var and settings.json

Add _resolve_session_ttl() with three-layer precedence:
  1. HERMES_WEBUI_SESSION_TTL env var (highest priority)
  2. session_ttl_seconds in settings.json
  3. Default: 86400 * 30 (30 days)

Clamped to [60s, 1 year] for safety. Settings changes take effect
immediately since the function is called dynamically at each login/cookie-write.

Closes #1954
This commit is contained in:
hermes-gimmethebeans committed 2026-05-09 17:11:53 +00:00
1 parent 0b7e1e60e8
commit 9d7c213971
2 files changed
+75 -3

No files matched your search

+22 -3
View File
@@ -17,6 +17,26 @@ from api.config import STATE_DIR, load_settings
logger = logging.getLogger(__name__)
def _resolve_session_ttl() -> int:
"""Resolve session TTL from env > settings > default.
Priority mirrors get_password_hash(): HERMES_WEBUI_SESSION_TTL env var
first, then settings.json, falling back to 30 days. Clamped to
[60s, 1 year] to prevent runaway cookies or self-lockout.
"""
env_v = os.getenv('HERMES_WEBUI_SESSION_TTL', '').strip()
if env_v.isdigit():
val = int(env_v)
if 60 <= val <= 86400 * 365:
return val
s = load_settings()
v = s.get('session_ttl_seconds')
if isinstance(v, int) and 60 <= v <= 86400 * 365:
return v
return 86400 * 30 # current default (30 days)
# ── Public paths (no auth required) ─────────────────────────────────────────
PUBLIC_PATHS = frozenset({
'/login', '/health', '/favicon.ico', '/sw.js',
@@ -25,7 +45,6 @@ PUBLIC_PATHS = frozenset({
})
COOKIE_NAME = 'hermes_session'
SESSION_TTL = 86400 * 30 # 30 days
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
@@ -210,7 +229,7 @@ def verify_password(plain) -> bool:
def create_session() -> str:
"""Create a new auth session. Returns signed cookie value."""
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_TTL
_sessions[token] = time.time() + _resolve_session_ttl()
_save_sessions(_sessions)
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
return f"{token}.{sig}"
@@ -323,7 +342,7 @@ def set_auth_cookie(handler, cookie_value) -> None:
cookie[COOKIE_NAME]['httponly'] = True
cookie[COOKIE_NAME]['samesite'] = 'Lax'
cookie[COOKIE_NAME]['path'] = '/'
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
cookie[COOKIE_NAME]['max-age'] = str(_resolve_session_ttl())
# Set Secure flag when connection is HTTPS
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
cookie[COOKIE_NAME]['secure'] = True
+53
View File
@@ -132,3 +132,56 @@ class TestSessionInvalidation(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestSessionTtlResolution(unittest.TestCase):
"""Verify the three-layer TTL resolution (env > settings > default)."""
def test_env_var_overrides_settings(self, monkeypatch):
"""HERMES_WEBUI_SESSION_TTL env var should take priority."""
monkeypatch.setenv("HERMES_WEBUI_SESSION_TTL", "3600")
from api.auth import _resolve_session_ttl
self.assertEqual(_resolve_session_ttl(), 3600)
def test_clamps_minimum(self, monkeypatch):
"""Values below 60 seconds are clamped to 60."""
monkeypatch.setenv("HERMES_WEBUI_SESSION_TTL", "10")
from api.auth import _resolve_session_ttl
self.assertEqual(_resolve_session_ttl(), 60)
def test_clamps_maximum(self, monkeypatch):
"""Values above 1 year are clamped to 31536000."""
monkeypatch.setenv("HERMES_WEBUI_SESSION_TTL", "100000000")
from api.auth import _resolve_session_ttl
self.assertEqual(_resolve_session_ttl(), 86400 * 365)
def test_invalid_env_falls_through(self, monkeypatch):
"""Non-integer env var falls through to default."""
monkeypatch.setenv("HERMES_WEBUI_SESSION_TTL", "not-a-number")
from api.auth import _resolve_session_ttl
self.assertEqual(_resolve_session_ttl(), 86400 * 365)
def test_empty_env_falls_through(self, monkeypatch):
"""Empty env var falls through to default."""
monkeypatch.setenv("HERMES_WEBUI_SESSION_TTL", "")
from api.auth import _resolve_session_ttl
self.assertEqual(_resolve_session_ttl(), 86400 * 365)
def test_settings_path_returns_value(self, monkeypatch):
"""settings.json session_ttl_seconds path works when env is unset."""
monkeypatch.delenv("HERMES_WEBUI_SESSION_TTL", raising=False)
fake_settings = {"session_ttl_seconds": 7200}
monkeypatch.setattr("api.auth.load_settings", lambda: fake_settings)
from api.auth import _resolve_session_ttl
self.assertEqual(_resolve_session_ttl(), 7200)
def test_session_uses_dynamic_ttl(self):
"""Newly created sessions should honor the resolved TTL."""
auth._sessions.clear()
token_hex = auth.create_session().split(".")[0]
from api.auth import _sessions
for t, exp in _sessions.items():
if t == token_hex:
expected = time.time() + 86400 * 365 # default
self.assertAlmostEqual(exp, expected, delta=5)
break