mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:41:58 -06:00
Added MFA User Authentication System
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user