mirror of https://github.com/Eugeny/tabby.git
Compare commits
5 Commits
d05bb0bd07
...
76e2aefc74
| Author | SHA1 | Date |
|---|---|---|
|
|
76e2aefc74 | |
|
|
a6f627bdac | |
|
|
13eab0b378 | |
|
|
6ffb1550f6 | |
|
|
1ae436ffdf |
|
|
@ -37,6 +37,7 @@ export interface SSHProfileOptions extends LoginScriptsOptions {
|
|||
httpProxyPort?: number
|
||||
reuseSession?: boolean
|
||||
input: InputProcessingOptions,
|
||||
totpSecret?: string
|
||||
}
|
||||
|
||||
export enum PortForwardType {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@
|
|||
|
||||
.prompt-text {{prompt.prompts[step].prompt}}
|
||||
|
||||
|
||||
.totp-info.mt-2(*ngIf='isTOTP() && profile.options.totpSecret')
|
||||
.d-flex.align-items-center
|
||||
.totp-code.me-3
|
||||
strong {{totpCode}}
|
||||
.totp-timer
|
||||
small {{totpTimeRemaining}}s remaining
|
||||
.ms-auto
|
||||
small.text-muted Auto-filled
|
||||
|
||||
input.form-control.mt-2(
|
||||
#input,
|
||||
autofocus,
|
||||
|
|
|
|||
|
|
@ -7,3 +7,18 @@
|
|||
.prompt-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.totp-info {
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
border: 1px solid rgba(40, 167, 69, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.totp-code {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.totp-timer {
|
||||
color: #6c757d;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core'
|
||||
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectionStrategy, ChangeDetectorRef, OnInit, OnDestroy } from '@angular/core'
|
||||
import { KeyboardInteractivePrompt } from '../session/ssh'
|
||||
import { SSHProfile } from '../api'
|
||||
import { PasswordStorageService } from '../services/passwordStorage.service'
|
||||
import { TOTPService } from '../services/totp.service'
|
||||
|
||||
@Component({
|
||||
selector: 'keyboard-interactive-auth-panel',
|
||||
|
|
@ -9,7 +10,7 @@ import { PasswordStorageService } from '../services/passwordStorage.service'
|
|||
styleUrls: ['./keyboardInteractiveAuthPanel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class KeyboardInteractiveAuthComponent {
|
||||
export class KeyboardInteractiveAuthComponent implements OnInit, OnDestroy {
|
||||
@Input() profile: SSHProfile
|
||||
@Input() prompt: KeyboardInteractivePrompt
|
||||
@Input() step = 0
|
||||
|
|
@ -17,17 +18,69 @@ export class KeyboardInteractiveAuthComponent {
|
|||
@ViewChild('input') input: ElementRef
|
||||
remember = false
|
||||
|
||||
constructor (private passwordStorage: PasswordStorageService) {}
|
||||
totpCode = ''
|
||||
totpTimeRemaining = 30
|
||||
private totpInterval?: any
|
||||
|
||||
constructor (
|
||||
private passwordStorage: PasswordStorageService,
|
||||
private totpService: TOTPService,
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
ngOnInit (): void {
|
||||
this.updateTOTPIfNeeded()
|
||||
this.startTOTPTimer()
|
||||
this.changeDetector.markForCheck()
|
||||
}
|
||||
|
||||
ngOnDestroy (): void {
|
||||
if (this.totpInterval) {
|
||||
clearInterval(this.totpInterval)
|
||||
}
|
||||
}
|
||||
|
||||
isPassword (): boolean {
|
||||
return this.prompt.isAPasswordPrompt(this.step)
|
||||
}
|
||||
|
||||
isTOTP (): boolean {
|
||||
return this.prompt.isTOTPPrompt(this.step)
|
||||
}
|
||||
|
||||
private updateTOTPIfNeeded (): void {
|
||||
if (this.isTOTP() && this.profile.options.totpSecret) {
|
||||
try {
|
||||
this.totpCode = this.totpService.generateTOTP(this.profile.options.totpSecret)
|
||||
this.prompt.responses[this.step] = this.totpCode
|
||||
this.changeDetector.markForCheck()
|
||||
} catch (error) {
|
||||
console.error('Failed to generate TOTP:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startTOTPTimer (): void {
|
||||
if (this.isTOTP() && this.profile.options.totpSecret) {
|
||||
this.totpInterval = setInterval(() => {
|
||||
this.totpTimeRemaining = this.totpService.getRemainingTime()
|
||||
if (this.totpTimeRemaining === 30) {
|
||||
// 生成新的TOTP代码
|
||||
this.updateTOTPIfNeeded()
|
||||
}
|
||||
this.changeDetector.markForCheck()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
previous (): void {
|
||||
if (this.step > 0) {
|
||||
this.step--
|
||||
this.updateTOTPIfNeeded()
|
||||
this.startTOTPTimer()
|
||||
}
|
||||
this.input.nativeElement.focus()
|
||||
this.changeDetector.markForCheck()
|
||||
}
|
||||
|
||||
next (): void {
|
||||
|
|
@ -41,6 +94,9 @@ export class KeyboardInteractiveAuthComponent {
|
|||
return
|
||||
}
|
||||
this.step++
|
||||
this.updateTOTPIfNeeded()
|
||||
this.startTOTPTimer()
|
||||
this.input.nativeElement.focus()
|
||||
this.changeDetector.markForCheck()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,15 @@ ul.nav-tabs(ngbNav, #nav='ngbNav')
|
|||
[(ngModel)]='profile.options.readyTimeout',
|
||||
)
|
||||
|
||||
.form-line(*ngIf='profile.options.user && (!profile.options.auth || profile.options.auth === "keyboardInteractive" || profile.options.auth === "password")')
|
||||
.header
|
||||
.title TOTP Secret Key
|
||||
input.form-control(
|
||||
type='password',
|
||||
placeholder='Enter your TOTP secret key (Base32)',
|
||||
[(ngModel)]='profile.options.totpSecret'
|
||||
)
|
||||
|
||||
li(ngbNavItem)
|
||||
a(ngbNavLink, translate) Ciphers
|
||||
ng-template(ngbNavContent)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export class SSHProfilesService extends QuickConnectProfileProvider<SSHProfile>
|
|||
httpProxyPort: null,
|
||||
reuseSession: true,
|
||||
input: { backspace: 'backspace' },
|
||||
totpSecret: null,
|
||||
},
|
||||
clearServiceMessagesOnConnect: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { Injectable } from '@angular/core'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TOTPService {
|
||||
/**
|
||||
* 生成TOTP代码
|
||||
* @param secret Base32编码的密钥
|
||||
* @param window 时间窗口(默认30秒)
|
||||
* @param digits 代码位数(默认6位)
|
||||
*/
|
||||
generateTOTP (secret: string, window = 30, digits = 6): string {
|
||||
if (!secret) {
|
||||
throw new Error('TOTP secret is required')
|
||||
}
|
||||
|
||||
try {
|
||||
// 解码Base32密钥
|
||||
const key = this.base32Decode(secret.toUpperCase().replace(/\s/g, ''))
|
||||
|
||||
// 计算时间步长
|
||||
const epoch = Math.floor(Date.now() / 1000)
|
||||
const timeStep = Math.floor(epoch / window)
|
||||
|
||||
// 生成HMAC
|
||||
const hmac = crypto.createHmac('sha1', key as any)
|
||||
const timeBuffer = Buffer.alloc(8)
|
||||
timeBuffer.writeUInt32BE(0, 0)
|
||||
timeBuffer.writeUInt32BE(timeStep, 4)
|
||||
hmac.update(timeBuffer as any)
|
||||
const hash = hmac.digest()
|
||||
|
||||
// 动态截取
|
||||
const offset = hash[hash.length - 1] & 0x0f
|
||||
const binary = (hash[offset] & 0x7f) << 24 |
|
||||
(hash[offset + 1] & 0xff) << 16 |
|
||||
(hash[offset + 2] & 0xff) << 8 |
|
||||
hash[offset + 3] & 0xff
|
||||
|
||||
// 生成代码
|
||||
const otp = binary % Math.pow(10, digits)
|
||||
return otp.toString().padStart(digits, '0')
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate TOTP: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证TOTP密钥格式
|
||||
*/
|
||||
validateSecret (secret: string): boolean {
|
||||
if (!secret) { return false }
|
||||
|
||||
try {
|
||||
// 移除空格并转为大写
|
||||
const cleanSecret = secret.toUpperCase().replace(/\s/g, '')
|
||||
|
||||
// 检查Base32字符
|
||||
const base32Regex = /^[A-Z2-7]+=*$/
|
||||
if (!base32Regex.test(cleanSecret)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 尝试解码
|
||||
this.base32Decode(cleanSecret)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余时间(秒)
|
||||
*/
|
||||
getRemainingTime (window = 30): number {
|
||||
const epoch = Math.floor(Date.now() / 1000)
|
||||
return window - epoch % window
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32解码
|
||||
*/
|
||||
private base32Decode (encoded: string): Uint8Array {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
let bits = 0
|
||||
let value = 0
|
||||
const output: number[] = []
|
||||
|
||||
for (const char of encoded) {
|
||||
if (char === '=') { break }
|
||||
|
||||
const index = alphabet.indexOf(char)
|
||||
if (index === -1) {
|
||||
throw new Error(`Invalid character in Base32: ${char}`)
|
||||
}
|
||||
|
||||
value = value << 5 | index
|
||||
bits += 5
|
||||
|
||||
if (bits >= 8) {
|
||||
output.push(value >>> bits - 8)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(output)
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,16 @@ export class KeyboardInteractivePrompt {
|
|||
return this.prompts[index].prompt.toLowerCase().includes('password') && !this.prompts[index].echo
|
||||
}
|
||||
|
||||
isTOTPPrompt (index: number): boolean {
|
||||
const prompt = this.prompts[index].prompt.toLowerCase()
|
||||
return (prompt.includes('verification code') ||
|
||||
prompt.includes('authenticator') ||
|
||||
prompt.includes('totp') ||
|
||||
prompt.includes('token') ||
|
||||
prompt.includes('code') ||
|
||||
prompt.includes('otp')) && !this.prompts[index].echo
|
||||
}
|
||||
|
||||
respond (): void {
|
||||
this._resolve(this.responses)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue