Added MFA User Authentication System

This commit is contained in:
2025-10-09 11:00:26 -06:00
parent 6b1f4c7994
commit 19f2197c90
4 changed files with 617 additions and 62 deletions

View File

@@ -22,6 +22,7 @@ import {
Select, Select,
FormControl, FormControl,
InputLabel, InputLabel,
Checkbox,
Popover Popover
} from "@mui/material"; } from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
@@ -87,6 +88,7 @@ export default function UserManagement({ isAdmin = false }) {
const [warnOpen, setWarnOpen] = useState(false); const [warnOpen, setWarnOpen] = useState(false);
const [warnMessage, setWarnMessage] = useState(""); const [warnMessage, setWarnMessage] = useState("");
const [me, setMe] = useState(null); const [me, setMe] = useState(null);
const [mfaBusyUser, setMfaBusyUser] = useState(null);
// Columns and filters // Columns and filters
const columns = useMemo(() => ([ const columns = useMemo(() => ([
@@ -94,6 +96,7 @@ export default function UserManagement({ isAdmin = false }) {
{ id: "username", label: "User Name" }, { id: "username", label: "User Name" },
{ id: "last_login", label: "Last Login" }, { id: "last_login", label: "Last Login" },
{ id: "role", label: "User Role" }, { id: "role", label: "User Role" },
{ id: "mfa_enabled", label: "MFA" },
{ id: "actions", label: "" } { id: "actions", label: "" }
]), []); ]), []);
const [filters, setFilters] = useState({}); // id -> string const [filters, setFilters] = useState({}); // id -> string
@@ -106,7 +109,16 @@ export default function UserManagement({ isAdmin = false }) {
try { try {
const res = await fetch("/api/users", { credentials: "include" }); const res = await fetch("/api/users", { credentials: "include" });
const data = await res.json(); 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 { } catch {
setRows([]); setRows([]);
} }
@@ -148,6 +160,7 @@ export default function UserManagement({ isAdmin = false }) {
const arr = rows.filter(applyFilters); const arr = rows.filter(applyFilters);
arr.sort((a, b) => { arr.sort((a, b) => {
if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir; 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() return String(a[orderBy] ?? "").toLowerCase()
.localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir; .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 doResetPassword = async () => {
const user = resetTarget; const user = resetTarget;
if (!user) return; if (!user) return;
@@ -358,6 +420,23 @@ export default function UserManagement({ isAdmin = false }) {
<TableCell>{u.username}</TableCell> <TableCell>{u.username}</TableCell>
<TableCell>{formatTs(u.last_login)}</TableCell> <TableCell>{formatTs(u.last_login)}</TableCell>
<TableCell>{u.role || "User"}</TableCell> <TableCell>{u.role || "User"}</TableCell>
<TableCell align="center">
<Checkbox
size="small"
checked={Boolean(u.mfa_enabled)}
disabled={Boolean(mfaBusyUser && String(mfaBusyUser).toLowerCase() === String(u.username).toLowerCase())}
onChange={(event) => {
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}` }}
/>
</TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: "#ccc" }}> <IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: "#ccc" }}>
<MoreVertIcon fontSize="inherit" /> <MoreVertIcon fontSize="inherit" />

View File

@@ -1,10 +1,23 @@
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { Box, TextField, Button, Typography } from "@mui/material"; import { Box, TextField, Button, Typography } from "@mui/material";
export default function Login({ onLogin }) { export default function Login({ onLogin }) {
const [username, setUsername] = useState("admin"); const [username, setUsername] = useState("admin");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = 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) => { const sha512 = async (text) => {
try { try {
@@ -22,8 +35,20 @@ export default function Login({ onLogin }) {
return null; return null;
}; };
const handleSubmit = async (e) => { const resetMfaState = () => {
setStep("credentials");
setPendingToken("");
setMfaStage(null);
setMfaCode("");
setSetupSecret("");
setSetupQr("");
setSetupUri("");
};
const handleCredentialsSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setIsSubmitting(true);
setError("");
try { try {
const hash = await sha512(password); const hash = await sha512(password);
const body = hash const body = hash
@@ -36,21 +61,101 @@ export default function Login({ onLogin }) {
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); if (!resp.ok) {
// Persist token via cookie as a proxy-friendly fallback 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) { if (data?.token) {
try { try {
// Set cookie for current host; SameSite=Lax for dev
document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`; document.cookie = `borealis_auth=${data.token}; Path=/; SameSite=Lax`;
} catch (_) {} } catch (_) {}
} }
setError(""); setError("");
onLogin({ username: data.username, role: data.role }); onLogin({ username: data.username, role: data.role });
} catch (err) { } 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 ( return (
<Box <Box
sx={{ sx={{
@@ -63,12 +168,12 @@ export default function Login({ onLogin }) {
> >
<Box <Box
component="form" component="form"
onSubmit={handleSubmit} onSubmit={step === "mfa" ? handleMfaSubmit : handleCredentialsSubmit}
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
width: 300, width: 320,
}} }}
> >
<img <img
@@ -76,39 +181,151 @@ export default function Login({ onLogin }) {
alt="Borealis Logo" alt="Borealis Logo"
style={{ width: "120px", marginBottom: "16px" }} style={{ width: "120px", marginBottom: "16px" }}
/> />
<Typography variant="h6" sx={{ mb: 3 }}> <Typography variant="h6" sx={{ mb: 2, textAlign: "center" }}>
Borealis - Automation Platform {formTitle}
</Typography> </Typography>
<TextField
label="Username" {step === "credentials" ? (
variant="outlined" <>
fullWidth <TextField
value={username} label="Username"
onChange={(e) => setUsername(e.target.value)} variant="outlined"
margin="normal" fullWidth
/> value={username}
<TextField disabled={isSubmitting}
label="Password" onChange={(e) => setUsername(e.target.value)}
type="password" margin="normal"
variant="outlined" />
fullWidth <TextField
value={password} label="Password"
onChange={(e) => setPassword(e.target.value)} type="password"
margin="normal" variant="outlined"
/> fullWidth
{error && ( value={password}
<Typography color="error" sx={{ mt: 1 }}> disabled={isSubmitting}
{error} onChange={(e) => setPassword(e.target.value)}
</Typography> margin="normal"
/>
{error && (
<Typography color="error" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Button
type="submit"
variant="contained"
fullWidth
disabled={isSubmitting}
sx={{ mt: 2, bgcolor: "#58a6ff", "&:hover": { bgcolor: "#1d82d3" } }}
>
{isSubmitting ? "Signing In..." : "Login"}
</Button>
</>
) : (
<>
{mfaStage === "setup" ? (
<>
<Typography variant="body2" sx={{ color: "#ccc", textAlign: "center", mb: 2 }}>
Scan the QR code with your authenticator app, then enter the 6-digit code to complete setup for {username}.
</Typography>
{setupQr ? (
<img
src={setupQr}
alt="MFA enrollment QR code"
style={{ width: "180px", height: "180px", marginBottom: "12px" }}
/>
) : null}
{formattedSecret ? (
<Box
sx={{
bgcolor: "#1d1d1d",
borderRadius: 1,
px: 2,
py: 1,
mb: 1.5,
width: "100%",
}}
>
<Typography variant="caption" sx={{ color: "#999" }}>
Manual code
</Typography>
<Typography
variant="body1"
sx={{
fontFamily: "monospace",
letterSpacing: "0.3rem",
color: "#fff",
mt: 0.5,
textAlign: "center",
wordBreak: "break-word",
}}
>
{formattedSecret}
</Typography>
</Box>
) : null}
{setupUri ? (
<Typography
variant="caption"
sx={{
color: "#888",
mb: 2,
wordBreak: "break-all",
textAlign: "center",
}}
>
{setupUri}
</Typography>
) : null}
</>
) : (
<Typography variant="body2" sx={{ color: "#ccc", textAlign: "center", mb: 2 }}>
Enter the 6-digit code from your authenticator app for {username}.
</Typography>
)}
<TextField
label="6-digit code"
variant="outlined"
fullWidth
value={mfaCode}
onChange={onCodeChange}
disabled={isSubmitting}
margin="normal"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
maxLength: 6,
style: { letterSpacing: "0.4rem", textAlign: "center", fontSize: "1.2rem" }
}}
autoComplete="one-time-code"
/>
{error && (
<Typography color="error" sx={{ mt: 1, textAlign: "center" }}>
{error}
</Typography>
)}
<Button
type="submit"
variant="contained"
fullWidth
disabled={isSubmitting || mfaCode.length < 6}
sx={{ mt: 2, bgcolor: "#58a6ff", "&:hover": { bgcolor: "#1d82d3" } }}
>
{isSubmitting ? "Verifying..." : "Verify Code"}
</Button>
<Button
type="button"
variant="text"
fullWidth
disabled={isSubmitting}
onClick={handleBackToLogin}
sx={{ mt: 1, color: "#58a6ff" }}
>
Use a different account
</Button>
</>
)} )}
<Button
type="submit"
variant="contained"
fullWidth
sx={{ mt: 2, bgcolor: "#58a6ff", "&:hover": { bgcolor: "#1d82d3" } }}
>
Login
</Button>
</Box> </Box>
</Box> </Box>
); );

View File

@@ -11,6 +11,8 @@ flask_socketio
flask-cors flask-cors
eventlet eventlet
cryptography cryptography
pyotp
qrcode
# GUI-related dependencies (Qt for GUI components) # GUI-related dependencies (Qt for GUI components)
Qt.py Qt.py

View File

@@ -29,6 +29,16 @@ try:
except Exception: except Exception:
Fernet = None # optional; we will fall back to reversible base64 if missing 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) # Centralized logging (Server)
def _server_logs_root() -> str: def _server_logs_root() -> str:
try: try:
@@ -946,6 +956,48 @@ def _sha512_hex(s: str) -> str:
return hashlib.sha512((s or '').encode('utf-8')).hexdigest() 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(): def _db_conn():
conn = sqlite3.connect(DB_PATH, timeout=15) conn = sqlite3.connect(DB_PATH, timeout=15)
try: try:
@@ -960,8 +1012,53 @@ def _db_conn():
return 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): 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 { return {
"id": row[0], "id": row[0],
"username": row[1], "username": row[1],
@@ -970,6 +1067,7 @@ def _user_row_to_dict(row):
"last_login": row[4] or 0, "last_login": row[4] or 0,
"created_at": row[5] or 0, "created_at": row[5] or 0,
"updated_at": row[6] or 0, "updated_at": row[6] or 0,
"mfa_enabled": mfa_enabled,
} }
@@ -1047,7 +1145,20 @@ def api_login():
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( 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,) (username,)
) )
row = cur.fetchone() row = cur.fetchone()
@@ -1060,26 +1171,58 @@ def api_login():
conn.close() conn.close()
return jsonify({"error": "invalid username or password"}), 401 return jsonify({"error": "invalid username or password"}), 401
role = row[4] or 'User' 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() conn.close()
# set session cookie mfa_enabled = bool(row[8] or 0)
session['username'] = row[1] existing_secret = (row[9] or '').strip()
session['role'] = role
# also issue a signed bearer token and set a dev-friendly cookie session.pop('username', None)
token = _make_token(row[1], role) session.pop('role', None)
resp = jsonify({"status": "ok", "username": row[1], "role": role, "token": token})
# mirror session cookie flags for the token cookie if not mfa_enabled:
samesite = app.config.get('SESSION_COOKIE_SAMESITE', 'Lax') session.pop('mfa_pending', None)
secure = bool(app.config.get('SESSION_COOKIE_SECURE', False)) return _finalize_login(row[1], role)
domain = app.config.get('SESSION_COOKIE_DOMAIN', None)
resp.set_cookie('borealis_auth', token, httponly=False, samesite=samesite, secure=secure, domain=domain, path='/') # MFA required path
return resp 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: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@@ -1093,6 +1236,61 @@ def api_logout():
return resp 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 @app.route("/api/auth/me", methods=["GET"]) # whoami
def api_me(): def api_me():
user = _current_user() user = _current_user()
@@ -1136,7 +1334,7 @@ def api_users_list():
conn = _db_conn() conn = _db_conn()
cur = conn.cursor() cur = conn.cursor()
cur.execute( 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() rows = cur.fetchall()
conn.close() conn.close()
@@ -1149,6 +1347,7 @@ def api_users_list():
"last_login": r[4] or 0, "last_login": r[4] or 0,
"created_at": r[5] or 0, "created_at": r[5] or 0,
"updated_at": r[6] or 0, "updated_at": r[6] or 0,
"mfa_enabled": 1 if (r[7] or 0) else 0,
} }
for r in rows for r in rows
] ]
@@ -1287,6 +1486,51 @@ def api_users_change_role(username):
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route("/api/users/<username>/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 # Borealis Python API Endpoints
# --------------------------------------------- # ---------------------------------------------
@@ -3116,11 +3360,24 @@ def init_db():
role TEXT NOT NULL DEFAULT 'Admin', role TEXT NOT NULL DEFAULT 'Admin',
last_login INTEGER, last_login INTEGER,
created_at 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) # Ansible play recap storage (one row per playbook run/session)
cur.execute( cur.execute(
""" """