mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 03:21:57 -06:00
Added MFA User Authentication System
This commit is contained in:
@@ -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 }) {
|
||||
<TableCell>{u.username}</TableCell>
|
||||
<TableCell>{formatTs(u.last_login)}</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">
|
||||
<IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: "#ccc" }}>
|
||||
<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";
|
||||
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -63,12 +168,12 @@ export default function Login({ onLogin }) {
|
||||
>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={step === "mfa" ? handleMfaSubmit : handleCredentialsSubmit}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
width: 300,
|
||||
width: 320,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -76,39 +181,151 @@ export default function Login({ onLogin }) {
|
||||
alt="Borealis Logo"
|
||||
style={{ width: "120px", marginBottom: "16px" }}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
Borealis - Automation Platform
|
||||
<Typography variant="h6" sx={{ mb: 2, textAlign: "center" }}>
|
||||
{formTitle}
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
{error && (
|
||||
<Typography color="error" sx={{ mt: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
|
||||
{step === "credentials" ? (
|
||||
<>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={username}
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={password}
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user