Add SSH TOTP authentication support #350

Merged
ZacharyZcR merged 3 commits from feature-ssh-totp into dev-1.8.0 2025-10-08 01:01:48 +00:00
10 changed files with 511 additions and 16 deletions
Showing only changes of commit dc5b9cb33b - Show all commits
+143
View File
@@ -94,7 +94,16 @@ interface SSHSession {
timeout?: NodeJS.Timeout; timeout?: NodeJS.Timeout;
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

medium

For better type safety and maintainability, the config property should be typed more specifically than any. The ConnectConfig type from the ssh2 library would be appropriate here.

  config: import("ssh2").ConnectConfig;
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) For better type safety and maintainability, the `config` property should be typed more specifically than `any`. The `ConnectConfig` type from the `ssh2` library would be appropriate here. ```suggestion config: import("ssh2").ConnectConfig; ```
} }
interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: any;
createdAt: number;
sessionId: string;
}
const sshSessions: Record<string, SSHSession> = {}; const sshSessions: Record<string, SSHSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

high

The pendingTOTPSessions object stores sessions awaiting TOTP verification. If a client initiates a connection but never completes the TOTP step, the corresponding entry in this object is never removed, leading to a memory leak. Consider implementing a periodic cleanup mechanism to remove stale entries from pendingTOTPSessions.

![high](https://www.gstatic.com/codereviewagent/high-priority.svg) The `pendingTOTPSessions` object stores sessions awaiting TOTP verification. If a client initiates a connection but never completes the TOTP step, the corresponding entry in this object is never removed, leading to a memory leak. Consider implementing a periodic cleanup mechanism to remove stale entries from `pendingTOTPSessions`.
function cleanupSession(sessionId: string) { function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId]; const session = sshSessions[sessionId];
@@ -239,6 +248,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
host: ip, host: ip,
port: port || 22, port: port || 22,
username, username,
tryKeyboard: true,
readyTimeout: 60000, readyTimeout: 60000,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
@@ -364,9 +374,142 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
cleanupSession(sessionId); 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); 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,
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

medium

The timeout value 120000 is a magic number. It's better to define it as a named constant at the top of the file for better readability and easier maintenance (e.g., const PENDING_TOTP_SESSION_TIMEOUT_MS = 120 * 1000;).

![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) The timeout value `120000` is a magic number. It's better to define it as a named constant at the top of the file for better readability and easier maintenance (e.g., `const PENDING_TOTP_SESSION_TIMEOUT_MS = 120 * 1000;`).
});
res.json({ status: "success", message: "TOTP verified, SSH connection established" });
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

medium

