""" Hermes Web UI -- Optional password authentication. Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var or configuring a password in the Settings panel. """ import hashlib import hmac import http.cookies import os import secrets import time from api.config import STATE_DIR, load_settings # ── Public paths (no auth required) ───────────────────────────────────────── PUBLIC_PATHS = frozenset({ '/login', '/health', '/favicon.ico', '/api/auth/login', '/api/auth/status', }) COOKIE_NAME = 'hermes_session' SESSION_TTL = 86400 # 24 hours # Active sessions: token -> expiry timestamp _sessions = {} def _signing_key(): """Derive a stable signing key from STATE_DIR.""" return hashlib.sha256(str(STATE_DIR).encode()).digest() def _hash_password(password): """SHA-256 hash with a salt derived from STATE_DIR.""" salt = str(STATE_DIR).encode() return hashlib.sha256(salt + password.encode()).hexdigest() def get_password_hash(): """Return the active password hash, or None if auth is disabled. Priority: env var > settings.json.""" env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip() if env_pw: return _hash_password(env_pw) settings = load_settings() return settings.get('password_hash') or None def is_auth_enabled(): """True if a password is configured (env var or settings).""" return get_password_hash() is not None def verify_password(plain): """Verify a plaintext password against the stored hash.""" expected = get_password_hash() if not expected: return False return hmac.compare_digest(_hash_password(plain), expected) def create_session(): """Create a new auth session. Returns signed cookie value.""" token = secrets.token_hex(32) _sessions[token] = time.time() + SESSION_TTL sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16] return f"{token}.{sig}" def verify_session(cookie_value): """Verify a signed session cookie. Returns True if valid and not expired.""" if not cookie_value or '.' not in cookie_value: return False token, sig = cookie_value.rsplit('.', 1) expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16] if not hmac.compare_digest(sig, expected_sig): return False expiry = _sessions.get(token) if not expiry or time.time() > expiry: _sessions.pop(token, None) return False return True def invalidate_session(cookie_value): """Remove a session token.""" if cookie_value and '.' in cookie_value: token = cookie_value.rsplit('.', 1)[0] _sessions.pop(token, None) def parse_cookie(handler): """Extract the auth cookie from the request headers.""" cookie_header = handler.headers.get('Cookie', '') if not cookie_header: return None cookie = http.cookies.SimpleCookie() try: cookie.load(cookie_header) except http.cookies.CookieError: return None morsel = cookie.get(COOKIE_NAME) return morsel.value if morsel else None def check_auth(handler, parsed): """Check if request is authorized. Returns True if OK. If not authorized, sends 401 (API) or 302 redirect (page) and returns False.""" if not is_auth_enabled(): return True # Public paths don't require auth if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/'): return True # Check session cookie cookie_val = parse_cookie(handler) if cookie_val and verify_session(cookie_val): return True # Not authorized if parsed.path.startswith('/api/'): handler.send_response(401) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(b'{"error":"Authentication required"}') else: handler.send_response(302) handler.send_header('Location', '/login') handler.end_headers() return False def set_auth_cookie(handler, cookie_value): """Set the auth cookie on the response.""" cookie = http.cookies.SimpleCookie() cookie[COOKIE_NAME] = cookie_value cookie[COOKIE_NAME]['httponly'] = True cookie[COOKIE_NAME]['samesite'] = 'Lax' cookie[COOKIE_NAME]['path'] = '/' cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL) handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString()) def clear_auth_cookie(handler): """Clear the auth cookie on the response.""" cookie = http.cookies.SimpleCookie() cookie[COOKIE_NAME] = '' cookie[COOKIE_NAME]['httponly'] = True cookie[COOKIE_NAME]['path'] = '/' cookie[COOKIE_NAME]['max-age'] = '0' handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString())