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:
committed by
GitHub
parent
650d693849
commit
02e40e6634
@@ -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$')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
83
app/sovran_systemsos_web/templates/login.html
Normal file
83
app/sovran_systemsos_web/templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user