feat: ssh keyboard-interactive authentication support

This commit is contained in:
Bill Church 2024-08-22 17:27:48 +00:00
parent 1c4bfc2680
commit 0f3c7ab230
No known key found for this signature in database
5 changed files with 285 additions and 175 deletions

View file

@ -13,6 +13,7 @@ WebSSH2 is an HTML5 web-based terminal emulator and SSH client. It uses SSH2 as
- [Docker Setup](#docker-setup) - [Docker Setup](#docker-setup)
- [Usage](#usage) - [Usage](#usage)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Features](#features)
- [Routes](#routes) - [Routes](#routes)
- [Deprecation Notice](#deprecation-notice) - [Deprecation Notice](#deprecation-notice)
- [Tips](#tips) - [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. You'll be prompted for SSH credentials via HTTP Basic Authentication.
P
## Configuration ## Configuration
### GET Parameters ### 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. 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 ## Routes
WebSSH2 provides two main routes: WebSSH2 provides two main routes:

View file

@ -2,6 +2,7 @@
// app/socket.js // app/socket.js
const validator = require("validator") const validator = require("validator")
const EventEmitter = require("events")
const SSHConnection = require("./ssh") const SSHConnection = require("./ssh")
const { createNamespacedDebug } = require("./logger") const { createNamespacedDebug } = require("./logger")
const { SSHConnectionError, handleError } = require("./errors") const { SSHConnectionError, handleError } = require("./errors")
@ -14,8 +15,9 @@ const {
} = require("./utils") } = require("./utils")
const { MESSAGES } = require("./constants") const { MESSAGES } = require("./constants")
class WebSSH2Socket { class WebSSH2Socket extends EventEmitter {
constructor(socket, config) { constructor(socket, config) {
super()
this.socket = socket this.socket = socket
this.config = config this.config = config
this.ssh = new SSHConnection(config) this.ssh = new SSHConnection(config)
@ -29,6 +31,7 @@ class WebSSH2Socket {
cols: null, cols: null,
rows: null rows: null
} }
this.initializeSocketEvents() this.initializeSocketEvents()
} }
@ -50,24 +53,50 @@ class WebSSH2Socket {
this.socket.emit("authentication", { action: "request_auth" }) this.socket.emit("authentication", { action: "request_auth" })
} }
this.socket.on( this.ssh.on("keyboard-interactive", data => {
"authenticate", this.handleKeyboardInteractive(data)
function(creds) { })
this.handleAuthenticate(creds)
}.bind(this) this.socket.on("authenticate", creds => {
) this.handleAuthenticate(creds)
this.socket.on( })
"terminal", this.socket.on("terminal", data => {
function(data) { this.handleTerminal(data)
this.handleTerminal(data) })
}.bind(this) this.socket.on("disconnect", reason => {
) this.handleConnectionClose(reason)
this.socket.on( })
"disconnect", }
function(reason) {
this.handleConnectionClose(reason) handleKeyboardInteractive(data) {
}.bind(this) 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) { handleAuthenticate(creds) {
@ -88,6 +117,7 @@ class WebSSH2Socket {
} }
initializeConnection(creds) { initializeConnection(creds) {
const self = this
debug( debug(
`initializeConnection: ${this.socket.id}, INITIALIZING SSH CONNECTION: Host: ${creds.host}, creds: %O`, `initializeConnection: ${this.socket.id}, INITIALIZING SSH CONNECTION: Host: ${creds.host}, creds: %O`,
maskSensitiveData(creds) maskSensitiveData(creds)

View file

@ -2,6 +2,7 @@
// app/ssh.js // app/ssh.js
const SSH = require("ssh2").Client const SSH = require("ssh2").Client
const EventEmitter = require("events")
const { createNamespacedDebug } = require("./logger") const { createNamespacedDebug } = require("./logger")
const { SSHConnectionError, handleError } = require("./errors") const { SSHConnectionError, handleError } = require("./errors")
const { maskSensitiveData } = require("./utils") const { maskSensitiveData } = require("./utils")
@ -10,163 +11,188 @@ const debug = createNamespacedDebug("ssh")
/** /**
* SSHConnection class handles SSH connections and operations. * SSHConnection class handles SSH connections and operations.
* @class * @extends EventEmitter
* @param {Object} config - Configuration object for the SSH connection.
*/ */
function SSHConnection(config) { class SSHConnection extends EventEmitter {
this.config = config /**
this.conn = null * Create an SSHConnection.
this.stream = null * @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. * Connects to the SSH server using the provided credentials.
* @function * @param {Object} creds - The credentials object containing host, port, username, and password.
* @memberof SSHConnection * @returns {Promise<SSH>} - A promise that resolves with the SSH connection instance.
* @param {Object} creds - The credentials object containing host, port, username, and password. */
* @returns {Promise<SSH>} - A promise that resolves with the SSH connection instance. connect(creds) {
*/ this.creds = creds
SSHConnection.prototype.connect = function(creds) { debug("connect: %O", maskSensitiveData(creds))
debug("connect: %O", maskSensitiveData(creds)) return new Promise((resolve, reject) => {
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<Object>} 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<Object>} - 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) { if (this.conn) {
this.conn.end() 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<Object>} 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<Object>} - 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
} }
} }

View file

@ -173,12 +173,10 @@ function modifyHtml(html, config) {
*/ */
function maskSensitiveData(obj, options) { function maskSensitiveData(obj, options) {
const defaultOptions = {} const defaultOptions = {}
debug("maskSensitiveData: %O", obj) debug("maskSensitiveData")
debug("maskSensitiveData: options: %O", options)
const maskingOptions = Object.assign({}, defaultOptions, options || {}) const maskingOptions = Object.assign({}, defaultOptions, options || {})
const maskedObject = maskObject(obj, maskingOptions) const maskedObject = maskObject(obj, maskingOptions)
debug("maskSensitiveData: maskedObject: %O", maskedObject)
return maskedObject return maskedObject
} }

View file

@ -43,7 +43,7 @@
"socket.io": "~2.2.0", "socket.io": "~2.2.0",
"ssh2": "~0.8.9", "ssh2": "~0.8.9",
"validator": "^12.2.0", "validator": "^12.2.0",
"webssh2_client": "^0.2.23" "webssh2_client": "^0.2.25"
}, },
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",