diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 94650bd6..42c29d3f 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -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,34 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.send(JSON.stringify({ type: "pong" })); break; + case "totp_response": + // Handle TOTP code submitted by user + if (keyboardInteractiveFinish && data.code) { + sshLogger.info("TOTP code received from user", { + operation: "totp_response", + userId, + codeLength: data.code.length, + }); + + // Call the finish callback with the TOTP code + keyboardInteractiveFinish([data.code]); + 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 +586,54 @@ wss.on("connection", async (ws: WebSocket, req) => { cleanupSSH(connectionTimeout); }); + // Handle keyboard-interactive authentication (TOTP/2FA) + 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", + }); + + // Check if any prompt looks like TOTP/2FA/OTP verification + const totpPrompt = prompts.find((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPrompt) { + // TOTP detected - request code from user via frontend + keyboardInteractiveFinish = finish; + ws.send( + JSON.stringify({ + type: "totp_required", + prompt: totpPrompt.prompt, + promptCount: prompts.length, + }), + ); + } else { + // Non-TOTP keyboard-interactive (e.g., password prompt) + // Provide password if available + const responses = prompts.map(() => resolvedCredentials.password || ""); + finish(responses); + } + }, + ); + const connectConfig: any = { host: ip, port, username, + tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 7b2b4063..eb6aaaeb 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -711,7 +711,11 @@ "connectionTimeout": "Connection timeout", "terminalTitle": "Terminal - {{host}}", "terminalWithPath": "Terminal - {{host}}:{{path}}", - "runTitle": "Running {{command}} - {{host}}" + "runTitle": "Running {{command}} - {{host}}", + "totpRequired": "Two-Factor Authentication Required", + "totpPlaceholder": "000000", + "submit": "Submit", + "cancel": "Cancel" }, "fileManager": { "title": "File Manager", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index d573da45..43fb04f1 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -732,7 +732,11 @@ "reconnecting": "重新连接中... ({{attempt}}/{{max}})", "reconnected": "重新连接成功", "maxReconnectAttemptsReached": "已达到最大重连尝试次数", - "connectionTimeout": "连接超时" + "connectionTimeout": "连接超时", + "totpRequired": "需要双因素认证", + "totpPlaceholder": "000000", + "submit": "提交", + "cancel": "取消" }, "fileManager": { "title": "文件管理器", diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 7367ff6f..aad1a344 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -55,6 +55,8 @@ export const Terminal = forwardRef(function SSHTerminal( const [isConnecting, setIsConnecting] = useState(false); const [connectionError, setConnectionError] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); + const [totpRequired, setTotpRequired] = useState(false); + const [totpPrompt, setTotpPrompt] = useState(""); const isVisibleRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -102,6 +104,20 @@ export const Terminal = forwardRef(function SSHTerminal( } catch (_) {} } + // Handle TOTP code submission + const handleTotpSubmit = (code: string) => { + if (webSocketRef.current && code.trim()) { + webSocketRef.current.send( + JSON.stringify({ + type: "totp_response", + code: code.trim(), + }), + ); + setTotpRequired(false); + setTotpPrompt(""); + } + }; + function scheduleNotify(cols: number, rows: number) { if (!(cols > 0 && rows > 0)) return; pendingSizeRef.current = { cols, rows }; @@ -422,6 +438,10 @@ export const Terminal = forwardRef(function SSHTerminal( if (onClose) { onClose(); } + } else if (msg.type === "totp_required") { + // TOTP/2FA verification required + setTotpRequired(true); + setTotpPrompt(msg.prompt || "Verification code:"); } } catch (error) { toast.error(t("terminal.messageParseError")); @@ -729,6 +749,63 @@ export const Terminal = forwardRef(function SSHTerminal( )} + + {totpRequired && ( +
+
+

+ {t("terminal.totpRequired")} +

+

{totpPrompt}

+
{ + e.preventDefault(); + const input = e.currentTarget.elements.namedItem( + "totpCode", + ) as HTMLInputElement; + if (input && input.value.trim()) { + handleTotpSubmit(input.value); + } + }} + > + +
+ + +
+
+
+
+ )} ); });