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:
@@ -5,10 +5,12 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -19,7 +21,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.requests import Request
|
from fastapi.requests import Request
|
||||||
@@ -68,6 +70,27 @@ REBOOT_COMMAND = ["reboot"]
|
|||||||
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
||||||
AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
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 constants ────────────────────────────────────────────
|
||||||
|
|
||||||
SECURITY_BANNER_DISMISSED_FLAG = "/var/lib/sovran/security-banner-dismissed"
|
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["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
response.headers["Pragma"] = "no-cache"
|
response.headers["Pragma"] = "no-cache"
|
||||||
response.headers["Expires"] = "0"
|
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
|
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)
|
app.add_middleware(NoCacheMiddleware)
|
||||||
|
|
||||||
_ICONS_DIR = os.environ.get(
|
_ICONS_DIR = os.environ.get(
|
||||||
@@ -1448,6 +1589,48 @@ def _verify_support_removed() -> bool:
|
|||||||
|
|
||||||
# ── Routes ───────────────────────────────────────────────────────
|
# ── 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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request):
|
async def index(request: Request):
|
||||||
return templates.TemplateResponse("index.html", {
|
return templates.TemplateResponse("index.html", {
|
||||||
@@ -3301,8 +3484,6 @@ async def api_security_verify_integrity():
|
|||||||
|
|
||||||
# ── System password change ────────────────────────────────────────
|
# ── System password change ────────────────────────────────────────
|
||||||
|
|
||||||
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
new_password: str
|
new_password: str
|
||||||
@@ -3740,6 +3921,13 @@ async def _startup_save_ip():
|
|||||||
_save_internal_ip(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 ──────────────────
|
# ── Startup: recover stale RUNNING status files ──────────────────
|
||||||
|
|
||||||
_SAFE_UNIT_RE = re.compile(r'^[a-zA-Z0-9@._\-]+\.service$')
|
_SAFE_UNIT_RE = re.compile(r'^[a-zA-Z0-9@._\-]+\.service$')
|
||||||
|
|||||||
@@ -74,4 +74,20 @@
|
|||||||
|
|
||||||
.ip-separator {
|
.ip-separator {
|
||||||
color: var(--border-color);
|
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 ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
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
|
// Rebuild modal
|
||||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const $supportModal = document.getElementById("support-modal");
|
|||||||
const $supportBody = document.getElementById("support-body");
|
const $supportBody = document.getElementById("support-body");
|
||||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
const $supportCloseBtn = document.getElementById("support-close-btn");
|
||||||
|
|
||||||
|
const $logoutBtn = document.getElementById("btn-logout");
|
||||||
|
|
||||||
// Feature Manager — rebuild modal
|
// Feature Manager — rebuild modal
|
||||||
const $rebuildModal = document.getElementById("rebuild-modal");
|
const $rebuildModal = document.getElementById("rebuild-modal");
|
||||||
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<span class="title">Sovran_SystemsOS Hub</span>
|
<span class="title">Sovran_SystemsOS Hub</span>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<span class="role-badge" id="role-badge">Loading…</span>
|
<span class="role-badge" id="role-badge">Loading…</span>
|
||||||
|
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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