Merge pull request #204 from naturallaw777/copilot/add-login-page-for-hub-authentication

Add password authentication to Sovran Hub web interface
This commit is contained in:
Sovran_Systems
2026-04-12 10:42:05 -05:00
committed by GitHub
6 changed files with 301 additions and 5 deletions

View File

@@ -5,10 +5,12 @@ from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import os
import pwd
import re
import secrets
import shutil
import socket
import subprocess
@@ -19,7 +21,7 @@ import urllib.parse
import urllib.request
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
@@ -68,6 +70,27 @@ REBOOT_COMMAND = ["reboot"]
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
# ── Hub web authentication ────────────────────────────────────────
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
SESSION_COOKIE_NAME = "hub_session"
SESSION_MAX_AGE = 86400 # 24 hours
# In-memory session store: token → expiry timestamp (float)
_sessions: dict[str, float] = {}
# Failed login tracking: ip → list of failure timestamps
_login_failures: dict[str, list[float]] = {}
LOGIN_FAIL_DELAY = 2.0 # seconds to sleep after a failed attempt
LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures
LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
# Public paths that are accessible without a valid session
_AUTH_EXEMPT_PATHS = {"/login", "/api/login"}
# Prefixes for static assets required by the login page
_AUTH_EXEMPT_PREFIXES = ("/static/css/", "/static/sovran-hub-icon.svg")
# ── Security constants ────────────────────────────────────────────
SECURITY_BANNER_DISMISSED_FLAG = "/var/lib/sovran/security-banner-dismissed"
@@ -468,10 +491,128 @@ class NoCacheMiddleware(BaseHTTPMiddleware):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
response.headers["Clear-Site-Data"] = '"cache", "cookies", "storage"'
# Note: "cookies" is intentionally omitted so session cookies are not cleared
response.headers["Clear-Site-Data"] = '"cache", "storage"'
return response
# ── Session / authentication helpers ─────────────────────────────
def _get_or_create_session_secret() -> bytes:
"""Return the Hub session secret, generating it on first boot.
The file is stored in /var/lib/secrets/ (mode 0600) so it is wiped
automatically during a security reset, which forces re-login after reset.
"""
try:
with open(HUB_SESSION_SECRET_FILE, "rb") as f:
data = f.read().strip()
if len(data) >= 32:
return data
except FileNotFoundError:
pass
# Generate 32 random bytes and hex-encode for human readability
token_bytes = secrets.token_bytes(32)
token_hex = token_bytes.hex().encode()
try:
os.makedirs(os.path.dirname(HUB_SESSION_SECRET_FILE), exist_ok=True)
with open(HUB_SESSION_SECRET_FILE, "wb") as f:
f.write(token_hex)
os.chmod(HUB_SESSION_SECRET_FILE, 0o600)
except OSError:
pass
return token_hex
def _create_session() -> str:
"""Create a new opaque session token and register it in the store."""
_purge_expired_sessions()
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_MAX_AGE
return token
def _destroy_session(token: str) -> None:
"""Remove a session token from the store."""
_sessions.pop(token, None)
def _purge_expired_sessions() -> None:
"""Remove all expired sessions from the in-memory store."""
now = time.time()
expired = [tok for tok, exp in _sessions.items() if exp <= now]
for tok in expired:
del _sessions[tok]
def _is_authenticated(request: Request) -> bool:
"""Return True if the request carries a valid, unexpired session cookie."""
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
return False
expiry = _sessions.get(token)
if expiry is None or time.time() >= expiry:
_sessions.pop(token, None)
return False
# Slide the expiry window on activity
_sessions[token] = time.time() + SESSION_MAX_AGE
return True
def _read_free_password() -> str | None:
"""Return the contents of the free-password secrets file, or None."""
try:
with open(FREE_PASSWORD_FILE, "r") as f:
return f.read().strip()
except Exception:
return None
def _check_password(submitted: str) -> bool:
"""Constant-time comparison of submitted password against the stored one."""
stored = _read_free_password()
if stored is None:
return False
return hmac.compare_digest(submitted.encode(), stored.encode())
def _record_failure(client_ip: str) -> None:
"""Record a failed login attempt and apply a rate-limit delay.
Must always be called via loop.run_in_executor() so that the blocking
time.sleep() does not stall the asyncio event loop.
"""
now = time.time()
failures = _login_failures.setdefault(client_ip, [])
# Prune old entries outside the window
_login_failures[client_ip] = [t for t in failures if now - t < LOGIN_FAIL_WINDOW]
_login_failures[client_ip].append(now)
# Sleep in the thread-pool thread to slow brute-force without blocking the loop
time.sleep(LOGIN_FAIL_DELAY)
# ── Authentication middleware ─────────────────────────────────────
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Allow public paths and static assets needed by the login page
if path in _AUTH_EXEMPT_PATHS:
return await call_next(request)
if any(path.startswith(prefix) for prefix in _AUTH_EXEMPT_PREFIXES):
return await call_next(request)
if not _is_authenticated(request):
accept = request.headers.get("accept", "")
if "text/html" in accept:
return RedirectResponse(url="/login", status_code=303)
return JSONResponse({"detail": "Unauthenticated"}, status_code=401)
return await call_next(request)
app.add_middleware(AuthMiddleware)
app.add_middleware(NoCacheMiddleware)
_ICONS_DIR = os.environ.get(
@@ -1448,6 +1589,48 @@ def _verify_support_removed() -> bool:
# ── Routes ───────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request})
class LoginRequest(BaseModel):
password: str
@app.post("/api/login")
async def api_login(req: LoginRequest, request: Request):
"""Validate the Hub password and issue a session cookie."""
client_ip = request.client.host if request.client else "unknown"
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(None, _check_password, req.password)
if not ok:
await loop.run_in_executor(None, _record_failure, client_ip)
raise HTTPException(status_code=401, detail="Incorrect password")
token = _create_session()
response = JSONResponse({"ok": True})
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port
)
return response
@app.post("/api/logout")
async def api_logout(request: Request):
"""Clear the session cookie and destroy the server-side session."""
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
_destroy_session(token)
response = JSONResponse({"ok": True})
response.delete_cookie(key=SESSION_COOKIE_NAME)
return response
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {
@@ -3301,8 +3484,6 @@ async def api_security_verify_integrity():
# ── System password change ────────────────────────────────────────
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
class ChangePasswordRequest(BaseModel):
new_password: str
@@ -3740,6 +3921,13 @@ async def _startup_save_ip():
_save_internal_ip(ip)
@app.on_event("startup")
async def _startup_session_secret():
"""Ensure the session secret exists on disk at startup."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _get_or_create_session_secret)
# ── Startup: recover stale RUNNING status files ──────────────────
_SAFE_UNIT_RE = re.compile(r'^[a-zA-Z0-9@._\-]+\.service$')

View File

@@ -75,3 +75,19 @@
.ip-separator {
color: var(--border-color);
}
.btn-logout {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.18);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-btn);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.btn-logout:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}

View File

@@ -9,6 +9,12 @@ if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
// Logout button
if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
fetch("/api/logout", { method: "POST", credentials: "same-origin" })
.finally(function () { window.location.replace("/login"); });
});
// Rebuild modal
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);

View File

@@ -59,6 +59,8 @@ const $supportModal = document.getElementById("support-modal");
const $supportBody = document.getElementById("support-body");
const $supportCloseBtn = document.getElementById("support-close-btn");
const $logoutBtn = document.getElementById("btn-logout");
// Feature Manager — rebuild modal
const $rebuildModal = document.getElementById("rebuild-modal");
const $rebuildSpinner = document.getElementById("rebuild-spinner");

View File

@@ -24,6 +24,7 @@
<span class="title">Sovran_SystemsOS Hub</span>
<div class="header-buttons">
<span class="role-badge" id="role-badge">Loading…</span>
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
</div>
</header>

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran Hub — Login</title>
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/buttons.css" />
</head>
<body>
<div class="login-wrapper">
<div class="login-card">
<div class="login-header">
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" />
<div class="login-title">Sovran Hub</div>
</div>
<form class="login-form" id="login-form" onsubmit="return false;">
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
autocomplete="current-password"
autofocus
placeholder="Enter your Hub password"
/>
</div>
<div class="login-error" id="login-error">Incorrect password. Please try again.</div>
<button type="submit" class="btn btn-login" id="btn-login">Sign In</button>
</form>
</div>
</div>
<script>
(function () {
var form = document.getElementById('login-form');
var input = document.getElementById('password');
var errEl = document.getElementById('login-error');
var btnEl = document.getElementById('btn-login');
form.addEventListener('submit', function () {
var password = input.value;
if (!password) return;
btnEl.disabled = true;
btnEl.textContent = 'Signing in…';
errEl.classList.remove('visible');
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password }),
credentials: 'same-origin',
})
.then(function (res) {
if (res.ok) {
window.location.replace('/');
} else {
return res.json().then(function (data) {
errEl.textContent = (data && data.detail) ? data.detail : 'Incorrect password. Please try again.';
errEl.classList.add('visible');
input.value = '';
input.focus();
btnEl.disabled = false;
btnEl.textContent = 'Sign In';
});
}
})
.catch(function () {
errEl.textContent = 'Network error. Please try again.';
errEl.classList.add('visible');
btnEl.disabled = false;
btnEl.textContent = 'Sign In';
});
});
})();
</script>
</body>
</html>