Add TOTP 2FA to Server Fleet Hub and limit verified telemetry stream to latest 50 events
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m15s

This commit is contained in:
2026-09-27 18:48:40 +02:00
parent 6843b22ac1
commit a8cd2cd83c
2 changed files with 433 additions and 12 deletions
+393 -12
View File
@@ -10,6 +10,8 @@ import asyncio
import secrets
import hashlib
import hmac
import base64
import urllib.parse
import time
import html
import warnings
@@ -276,6 +278,89 @@ def change_user_password(username: str, new_password: str, db_path: str = DEFAUL
return changed
# --- Two-Factor Authentication (TOTP / RFC 6238) ---
def generate_totp_secret(length: int = 20) -> str:
"""Generates a cryptographically random Base32-encoded TOTP secret key."""
return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "")
def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str:
"""Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238."""
if intervals_no is None:
intervals_no = int(time.time()) // 30
key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True)
msg = struct.pack(">Q", intervals_no)
h = hmac.new(key, msg, hashlib.sha1).digest()
o = h[19] & 15
h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000
return f"{h_int:06d}"
def verify_totp_code(secret: str, code: str, window: int = 1) -> bool:
"""Verifies a 6-digit TOTP code with standard drift window (+/- 30s)."""
if not secret or not code:
return False
code = str(code).strip().replace(" ", "")
if len(code) != 6 or not code.isdigit():
return False
now = int(time.time())
current_step = now // 30
for offset in range(-window, window + 1):
if hmac.compare_digest(get_totp_token(secret, current_step + offset), code):
return True
return False
def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str:
"""Generates standard otpauth URI for QR code generation."""
user_enc = urllib.parse.quote(username)
issuer_enc = urllib.parse.quote(issuer)
return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30"
def get_user_totp_info(username: str, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]:
"""Retrieves TOTP configuration for a user from the SQLite database."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,))
row = c.fetchone()
if row:
return {"secret": row[0] or "", "enabled": bool(row[1])}
except Exception:
pass
finally:
conn.close()
return {"secret": "", "enabled": False}
def enable_user_totp(username: str, secret: str, db_path: str = DEFAULT_DB_FILE) -> bool:
"""Enables and activates TOTP for a user in the SQLite database."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username))
changed = c.rowcount > 0
conn.commit()
return changed
finally:
conn.close()
def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool:
"""Disables TOTP for a user in the SQLite database."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,))
changed = c.rowcount > 0
conn.commit()
return changed
finally:
conn.close()
# --- Hermes Token Management ---
def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]:
@@ -765,10 +850,22 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER DEFAULT 0
)
""")
# Migrations for users table (2FA)
for col, col_def in [
("totp_secret", "TEXT"),
("totp_enabled", "INTEGER DEFAULT 0")
]:
try:
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}")
except sqlite3.OperationalError:
pass
# Ensure default admin user exists
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM users")
@@ -1342,14 +1439,19 @@ def login_page(request: Request):
</div>
<div id="loginError" class="alert-error"></div>
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<div class="form-group" id="userGroup">
<label class="form-label">Username</label>
<input type="text" id="username" class="form-control" placeholder="admin" required autofocus>
</div>
<div class="form-group">
<div class="form-group" id="passGroup">
<label class="form-label">Password</label>
<input type="password" id="password" class="form-control" placeholder="••••••••" required>
</div>
<div class="form-group" id="totpGroup" style="display: none;">
<label class="form-label">🛡️ 2FA Verification Code</label>
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">Enter the 6-digit code from your authenticator app:</p>
<input type="text" id="totpCode" class="form-control" placeholder="123456" maxlength="6" inputmode="numeric" style="font-family: 'JetBrains Mono', monospace; font-size: 18px; letter-spacing: 4px; text-align: center;">
</div>
<button type="submit" class="btn" id="submitBtn">Sign In</button>
</form>
</div>
@@ -1358,26 +1460,37 @@ def login_page(request: Request):
e.preventDefault();
const u = document.getElementById('username').value.trim();
const p = document.getElementById('password').value;
const totp = document.getElementById('totpCode').value.trim();
const errEl = document.getElementById('loginError');
const btn = document.getElementById('submitBtn');
const totpGrp = document.getElementById('totpGroup');
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Authenticating...';
const payload = { username: u, password: p };
if (totp) payload.totp_code = totp;
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok && data.status === 'success') {
window.location.href = '/';
} else if (data.requires_2fa || data.status === '2fa_required') {
totpGrp.style.display = 'block';
document.getElementById('totpCode').focus();
btn.disabled = false;
btn.textContent = 'Verify & Sign In';
errEl.style.display = 'none';
} else {
errEl.textContent = data.detail || 'Invalid username or password';
errEl.style.display = 'block';
btn.disabled = false;
btn.textContent = 'Sign In';
btn.textContent = totpGrp.style.display === 'block' ? 'Verify & Sign In' : 'Sign In';
}
} catch(ex) {
errEl.textContent = 'Connection error: ' + ex.message;
@@ -1393,24 +1506,26 @@ def login_page(request: Request):
@app.post("/login")
async def process_login(request: Request):
"""Processes login credentials and issues session cookie."""
"""Processes login credentials and issues session cookie with 2FA TOTP enforcement."""
client_ip = get_client_ip(request)
if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60):
raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.")
username, password = None, None
username, password, totp_code = None, None, None
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
try:
body = await request.json()
username = body.get("username")
password = body.get("password")
totp_code = body.get("totp_code")
except Exception:
pass
else:
form = await request.form()
username = form.get("username")
password = form.get("password")
totp_code = form.get("totp_code")
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password are required")
@@ -1421,6 +1536,20 @@ async def process_login(request: Request):
await asyncio.sleep(0.5)
raise HTTPException(status_code=401, detail="Invalid username or password")
# Check 2FA TOTP requirement
totp_info = get_user_totp_info(username, db_path=db_path)
if totp_info.get("enabled"):
if not totp_code:
return JSONResponse(status_code=200, content={
"status": "2fa_required",
"requires_2fa": True,
"message": "Two-factor authentication code required"
})
if not verify_totp_code(totp_info["secret"], totp_code):
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES)
await asyncio.sleep(0.3)
raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code")
reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES)
token = generate_session_token(username)
is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
@@ -1456,6 +1585,55 @@ async def api_change_password(req: ChangePasswordRequest, current_user: str = De
return {"status": "success", "message": "Password updated successfully"}
# --- 2FA Authentication Management Endpoints ---
@app.get("/api/user/2fa/status")
def api_user_2fa_status(auth_user: str = Depends(require_admin_auth)):
"""Returns 2FA status for currently logged in administrator."""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
info = get_user_totp_info(auth_user, db_path=db_path)
return {"enabled": info["enabled"], "username": auth_user}
@app.get("/api/user/2fa/setup")
def api_user_2fa_setup(auth_user: str = Depends(require_admin_auth)):
"""Generates a new TOTP secret and otpauth URI for 2FA provisioning."""
secret = generate_totp_secret()
otpauth = get_totp_uri(auth_user, secret, issuer="LOGAR Server Hub")
return {
"secret": secret,
"otpauth_uri": otpauth,
"username": auth_user
}
@app.post("/api/user/2fa/enable")
async def api_user_2fa_enable(request: Request, auth_user: str = Depends(require_admin_auth)):
"""Enables TOTP for the current user after validating 6-digit code."""
body = await request.json()
secret = body.get("secret", "").strip()
code = body.get("code", "").strip()
if not secret or not code:
raise HTTPException(status_code=400, detail="Secret and 6-digit verification code required")
if not verify_totp_code(secret, code):
raise HTTPException(status_code=400, detail="Invalid 6-digit TOTP verification code. Please check your device clock.")
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
enable_user_totp(auth_user, secret, db_path=db_path)
return {"status": "success", "message": "2FA successfully activated"}
@app.post("/api/user/2fa/disable")
async def api_user_2fa_disable(request: Request, auth_user: str = Depends(require_admin_auth)):
"""Disables TOTP for the current user after verifying password."""
body = await request.json()
password = body.get("password", "")
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not verify_user_credentials(auth_user, password, db_path=db_path):
raise HTTPException(status_code=403, detail="Current password incorrect")
disable_user_totp(auth_user, db_path=db_path)
return {"status": "success", "message": "2FA successfully disabled"}
@app.post("/api/client/enroll")
def enroll_client(req: ClientEnrollRequest, request: Request = None):
"""
@@ -2303,7 +2481,7 @@ def serve_webui(request: Request = None):
border-radius: 10px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
min-width: 200px; z-index: 200; overflow: hidden;
}}
.user-menu-container:hover .user-dropdown, .user-menu-container:focus-within .user-dropdown {{ display: block; }}
.user-menu-container:hover .user-dropdown, .user-menu-container:focus-within .user-dropdown, .user-dropdown.show {{ display: block; }}
.user-dropdown-header {{ padding: 10px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); background: #0f172a; border-bottom: 1px solid var(--border); }}
.user-dropdown-item {{
display: flex; align-items: center; gap: 8px; padding: 10px 14px;
@@ -2442,13 +2620,14 @@ def serve_webui(request: Request = None):
<div class="header-actions">
<button onclick="openExportProfileModal()" class="btn btn-primary">📥 Export Client Profile</button>
<div class="user-menu-container" tabindex="0">
<div class="user-tag">
<div class="user-tag" onclick="toggleUserDropdown(event)">
<span>👤 <strong>{auth_user}</strong></span>
<span style="font-size: 10px; color: var(--text-muted);">▼</span>
</div>
<div class="user-dropdown">
<div class="user-dropdown" id="userDropdown">
<div class="user-dropdown-header">Logged in as {auth_user}</div>
<button class="user-dropdown-item" onclick="openPasswordModal()">🔑 Change Password</button>
<button class="user-dropdown-item" onclick="openTwoFactorModal(); hideUserDropdown();">🛡️ Two-Factor Auth (2FA)</button>
<button class="user-dropdown-item" onclick="openPasswordModal(); hideUserDropdown();">🔑 Change Password</button>
<a href="/logout" class="user-dropdown-item danger">🚪 Logout</a>
</div>
</div>
@@ -2583,7 +2762,7 @@ def serve_webui(request: Request = None):
<div class="tab-pane" id="tabPane-anomalies">
<div class="card">
<div class="card-header">
<span class="card-title" style="font-size: 15px;">Verified Anomaly Stream (Rolling 12h Window)</span>
<span class="card-title" style="font-size: 15px;">Verified Anomaly Stream (Latest 50 Events • Rolling 12h Window)</span>
<span class="badge badge-active">Direct Hermes Intelligence</span>
</div>
<div style="overflow-x: auto;">
@@ -2732,7 +2911,92 @@ def serve_webui(request: Request = None):
</div>
</div>
<!-- Two-Factor Authentication Modal -->
<div id="twoFactorModal" class="modal">
<div class="modal-content" style="max-width: 480px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--border); padding-bottom: 14px;">
<div class="modal-title" style="margin-bottom: 0; display: flex; align-items: center; gap: 8px;">
<span>🛡️</span> Two-Factor Authentication (2FA)
</div>
<div style="cursor: pointer; font-size: 22px; color: var(--text-muted); line-height: 1;" onclick="closeModal('twoFactorModal')">&times;</div>
</div>
<div id="twoFactorLoading" style="text-align: center; padding: 24px; color: var(--text-muted);">
Loading 2FA security status...
</div>
<!-- 2FA Active Section -->
<div id="twoFactorActiveSection" style="display: none;">
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: center; gap: 12px;">
<div style="font-size: 24px;">🟢</div>
<div>
<div style="font-weight: 700; color: #34d399;">2FA is Currently Active</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Your account is secured with TOTP authenticator verification.</div>
</div>
</div>
<div style="margin-bottom: 20px; background: #0f172a; padding: 16px; border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 13px; font-weight: 700; color: #ef4444; margin-bottom: 8px;">Disable Two-Factor Authentication</div>
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">Enter your account password to confirm disabling 2FA:</p>
<div class="form-group" style="margin-bottom: 12px;">
<input type="password" id="twoFactorDisablePassword" class="form-control" placeholder="Current Account Password">
</div>
<button onclick="submitDisable2FA()" class="btn btn-danger" style="width: 100%;">Disable 2FA</button>
</div>
<div style="text-align: center; margin-top: 16px;">
<button onclick="start2FASetup()" class="btn btn-outline" style="font-size: 12px;">🔄 Re-configure on New Device</button>
</div>
<div class="modal-actions" style="margin-top: 20px;">
<button onclick="closeModal('twoFactorModal')" class="btn btn-primary">Close</button>
</div>
</div>
<!-- 2FA Setup Section -->
<div id="twoFactorSetupSection" style="display: none;">
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
Scan this QR code with an authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, etc.):
</p>
<div style="display: flex; justify-content: center; align-items: center; background: #ffffff; padding: 16px; border-radius: 12px; margin-bottom: 16px; min-height: 190px;">
<div id="qrcodeCanvas"></div>
</div>
<div style="background: #0f172a; padding: 12px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 18px;">
<div style="font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px;">Manual Setup Secret Key</div>
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
<code id="twoFactorSecretText" style="font-size: 13px; color: #60a5fa; word-break: break-all; user-select: all;"></code>
<button onclick="copy2FASecret()" class="btn btn-outline" style="padding: 4px 8px; font-size: 11px; white-space: nowrap;">📋 Copy</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 18px;">
<label class="form-label">Enter 6-Digit Authenticator Code</label>
<input type="text" id="twoFactorVerifyCode" class="form-control" placeholder="123456" maxlength="6" inputmode="numeric" style="text-align: center; letter-spacing: 4px; font-size: 20px; font-family: 'JetBrains Mono', monospace; font-weight: 700;">
</div>
<div class="modal-actions">
<button onclick="closeModal('twoFactorModal')" class="btn btn-outline">Cancel</button>
<button onclick="submitEnable2FA()" class="btn btn-accent">✅ Verify &amp; Activate 2FA</button>
</div>
</div>
</div>
</div>
<script>
function toggleUserDropdown(e) {{
if (e) e.stopPropagation();
const d = document.getElementById('userDropdown');
if (d) d.classList.toggle('show');
}}
function hideUserDropdown() {{
const d = document.getElementById('userDropdown');
if (d) d.classList.remove('show');
}}
window.addEventListener('click', () => hideUserDropdown());
function switchTab(tabId) {{
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
@@ -2948,6 +3212,123 @@ def serve_webui(request: Request = None):
alert(data.detail || 'Failed to change password');
}}
}}
// --- 2FA Management & QRCode Logic ---
let current2FASecret = '';
async function openTwoFactorModal() {{
openModal('twoFactorModal');
document.getElementById('twoFactorLoading').style.display = 'block';
document.getElementById('twoFactorActiveSection').style.display = 'none';
document.getElementById('twoFactorSetupSection').style.display = 'none';
try {{
const res = await fetch('/api/user/2fa/status');
const data = await res.json();
document.getElementById('twoFactorLoading').style.display = 'none';
if (data.enabled) {{
document.getElementById('twoFactorActiveSection').style.display = 'block';
}} else {{
start2FASetup();
}}
}} catch(ex) {{
document.getElementById('twoFactorLoading').textContent = 'Failed to load 2FA status: ' + ex.message;
}}
}}
async function start2FASetup() {{
document.getElementById('twoFactorLoading').style.display = 'block';
document.getElementById('twoFactorActiveSection').style.display = 'none';
document.getElementById('twoFactorSetupSection').style.display = 'none';
try {{
const res = await fetch('/api/user/2fa/setup');
const data = await res.json();
current2FASecret = data.secret;
document.getElementById('twoFactorSecretText').textContent = data.secret;
document.getElementById('twoFactorVerifyCode').value = '';
// Render QR code
renderQRCode('qrcodeCanvas', data.otpauth_uri);
document.getElementById('twoFactorLoading').style.display = 'none';
document.getElementById('twoFactorSetupSection').style.display = 'block';
}} catch(ex) {{
document.getElementById('twoFactorLoading').textContent = 'Failed to generate 2FA setup: ' + ex.message;
}}
}}
function copy2FASecret() {{
if (!current2FASecret) return;
navigator.clipboard.writeText(current2FASecret);
showAlert('2FA Secret copied to clipboard!');
}}
async function submitEnable2FA() {{
const code = document.getElementById('twoFactorVerifyCode').value.trim();
if (!code || code.length !== 6) {{
alert('Please enter a valid 6-digit verification code.');
return;
}}
try {{
const res = await fetch('/api/user/2fa/enable', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ secret: current2FASecret, code: code }})
}});
const data = await res.json();
if (res.ok) {{
showAlert('Two-factor authentication (2FA) successfully activated!');
closeModal('twoFactorModal');
setTimeout(() => location.reload(), 1000);
}} else {{
alert(data.detail || 'Failed to verify code. Please check your authenticator clock.');
}}
}} catch(ex) {{
alert('Connection error: ' + ex.message);
}}
}}
async function submitDisable2FA() {{
const pwd = document.getElementById('twoFactorDisablePassword').value;
if (!pwd) {{
alert('Please enter your account password to confirm.');
return;
}}
if (!confirm('Are you sure you want to disable two-factor authentication?')) return;
try {{
const res = await fetch('/api/user/2fa/disable', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ password: pwd }})
}});
const data = await res.json();
if (res.ok) {{
showAlert('2FA disabled successfully.');
closeModal('twoFactorModal');
setTimeout(() => location.reload(), 1000);
}} else {{
alert(data.detail || 'Failed to disable 2FA. Incorrect password.');
}}
}} catch(ex) {{
alert('Connection error: ' + ex.message);
}}
}}
function renderQRCode(containerId, text) {{
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = '';
const img = document.createElement('img');
img.style.width = '180px';
img.style.height = '180px';
img.style.display = 'block';
img.alt = '2FA QR Code';
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(text);
el.appendChild(img);
}}
</script>
</body>
</html>"""
+40
View File
@@ -470,6 +470,46 @@ class TestServerComponent(unittest.TestCase):
self.assertEqual(client_data["active_seats"], 0)
self.assertEqual(len(client_data["clients"]), 1)
def test_two_factor_auth_and_totp_flow(self):
"""Tests TOTP secret generation, token verification, URI generation, and enable/disable lifecycle."""
Server.init_db(self.test_db)
# 1. Test TOTP helpers
secret = Server.generate_totp_secret()
self.assertTrue(len(secret) >= 16)
token = Server.get_totp_token(secret)
self.assertEqual(len(token), 6)
self.assertTrue(token.isdigit())
self.assertTrue(Server.verify_totp_code(secret, token))
self.assertFalse(Server.verify_totp_code(secret, "000000" if token != "000000" else "999999"))
self.assertFalse(Server.verify_totp_code(secret, "invalid"))
# 2. Test OTPAuth URI
uri = Server.get_totp_uri("admin", secret, issuer="LOGAR Fleet Hub")
self.assertTrue(uri.startswith("otpauth://totp/"))
self.assertIn("secret=" + secret, uri)
self.assertIn("LOGAR", uri)
# 3. Test initial user 2FA status
info = Server.get_user_totp_info("admin", db_path=self.test_db)
self.assertFalse(info["enabled"])
self.assertEqual(info["secret"], "")
# 4. Test enabling 2FA
enabled = Server.enable_user_totp("admin", secret, db_path=self.test_db)
self.assertTrue(enabled)
info_active = Server.get_user_totp_info("admin", db_path=self.test_db)
self.assertTrue(info_active["enabled"])
self.assertEqual(info_active["secret"], secret)
# 5. Test disabling 2FA
disabled = Server.disable_user_totp("admin", db_path=self.test_db)
self.assertTrue(disabled)
info_disabled = Server.get_user_totp_info("admin", db_path=self.test_db)
self.assertFalse(info_disabled["enabled"])
if __name__ == "__main__":
unittest.main()