Swallowing errors in an empty catch block can hide potential issues and make debugging difficult. It's better to at least log the error, even if it's just a warning.

    } catch (e) {
      fileLogger.warn("Failed to end client on TOTP timeout", { error: e instanceof Error ? e.message : String(e), sessionId });
    }
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) Swallowing errors in an empty `catch` block can hide potential issues and make debugging difficult. It's better to at least log the error, even if it's just a warning. ```typescript } catch (e) { fileLogger.warn("Failed to end client on TOTP timeout", { error: e instanceof Error ? e.message : String(e), sessionId }); } ```
});
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) => { app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
const { sessionId } = req.body; const { sessionId } = req.body;
cleanupSession(sessionId); cleanupSession(sessionId);
1
+54 -1
View File
@@ -95,6 +95,40 @@ class SSHConnectionPool {
reject(err); reject(err);
gemini-code-assist[bot] commented 2025-10-05 07:19:11 +00:00 (Migrated from github.com)
Review

high

Calling finish([]) sends an empty array of responses. If the server expects a response for each prompt (even an empty string), this will likely cause authentication to fail. To ensure compatibility, you should provide one empty string response for each prompt.

            finish(prompts.map(() => ""));
![high](https://www.gstatic.com/codereviewagent/high-priority.svg) Calling `finish([])` sends an empty array of responses. If the server expects a response for each prompt (even an empty string), this will likely cause authentication to fail. To ensure compatibility, you should provide one empty string response for each prompt. ```suggestion finish(prompts.map(() => "")); ```
}); });
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([]);
}
},
);
try { try {
client.connect(buildSshConfig(host)); client.connect(buildSshConfig(host));
} catch (err) { } catch (err) {
@@ -487,6 +521,7 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
host: host.ip, host: host.ip,
port: host.port || 22, port: host.port || 22,
username: host.username || "root", username: host.username || "root",
tryKeyboard: true,
readyTimeout: 10_000, readyTimeout: 10_000,
algorithms: { algorithms: {
kex: [ kex: [
@@ -651,7 +686,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
} }
return requestQueue.queueRequest(host.id, async () => { return requestQueue.queueRequest(host.id, async () => {
return withSshConnection(host, async (client) => { try {
return await withSshConnection(host, async (client) => {
let cpuPercent: number | null = null; let cpuPercent: number | null = null;
let cores: number | null = null; let cores: number | null = null;
let loadTriplet: [number, number, 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); metricsCache.set(host.id, result);
return result; return result;
}); });
} catch (error) {
if (error instanceof Error && error.message.includes("TOTP authentication required")) {
throw error;
}
throw error;
}
}); });
} }
1
@@ -982,6 +1024,17 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
const metrics = await collectMetrics(host); const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() }); res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) { } 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); statsLogger.error("Failed to collect metrics", err);
if (err instanceof Error && err.message.includes("timeout")) { if (err instanceof Error && err.message.includes("timeout")) {
+74
View File
@@ -154,6 +154,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sshConn: Client | null = null; let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null; let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null; let pingInterval: NodeJS.Timeout | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
ws.on("close", () => { ws.on("close", () => {
const userWs = userConnections.get(userId); const userWs = userConnections.get(userId);
@@ -257,6 +258,33 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(JSON.stringify({ type: "pong" })); ws.send(JSON.stringify({ type: "pong" }));
break; 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: default:
sshLogger.warn("Unknown message type received", { sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type", operation: "websocket_message_unknown_type",
@@ -557,10 +585,56 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanupSSH(connectionTimeout); 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 = { const connectConfig: any = {
host: ip, host: ip,
port, port,
username, username,
tryKeyboard: true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 60000, readyTimeout: 60000,
1
+10 -2
View File
@@ -709,7 +709,11 @@
"connectionTimeout": "Connection timeout", "connectionTimeout": "Connection timeout",
"terminalTitle": "Terminal - {{host}}", "terminalTitle": "Terminal - {{host}}",
"terminalWithPath": "Terminal - {{host}}:{{path}}", "terminalWithPath": "Terminal - {{host}}:{{path}}",
"runTitle": "Running {{command}} - {{host}}" "runTitle": "Running {{command}} - {{host}}",
"totpRequired": "Two-Factor Authentication Required",
"totpCodeLabel": "Verification Code",
"totpPlaceholder": "000000",
"totpVerify": "Verify"
}, },
"fileManager": { "fileManager": {
"title": "File Manager", "title": "File Manager",
@@ -993,7 +997,9 @@
"fileComparison": "File Comparison: {{file1}} vs {{file2}}", "fileComparison": "File Comparison: {{file1}} vs {{file2}}",
"fileTooLarge": "File too large: {{error}}", "fileTooLarge": "File too large: {{error}}",
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}" "loadFileFailed": "Failed to load file: {{error}}",
"connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed"
}, },
"tunnels": { "tunnels": {
"title": "SSH Tunnels", "title": "SSH Tunnels",
@@ -1093,6 +1099,8 @@
"refreshing": "Refreshing...", "refreshing": "Refreshing...",
"serverOffline": "Server Offline", "serverOffline": "Server Offline",
"cannotFetchMetrics": "Cannot fetch metrics from offline server", "cannotFetchMetrics": "Cannot fetch metrics from offline server",
"totpRequired": "TOTP Authentication Required",
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
"load": "Load", "load": "Load",
"free": "Free", "free": "Free",
"available": "Available" "available": "Available"
+9 -1
View File
@@ -703,6 +703,10 @@
"terminalTitle": "终端 - {{host}}", "terminalTitle": "终端 - {{host}}",
"terminalWithPath": "终端 - {{host}}:{{path}}", "terminalWithPath": "终端 - {{host}}:{{path}}",
"runTitle": "运行 {{command}} - {{host}}", "runTitle": "运行 {{command}} - {{host}}",
"totpRequired": "需要双因素认证",
"totpCodeLabel": "验证码",
"totpPlaceholder": "000000",
"totpVerify": "验证",
"connect": "连接主机", "connect": "连接主机",
"disconnect": "断开连接", "disconnect": "断开连接",
"clear": "清屏", "clear": "清屏",
@@ -984,7 +988,9 @@
"fileComparison": "文件对比:{{file1}} 与 {{file2}}", "fileComparison": "文件对比:{{file1}} 与 {{file2}}",
"fileTooLarge": "文件过大:{{error}}", "fileTooLarge": "文件过大:{{error}}",
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接", "sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}" "loadFileFailed": "加载文件失败:{{error}}",
"connectedSuccessfully": "连接成功",
"totpVerificationFailed": "TOTP 验证失败"
}, },
"tunnels": { "tunnels": {
"title": "SSH 隧道", "title": "SSH 隧道",
@@ -1072,6 +1078,8 @@
"refreshing": "正在刷新...", "refreshing": "正在刷新...",
"serverOffline": "服务器离线", "serverOffline": "服务器离线",
"cannotFetchMetrics": "无法从离线服务器获取指标", "cannotFetchMetrics": "无法从离线服务器获取指标",
"totpRequired": "需要 TOTP 认证",
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
"load": "负载" "load": "负载"
}, },
"auth": { "auth": {
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/components/TOTPDialog";
import { import {
Upload, Upload,
FolderPlus, FolderPlus,
@@ -38,6 +39,7 @@ import {
renameSSHItem, renameSSHItem,
moveSSHItem, moveSSHItem,
connectSSH, connectSSH,
verifySSHTOTP,
getSSHStatus, getSSHStatus,
keepSSHAlive, keepSSHAlive,
identifySSHSymlink, identifySSHSymlink,
@@ -98,6 +100,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0); const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [totpRequired, setTotpRequired] = useState(false);
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set()); const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
const [isClosing, setIsClosing] = useState<boolean>(false); const [isClosing, setIsClosing] = useState<boolean>(false);
@@ -288,6 +293,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
userId: currentHost.userId, userId: currentHost.userId,
}); });
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
setTotpPrompt(result.prompt || "Verification code:");
setIsLoading(false);
return;
}
setSshSessionId(sessionId); setSshSessionId(sessionId);
try { try {
@@ -1241,6 +1254,47 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(null); setEditingFile(null);
} }
async function handleTotpSubmit(code: string) {
if (!totpSessionId || !code) return;
try {
setIsLoading(true);
const result = await verifySSHTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSshSessionId(totpSessionId);
setTotpSessionId(null);
try {
const response = await listSSHFiles(totpSessionId, currentPath);
const files = Array.isArray(response)
? response
: response?.files || [];
setFiles(files);
clearSelection();
initialLoadDoneRef.current = true;
toast.success(t("fileManager.connectedSuccessfully"));
} catch (dirError: any) {
console.error("Failed to load initial directory:", dirError);
}
}
} catch (error: any) {
console.error("TOTP verification failed:", error);
toast.error(t("fileManager.totpVerificationFailed"));
} finally {
setIsLoading(false);
}
}
function handleTotpCancel() {
setTotpRequired(false);
setTotpPrompt("");
setTotpSessionId(null);
if (onClose) onClose();
}
function generateUniqueName( function generateUniqueName(
baseName: string, baseName: string,
type: "file" | "directory", type: "file" | "directory",
@@ -1809,6 +1863,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
/> />
</div> </div>
</div> </div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
</div> </div>
); );
} }
+26 -10
View File
@@ -119,12 +119,17 @@ export function Server({
setMetrics(data); setMetrics(data);
setShowStatsUI(true); setShowStatsUI(true);
} }
} catch (error) { } catch (error: any) {
if (!cancelled) { if (!cancelled) {
setMetrics(null); setMetrics(null);
setShowStatsUI(false); setShowStatsUI(false);
if (error?.code === "TOTP_REQUIRED" ||
(error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
toast.error(t("serverStats.totpUnavailable"));
} else {
toast.error(t("serverStats.failedToFetchMetrics")); toast.error(t("serverStats.failedToFetchMetrics"));
} }
}
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setIsLoadingMetrics(false); setIsLoadingMetrics(false);
@@ -210,17 +215,28 @@ export function Server({
setMetrics(data); setMetrics(data);
setShowStatsUI(true); setShowStatsUI(true);
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 503) { if (error?.code === "TOTP_REQUIRED" ||
setServerStatus("offline"); (error?.response?.status === 403 && error?.response?.data?.error === "TOTP_REQUIRED")) {
} else if (error?.response?.status === 504) { toast.error(t("serverStats.totpUnavailable"));
setServerStatus("offline");
} else if (error?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
setMetrics(null); setMetrics(null);
setShowStatsUI(false); setShowStatsUI(false);
} else if (error?.response?.status === 503 || error?.status === 503) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (error?.response?.status === 504 || error?.status === 504) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (error?.response?.status === 404 || error?.status === 404) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
}
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
1
+32
View File
@@ -13,6 +13,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { getCookie, isElectron } from "@/ui/main-axios.ts"; import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/components/TOTPDialog";
interface SSHTerminalProps { interface SSHTerminalProps {
hostConfig: any; hostConfig: any;
@@ -55,6 +56,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState<string>("");
const isVisibleRef = useRef<boolean>(false); const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0); const reconnectAttempts = useRef(0);
@@ -102,6 +105,25 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} catch (_) {} } catch (_) {}
} }
function handleTotpSubmit(code: string) {
if (webSocketRef.current && code) {
webSocketRef.current.send(
JSON.stringify({
type: "totp_response",
data: { code },
}),
);
setTotpRequired(false);
setTotpPrompt("");
}
}
function handleTotpCancel() {
setTotpRequired(false);
setTotpPrompt("");
if (onClose) onClose();
}
function scheduleNotify(cols: number, rows: number) { function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return; if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = { cols, rows }; pendingSizeRef.current = { cols, rows };
@@ -422,6 +444,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (onClose) { if (onClose) {
onClose(); onClose();
} }
} else if (msg.type === "totp_required") {
setTotpRequired(true);
setTotpPrompt(msg.prompt || "Verification code:");
} }
} catch (error) { } catch (error) {
toast.error(t("terminal.messageParseError")); toast.error(t("terminal.messageParseError"));
@@ -729,6 +754,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
</div> </div>
</div> </div>
)} )}
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
</div> </div>
); );
}); });
+81
View File
@@ -0,0 +1,81 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Shield } from "lucide-react";
import { useTranslation } from "react-i18next";
interface TOTPDialogProps {
isOpen: boolean;
prompt: string;
onSubmit: (code: string) => void;
onCancel: () => void;
}
export function TOTPDialog({
isOpen,
prompt,
onSubmit,
onCancel,
}: TOTPDialogProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-black/50" />
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">
{t("terminal.totpRequired")}
</h3>
</div>
<p className="text-muted-foreground text-sm mb-4">{prompt}</p>
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem(
"totpCode",
) as HTMLInputElement;
if (input && input.value.trim()) {
onSubmit(input.value.trim());
}
}}
gemini-code-assist[bot] commented 2025-10-05 07:19:12 +00:00 (Migrated from github.com)
Review

