From d6fa3b02a92300df34be892735bcdf72b1b0e10c Mon Sep 17 00:00:00 2001 From: Eugene Pankov Date: Thu, 9 Sep 2021 22:43:39 +0200 Subject: [PATCH] ssh: reworked keyboard-interactive auth UI - fixes #4540 --- ...keyboardInteractiveAuthPanel.component.pug | 26 +++++++++++++ ...eyboardInteractiveAuthPanel.component.scss | 9 +++++ .../keyboardInteractiveAuthPanel.component.ts | 37 +++++++++++++++++++ tabby-ssh/src/components/sshTab.component.pug | 7 ++++ tabby-ssh/src/components/sshTab.component.ts | 17 ++++++++- tabby-ssh/src/index.ts | 2 + tabby-ssh/src/services/ssh.service.ts | 28 ++++---------- tabby-ssh/src/session/ssh.ts | 28 ++++++++++++++ .../src/api/baseTerminalTab.component.ts | 4 +- 9 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug create mode 100644 tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss create mode 100644 tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug new file mode 100644 index 00000000..96c9f730 --- /dev/null +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.pug @@ -0,0 +1,26 @@ +.d-flex + strong Keyboard-interactive auth + .ml-2 {{prompt.name}} + +.prompt-text {{prompt.prompts[step]}} + +input.form-control.mt-2( + #input, + autofocus, + [type]='isPassword() ? "password": "text"', + placeholder='Response', + (keyup.enter)='next()', + [(ngModel)]='prompt.responses[step]' +) + +.d-flex.mt-3 + button.btn.btn-secondary( + *ngIf='step > 0', + (click)='previous()' + ) + .ml-auto + button.btn.btn-primary( + (click)='next()' + ) + span(*ngIf='step < prompt.prompts.length - 1') Next + span(*ngIf='step == prompt.prompts.length - 1') Finish diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss new file mode 100644 index 00000000..34118097 --- /dev/null +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.scss @@ -0,0 +1,9 @@ +:host { + display: flex; + flex-direction: column; + padding: 15px 20px; +} + +.prompt-text { + white-space: pre-wrap; +} diff --git a/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts new file mode 100644 index 00000000..35d274e8 --- /dev/null +++ b/tabby-ssh/src/components/keyboardInteractiveAuthPanel.component.ts @@ -0,0 +1,37 @@ +import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core' +import { KeyboardInteractivePrompt } from '../session/ssh' + + +@Component({ + selector: 'keyboard-interactive-auth-panel', + template: require('./keyboardInteractiveAuthPanel.component.pug'), + styles: [require('./keyboardInteractiveAuthPanel.component.scss')], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class KeyboardInteractiveAuthComponent { + @Input() prompt: KeyboardInteractivePrompt + @Input() step = 0 + @Output() done = new EventEmitter() + @ViewChild('input') input: ElementRef + + isPassword (): boolean { + return this.prompt.prompts[this.step].toLowerCase().includes('password') + } + + previous (): void { + if (this.step > 0) { + this.step-- + } + this.input.nativeElement.focus() + } + + next (): void { + if (this.step === this.prompt.prompts.length - 1) { + this.prompt.respond() + this.done.emit() + return + } + this.step++ + this.input.nativeElement.focus() + } +} diff --git a/tabby-ssh/src/components/sshTab.component.pug b/tabby-ssh/src/components/sshTab.component.pug index a30de9fc..1fb29e24 100644 --- a/tabby-ssh/src/components/sshTab.component.pug +++ b/tabby-ssh/src/components/sshTab.component.pug @@ -45,3 +45,10 @@ sftp-panel.bg-dark( [session]='session', (closed)='sftpPanelVisible = false' ) + +keyboard-interactive-auth-panel.bg-dark( + *ngIf='activeKIPrompt', + [prompt]='activeKIPrompt', + (click)='$event.stopPropagation()', + (done)='activeKIPrompt = null; frontend.focus()' +) diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index 1246d53d..2daa9576 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -5,7 +5,7 @@ import { first } from 'rxjs' import { PartialProfile, Platform, ProfilesService, RecoveryToken } from 'tabby-core' import { BaseTerminalTabComponent } from 'tabby-terminal' import { SSHService } from '../services/ssh.service' -import { SSHSession } from '../session/ssh' +import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh' import { SSHPortForwardingModalComponent } from './sshPortForwardingModal.component' import { SSHProfile } from '../api' @@ -24,6 +24,7 @@ export class SSHTabComponent extends BaseTerminalTabComponent { sftpPanelVisible = false sftpPath = '/' enableToolbar = true + activeKIPrompt: KeyboardInteractivePrompt|null = null private sessionStack: SSHSession[] = [] private recentInputs = '' private reconnectOffered = false @@ -35,6 +36,9 @@ export class SSHTabComponent extends BaseTerminalTabComponent { private profilesService: ProfilesService, ) { super(injector) + this.sessionChanged$.subscribe(() => { + this.activeKIPrompt = null + }) } ngOnInit (): void { @@ -126,6 +130,17 @@ export class SSHTabComponent extends BaseTerminalTabComponent { session.resize(this.size.columns, this.size.rows) }) + this.attachSessionHandler(session.destroyed$, () => { + this.activeKIPrompt = null + }) + + this.attachSessionHandler(session.keyboardInteractivePrompt$, prompt => { + this.activeKIPrompt = prompt + setTimeout(() => { + this.frontend?.scrollToBottom() + }) + }) + try { await this.ssh.connectSession(session) this.stopSpinner() diff --git a/tabby-ssh/src/index.ts b/tabby-ssh/src/index.ts index d5ec9a18..a38c516b 100644 --- a/tabby-ssh/src/index.ts +++ b/tabby-ssh/src/index.ts @@ -15,6 +15,7 @@ import { SSHSettingsTabComponent } from './components/sshSettingsTab.component' import { SSHTabComponent } from './components/sshTab.component' import { SFTPPanelComponent } from './components/sftpPanel.component' import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component' +import { KeyboardInteractiveAuthComponent } from './components/keyboardInteractiveAuthPanel.component' import { SSHConfigProvider } from './config' import { SSHSettingsTabProvider } from './settings' @@ -60,6 +61,7 @@ import { CommonSFTPContextMenu } from './sftpContextMenu' SSHSettingsTabComponent, SSHTabComponent, SFTPPanelComponent, + KeyboardInteractiveAuthComponent, ], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class diff --git a/tabby-ssh/src/services/ssh.service.ts b/tabby-ssh/src/services/ssh.service.ts index 6621ad24..90fb55b2 100644 --- a/tabby-ssh/src/services/ssh.service.ts +++ b/tabby-ssh/src/services/ssh.service.ts @@ -2,13 +2,12 @@ import colors from 'ansi-colors' import * as shellQuote from 'shell-quote' import { Duplex } from 'stream' import { Injectable, NgZone } from '@angular/core' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { Client } from 'ssh2' import { spawn } from 'child_process' import { ChildProcess } from 'node:child_process' import { Subject, Observable } from 'rxjs' -import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService, PromptModalComponent } from 'tabby-core' -import { SSHSession } from '../session/ssh' +import { Logger, LogService, ConfigService, NotificationsService, HostAppService, Platform, PlatformService } from 'tabby-core' +import { KeyboardInteractivePrompt, SSHSession } from '../session/ssh' import { ForwardedPort } from '../session/forwards' import { ALGORITHM_BLACKLIST, SSHAlgorithmType, SSHProfile } from '../api' import { PasswordStorageService } from './passwordStorage.service' @@ -21,7 +20,6 @@ export class SSHService { private constructor ( log: LogService, private zone: NgZone, - private ngbModal: NgbModal, private passwordStorage: PasswordStorageService, private notifications: NotificationsService, private config: ConfigService, @@ -83,22 +81,12 @@ export class SSHService { }) ssh.on('keyboard-interactive', (name, instructions, instructionsLang, prompts, finish) => this.zone.run(async () => { - log(colors.bgBlackBright(' ') + ` Keyboard-interactive auth requested: ${name}`) - this.logger.info('Keyboard-interactive auth:', name, instructions, instructionsLang) - const results: string[] = [] - for (const prompt of prompts) { - const modal = this.ngbModal.open(PromptModalComponent) - modal.componentInstance.prompt = prompt.prompt - modal.componentInstance.password = !prompt.echo - - try { - const result = await modal.result - results.push(result ? result.value : '') - } catch { - results.push('') - } - } - finish(results) + session.emitKeyboardInteractivePrompt(new KeyboardInteractivePrompt( + name, + instructions, + prompts.map(x => x.prompt), + finish, + )) })) ssh.on('greeting', greeting => { diff --git a/tabby-ssh/src/session/ssh.ts b/tabby-ssh/src/session/ssh.ts index 6934d500..dbf88ed7 100644 --- a/tabby-ssh/src/session/ssh.ts +++ b/tabby-ssh/src/session/ssh.ts @@ -27,6 +27,21 @@ interface AuthMethod { contents?: Buffer } +export class KeyboardInteractivePrompt { + responses: string[] = [] + + constructor ( + public name: string, + public instruction: string, + public prompts: string[], + private callback: (_: string[]) => void, + ) { } + + respond (): void { + this.callback(this.responses) + } +} + export class SSHSession extends BaseSession { shell?: ClientChannel ssh: Client @@ -36,12 +51,14 @@ export class SSHSession extends BaseSession { proxyCommandStream: ProxyCommandStream|null = null savedPassword?: string get serviceMessage$ (): Observable { return this.serviceMessage } + get keyboardInteractivePrompt$ (): Observable { return this.keyboardInteractivePrompt } agentPath?: string activePrivateKey: string|null = null private remainingAuthMethods: AuthMethod[] = [] private serviceMessage = new Subject() + private keyboardInteractivePrompt = new Subject() private keychainPasswordUsed = false private passwordStorage: PasswordStorageService @@ -246,6 +263,17 @@ export class SSHSession extends BaseSession { this.logger.info(stripAnsi(msg)) } + emitKeyboardInteractivePrompt (prompt: KeyboardInteractivePrompt): void { + this.logger.info('Keyboard-interactive auth:', prompt.name, prompt.instruction) + this.emitServiceMessage(colors.bgBlackBright(' ') + ` Keyboard-interactive auth requested: ${prompt.name}`) + if (prompt.instruction) { + for (const line of prompt.instruction.split('\n')) { + this.emitServiceMessage(line) + } + } + this.keyboardInteractivePrompt.next(prompt) + } + async handleAuth (methodsLeft?: string[] | null): Promise { this.activePrivateKey = null diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index b38dc5d0..9acac3f9 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/tabby-terminal/src/api/baseTerminalTab.component.ts @@ -723,7 +723,9 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit this.spinner.text = text } this.spinner.setSpinnerString(6) - this.spinner.start() + this.zone.runOutsideAngular(() => { + this.spinner.start() + }) this.spinnerActive = true }