Add SSH TOTP authentication support (#350)
* Add SSH TOTP authentication support - Implement keyboard-interactive authentication for SSH connections - Add TOTP dialog component for Terminal and File Manager - Handle TOTP prompts in WebSocket and HTTP connections - Disable Server Stats for TOTP-enabled servers - Add i18n support for TOTP-related messages * Update src/backend/ssh/server-stats.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/backend/ssh/file-manager.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit was merged in pull request #350.
This commit is contained in:
@@ -94,7 +94,16 @@ interface SSHSession {
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
@@ -239,6 +248,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
@@ -364,9 +374,142 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
fileLogger.info("Keyboard-interactive authentication requested", {
|
||||
operation: "file_keyboard_interactive",
|
||||
hostId,
|
||||
sessionId,
|
||||
promptsCount: prompts.length,
|
||||
});
|
||||
|
||||
const totpPrompt = prompts.find((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPrompt) {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
|
||||
pendingTOTPSessions[sessionId] = {
|
||||
client,
|
||||
finish,
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
};
|
||||
|
||||
res.json({
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt: totpPrompt.prompt,
|
||||
});
|
||||
} else {
|
||||
if (resolvedCredentials.password) {
|
||||
const responses = prompts.map(() => resolvedCredentials.password || "");
|
||||
finish(responses);
|
||||
} else {
|
||||
finish(prompts.map(() => ""));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
const { sessionId, totpCode } = req.body;
|
||||
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId) {
|
||||
fileLogger.error("TOTP verification rejected: no authenticated user", {
|
||||
operation: "file_totp_auth",
|
||||
sessionId,
|
||||
});
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!sessionId || !totpCode) {
|
||||
return res.status(400).json({ error: "Session ID and TOTP code required" });
|
||||
}
|
||||
|
||||
const session = pendingTOTPSessions[sessionId];
|
||||
|
||||
if (!session) {
|
||||
fileLogger.warn("TOTP session not found or expired", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
return res.status(404).json({ error: "TOTP session expired. Please reconnect." });
|
||||
}
|
||||
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
|
||||
if (Date.now() - session.createdAt > 120000) {
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {}
|
||||
return res.status(408).json({ error: "TOTP session timeout. Please reconnect." });
|
||||
}
|
||||
|
||||
session.finish([totpCode]);
|
||||
|
||||
let responseSent = false;
|
||||
|
||||
session.client.on("ready", () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
|
||||
sshSessions[sessionId] = {
|
||||
client: session.client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
fileLogger.success("TOTP verification successful", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ status: "success", message: "TOTP verified, SSH connection established" });
|
||||
});
|
||||
|
||||
session.client.on("error", (err) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
|
||||
fileLogger.error("TOTP verification failed", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
error: err.message,
|
||||
});
|
||||
|
||||
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
res.status(408).json({ error: "TOTP verification timeout" });
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
cleanupSession(sessionId);
|
||||
|
||||
@@ -95,6 +95,40 @@ class SSHConnectionPool {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const totpPrompt = prompts.find((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPrompt) {
|
||||
statsLogger.warn(
|
||||
`Server Stats cannot handle TOTP for host ${host.ip}. Connection will fail.`,
|
||||
{
|
||||
operation: "server_stats_totp_detected",
|
||||
hostId: host.id,
|
||||
},
|
||||
);
|
||||
client.end();
|
||||
reject(new Error("TOTP authentication required but not supported in Server Stats"));
|
||||
} else if (host.password) {
|
||||
const responses = prompts.map(() => host.password || "");
|
||||
finish(responses);
|
||||
} else {
|
||||
finish(prompts.map(() => ""));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
client.connect(buildSshConfig(host));
|
||||
} catch (err) {
|
||||
@@ -487,6 +521,7 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
|
||||
host: host.ip,
|
||||
port: host.port || 22,
|
||||
username: host.username || "root",
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 10_000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
@@ -651,7 +686,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
}
|
||||
|
||||
return requestQueue.queueRequest(host.id, async () => {
|
||||
return withSshConnection(host, async (client) => {
|
||||
try {
|
||||
return await withSshConnection(host, async (client) => {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
@@ -812,6 +848,12 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
metricsCache.set(host.id, result);
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("TOTP authentication required")) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -982,6 +1024,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const metrics = await collectMetrics(host);
|
||||
res.json({ ...metrics, lastChecked: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes("TOTP authentication required")) {
|
||||
return res.status(403).json({
|
||||
error: "TOTP_REQUIRED",
|
||||
message: "Server Stats unavailable for TOTP-enabled servers",
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: { percent: null, usedHuman: null, totalHuman: null },
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
statsLogger.error("Failed to collect metrics", err);
|
||||
|
||||
if (err instanceof Error && err.message.includes("timeout")) {
|
||||
|
||||
@@ -154,6 +154,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||
|
||||
ws.on("close", () => {
|
||||
const userWs = userConnections.get(userId);
|
||||
@@ -257,6 +258,33 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
case "totp_response":
|
||||
if (keyboardInteractiveFinish && data?.code) {
|
||||
const totpCode = data.code;
|
||||
sshLogger.info("TOTP code received from user", {
|
||||
operation: "totp_response",
|
||||
userId,
|
||||
codeLength: totpCode.length,
|
||||
});
|
||||
|
||||
keyboardInteractiveFinish([totpCode]);
|
||||
keyboardInteractiveFinish = null;
|
||||
} else {
|
||||
sshLogger.warn("TOTP response received but no callback available", {
|
||||
operation: "totp_response_error",
|
||||
userId,
|
||||
hasCallback: !!keyboardInteractiveFinish,
|
||||
hasCode: !!data?.code,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "TOTP authentication state lost. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message_unknown_type",
|
||||
@@ -557,10 +585,56 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
sshConn.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
sshLogger.info("Keyboard-interactive authentication requested", {
|
||||
operation: "ssh_keyboard_interactive",
|
||||
hostId: id,
|
||||
promptsCount: prompts.length,
|
||||
instructions: instructions || "none",
|
||||
});
|
||||
|
||||
const totpPrompt = prompts.find((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPrompt) {
|
||||
keyboardInteractiveFinish = finish;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: totpPrompt.prompt,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
if (resolvedCredentials.password) {
|
||||
const responses = prompts.map(() => resolvedCredentials.password || "");
|
||||
finish(responses);
|
||||
} else {
|
||||
sshLogger.warn("Keyboard-interactive requires password but none available", {
|
||||
operation: "ssh_keyboard_interactive_no_password",
|
||||
hostId: id,
|
||||
});
|
||||
finish(prompts.map(() => ""));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
|
||||
Reference in New Issue
Block a user