diff --git a/api/auth.py b/api/auth.py index b6187455..14fab99a 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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 diff --git a/tests/test_auth_sessions.py b/tests/test_auth_sessions.py index ce685fbd..15c24149 100644 --- a/tests/test_auth_sessions.py +++ b/tests/test_auth_sessions.py @@ -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