ssh: reworked keyboard-interactive auth UI - fixes #4540

This commit is contained in:
Eugene Pankov 2021-09-09 22:43:39 +02:00
parent 8a514fff17
commit d6fa3b02a9
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
9 changed files with 136 additions and 22 deletions

View file

@ -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

View file

@ -0,0 +1,9 @@
:host {
display: flex;
flex-direction: column;
padding: 15px 20px;
}
.prompt-text {
white-space: pre-wrap;
}

View file

@ -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()
}
}

View file

@ -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()'
)

View file

@ -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()

View file

@ -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

View file

@ -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 => {

View file

@ -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<string> { return this.serviceMessage }
get keyboardInteractivePrompt$ (): Observable<KeyboardInteractivePrompt> { return this.keyboardInteractivePrompt }
agentPath?: string
activePrivateKey: string|null = null
private remainingAuthMethods: AuthMethod[] = []
private serviceMessage = new Subject<string>()
private keyboardInteractivePrompt = new Subject<KeyboardInteractivePrompt>()
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<any> {
this.activePrivateKey = null

View file

@ -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
}