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:
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "文件管理器",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user