add 2MFA
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractTokenFromCookie, extractTokenFromHeader } from "./auth";
|
||||
import { generateTotpCode } from "./mfa";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
enableUserMfa,
|
||||
getUserById,
|
||||
setUserMfaSecret,
|
||||
} from "./users";
|
||||
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
||||
|
||||
const PASSWORD = "Aa1!AuthMfaTest_2026";
|
||||
const mockServer = {
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
};
|
||||
|
||||
describe("auth token extraction", () => {
|
||||
test("extractTokenFromHeader returns bearer token", () => {
|
||||
@@ -16,3 +30,49 @@ describe("auth token extraction", () => {
|
||||
expect(extractTokenFromCookie("foo=bar")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth MFA login", () => {
|
||||
test("MFA-enabled user gets a challenge and can login with TOTP", async () => {
|
||||
const username = `mfa_user_${Date.now().toString(36)}`;
|
||||
const created = await createUser(username, PASSWORD, "operator", "test");
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
try {
|
||||
const secret = "JBSWY3DPEHPK3PXP";
|
||||
expect(setUserMfaSecret(created.userId!, secret).success).toBe(true);
|
||||
expect(enableUserMfa(created.userId!).success).toBe(true);
|
||||
|
||||
const url = new URL("https://localhost/api/login");
|
||||
const challenge = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user: username, pass: PASSWORD }),
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(challenge?.status).toBe(202);
|
||||
expect((await challenge!.json()).mfaRequired).toBe(true);
|
||||
|
||||
const login = await handleAuthRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
user: username,
|
||||
pass: PASSWORD,
|
||||
mfaCode: generateTotpCode(secret),
|
||||
}),
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
expect(login?.status).toBe(200);
|
||||
const body = (await login!.json()) as any;
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.token).toBeTruthy();
|
||||
expect(getUserById(created.userId!)).not.toBeNull();
|
||||
} finally {
|
||||
deleteUser(created.userId!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface Config {
|
||||
passwordRequireLowercase: boolean;
|
||||
passwordRequireNumber: boolean;
|
||||
passwordRequireSymbol: boolean;
|
||||
mfaRequiredForAdmins: boolean;
|
||||
mfaRequiredForNonAdmins: boolean;
|
||||
};
|
||||
enrollment: {
|
||||
requireApproval: boolean;
|
||||
@@ -140,6 +142,8 @@ const DEFAULT_CONFIG: Config = {
|
||||
passwordRequireLowercase: false,
|
||||
passwordRequireNumber: false,
|
||||
passwordRequireSymbol: false,
|
||||
mfaRequiredForAdmins: false,
|
||||
mfaRequiredForNonAdmins: false,
|
||||
},
|
||||
enrollment: {
|
||||
requireApproval: true,
|
||||
@@ -506,6 +510,14 @@ export function loadConfig(): Config {
|
||||
String(process.env.OVERLORD_PASSWORD_REQUIRE_SYMBOL || "").toLowerCase() === "true" ||
|
||||
fileConfig.security?.passwordRequireSymbol ||
|
||||
DEFAULT_CONFIG.security.passwordRequireSymbol,
|
||||
mfaRequiredForAdmins:
|
||||
String(process.env.OVERLORD_MFA_REQUIRED_FOR_ADMINS || "").toLowerCase() === "true" ||
|
||||
fileConfig.security?.mfaRequiredForAdmins ||
|
||||
DEFAULT_CONFIG.security.mfaRequiredForAdmins,
|
||||
mfaRequiredForNonAdmins:
|
||||
String(process.env.OVERLORD_MFA_REQUIRED_FOR_NON_ADMINS || "").toLowerCase() === "true" ||
|
||||
fileConfig.security?.mfaRequiredForNonAdmins ||
|
||||
DEFAULT_CONFIG.security.mfaRequiredForNonAdmins,
|
||||
},
|
||||
enrollment: {
|
||||
requireApproval:
|
||||
@@ -675,6 +687,8 @@ export async function updateSecurityConfig(
|
||||
next.passwordRequireLowercase = Boolean(next.passwordRequireLowercase);
|
||||
next.passwordRequireNumber = Boolean(next.passwordRequireNumber);
|
||||
next.passwordRequireSymbol = Boolean(next.passwordRequireSymbol);
|
||||
next.mfaRequiredForAdmins = Boolean(next.mfaRequiredForAdmins);
|
||||
next.mfaRequiredForNonAdmins = Boolean(next.mfaRequiredForNonAdmins);
|
||||
|
||||
configCache = {
|
||||
...current,
|
||||
|
||||
@@ -216,6 +216,14 @@ export function initializeUserSchema(): void {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "013_users_mfa",
|
||||
run: () => {
|
||||
addColumnIfMissing("users", `mfa_secret TEXT DEFAULT NULL`);
|
||||
addColumnIfMissing("users", `mfa_enabled INTEGER NOT NULL DEFAULT 0`);
|
||||
addColumnIfMissing("users", `mfa_enabled_at INTEGER DEFAULT NULL`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
runMigrations(userMigrations);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildTotpUri, generateMfaSecret, generateTotpCode, verifyTotpCode } from "./mfa";
|
||||
|
||||
describe("mfa totp", () => {
|
||||
test("matches RFC 6238 test vector with 8 digits", () => {
|
||||
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
|
||||
expect(generateTotpCode(secret, 59_000, 8)).toBe("94287082");
|
||||
});
|
||||
|
||||
test("verifies current and adjacent window codes", () => {
|
||||
const secret = generateMfaSecret();
|
||||
const now = Date.now();
|
||||
const code = generateTotpCode(secret, now);
|
||||
|
||||
expect(verifyTotpCode(secret, code, now)).toBe(true);
|
||||
expect(verifyTotpCode(secret, "000000", now)).toBe(false);
|
||||
});
|
||||
|
||||
test("builds an authenticator URI", () => {
|
||||
const uri = buildTotpUri({
|
||||
issuer: "Overlord",
|
||||
accountName: "admin",
|
||||
secret: "JBSWY3DPEHPK3PXP",
|
||||
});
|
||||
|
||||
expect(uri).toContain("otpauth://totp/");
|
||||
expect(uri).toContain("secret=JBSWY3DPEHPK3PXP");
|
||||
expect(uri).toContain("issuer=Overlord");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const DEFAULT_PERIOD_SECONDS = 30;
|
||||
const DEFAULT_DIGITS = 6;
|
||||
const DEFAULT_WINDOW = 1;
|
||||
|
||||
export function generateMfaSecret(length = 20): string {
|
||||
const bytes = new Uint8Array(Math.max(10, Math.min(64, length)));
|
||||
crypto.getRandomValues(bytes);
|
||||
return base32Encode(Buffer.from(bytes));
|
||||
}
|
||||
|
||||
export function buildTotpUri(params: {
|
||||
issuer: string;
|
||||
accountName: string;
|
||||
secret: string;
|
||||
}): string {
|
||||
const issuer = params.issuer || "Overlord";
|
||||
const label = `${issuer}:${params.accountName}`;
|
||||
const search = new URLSearchParams({
|
||||
secret: params.secret,
|
||||
issuer,
|
||||
algorithm: "SHA1",
|
||||
digits: String(DEFAULT_DIGITS),
|
||||
period: String(DEFAULT_PERIOD_SECONDS),
|
||||
});
|
||||
return `otpauth://totp/${encodeURIComponent(label)}?${search.toString()}`;
|
||||
}
|
||||
|
||||
export function generateTotpCode(
|
||||
secret: string,
|
||||
timestamp = Date.now(),
|
||||
digits = DEFAULT_DIGITS,
|
||||
periodSeconds = DEFAULT_PERIOD_SECONDS,
|
||||
): string {
|
||||
const key = base32Decode(secret);
|
||||
const counter = Math.floor(timestamp / 1000 / periodSeconds);
|
||||
const counterBuffer = Buffer.alloc(8);
|
||||
counterBuffer.writeBigUInt64BE(BigInt(counter), 0);
|
||||
|
||||
const digest = createHmac("sha1", key).update(counterBuffer).digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((digest[offset] & 0x7f) << 24) |
|
||||
((digest[offset + 1] & 0xff) << 16) |
|
||||
((digest[offset + 2] & 0xff) << 8) |
|
||||
(digest[offset + 3] & 0xff);
|
||||
|
||||
return String(binary % 10 ** digits).padStart(digits, "0");
|
||||
}
|
||||
|
||||
export function verifyTotpCode(
|
||||
secret: string,
|
||||
code: string,
|
||||
timestamp = Date.now(),
|
||||
window = DEFAULT_WINDOW,
|
||||
): boolean {
|
||||
const normalized = String(code || "").replace(/\s+/g, "");
|
||||
if (!/^\d{6}$/.test(normalized)) return false;
|
||||
|
||||
for (let offset = -window; offset <= window; offset++) {
|
||||
const candidate = generateTotpCode(
|
||||
secret,
|
||||
timestamp + offset * DEFAULT_PERIOD_SECONDS * 1000,
|
||||
);
|
||||
if (safeEqual(candidate, normalized)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function base32Encode(buffer: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = "";
|
||||
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (bits > 0) {
|
||||
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function base32Decode(input: string): Buffer {
|
||||
const clean = input.toUpperCase().replace(/=+$/g, "").replace(/\s+/g, "");
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (const char of clean) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) {
|
||||
throw new Error("Invalid MFA secret");
|
||||
}
|
||||
value = (value << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createQrSvg } from "./qr";
|
||||
|
||||
describe("qr svg", () => {
|
||||
test("creates an inline SVG QR for otpauth payloads", () => {
|
||||
const svg = createQrSvg(
|
||||
"otpauth://totp/Overlord:admin?secret=JBSWY3DPEHPK3PXP&issuer=Overlord&algorithm=SHA1&digits=6&period=30",
|
||||
);
|
||||
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("aria-label=\"MFA setup QR code\"");
|
||||
expect(svg).toContain("<rect");
|
||||
});
|
||||
|
||||
test("supports larger payloads through the QR library", () => {
|
||||
const svg = createQrSvg("x".repeat(200));
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("viewBox=");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import QRCode from "qrcode";
|
||||
|
||||
export function createQrSvg(text: string, scale = 8, margin = 2): string {
|
||||
const qr = QRCode.create(text, {
|
||||
errorCorrectionLevel: "M",
|
||||
margin,
|
||||
});
|
||||
const size = qr.modules.size;
|
||||
const total = (size + margin * 2) * scale;
|
||||
const rects: string[] = [];
|
||||
|
||||
for (let row = 0; row < size; row++) {
|
||||
for (let col = 0; col < size; col++) {
|
||||
if (!qr.modules.get(col, row)) continue;
|
||||
rects.push(
|
||||
`<rect x="${(col + margin) * scale}" y="${(row + margin) * scale}" width="${scale}" height="${scale}"/>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" width="100%" height="100%" shape-rendering="crispEdges" role="img" aria-label="MFA setup QR code"><rect width="100%" height="100%" fill="#fff"/><g fill="#000">${rects.join("")}</g></svg>`;
|
||||
}
|
||||
@@ -49,6 +49,8 @@ describe("security settings", () => {
|
||||
loginWindowMinutes: 0,
|
||||
loginLockoutMinutes: 0,
|
||||
passwordMinLength: 2,
|
||||
mfaRequiredForAdmins: true,
|
||||
mfaRequiredForNonAdmins: true,
|
||||
});
|
||||
|
||||
expect(updated.sessionTtlHours).toBe(24 * 30);
|
||||
@@ -56,5 +58,7 @@ describe("security settings", () => {
|
||||
expect(updated.loginWindowMinutes).toBe(1);
|
||||
expect(updated.loginLockoutMinutes).toBe(1);
|
||||
expect(updated.passwordMinLength).toBe(6);
|
||||
expect(updated.mfaRequiredForAdmins).toBe(true);
|
||||
expect(updated.mfaRequiredForNonAdmins).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
revokeToken,
|
||||
} from "../../auth";
|
||||
import { AuditAction, logAudit } from "../../auditLog";
|
||||
import { buildTotpUri, generateMfaSecret, verifyTotpCode } from "../../mfa";
|
||||
import { createQrSvg } from "../../qr";
|
||||
import {
|
||||
listUserSessions,
|
||||
revokeSessionById,
|
||||
@@ -23,7 +25,19 @@ import {
|
||||
recordFailedAttempt,
|
||||
recordSuccessfulAttempt,
|
||||
} from "../../rateLimit";
|
||||
import { getUserById, canUserAccessClient, canUserAccessFeature, getUserFeaturePermissions, type FeatureName, ALL_FEATURES } from "../../users";
|
||||
import {
|
||||
getUserById,
|
||||
canUserAccessClient,
|
||||
canUserAccessFeature,
|
||||
getUserFeaturePermissions,
|
||||
type FeatureName,
|
||||
ALL_FEATURES,
|
||||
disableUserMfa,
|
||||
enableUserMfa,
|
||||
getUserMfaStatus,
|
||||
isMfaRequiredForUser,
|
||||
setUserMfaSecret,
|
||||
} from "../../users";
|
||||
import { getUserPermissions, requirePermission } from "../../rbac";
|
||||
import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie";
|
||||
|
||||
@@ -69,10 +83,44 @@ export async function handleAuthRoutes(
|
||||
const body = await req.json();
|
||||
const username = body?.user || "";
|
||||
const password = body?.pass || "";
|
||||
const mfaCode = typeof body?.mfaCode === "string" ? body.mfaCode : "";
|
||||
|
||||
const user = await authenticateUser(username, password);
|
||||
|
||||
if (user) {
|
||||
const mfaEnabled = Boolean(user.mfa_enabled && user.mfa_secret);
|
||||
if (mfaEnabled) {
|
||||
if (!mfaCode) {
|
||||
return Response.json({ ok: false, mfaRequired: true }, { status: 202 });
|
||||
}
|
||||
if (!verifyTotpCode(user.mfa_secret!, mfaCode)) {
|
||||
recordFailedAttempt(ip);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
success: false,
|
||||
errorMessage: "Invalid MFA code",
|
||||
});
|
||||
return Response.json({ ok: false, error: "Invalid MFA code", mfaRequired: true }, { status: 401 });
|
||||
}
|
||||
} else if (isMfaRequiredForUser(user)) {
|
||||
recordFailedAttempt(ip);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: user.username,
|
||||
ip,
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
success: false,
|
||||
errorMessage: "MFA required but not enrolled",
|
||||
});
|
||||
return Response.json(
|
||||
{ ok: false, error: "MFA is required for this account, but it is not enrolled yet." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = req.headers.get("User-Agent") || null;
|
||||
const token = await generateToken(user, { ip, userAgent: userAgent || undefined });
|
||||
const sessionTtlSeconds = getSessionTtlSeconds();
|
||||
@@ -140,6 +188,102 @@ export async function handleAuthRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/mfa/status") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const dbUser = getUserById(user.userId);
|
||||
if (!dbUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
const status = getUserMfaStatus(user.userId);
|
||||
return Response.json({
|
||||
enabled: Boolean(status?.enabled),
|
||||
enabledAt: status?.enabledAt || null,
|
||||
required: isMfaRequiredForUser(dbUser),
|
||||
policy: {
|
||||
admins: isMfaRequiredForUser({ role: "admin" }),
|
||||
nonAdmins: isMfaRequiredForUser({ role: "operator" }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/mfa/setup") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const status = getUserMfaStatus(user.userId);
|
||||
if (status?.enabled) {
|
||||
return Response.json({ error: "MFA is already enabled" }, { status: 400 });
|
||||
}
|
||||
const secret = generateMfaSecret();
|
||||
const result = setUserMfaSecret(user.userId, secret);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
const otpauthUrl = buildTotpUri({
|
||||
issuer: "Overlord",
|
||||
accountName: user.username,
|
||||
secret,
|
||||
});
|
||||
return Response.json({
|
||||
secret,
|
||||
otpauthUrl,
|
||||
qrSvg: createQrSvg(otpauthUrl),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/mfa/enable") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
const status = getUserMfaStatus(user.userId);
|
||||
if (!status?.secret) {
|
||||
return Response.json({ error: "Start MFA setup first" }, { status: 400 });
|
||||
}
|
||||
if (!verifyTotpCode(status.secret, code)) {
|
||||
return Response.json({ error: "Invalid MFA code" }, { status: 400 });
|
||||
}
|
||||
const result = enableUserMfa(user.userId);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return Response.json({ ok: true, enabled: true });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/mfa/disable") {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const dbUser = getUserById(user.userId);
|
||||
if (!dbUser) {
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
if (isMfaRequiredForUser(dbUser)) {
|
||||
return Response.json({ error: "MFA is required by policy for this account" }, { status: 400 });
|
||||
}
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
|
||||
const code = typeof body?.code === "string" ? body.code : "";
|
||||
if (!currentPassword || !(await Bun.password.verify(currentPassword, dbUser.password_hash))) {
|
||||
return Response.json({ error: "Current password is incorrect" }, { status: 400 });
|
||||
}
|
||||
if (dbUser.mfa_enabled && dbUser.mfa_secret && !verifyTotpCode(dbUser.mfa_secret, code)) {
|
||||
return Response.json({ error: "Invalid MFA code" }, { status: 400 });
|
||||
}
|
||||
const result = disableUserMfa(user.userId);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return Response.json({ ok: true, enabled: false });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/logout") {
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
const user = await getUserFromRequest(req);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getThumbnailStats } from "../../thumbnails";
|
||||
import { getClientCount, getOnlineClients } from "../../clientManager";
|
||||
import { metrics } from "../../metrics";
|
||||
import { requirePermission } from "../../rbac";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient } from "../../users";
|
||||
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess, canUserAccessClient, getUserById } from "../../users";
|
||||
import { runCertbotSetup } from "../certbot-setup";
|
||||
import {
|
||||
getActiveProxies,
|
||||
@@ -131,6 +131,16 @@ export async function handleMiscRoutes(
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (Boolean(body?.mfaRequiredForAdmins)) {
|
||||
const dbUser = getUserById(user.userId);
|
||||
if (!dbUser?.mfa_enabled) {
|
||||
return Response.json(
|
||||
{ error: "Enable MFA on your own admin account before requiring it for admins." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateSecurityConfig({
|
||||
sessionTtlHours: Number(body?.sessionTtlHours),
|
||||
loginMaxAttempts: Number(body?.loginMaxAttempts),
|
||||
@@ -141,6 +151,8 @@ export async function handleMiscRoutes(
|
||||
passwordRequireLowercase: Boolean(body?.passwordRequireLowercase),
|
||||
passwordRequireNumber: Boolean(body?.passwordRequireNumber),
|
||||
passwordRequireSymbol: Boolean(body?.passwordRequireSymbol),
|
||||
mfaRequiredForAdmins: Boolean(body?.mfaRequiredForAdmins),
|
||||
mfaRequiredForNonAdmins: Boolean(body?.mfaRequiredForNonAdmins),
|
||||
});
|
||||
|
||||
logAudit({
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface User {
|
||||
can_build: number;
|
||||
can_upload_files: number;
|
||||
telegram_chat_id: string | null;
|
||||
mfa_secret: string | null;
|
||||
mfa_enabled: number;
|
||||
mfa_enabled_at: number | null;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
@@ -36,6 +39,8 @@ export interface UserInfo {
|
||||
can_build: number;
|
||||
can_upload_files: number;
|
||||
telegram_chat_id: string | null;
|
||||
mfa_enabled: number;
|
||||
mfa_enabled_at: number | null;
|
||||
}
|
||||
|
||||
export interface UserClientAccessRule {
|
||||
@@ -125,12 +130,75 @@ export function getUserByUsername(username: string): User | null {
|
||||
export function listUsers(): UserInfo[] {
|
||||
const users = db
|
||||
.prepare(
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope, plugin_scope, can_build, can_upload_files, telegram_chat_id FROM users ORDER BY created_at DESC",
|
||||
"SELECT id, username, role, created_at, last_login, created_by, client_scope, plugin_scope, can_build, can_upload_files, telegram_chat_id, mfa_enabled, mfa_enabled_at FROM users ORDER BY created_at DESC",
|
||||
)
|
||||
.all() as UserInfo[];
|
||||
return users;
|
||||
}
|
||||
|
||||
export function isMfaRequiredForUser(user: Pick<User, "role">): boolean {
|
||||
const security = getConfig().security;
|
||||
return user.role === "admin"
|
||||
? Boolean(security.mfaRequiredForAdmins)
|
||||
: Boolean(security.mfaRequiredForNonAdmins);
|
||||
}
|
||||
|
||||
export function getUserMfaStatus(userId: number): {
|
||||
enabled: boolean;
|
||||
enabledAt: number | null;
|
||||
secret: string | null;
|
||||
} | null {
|
||||
const row = db
|
||||
.prepare("SELECT mfa_enabled, mfa_enabled_at, mfa_secret FROM users WHERE id = ?")
|
||||
.get(userId) as
|
||||
| { mfa_enabled: number; mfa_enabled_at: number | null; mfa_secret: string | null }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
enabled: Boolean(row.mfa_enabled),
|
||||
enabledAt: row.mfa_enabled_at || null,
|
||||
secret: row.mfa_secret || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function setUserMfaSecret(userId: number, secret: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET mfa_secret = ?, mfa_enabled = 0, mfa_enabled_at = NULL WHERE id = ?").run(
|
||||
secret,
|
||||
userId,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] setUserMfaSecret error:", err);
|
||||
return { success: false, error: err.message || "Failed to update MFA secret" };
|
||||
}
|
||||
}
|
||||
|
||||
export function enableUserMfa(userId: number): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET mfa_enabled = 1, mfa_enabled_at = ? WHERE id = ?").run(
|
||||
Date.now(),
|
||||
userId,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] enableUserMfa error:", err);
|
||||
return { success: false, error: err.message || "Failed to enable MFA" };
|
||||
}
|
||||
}
|
||||
|
||||
export function disableUserMfa(userId: number): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare("UPDATE users SET mfa_secret = NULL, mfa_enabled = 0, mfa_enabled_at = NULL WHERE id = ?").run(
|
||||
userId,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
logger.error("[users] disableUserMfa error:", err);
|
||||
return { success: false, error: err.message || "Failed to disable MFA" };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserClientAccessScope(userId: number): ClientAccessScope {
|
||||
return getUserAccessCacheEntry(userId).scope;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user