Add TOTP/2FA authentication support for SSH connections

Implement keyboard-interactive authentication to support servers with
PAM-based two-factor authentication configured. When TOTP verification
is detected, users are prompted with an inline modal to enter their
authentication code.

Fixes #220
This commit is contained in:
ZacharyZcR
2025-10-04 04:49:18 +08:00
parent 522fe3e571
commit fb3f5f435b
4 changed files with 160 additions and 2 deletions
+73
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,34 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(JSON.stringify({ type: "pong" })); ws.send(JSON.stringify({ type: "pong" }));
break; 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: 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 +586,54 @@ wss.on("connection", async (ws: WebSocket, req) => {
cleanupSSH(connectionTimeout); 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 = { 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,
+5 -1
View File
@@ -711,7 +711,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",
"totpPlaceholder": "000000",
"submit": "Submit",
"cancel": "Cancel"
}, },
"fileManager": { "fileManager": {
"title": "File Manager", "title": "File Manager",
+5 -1
View File
@@ -732,7 +732,11 @@
"reconnecting": "重新连接中... ({{attempt}}/{{max}})", "reconnecting": "重新连接中... ({{attempt}}/{{max}})",
"reconnected": "重新连接成功", "reconnected": "重新连接成功",
"maxReconnectAttemptsReached": "已达到最大重连尝试次数", "maxReconnectAttemptsReached": "已达到最大重连尝试次数",
"connectionTimeout": "连接超时" "connectionTimeout": "连接超时",
"totpRequired": "需要双因素认证",
"totpPlaceholder": "000000",
"submit": "提交",
"cancel": "取消"
}, },
"fileManager": { "fileManager": {
"title": "文件管理器", "title": "文件管理器",
+77
View File
@@ -55,6 +55,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 +104,20 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} catch (_) {} } 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) { 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 +438,10 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
if (onClose) { if (onClose) {
onClose(); onClose();
} }
} else if (msg.type === "totp_required") {
// TOTP/2FA verification required
setTotpRequired(true);
setTotpPrompt(msg.prompt || "Verification code:");
} }
} catch (error) { } catch (error) {
toast.error(t("terminal.messageParseError")); toast.error(t("terminal.messageParseError"));
@@ -729,6 +749,63 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
</div> </div>
</div> </div>
)} )}
{totpRequired && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 backdrop-blur-sm z-50">
<div className="bg-gray-800 rounded-lg p-6 shadow-xl max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-white mb-2">
{t("terminal.totpRequired")}
</h3>
<p className="text-gray-300 text-sm mb-4">{totpPrompt}</p>
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem(
"totpCode",
) as HTMLInputElement;
if (input && input.value.trim()) {
handleTotpSubmit(input.value);
}
}}
>
<input
type="text"
name="totpCode"
autoFocus
maxLength={6}
pattern="[0-9]*"
inputMode="numeric"
placeholder={t("terminal.totpPlaceholder")}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-md text-white text-center text-lg tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
/>
<div className="flex gap-3">
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium transition-colors"
>
{t("terminal.submit")}
</button>
<button
type="button"
onClick={() => {
setTotpRequired(false);
setTotpPrompt("");
if (webSocketRef.current) {
webSocketRef.current.close();
}
if (onClose) {
onClose();
}
}}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-md font-medium transition-colors"
>
{t("terminal.cancel")}
</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
}); });