medium

The form submission logic directly accesses the form element from the event to get the input value. A more idiomatic React approach is to use a controlled component by managing the input's value with useState. This makes the component's state more predictable and easier to manage.

Example:

const [code, setCode] = React.useState('');

// in form
<Input value={code} onChange={(e) => setCode(e.target.value)} ... />

// in onSubmit
if (code.trim()) {
  onSubmit(code.trim());
}
![medium](https://www.gstatic.com/codereviewagent/medium-priority.svg) The form submission logic directly accesses the form element from the event to get the input value. A more idiomatic React approach is to use a controlled component by managing the input's value with `useState`. This makes the component's state more predictable and easier to manage. Example: ```tsx const [code, setCode] = React.useState(''); // in form <Input value={code} onChange={(e) => setCode(e.target.value)} ... /> // in onSubmit if (code.trim()) { onSubmit(code.trim()); } ```
className="space-y-4"
>
<div>
<Label htmlFor="totpCode">
{t("terminal.totpCodeLabel")}
</Label>
<Input
id="totpCode"
name="totpCode"
type="text"
autoFocus
maxLength={6}
pattern="[0-9]*"
inputMode="numeric"
placeholder="000000"
className="text-center text-lg tracking-widest mt-1.5"
/>
</div>
<div className="flex gap-2">
<Button type="submit" className="flex-1">
{t("terminal.totpVerify")}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1"
>
{t("common.cancel")}
</Button>
</div>
</form>
</div>
</div>
);
}
+24 -5
View File
@@ -526,8 +526,8 @@ function handleApiError(error: unknown, operation: string): never {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const status = error.response?.status; const status = error.response?.status;
const message = error.response?.data?.error || error.message; const message = error.response?.data?.message || error.response?.data?.error || error.message;
const code = error.response?.data?.code; const code = error.response?.data?.code || error.response?.data?.error;
const url = error.config?.url; const url = error.config?.url;
const method = error.config?.method?.toUpperCase(); const method = error.config?.method?.toUpperCase();
@@ -554,11 +554,15 @@ function handleApiError(error: unknown, operation: string): never {
throw new ApiError(errorMessage, 401, "AUTH_REQUIRED"); throw new ApiError(errorMessage, 401, "AUTH_REQUIRED");
} else if (status === 403) { } else if (status === 403) {
authLogger.warn(`Access denied: ${method} ${url}`, errorContext); authLogger.warn(`Access denied: ${method} ${url}`, errorContext);
throw new ApiError( const apiError = new ApiError(
"Access denied. You do not have permission to perform this action.", code === "TOTP_REQUIRED"
? message
: "Access denied. You do not have permission to perform this action.",
403, 403,
"ACCESS_DENIED", code || "ACCESS_DENIED",
); );
(apiError as any).response = error.response;
throw apiError;
} else if (status === 404) { } else if (status === 404) {
apiLogger.warn(`Not found: ${method} ${url}`, errorContext); apiLogger.warn(`Not found: ${method} ${url}`, errorContext);
throw new ApiError( throw new ApiError(
1
@@ -1057,6 +1061,21 @@ export async function disconnectSSH(sessionId: string): Promise<any> {
} }
} }
export async function verifySSHTOTP(
sessionId: string,
totpCode: string,
): Promise<any> {
try {
const response = await fileManagerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
} catch (error) {
handleApiError(error, "verify SSH TOTP");
}
}
export async function getSSHStatus( export async function getSSHStatus(
sessionId: string, sessionId: string,
): Promise<{ connected: boolean }> { ): Promise<{ connected: boolean }> {