Add Hub web authentication: login page, session middleware, logout button

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/afb996f6-f6f5-4d4a-9f99-e46e3f89b4d7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-12 15:37:35 +00:00
committed by GitHub
parent 650d693849
commit 02e40e6634
6 changed files with 291 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,118 @@ 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."""
try:
with open(HUB_SESSION_SECRET_FILE, "rb") as f:
data = f.read().strip()
if len(data) >= 32:
return data
except FileNotFoundError:
pass
secret = secrets.token_hex(32).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(secret)
os.chmod(HUB_SESSION_SECRET_FILE, 0o600)
except OSError:
pass
return secret
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."""
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)
# Always sleep a fixed delay to slow brute force
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 +1579,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 +3474,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 +3911,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

@@ -74,4 +74,20 @@
.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>