diff --git a/README.md b/README.md index d9d389d..437ab13 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ WebSSH2 is an HTML5 web-based terminal emulator and SSH client. It uses SSH2 as - [Docker Setup](#docker-setup) - [Usage](#usage) - [Configuration](#configuration) +- [Features](#features) - [Routes](#routes) - [Deprecation Notice](#deprecation-notice) - [Tips](#tips) @@ -68,7 +69,7 @@ http://localhost:2222/ssh/host/127.0.0.1 ``` You'll be prompted for SSH credentials via HTTP Basic Authentication. - +P ## Configuration ### GET Parameters @@ -105,6 +106,61 @@ Edit `config.json` to customize the following options: For detailed SSH algorithm configurations, refer to the full config file. +## Features + +### Keyboard Interactive Authentication + +Keyboard Interactive authentication provides a flexible way to handle various authentication scenarios, including multi-factor authentication. + +#### How it works + +1. When the SSH server requests Keyboard Interactive authentication, WebSSH2 can handle it in two ways: + a) Automatically (default behavior) + b) By prompting the user through the web interface + +2. In automatic mode: + - If all prompts contain the word "password" (case-insensitive), WebSSH2 will automatically respond using the password provided during the initial connection attempt. + - If any prompt doesn't contain "password", all prompts will be forwarded to the web client for user input. + +3. When prompts are sent to the web client: + - A dialog box appears in the user's browser, displaying all prompts from the SSH server. + - The user can input responses for each prompt. + - Responses are sent back to the SSH server to complete the authentication process. + +#### Configuration Options + +You can customize the Keyboard Interactive authentication behavior using the following option in your `config.json`: + +```json +{ + "ssh": { + "alwaysSendKeyboardInteractivePrompts": false + } +} +``` + +- `alwaysSendKeyboardInteractivePrompts` (boolean, default: false): + - When set to `true`, all Keyboard Interactive prompts will always be sent to the web client, regardless of their content. + - When set to `false` (default), WebSSH2 will attempt to automatically handle password prompts and only send non-password prompts to the web client. + +#### Use Cases + +1. **Simple Password Authentication**: + With default settings, if the SSH server uses Keyboard Interactive for password authentication, WebSSH2 will automatically handle it without additional user interaction. + +2. **Multi-Factor Authentication**: + For SSH servers requiring additional factors (e.g., OTP), WebSSH2 will present prompts to the user through the web interface. + +3. **Always Prompt User**: + By setting `alwaysSendKeyboardInteractivePrompts` to `true`, you can ensure that users always see and respond to all authentication prompts, which can be useful for security-sensitive environments or for debugging purposes. + +#### Security Considerations + +- The automatic password handling feature is designed for convenience but may not be suitable for high-security environments. Consider setting `alwaysSendKeyboardInteractivePrompts` to `true` if you want users to explicitly enter their credentials for each session. +- Ensure that your WebSSH2 installation uses HTTPS to protect the communication between the web browser and the WebSSH2 server. + +For more information on SSH keyboard-interactive authentication, refer to [RFC 4256](https://tools.ietf.org/html/rfc4256). + ## Routes WebSSH2 provides two main routes: diff --git a/app/socket.js b/app/socket.js index 8bb55a8..ac23d70 100644 --- a/app/socket.js +++ b/app/socket.js @@ -2,6 +2,7 @@ // app/socket.js const validator = require("validator") +const EventEmitter = require("events") const SSHConnection = require("./ssh") const { createNamespacedDebug } = require("./logger") const { SSHConnectionError, handleError } = require("./errors") @@ -14,8 +15,9 @@ const { } = require("./utils") const { MESSAGES } = require("./constants") -class WebSSH2Socket { +class WebSSH2Socket extends EventEmitter { constructor(socket, config) { + super() this.socket = socket this.config = config this.ssh = new SSHConnection(config) @@ -29,6 +31,7 @@ class WebSSH2Socket { cols: null, rows: null } + this.initializeSocketEvents() } @@ -50,24 +53,50 @@ class WebSSH2Socket { this.socket.emit("authentication", { action: "request_auth" }) } - this.socket.on( - "authenticate", - function(creds) { - this.handleAuthenticate(creds) - }.bind(this) - ) - this.socket.on( - "terminal", - function(data) { - this.handleTerminal(data) - }.bind(this) - ) - this.socket.on( - "disconnect", - function(reason) { - this.handleConnectionClose(reason) - }.bind(this) + this.ssh.on("keyboard-interactive", data => { + this.handleKeyboardInteractive(data) + }) + + this.socket.on("authenticate", creds => { + this.handleAuthenticate(creds) + }) + this.socket.on("terminal", data => { + this.handleTerminal(data) + }) + this.socket.on("disconnect", reason => { + this.handleConnectionClose(reason) + }) + } + + handleKeyboardInteractive(data) { + const self = this + debug(`handleKeyboardInteractive: ${this.socket.id}, %O`, data) + + // Send the keyboard-interactive request to the client + this.socket.emit( + "authentication", + Object.assign( + { + action: "keyboard-interactive" + }, + data + ) ) + + // Set up a one-time listener for the client's response + this.socket.once("authentication", clientResponse => { + const maskedclientResponse = maskSensitiveData(clientResponse, { + properties: ["responses"] + }) + debug( + "handleKeyboardInteractive: Client response masked %O", + maskedclientResponse + ) + if (clientResponse.action === "keyboard-interactive") { + // Forward the client's response to the SSH connection + self.ssh.emit("keyboard-interactive-response", clientResponse.responses) + } + }) } handleAuthenticate(creds) { @@ -88,6 +117,7 @@ class WebSSH2Socket { } initializeConnection(creds) { + const self = this debug( `initializeConnection: ${this.socket.id}, INITIALIZING SSH CONNECTION: Host: ${creds.host}, creds: %O`, maskSensitiveData(creds) diff --git a/app/ssh.js b/app/ssh.js index 62c360b..80cbd38 100644 --- a/app/ssh.js +++ b/app/ssh.js @@ -2,6 +2,7 @@ // app/ssh.js const SSH = require("ssh2").Client +const EventEmitter = require("events") const { createNamespacedDebug } = require("./logger") const { SSHConnectionError, handleError } = require("./errors") const { maskSensitiveData } = require("./utils") @@ -10,163 +11,188 @@ const debug = createNamespacedDebug("ssh") /** * SSHConnection class handles SSH connections and operations. - * @class - * @param {Object} config - Configuration object for the SSH connection. + * @extends EventEmitter */ -function SSHConnection(config) { - this.config = config - this.conn = null - this.stream = null -} +class SSHConnection extends EventEmitter { + /** + * Create an SSHConnection. + * @param {Object} config - Configuration object for the SSH connection. + */ + constructor(config) { + super() + this.config = config + this.conn = null + this.stream = null + this.creds = null + } -/** - * Connects to the SSH server using the provided credentials. - * @function - * @memberof SSHConnection - * @param {Object} creds - The credentials object containing host, port, username, and password. - * @returns {Promise} - A promise that resolves with the SSH connection instance. - */ -SSHConnection.prototype.connect = function(creds) { - debug("connect: %O", maskSensitiveData(creds)) - return new Promise((resolve, reject) => { + /** + * Connects to the SSH server using the provided credentials. + * @param {Object} creds - The credentials object containing host, port, username, and password. + * @returns {Promise} - A promise that resolves with the SSH connection instance. + */ + connect(creds) { + this.creds = creds + debug("connect: %O", maskSensitiveData(creds)) + return new Promise((resolve, reject) => { + if (this.conn) { + this.conn.end() + } + + this.conn = new SSH() + + const sshConfig = this.getSSHConfig(creds) + + this.conn.on("ready", () => { + debug(`connect: ready: ${creds.host}`) + resolve(this.conn) + }) + + this.conn.on("error", err => { + const error = new SSHConnectionError( + `SSH Connection error: ${err.message}` + ) + handleError(error) + reject(error) + }) + + this.conn.on( + "keyboard-interactive", + (name, instructions, lang, prompts, finish) => { + this.handleKeyboardInteractive( + name, + instructions, + lang, + prompts, + finish + ) + } + ) + + this.conn.connect(sshConfig) + }) + } + + /** + * Handles keyboard-interactive authentication prompts. + * @param {string} name - The name of the authentication request. + * @param {string} instructions - The instructions for the keyboard-interactive prompt. + * @param {string} lang - The language of the prompt. + * @param {Array} prompts - The list of prompts provided by the server. + * @param {Function} finish - The callback to complete the keyboard-interactive authentication. + */ + handleKeyboardInteractive(name, instructions, lang, prompts, finish) { + debug("handleKeyboardInteractive: Keyboard-interactive auth %O", prompts) + + // Check if we should always send prompts to the client + if (this.config.ssh.alwaysSendKeyboardInteractivePrompts) { + this.sendPromptsToClient(name, instructions, prompts, finish) + return + } + + const responses = [] + let shouldSendToClient = false + + for (let i = 0; i < prompts.length; i += 1) { + if ( + prompts[i].prompt.toLowerCase().includes("password") && + this.creds.password + ) { + responses.push(this.creds.password) + } else { + shouldSendToClient = true + break + } + } + + if (shouldSendToClient) { + this.sendPromptsToClient(name, instructions, prompts, finish) + } else { + finish(responses) + } + } + + /** + * Sends prompts to the client for keyboard-interactive authentication. + * + * @param {string} name - The name of the authentication method. + * @param {string} instructions - The instructions for the authentication. + * @param {Array<{ prompt: string, echo: boolean }>} prompts - The prompts to be sent to the client. + * @param {Function} finish - The callback function to be called when the client responds. + */ + sendPromptsToClient(name, instructions, prompts, finish) { + this.emit("keyboard-interactive", { + name: name, + instructions: instructions, + prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })) + }) + + this.once("keyboard-interactive-response", responses => { + finish(responses) + }) + } + + /** + * Generates the SSH configuration object based on credentials. + * @param {Object} creds - The credentials object containing host, port, username, and password. + * @returns {Object} - The SSH configuration object. + */ + getSSHConfig(creds) { + return { + host: creds.host, + port: creds.port, + username: creds.username, + password: creds.password, + tryKeyboard: true, + algorithms: this.config.ssh.algorithms, + readyTimeout: this.config.ssh.readyTimeout, + keepaliveInterval: this.config.ssh.keepaliveInterval, + keepaliveCountMax: this.config.ssh.keepaliveCountMax, + debug: createNamespacedDebug("ssh2") + } + } + + /** + * Opens an interactive shell session over the SSH connection. + * @param {Object} [options] - Optional parameters for the shell. + * @returns {Promise} - A promise that resolves with the SSH shell stream. + */ + shell(options) { + return new Promise((resolve, reject) => { + this.conn.shell(options, (err, stream) => { + if (err) { + reject(err) + } else { + this.stream = stream + resolve(stream) + } + }) + }) + } + + /** + * Resizes the terminal window for the current SSH session. + * @param {number} rows - The number of rows for the terminal. + * @param {number} cols - The number of columns for the terminal. + */ + resizeTerminal(rows, cols) { + if (this.stream) { + this.stream.setWindow(rows, cols) + } + } + + /** + * Ends the SSH connection and stream. + */ + end() { + if (this.stream) { + this.stream.end() + this.stream = null + } if (this.conn) { this.conn.end() + this.conn = null } - - this.conn = new SSH() - - const sshConfig = this.getSSHConfig(creds) - - this.conn.on("ready", () => { - debug(`connect: ready: ${creds.host}`) - resolve(this.conn) - }) - - this.conn.on("error", err => { - const error = new SSHConnectionError( - `SSH Connection error: ${err.message}` - ) - handleError(error) - reject(error) - }) - - this.conn.on( - "keyboard-interactive", - (name, instructions, lang, prompts, finish) => { - this.handleKeyboardInteractive( - creds, - name, - instructions, - lang, - prompts, - finish - ) - } - ) - - this.conn.connect(sshConfig) - }) -} - -/** - * Handles keyboard-interactive authentication prompts. - * @function - * @memberof SSHConnection - * @param {Object} creds - The credentials object containing password. - * @param {string} name - The name of the authentication request. - * @param {string} instructions - The instructions for the keyboard-interactive prompt. - * @param {string} lang - The language of the prompt. - * @param {Array} prompts - The list of prompts provided by the server. - * @param {Function} finish - The callback to complete the keyboard-interactive authentication. - */ -SSHConnection.prototype.handleKeyboardInteractive = function( - creds, - name, - instructions, - lang, - prompts, - finish -) { - debug("handleKeyboardInteractive: Keyboard-interactive auth %O", prompts) - const responses = [] - - for (let i = 0; i < prompts.length; i += 1) { - if (prompts[i].prompt.toLowerCase().includes("password")) { - responses.push(creds.password) - } else { - // todo: For any non-password prompts, we meed to implement a way to - // get responses from the user through a modal. For now, we'll just - // send an empty string - responses.push("") - } - } - - finish(responses) -} - -/** - * Generates the SSH configuration object based on credentials. - * @function - * @memberof SSHConnection - * @param {Object} creds - The credentials object containing host, port, username, and password. - * @returns {Object} - The SSH configuration object. - */ -SSHConnection.prototype.getSSHConfig = function(creds) { - return { - host: creds.host, - port: creds.port, - username: creds.username, - password: creds.password, - tryKeyboard: true, - algorithms: this.config.ssh.algorithms, - readyTimeout: this.config.ssh.readyTimeout, - keepaliveInterval: this.config.ssh.keepaliveInterval, - keepaliveCountMax: this.config.ssh.keepaliveCountMax, - debug: createNamespacedDebug("ssh2") - } -} - -/** - * Opens an interactive shell session over the SSH connection. - * @function - * @memberof SSHConnection - * @param {Object} [options] - Optional parameters for the shell. - * @returns {Promise} - A promise that resolves with the SSH shell stream. - */ -SSHConnection.prototype.shell = function(options) { - return new Promise((resolve, reject) => { - this.conn.shell(options, (err, stream) => { - if (err) { - reject(err) - } else { - this.stream = stream - resolve(stream) - } - }) - }) -} - -/** - * Resizes the terminal window for the current SSH session. - * @function - * @memberof SSHConnection - * @param {number} rows - The number of rows for the terminal. - * @param {number} cols - The number of columns for the terminal. - */ -SSHConnection.prototype.resizeTerminal = function(rows, cols) { - if (this.stream) { - this.stream.setWindow(rows, cols) - } -} - -SSHConnection.prototype.end = function() { - if (this.stream) { - this.stream.end() - this.stream = null - } - if (this.conn) { - this.conn.end() - this.conn = null } } diff --git a/app/utils.js b/app/utils.js index e1410e6..0e36dfe 100644 --- a/app/utils.js +++ b/app/utils.js @@ -173,12 +173,10 @@ function modifyHtml(html, config) { */ function maskSensitiveData(obj, options) { const defaultOptions = {} - debug("maskSensitiveData: %O", obj) - debug("maskSensitiveData: options: %O", options) + debug("maskSensitiveData") const maskingOptions = Object.assign({}, defaultOptions, options || {}) const maskedObject = maskObject(obj, maskingOptions) - debug("maskSensitiveData: maskedObject: %O", maskedObject) return maskedObject } diff --git a/package.json b/package.json index cea74c9..956fe10 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "socket.io": "~2.2.0", "ssh2": "~0.8.9", "validator": "^12.2.0", - "webssh2_client": "^0.2.23" + "webssh2_client": "^0.2.25" }, "scripts": { "start": "node index.js",