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:
ZacharyZcR
2025-10-08 09:01:48 +08:00
committed by GitHub
parent 24d622d8e4
commit fde64bd3df
10 changed files with 511 additions and 16 deletions

View File

@@ -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);