'/' and '/index.html' were in PUBLIC_PATHS, so setting a password and refreshing the root URL would show the app blank (JS loaded but all API calls returned 401) instead of redirecting to /login. Root and index.html must be protected paths so the browser gets a 302 -> /login when auth is active and no valid session cookie exists.
150 lines
4.7 KiB
Python
150 lines
4.7 KiB
Python
"""
|
|
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())
|