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:
1 parent
0b7e1e60e8
commit
9d7c213971
2 files changed
+75
-3
No files matched your search
+22
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user