From 19f2197c90b340d037e0790dcfeaa96f53028198 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Thu, 9 Oct 2025 11:00:26 -0600 Subject: [PATCH] Added MFA User Authentication System --- .../WebUI/src/Admin/User_Management.jsx | 81 ++++- Data/Server/WebUI/src/Login.jsx | 295 ++++++++++++++--- Data/Server/server-requirements.txt | 2 + Data/Server/server.py | 301 ++++++++++++++++-- 4 files changed, 617 insertions(+), 62 deletions(-) diff --git a/Data/Server/WebUI/src/Admin/User_Management.jsx b/Data/Server/WebUI/src/Admin/User_Management.jsx index d28e1de..f992f11 100644 --- a/Data/Server/WebUI/src/Admin/User_Management.jsx +++ b/Data/Server/WebUI/src/Admin/User_Management.jsx @@ -22,6 +22,7 @@ import { Select, FormControl, InputLabel, + Checkbox, Popover } from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; @@ -87,6 +88,7 @@ export default function UserManagement({ isAdmin = false }) { const [warnOpen, setWarnOpen] = useState(false); const [warnMessage, setWarnMessage] = useState(""); const [me, setMe] = useState(null); + const [mfaBusyUser, setMfaBusyUser] = useState(null); // Columns and filters const columns = useMemo(() => ([ @@ -94,6 +96,7 @@ export default function UserManagement({ isAdmin = false }) { { id: "username", label: "User Name" }, { id: "last_login", label: "Last Login" }, { id: "role", label: "User Role" }, + { id: "mfa_enabled", label: "MFA" }, { id: "actions", label: "" } ]), []); const [filters, setFilters] = useState({}); // id -> string @@ -106,7 +109,16 @@ export default function UserManagement({ isAdmin = false }) { try { const res = await fetch("/api/users", { credentials: "include" }); const data = await res.json(); - setRows(Array.isArray(data?.users) ? data.users : []); + if (Array.isArray(data?.users)) { + setRows( + data.users.map((u) => ({ + ...u, + mfa_enabled: u && typeof u.mfa_enabled !== "undefined" ? (u.mfa_enabled ? 1 : 0) : 0 + })) + ); + } else { + setRows([]); + } } catch { setRows([]); } @@ -148,6 +160,7 @@ export default function UserManagement({ isAdmin = false }) { const arr = rows.filter(applyFilters); arr.sort((a, b) => { if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir; + if (orderBy === "mfa_enabled") return ((a.mfa_enabled ? 1 : 0) - (b.mfa_enabled ? 1 : 0)) * dir; return String(a[orderBy] ?? "").toLowerCase() .localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir; }); @@ -230,6 +243,55 @@ export default function UserManagement({ isAdmin = false }) { } }; + const toggleMfa = async (user, enabled) => { + if (!user) return; + const previous = Boolean(user.mfa_enabled); + const nextFlag = enabled ? 1 : 0; + setRows((prev) => + prev.map((r) => + String(r.username).toLowerCase() === String(user.username).toLowerCase() + ? { ...r, mfa_enabled: nextFlag } + : r + ) + ); + setMfaBusyUser(user.username); + try { + const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/mfa`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ enabled }) + }); + const data = await resp.json(); + if (!resp.ok) { + setRows((prev) => + prev.map((r) => + String(r.username).toLowerCase() === String(user.username).toLowerCase() + ? { ...r, mfa_enabled: previous ? 1 : 0 } + : r + ) + ); + setWarnMessage(data?.error || "Failed to update MFA settings."); + setWarnOpen(true); + return; + } + await fetchUsers(); + } catch (e) { + console.error(e); + setRows((prev) => + prev.map((r) => + String(r.username).toLowerCase() === String(user.username).toLowerCase() + ? { ...r, mfa_enabled: previous ? 1 : 0 } + : r + ) + ); + setWarnMessage("Failed to update MFA settings."); + setWarnOpen(true); + } finally { + setMfaBusyUser(null); + } + }; + const doResetPassword = async () => { const user = resetTarget; if (!user) return; @@ -358,6 +420,23 @@ export default function UserManagement({ isAdmin = false }) { {u.username} {formatTs(u.last_login)} {u.role || "User"} + + { + event.stopPropagation(); + toggleMfa(u, event.target.checked); + }} + onClick={(event) => event.stopPropagation()} + sx={{ + color: "#888", + "&.Mui-checked": { color: "#58a6ff" } + }} + inputProps={{ "aria-label": `Toggle MFA for ${u.username}` }} + /> + openMenu(e, u)} sx={{ color: "#ccc" }}> diff --git a/Data/Server/WebUI/src/Login.jsx b/Data/Server/WebUI/src/Login.jsx index 0d015fc..375bc61 100644 --- a/Data/Server/WebUI/src/Login.jsx +++ b/Data/Server/WebUI/src/Login.jsx @@ -1,10 +1,23 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Box, TextField, Button, Typography } from "@mui/material"; export default function Login({ onLogin }) { const [username, setUsername] = useState("admin"); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [step, setStep] = useState("credentials"); // 'credentials' | 'mfa' + const [pendingToken, setPendingToken] = useState(""); + const [mfaStage, setMfaStage] = useState(null); + const [mfaCode, setMfaCode] = useState(""); + const [setupSecret, setSetupSecret] = useState(""); + const [setupQr, setSetupQr] = useState(""); + const [setupUri, setSetupUri] = useState(""); + + const formattedSecret = useMemo(() => { + if (!setupSecret) return ""; + return setupSecret.replace(/(.{4})/g, "$1 ").trim(); + }, [setupSecret]); const sha512 = async (text) => { try { @@ -22,8 +35,20 @@ export default function Login({ onLogin }) { return null; }; - const handleSubmit = async (e) => { + const resetMfaState = () => { + setStep("credentials"); + setPendingToken(""); + setMfaStage(null); + setMfaCode(""); + setSetupSecret(""); + setSetupQr(""); + setSetupUri(""); + }; + + const handleCredentialsSubmit = async (e) => { e.preventDefault(); + setIsSubmitting(true); + setError(""); try { const hash = await sha512(password); const body = hash @@ -36,21 +61,101 @@ export default function Login({ onLogin }) { body: JSON.stringify(body) }); const data = await resp.json(); - if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); - // Persist token via cookie as a proxy-friendly fallback + if (!resp.ok) { + throw new Error(data?.error || "Invalid username or password"); + } + if (data?.status === "mfa_required") { + setPendingToken(data.pending_token || ""); + setMfaStage(data.stage || "verify"); + setStep("mfa"); + setMfaCode(""); + setSetupSecret(data.secret || ""); + setSetupQr(data.qr_image || ""); + setSetupUri(data.otpauth_url || ""); + setError(""); + setPassword(""); + return; + } + if (data?.token) { + try { + document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`; + } catch (_) {} + } + onLogin({ username: data.username, role: data.role }); + } catch (err) { + const msg = err?.message || "Unable to log in"; + setError(msg); + resetMfaState(); + } finally { + setIsSubmitting(false); + } + }; + + const handleMfaSubmit = async (e) => { + e.preventDefault(); + if (!pendingToken) { + setError("Your MFA session expired. Please log in again."); + resetMfaState(); + return; + } + if (!mfaCode || mfaCode.trim().length < 6) { + setError("Enter the 6-digit code from your authenticator app."); + return; + } + setIsSubmitting(true); + setError(""); + try { + const resp = await fetch("/api/auth/mfa/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ pending_token: pendingToken, code: mfaCode }) + }); + const data = await resp.json(); + if (!resp.ok) { + const errKey = data?.error; + if (errKey === "expired" || errKey === "invalid_session" || errKey === "mfa_pending") { + setError("Your MFA session expired. Please log in again."); + resetMfaState(); + return; + } + const msgMap = { + invalid_code: "Incorrect code. Please try again.", + mfa_not_configured: "MFA is not configured for this account." + }; + setError(msgMap[errKey] || data?.error || "Failed to verify code."); + return; + } if (data?.token) { try { - // Set cookie for current host; SameSite=Lax for dev document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`; } catch (_) {} } setError(""); onLogin({ username: data.username, role: data.role }); } catch (err) { - setError("Invalid username or password"); + setError("Failed to verify code."); + } finally { + setIsSubmitting(false); } }; + const handleBackToLogin = () => { + resetMfaState(); + setPassword(""); + setError(""); + }; + + const onCodeChange = (event) => { + const raw = event.target.value || ""; + const digits = raw.replace(/\D/g, "").slice(0, 6); + setMfaCode(digits); + }; + + const formTitle = step === "mfa" + ? "Multi-Factor Authentication" + : "Borealis - Automation Platform"; + return ( Borealis Logo - - Borealis - Automation Platform + + {formTitle} - setUsername(e.target.value)} - margin="normal" - /> - setPassword(e.target.value)} - margin="normal" - /> - {error && ( - - {error} - + + {step === "credentials" ? ( + <> + setUsername(e.target.value)} + margin="normal" + /> + setPassword(e.target.value)} + margin="normal" + /> + {error && ( + + {error} + + )} + + + ) : ( + <> + {mfaStage === "setup" ? ( + <> + + Scan the QR code with your authenticator app, then enter the 6-digit code to complete setup for {username}. + + {setupQr ? ( + MFA enrollment QR code + ) : null} + {formattedSecret ? ( + + + Manual code + + + {formattedSecret} + + + ) : null} + {setupUri ? ( + + {setupUri} + + ) : null} + + ) : ( + + Enter the 6-digit code from your authenticator app for {username}. + + )} + + + {error && ( + + {error} + + )} + + + )} - ); diff --git a/Data/Server/server-requirements.txt b/Data/Server/server-requirements.txt index 5e3dcc7..9c60208 100644 --- a/Data/Server/server-requirements.txt +++ b/Data/Server/server-requirements.txt @@ -11,6 +11,8 @@ flask_socketio flask-cors eventlet cryptography +pyotp +qrcode # GUI-related dependencies (Qt for GUI components) Qt.py diff --git a/Data/Server/server.py b/Data/Server/server.py index ac6b5cf..513a465 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -29,6 +29,16 @@ try: except Exception: Fernet = None # optional; we will fall back to reversible base64 if missing +try: + import pyotp # type: ignore +except Exception: + pyotp = None # type: ignore + +try: + import qrcode # type: ignore +except Exception: + qrcode = None # type: ignore + # Centralized logging (Server) def _server_logs_root() -> str: try: @@ -946,6 +956,48 @@ def _sha512_hex(s: str) -> str: return hashlib.sha512((s or '').encode('utf-8')).hexdigest() +def _generate_totp_secret() -> str: + if not pyotp: + raise RuntimeError("pyotp is not installed; MFA unavailable") + return pyotp.random_base32() + + +def _totp_for_secret(secret: str): + if not pyotp: + raise RuntimeError("pyotp is not installed; MFA unavailable") + normalized = (secret or "").replace(" ", "").strip().upper() + if not normalized: + raise ValueError("empty MFA secret") + return pyotp.TOTP(normalized, digits=6, interval=30) + + +def _totp_provisioning_uri(secret: str, username: str) -> Optional[str]: + try: + totp = _totp_for_secret(secret) + except Exception: + return None + issuer = os.environ.get('BOREALIS_MFA_ISSUER', 'Borealis') + try: + return totp.provisioning_uri(name=username, issuer_name=issuer) + except Exception: + return None + + +def _totp_qr_data_uri(payload: str) -> Optional[str]: + if not payload: + return None + if qrcode is None: + return None + try: + img = qrcode.make(payload, box_size=6, border=4) + buf = io.BytesIO() + img.save(buf, format="PNG") + encoded = base64.b64encode(buf.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" + except Exception: + return None + + def _db_conn(): conn = sqlite3.connect(DB_PATH, timeout=15) try: @@ -960,8 +1012,53 @@ def _db_conn(): return conn +def _update_last_login(username: str) -> None: + if not username: + return + try: + conn = _db_conn() + cur = conn.cursor() + now = _now_ts() + cur.execute( + "UPDATE users SET last_login=?, updated_at=? WHERE LOWER(username)=LOWER(?)", + (now, now, username) + ) + conn.commit() + conn.close() + except Exception: + pass + + +def _finalize_login(username: str, role: str): + session.pop("mfa_pending", None) + session["username"] = username + session["role"] = role + _update_last_login(username) + token = _make_token(username, role or "User") + resp = jsonify({"status": "ok", "username": username, "role": role, "token": token}) + samesite = app.config.get("SESSION_COOKIE_SAMESITE", "Lax") + secure = bool(app.config.get("SESSION_COOKIE_SECURE", False)) + domain = app.config.get("SESSION_COOKIE_DOMAIN", None) + resp.set_cookie( + "borealis_auth", + token, + httponly=False, + samesite=samesite, + secure=secure, + domain=domain, + path="/", + ) + return resp + + def _user_row_to_dict(row): - # id, username, display_name, role, last_login, created_at, updated_at + # id, username, display_name, role, last_login, created_at, updated_at, mfa_enabled?, mfa_secret? + mfa_enabled = 0 + if len(row) > 7: + try: + mfa_enabled = 1 if (row[7] or 0) else 0 + except Exception: + mfa_enabled = 0 return { "id": row[0], "username": row[1], @@ -970,6 +1067,7 @@ def _user_row_to_dict(row): "last_login": row[4] or 0, "created_at": row[5] or 0, "updated_at": row[6] or 0, + "mfa_enabled": mfa_enabled, } @@ -1047,7 +1145,20 @@ def api_login(): conn = _db_conn() cur = conn.cursor() cur.execute( - "SELECT id, username, display_name, password_sha512, role, last_login, created_at, updated_at FROM users WHERE LOWER(username)=LOWER(?)", + """ + SELECT + id, + username, + display_name, + password_sha512, + role, + last_login, + created_at, + updated_at, + COALESCE(mfa_enabled, 0) AS mfa_enabled, + COALESCE(mfa_secret, '') AS mfa_secret + FROM users WHERE LOWER(username)=LOWER(?) + """, (username,) ) row = cur.fetchone() @@ -1060,26 +1171,58 @@ def api_login(): conn.close() return jsonify({"error": "invalid username or password"}), 401 role = row[4] or 'User' - # update last_login - now = _now_ts() - cur.execute("UPDATE users SET last_login=?, updated_at=? WHERE id=?", (now, now, row[0])) - conn.commit() - conn.commit() - conn.commit() conn.close() - # set session cookie - session['username'] = row[1] - session['role'] = role + mfa_enabled = bool(row[8] or 0) + existing_secret = (row[9] or '').strip() - # also issue a signed bearer token and set a dev-friendly cookie - token = _make_token(row[1], role) - resp = jsonify({"status": "ok", "username": row[1], "role": role, "token": token}) - # mirror session cookie flags for the token cookie - samesite = app.config.get('SESSION_COOKIE_SAMESITE', 'Lax') - secure = bool(app.config.get('SESSION_COOKIE_SECURE', False)) - domain = app.config.get('SESSION_COOKIE_DOMAIN', None) - resp.set_cookie('borealis_auth', token, httponly=False, samesite=samesite, secure=secure, domain=domain, path='/') - return resp + session.pop('username', None) + session.pop('role', None) + + if not mfa_enabled: + session.pop('mfa_pending', None) + return _finalize_login(row[1], role) + + # MFA required path + stage = 'verify' if existing_secret else 'setup' + pending_token = uuid.uuid4().hex + pending = { + "username": row[1], + "role": role, + "token": pending_token, + "stage": stage, + "expires": _now_ts() + 300 # 5 minutes window + } + secret = None + otpauth_url = None + qr_image = None + if stage == 'setup': + try: + secret = _generate_totp_secret() + except Exception as exc: + return jsonify({"error": f"MFA setup unavailable: {exc}"}), 500 + pending['secret'] = secret + otpauth_url = _totp_provisioning_uri(secret, row[1]) + if otpauth_url: + qr_image = _totp_qr_data_uri(otpauth_url) + else: + # For verification we rely on stored secret in DB + pending['secret'] = None + session['mfa_pending'] = pending + session.modified = True + resp_payload = { + "status": "mfa_required", + "stage": stage, + "pending_token": pending_token, + "username": row[1], + "role": role, + } + if stage == 'setup': + resp_payload.update({ + "secret": secret, + "otpauth_url": otpauth_url, + "qr_image": qr_image, + }) + return jsonify(resp_payload) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -1093,6 +1236,61 @@ def api_logout(): return resp +@app.route("/api/auth/mfa/verify", methods=["POST"]) +def api_mfa_verify(): + pending = session.get("mfa_pending") or {} + if not pending or not isinstance(pending, dict): + return jsonify({"error": "mfa_pending"}), 401 + payload = request.get_json(silent=True) or {} + token = (payload.get("pending_token") or "").strip() + code_raw = str(payload.get("code") or "").strip() + code = "".join(ch for ch in code_raw if ch.isdigit()) + if not token or token != pending.get("token"): + return jsonify({"error": "invalid_session"}), 401 + if pending.get("expires", 0) < _now_ts(): + session.pop("mfa_pending", None) + return jsonify({"error": "expired"}), 401 + if len(code) < 6: + return jsonify({"error": "invalid_code"}), 400 + username = pending.get("username") or "" + role = pending.get("role") or "User" + stage = pending.get("stage") or "verify" + try: + if stage == "setup": + secret = pending.get("secret") or "" + totp = _totp_for_secret(secret) + if not totp.verify(code, valid_window=1): + return jsonify({"error": "invalid_code"}), 401 + # Persist the secret only after successful verification + now = _now_ts() + conn = _db_conn() + cur = conn.cursor() + cur.execute( + "UPDATE users SET mfa_secret=?, updated_at=? WHERE LOWER(username)=LOWER(?)", + (secret, now, username) + ) + conn.commit() + conn.close() + else: + conn = _db_conn() + cur = conn.cursor() + cur.execute( + "SELECT COALESCE(mfa_secret,'') FROM users WHERE LOWER(username)=LOWER(?)", + (username,) + ) + row = cur.fetchone() + conn.close() + secret = (row[0] or "").strip() if row else "" + if not secret: + return jsonify({"error": "mfa_not_configured"}), 403 + totp = _totp_for_secret(secret) + if not totp.verify(code, valid_window=1): + return jsonify({"error": "invalid_code"}), 401 + return _finalize_login(username, role) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + + @app.route("/api/auth/me", methods=["GET"]) # whoami def api_me(): user = _current_user() @@ -1136,7 +1334,7 @@ def api_users_list(): conn = _db_conn() cur = conn.cursor() cur.execute( - "SELECT id, username, display_name, role, last_login, created_at, updated_at FROM users ORDER BY LOWER(username) ASC" + "SELECT id, username, display_name, role, last_login, created_at, updated_at, COALESCE(mfa_enabled,0) FROM users ORDER BY LOWER(username) ASC" ) rows = cur.fetchall() conn.close() @@ -1149,6 +1347,7 @@ def api_users_list(): "last_login": r[4] or 0, "created_at": r[5] or 0, "updated_at": r[6] or 0, + "mfa_enabled": 1 if (r[7] or 0) else 0, } for r in rows ] @@ -1287,6 +1486,51 @@ def api_users_change_role(username): except Exception as e: return jsonify({"error": str(e)}), 500 + +@app.route("/api/users//mfa", methods=["POST"]) +def api_users_toggle_mfa(username): + chk = _require_admin() + if chk: + return chk + username = (username or "").strip() + if not username: + return jsonify({"error": "invalid username"}), 400 + data = request.get_json(silent=True) or {} + enabled = bool(data.get("enabled")) + reset_secret = bool(data.get("reset_secret", False)) + try: + conn = _db_conn() + cur = conn.cursor() + now = _now_ts() + if enabled: + cur.execute( + "UPDATE users SET mfa_enabled=1, updated_at=? WHERE LOWER(username)=LOWER(?)", + (now, username) + ) + else: + if reset_secret: + cur.execute( + "UPDATE users SET mfa_enabled=0, mfa_secret=NULL, updated_at=? WHERE LOWER(username)=LOWER(?)", + (now, username) + ) + else: + cur.execute( + "UPDATE users SET mfa_enabled=0, updated_at=? WHERE LOWER(username)=LOWER(?)", + (now, username) + ) + if cur.rowcount == 0: + conn.close() + return jsonify({"error": "user not found"}), 404 + conn.commit() + conn.close() + # If the current user disabled MFA for themselves, clear pending session state + me = _current_user() + if me and me.get("username", "").lower() == username.lower() and not enabled: + session.pop("mfa_pending", None) + return jsonify({"status": "ok"}) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + # --------------------------------------------- # Borealis Python API Endpoints # --------------------------------------------- @@ -3116,11 +3360,24 @@ def init_db(): role TEXT NOT NULL DEFAULT 'Admin', last_login INTEGER, created_at INTEGER, - updated_at INTEGER + updated_at INTEGER, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + mfa_secret TEXT ) """ ) + try: + cur.execute("PRAGMA table_info(users)") + user_cols = [r[1] for r in cur.fetchall()] + if "mfa_enabled" not in user_cols: + cur.execute("ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0") + user_cols.append("mfa_enabled") + if "mfa_secret" not in user_cols: + cur.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT") + except Exception: + pass + # Ansible play recap storage (one row per playbook run/session) cur.execute( """