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 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -732,7 +732,11 @@
|
||||
"reconnecting": "重新连接中... ({{attempt}}/{{max}})",
|
||||
"reconnected": "重新连接成功",
|
||||
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
|
||||
"connectionTimeout": "连接超时"
|
||||
"connectionTimeout": "连接超时",
|
||||
"totpRequired": "需要双因素认证",
|
||||
"totpPlaceholder": "000000",
|
||||
"submit": "提交",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "文件管理器",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
@@ -102,6 +104,20 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(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<any, SSHTerminalProps>(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<any, SSHTerminalProps>(function SSHTerminal(
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user