-
Notifications
You must be signed in to change notification settings - Fork 62
feat: username and password support for SSH connection type #1126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 25 commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
482f2f2
feat: auth handler skeleton
smorrisj bda97f1
fix: type on authsLeft
smorrisj 86840c7
chore: progress on auth handler
smorrisj 7404025
fix: brain working faster than fingers
smorrisj 54d8d66
Merge branch 'main' into feat/ssh-userpass
smorrisj 36a778c
Merge branch 'main' into feat/ssh-userpass
smorrisj 69b6664
feat: progress on userpass
smorrisj cc667a4
refactor: auth state cleanup
smorrisj 38a575a
refactor: move auth details to separate module
smorrisj f0016cb
feat: keepalive and max unanswered settings
smorrisj 21284e4
use const for keepalive
smorrisj 8aa0c23
refactor: remove redundant timeout test
smorrisj 91a6d0f
chore: copyright header
smorrisj 0607d64
refactor for testability, auth tests
smorrisj 6ac98e8
feat: set working path to work dir
smorrisj a092524
metadata for private key, various fixes found during testing
smorrisj 978fdb4
Merge branch 'main' into feat/ssh-userpass
smorrisj debafaf
fix: minor cleanup
smorrisj e0e26dd
fix: bug fixes found during mfa testing
smorrisj 51ce78a
doc: ssh auth refactor
smorrisj 9879746
Merge branch 'main' into feat/ssh-userpass
smorrisj ea17b75
rename Connection Profile to Private Key File Path
smorrisj 3e1f267
refactor: remove unused types
smorrisj 76a271f
refactor: use one LineParser
smorrisj 7a2c0b4
refactor: shorter prompting for private key file path
smorrisj 430f80b
fix: remove console logging for debug msgs
smorrisj 04444a7
fix: use a more reasonable unanswered threshhold
smorrisj 5f9ec57
doc: update prompt wording
smorrisj d3c9cd2
fix: only write private key file key if there is an input value
smorrisj 6d28a43
fix: connection close lifecycle bug
smorrisj a0b7f88
fix: better error handling
smorrisj 9e96502
DCO Remediation Commit for Joe Morris <[email protected]>
smorrisj 6e46440
fix: further error handling enhancements
smorrisj 2195eb9
fix: work dir on single line
smorrisj cde43f0
fix:parse structure log output for work dir
smorrisj 6cd548a
chore: remove logging
smorrisj File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| // Copyright © 2022-2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { l10n, window } from "vscode"; | ||
|
|
||
| import { readFileSync } from "fs"; | ||
| import { NextAuthHandler, ParsedKey, Prompt, utils } from "ssh2"; | ||
|
|
||
| /** | ||
| * Abstraction for presenting authentication prompts to the user. | ||
| */ | ||
| export interface AuthPresenter { | ||
| /** | ||
| * Prompt the user for a passphrase. | ||
| * @returns the passphrase entered by the user | ||
| */ | ||
| presentPasswordPrompt: (username: string) => Promise<string>; | ||
| /** | ||
| * Prompt the user for a password. | ||
| * @returns the password entered by the user | ||
| */ | ||
| presentPassphrasePrompt: () => Promise<string>; | ||
| /** | ||
| * Present multiple prompts to the user. | ||
| * This scenario can happen when the server sends multiple input prompts to the user during keyboard-interactive authentication. | ||
| * Auth setups involving MFA or PAM can trigger this scenario. | ||
| * One input box will be presented for each prompt. | ||
| * @param prompts an array of prompts to present to the user | ||
| * @returns array of answers to the prompts | ||
| */ | ||
| presentMultiplePrompts: ( | ||
| username: string, | ||
| prompts: Prompt[], | ||
| ) => Promise<string[]>; | ||
| } | ||
|
|
||
| class AuthPresenterImpl implements AuthPresenter { | ||
| presentPasswordPrompt = async (username: string): Promise<string> => { | ||
| return this.presentPrompt( | ||
| l10n.t("Enter the password for user: {username}", { username }), | ||
| l10n.t("Password Required"), | ||
| true, | ||
| ); | ||
| }; | ||
|
|
||
| presentPassphrasePrompt = async (): Promise<string> => { | ||
| return this.presentPrompt( | ||
| l10n.t("Enter the passphrase for the private key"), | ||
| l10n.t("Passphrase Required"), | ||
| true, | ||
| ); | ||
| }; | ||
|
|
||
| presentMultiplePrompts = async ( | ||
| username: string, | ||
| prompts: Prompt[], | ||
| ): Promise<string[]> => { | ||
| const answers: string[] = []; | ||
| for (const prompt of prompts) { | ||
| const answer = await this.presentPrompt( | ||
| undefined, | ||
| l10n.t("User {username} {prompt}", { | ||
| username, | ||
| prompt: prompt.prompt, | ||
| }), | ||
| !prompt.echo, | ||
| ); | ||
| if (answer) { | ||
| answers.push(answer); | ||
| } | ||
| } | ||
| return answers; | ||
| }; | ||
|
|
||
| /** | ||
| * Present a secure prompt to the user. | ||
| * @param prompt the prompt to display to the user | ||
| * @param title optional title for the prompt | ||
| * @param isSecureInput whether the input should be hidden | ||
| * @returns the user's response to the prompt | ||
| */ | ||
| private presentPrompt = async ( | ||
| prompt: string, | ||
| title?: string, | ||
| isSecureInput?: boolean, | ||
| ): Promise<string> => { | ||
| return window.showInputBox({ | ||
| ignoreFocusOut: true, | ||
| prompt: prompt, | ||
| title: title, | ||
| password: isSecureInput, | ||
| }); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Handles the authentication process for the ssh connection. | ||
| * | ||
| */ | ||
| export class AuthHandler { | ||
| private _authPresenter: AuthPresenter; | ||
| private _keyParser: KeyParser; | ||
|
|
||
| constructor(authPresenter?: AuthPresenter, keyParser?: KeyParser) { | ||
| this._authPresenter = authPresenter; | ||
| this._keyParser = keyParser; | ||
|
|
||
| if (!authPresenter) { | ||
| this._authPresenter = new AuthPresenterImpl(); | ||
| } | ||
| if (!keyParser) { | ||
| this._keyParser = new KeyParserImpl(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Authenticate to the server using the password method. | ||
| * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. | ||
| * @param resolve a function that resolves the promise that is waiting for the password | ||
| * @param username the user name to use for the connection | ||
| */ | ||
| passwordAuth = (cb: NextAuthHandler, username: string) => { | ||
| this._authPresenter.presentPasswordPrompt(username).then((pw) => { | ||
| cb({ | ||
| type: "password", | ||
| password: pw, | ||
| username: username, | ||
| }); | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Authenticate to the server using the keyboard-interactive method. | ||
| * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. | ||
| * @param resolve a function that resolves the promise that is waiting for authentication | ||
| * @param username the user name to use for the connection | ||
| */ | ||
| keyboardInteractiveAuth = (cb: NextAuthHandler, username: string) => { | ||
| cb({ | ||
| type: "keyboard-interactive", | ||
| username: username, | ||
| prompt: (_name, _instructions, _instructionsLang, prompts, finish) => { | ||
| // often, the server will only send a single prompt for the password. | ||
| // however, PAM can send multiple prompts, so we need to handle that case | ||
| this._authPresenter | ||
| .presentMultiplePrompts(username, prompts) | ||
| .then((answers) => { | ||
| finish(answers); | ||
| }); | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Authenticate to the server using the ssh-agent. See the extension Docs for more information on how to set up the ssh-agent. | ||
| * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. | ||
| * @param username the user name to use for the connection | ||
| */ | ||
| sshAgentAuth = (cb: NextAuthHandler, username: string) => { | ||
| cb({ | ||
| type: "agent", | ||
| agent: process.env.SSH_AUTH_SOCK, | ||
| username: username, | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Authenticate to the server using a private key file. | ||
| * If a private key file is defined in the connection profile, this function will read the file and use it to authenticate to the server. | ||
| * If the key is encrypted, the user will be prompted for the passphrase. | ||
| * @param cb ssh2 NextHandler callback instance. This is used to pass the authentication information to the ssh server. | ||
| * @param resolve a function that resolves the promise that is waiting for authentication | ||
| * @param privateKeyFilePath the path to the private key file defined in the connection profile | ||
| * @param username the user name to use for the connection | ||
| */ | ||
| privateKeyAuth = ( | ||
| cb: NextAuthHandler, | ||
| privateKeyFilePath: string, | ||
| username: string, | ||
| ) => { | ||
| // first, try to parse the key file without a passphrase | ||
| const parsedKeyResult = this._keyParser.parseKey(privateKeyFilePath); | ||
| const hasParseError = parsedKeyResult instanceof Error; | ||
| const passphraseRequired = | ||
| hasParseError && | ||
| parsedKeyResult.message === | ||
| "Encrypted private OpenSSH key detected, but no passphrase given"; | ||
| // key is encrypted, prompt for passphrase | ||
| if (passphraseRequired) { | ||
| this._authPresenter.presentPassphrasePrompt().then((passphrase) => { | ||
| //parse the keyfile using the passphrase | ||
| const passphrasedKeyContentsResult = this._keyParser.parseKey( | ||
| privateKeyFilePath, | ||
| passphrase, | ||
| ); | ||
|
|
||
| if (passphrasedKeyContentsResult instanceof Error) { | ||
| throw passphrasedKeyContentsResult; | ||
| } | ||
| cb({ | ||
| type: "publickey", | ||
| key: passphrasedKeyContentsResult, | ||
| passphrase: passphrase, | ||
| username: username, | ||
| }); | ||
| }); | ||
| } else { | ||
| if (hasParseError) { | ||
| throw parsedKeyResult; | ||
| } | ||
| cb({ | ||
| type: "publickey", | ||
| key: parsedKeyResult, | ||
| username: username, | ||
| }); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Parses a private key file. | ||
| */ | ||
| export interface KeyParser { | ||
| /** | ||
| * Parse the private key file. | ||
| * If a passphrase is specified, the key will be decrypted using the passphrase. | ||
| * @param privateKeyPath the path to the private key file | ||
| * @param passphrase the passphrase to decrypt the key if applicable | ||
| * @returns the parsed key or an error if the key could not be parsed | ||
| */ | ||
| parseKey: (privateKeyPath: string, passphrase?: string) => ParsedKey | Error; | ||
| } | ||
|
|
||
| class KeyParserImpl implements KeyParser { | ||
| private readKeyFile = (privateKeyPath: string): Buffer => { | ||
| try { | ||
| return readFileSync(privateKeyPath); | ||
| } catch (e) { | ||
| throw new Error( | ||
| l10n.t("Error reading private key file: {filePath}, error: {message}", { | ||
| filePath: privateKeyPath, | ||
| message: e.message, | ||
| }), | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| public parseKey = ( | ||
| privateKeyPath: string, | ||
| passphrase?: string, | ||
| ): ParsedKey | Error => { | ||
| const keyContents = this.readKeyFile(privateKeyPath); | ||
| return utils.parseKey(keyContents, passphrase); | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| // Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| const SECOND = 1000; | ||
| const MINUTE = 60 * SECOND; | ||
| const HOUR = 60 * MINUTE; | ||
| export const KEEPALIVE_INTERVAL = 60 * SECOND; //How often (in milliseconds) to send SSH-level keepalive packets to the server. Set to 0 to disable. | ||
| export const KEEPALIVE_UNANSWERED_THRESHOLD = 12 * HOUR; //How many consecutive, unanswered SSH-level keepalive packets that can be sent to the server before disconnection. | ||
| export const WORK_DIR_START_TAG = "<WorkDirectory>"; | ||
| export const WORK_DIR_END_TAG = "</WorkDirectory>"; | ||
| export const CONNECT_READY_TIMEOUT = 5 * MINUTE; //allow extra time due to possible prompting | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chunky any thoughts on going with these values for keepalive and max unanswered?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@smorrisj 60 seconds is a reasonable figure - I think I usually set it to 30, but I have no strong feelings as long as it's well inside 90.
The unanswered_threshold, I confess to be unsure about appropriate behaviour. If the comment is right, it's measured in "pings" not in "time". So if you're aiming for 12 hours, it should be (12 * HOUR / KEEPALIVE_INTERVAL). Either way, because this is happening at the SSH/networking level, then 12 hours is probably way too long. Maybe 15 minutes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @chunky. Must have forgot my morning coffee when making the first pass on that value =) See 04444a7.