From b40bada41aabb0206e5188473d165033a5e94021 Mon Sep 17 00:00:00 2001 From: halo Date: Thu, 26 Mar 2026 15:32:33 +0800 Subject: [PATCH 1/7] feat: implement direct SFTP asset authentication and connection handling --- pkg/auth/direct_sftp_asset.go | 238 ++++++++++++++++++++++++++++++++++ pkg/auth/direct_token.go | 118 +++++++++++++++++ pkg/auth/ssh.go | 41 +++++- pkg/handler/direct_handler.go | 12 +- pkg/handler/server_ssh.go | 122 ++++------------- pkg/srvconn/prepared_sftp.go | 25 ++++ pkg/srvconn/sftp_asset.go | 33 +++-- pkg/srvconn/sftpconn.go | 6 +- pkg/srvconn/sftpfile.go | 15 ++- 9 files changed, 490 insertions(+), 120 deletions(-) create mode 100644 pkg/auth/direct_sftp_asset.go create mode 100644 pkg/auth/direct_token.go create mode 100644 pkg/srvconn/prepared_sftp.go diff --git a/pkg/auth/direct_sftp_asset.go b/pkg/auth/direct_sftp_asset.go new file mode 100644 index 000000000..92ef1f7a1 --- /dev/null +++ b/pkg/auth/direct_sftp_asset.go @@ -0,0 +1,238 @@ +package auth + +import ( + "errors" + "strconv" + "strings" + + "github.com/gliderlabs/ssh" + gossh "golang.org/x/crypto/ssh" + + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/config" + "github.com/jumpserver/koko/pkg/logger" + "github.com/jumpserver/koko/pkg/srvconn" +) + +type AuthPhase string + +const ( + AuthPhaseNone AuthPhase = "" + AuthPhaseUserKeyboardInteractive AuthPhase = "user_keyboard_interactive" + AuthPhaseDirectSFTPAssetInteractive AuthPhase = "direct_sftp_asset_keyboard_interactive" +) + +type directSFTPAssetAuthState struct { + Token *model.ConnectToken +} + +func completeSSHAuth(ctx ssh.Context) error { + ctx.SetValue(ContextKeyAuthPhase, AuthPhaseNone) + return prepareDirectSFTPAssetIfNeeded(ctx) +} + +func continueDirectSFTPAssetAuth(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) error { + state, ok := ctx.Value(ContextKeyDirectSFTPAssetAuthState).(*directSFTPAssetAuthState) + if !ok || state == nil || state.Token == nil { + logger.Errorf("SSH conn[%s] direct sftp asset auth state not found", ctx.SessionID()) + return authErr + } + prepared, err := newPreparedDirectSFTP(state.Token, srvconn.SSHClientKeyboardAuth( + buildDirectSFTPChallengeBridge(state.Token, challenger), + )) + if err != nil { + logger.Errorf("SSH conn[%s] direct sftp asset keyboard auth failed: %s", ctx.SessionID(), err) + return authErr + } + storePreparedDirectSFTP(ctx, prepared) + ctx.SetValue(ContextKeyDirectSFTPAssetAuthState, nil) + ctx.SetValue(ContextKeyAuthPhase, AuthPhaseNone) + return nil +} + +func prepareDirectSFTPAssetIfNeeded(ctx ssh.Context) error { + req, ok := ctx.Value(ContextKeyDirectLoginFormat).(*DirectLoginAssetReq) + if !ok || req == nil { + return nil + } + if req.Protocol != model.ProtocolSFTP { + return nil + } + if prepared, ok := ctx.Value(ContextKeyPreparedDirectSFTP).(*srvconn.PreparedDirectSFTP); ok && prepared != nil && prepared.IsValid() { + return nil + } + user, ok := ctx.Value(ContextKeyUser).(*model.User) + if !ok || user == nil || user.ID == "" { + logger.Errorf("SSH conn[%s] direct sftp auth user not found", ctx.SessionID()) + return authErr + } + jmsService, ok := ctx.Value(ContextKeyJMService).(*service.JMService) + if !ok || jmsService == nil { + logger.Errorf("SSH conn[%s] direct sftp auth jms service not found", ctx.SessionID()) + return authErr + } + token, err := BuildDirectConnectToken(ctx, jmsService, user, req) + if err != nil { + logger.Errorf("SSH conn[%s] build direct sftp token failed: %s", ctx.SessionID(), err) + return authErr + } + detector := &directSFTPChallengeDetector{password: token.Account.Secret} + prepared, err := newPreparedDirectSFTP(token, srvconn.SSHClientKeyboardAuth(detector.challenge)) + if err == nil { + storePreparedDirectSFTP(ctx, prepared) + ctx.SetValue(ContextKeyAuthPhase, AuthPhaseNone) + return nil + } + if detector.needInteractive { + ctx.SetValue(ContextKeyDirectSFTPAssetAuthState, &directSFTPAssetAuthState{Token: token}) + ctx.SetValue(ContextKeyAuthPhase, AuthPhaseDirectSFTPAssetInteractive) + return &ssh.PartialSuccessError{Next: ssh.ServerAuthCallbacks{ + KeyboardInteractiveCallback: SSHKeyboardInteractiveAuth, + }} + } + logger.Errorf("SSH conn[%s] prepare direct sftp asset failed: %s", ctx.SessionID(), err) + return authErr +} + +type directSFTPChallengeDetector struct { + password string + needInteractive bool +} + +func (d *directSFTPChallengeDetector) challenge(user, instruction string, questions []string, echos []bool) (answers []string, err error) { + if len(questions) == 0 { + return []string{}, nil + } + answers = make([]string, len(questions)) + for i := range questions { + if isPasswordQuestion(questions[i]) && d.password != "" { + answers[i] = d.password + continue + } + d.needInteractive = true + } + return answers, nil +} + +func buildDirectSFTPChallengeBridge(token *model.ConnectToken, + challenger gossh.KeyboardInteractiveChallenge) gossh.KeyboardInteractiveChallenge { + password := "" + if token != nil && !token.Account.IsSSHKey() { + password = token.Account.Secret + } + displayUser := "" + if token != nil { + displayUser = token.Account.Username + } + return func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { + if len(questions) == 0 { + return []string{}, nil + } + + answers = make([]string, len(questions)) + pendingQuestions := make([]string, 0, len(questions)) + pendingEchos := make([]bool, 0, len(questions)) + pendingIndexes := make([]int, 0, len(questions)) + for i := range questions { + if password != "" && isPasswordQuestion(questions[i]) { + answers[i] = password + continue + } + echo := false + if i < len(echos) { + echo = echos[i] + } + pendingIndexes = append(pendingIndexes, i) + pendingQuestions = append(pendingQuestions, questions[i]) + pendingEchos = append(pendingEchos, echo) + } + if len(pendingIndexes) == 0 { + return answers, nil + } + + challengeUser := user + if challengeUser == "" { + challengeUser = displayUser + } + pendingAnswers, err := challenger(challengeUser, instruction, pendingQuestions, pendingEchos) + if err != nil { + return nil, err + } + if len(pendingAnswers) != len(pendingIndexes) { + return nil, errors.New("direct sftp challenge answers mismatch") + } + for i, answer := range pendingAnswers { + answers[pendingIndexes[i]] = answer + } + return answers, nil + } +} + +func isPasswordQuestion(question string) bool { + normalized := strings.ToLower(strings.TrimSpace(question)) + return strings.Contains(normalized, "password") +} + +func newPreparedDirectSFTP(token *model.ConnectToken, extraOpts ...srvconn.SSHClientOption) (*srvconn.PreparedDirectSFTP, error) { + sshClient, err := srvconn.NewSSHClient(buildSSHClientOptionsFromToken(token, extraOpts...)...) + if err != nil { + return nil, err + } + return &srvconn.PreparedDirectSFTP{ + Token: token, + Client: sshClient, + DisableIdleRecycle: true, + }, nil +} + +func storePreparedDirectSFTP(ctx ssh.Context, prepared *srvconn.PreparedDirectSFTP) { + ctx.SetValue(ContextKeyPreparedDirectSFTP, prepared) + go func(done <-chan struct{}, client *srvconn.SSHClient) { + <-done + _ = client.Close() + }(ctx.Done(), prepared.Client) +} + +func buildSSHClientOptionsFromToken(token *model.ConnectToken, extraOpts ...srvconn.SSHClientOption) []srvconn.SSHClientOption { + asset := token.Asset + account := token.Account + timeout := config.GlobalConfig.SSHTimeout + sshAuthOpts := make([]srvconn.SSHClientOption, 0, 8+len(extraOpts)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientUsername(account.Username)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientHost(asset.Address)) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(asset.ProtocolPort(token.Protocol))) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientTimeout(timeout)) + if account.IsSSHKey() { + if signer, err := gossh.ParsePrivateKey([]byte(account.Secret)); err == nil { + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) + } + } else { + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPassword(account.Secret)) + } + if token.Gateway != nil { + gateway := token.Gateway + proxyArgs := make([]srvconn.SSHClientOptions, 0, 1) + loginAccount := gateway.Account + proxyArg := srvconn.SSHClientOptions{ + Host: gateway.Address, + Port: strconv.Itoa(gateway.Protocols.GetProtocolPort(model.ProtocolSSH)), + Username: loginAccount.Username, + Timeout: timeout, + } + if loginAccount.IsSSHKey() { + proxyArg.PrivateKey = loginAccount.Secret + } else { + proxyArg.Password = loginAccount.Secret + } + proxyArgs = append(proxyArgs, proxyArg) + sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientProxyClient(proxyArgs...)) + } + sshAuthOpts = append(sshAuthOpts, extraOpts...) + return sshAuthOpts +} + +func isPartialSuccess(err error) bool { + var partialErr *ssh.PartialSuccessError + return errors.As(err, &partialErr) +} diff --git a/pkg/auth/direct_token.go b/pkg/auth/direct_token.go new file mode 100644 index 000000000..75b4931ed --- /dev/null +++ b/pkg/auth/direct_token.go @@ -0,0 +1,118 @@ +package auth + +import ( + "errors" + "fmt" + "net" + + "github.com/gliderlabs/ssh" + + "github.com/jumpserver-dev/sdk-go/common" + "github.com/jumpserver-dev/sdk-go/model" + "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/i18n" + "github.com/jumpserver/koko/pkg/logger" +) + +func GetMatchedAssetsByDirectReq(jmsService *service.JMService, user *model.User, req *DirectLoginAssetReq) ([]model.PermAsset, error) { + var getUserPermAssets func() ([]model.PermAsset, error) + if common.IsUUID(req.AssetTarget) { + getUserPermAssets = func() ([]model.PermAsset, error) { + return jmsService.GetUserPermAssetsById(user.ID, req.AssetTarget) + } + } else { + getUserPermAssets = func() ([]model.PermAsset, error) { + return jmsService.GetUserPermAssetsByIP(user.ID, req.AssetTarget) + } + } + i18nLang := i18n.NewLang(user.Language) + assets, err := getUserPermAssets() + if err != nil { + logger.Errorf("Get user %s perm asset failed: %s", user.String(), err) + return nil, fmt.Errorf("match asset failed: %s", i18nLang.T("Core API failed")) + } + if len(assets) == 0 { + logger.Infof("User %s no perm for asset %s", user.String(), req.AssetTarget) + return nil, fmt.Errorf("match asset failed: %s", i18nLang.T("No found asset")) + } + return assets, nil +} + +func GetMatchedAccounts(req *DirectLoginAssetReq, permAssetDetail model.PermAssetDetail) ([]model.PermAccount, error) { + matched := make([]model.PermAccount, 0, len(permAssetDetail.PermedAccounts)) + for i := range permAssetDetail.PermedAccounts { + account := permAssetDetail.PermedAccounts[i] + if account.Username == req.AccountUsername { + matched = append(matched, account) + } + } + return matched, nil +} + +func BuildDirectConnectToken(ctx ssh.Context, jmsService *service.JMService, user *model.User, + req *DirectLoginAssetReq) (*model.ConnectToken, error) { + if req.IsToken() { + return req.ConnectToken, nil + } + selectedAssets, err := GetMatchedAssetsByDirectReq(jmsService, user, req) + if err != nil { + return nil, err + } + i18nLang := i18n.NewLang(user.Language) + if len(selectedAssets) != 1 { + msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), req.AssetTarget) + return nil, errors.New(msg) + } + permAssetDetail, err := jmsService.GetUserPermAssetDetailById(user.ID, selectedAssets[0].ID) + if err != nil { + msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), req.AssetTarget) + logger.Errorf("Get permAssetDetail failed: %s", err) + return nil, errors.New(msg) + } + if !permAssetDetail.SupportProtocol(req.Protocol) { + msg := fmt.Sprintf("not %s asset connection", req.Protocol) + logger.Errorf("Direct Request %s failed: %s", req.Protocol, msg) + return nil, errors.New(msg) + } + + selectAccounts, err := GetMatchedAccounts(req, permAssetDetail) + if err != nil { + return nil, err + } + if len(selectAccounts) != 1 { + msg := fmt.Sprintf(i18nLang.T("Must be unique account for %s"), req.AccountUsername) + logger.Error(msg) + return nil, errors.New(msg) + } + selectAccount := selectAccounts[0] + switch selectAccount.Username { + case model.InputUser, model.DynamicUser, model.ANONUser: + msg := fmt.Sprintf(i18nLang.T("Must be auto login account for %s"), req.AccountUsername) + logger.Error(msg) + return nil, errors.New(msg) + } + remoteAddr, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) + sessReq := &service.SuperConnectTokenReq{ + UserId: user.ID, + AssetId: permAssetDetail.ID, + Account: selectAccount.Alias, + Protocol: req.Protocol, + ConnectMethod: req.Protocol, + RemoteAddr: remoteAddr, + } + tokenInfo, err := jmsService.CreateSuperConnectToken(sessReq) + if err != nil { + msg := err.Error() + if tokenInfo.Detail != "" { + msg = tokenInfo.Detail + } + logger.Errorf("Create super connect token failed: %s", msg) + return nil, err + } + connectToken, err := jmsService.GetConnectTokenInfo(tokenInfo.ID, true) + if err != nil { + logger.Errorf("Create super connect token err: %s", err) + return nil, err + } + return &connectToken, nil +} diff --git a/pkg/auth/ssh.go b/pkg/auth/ssh.go index a1b60a6ce..59397c9b9 100644 --- a/pkg/auth/ssh.go +++ b/pkg/auth/ssh.go @@ -32,19 +32,31 @@ func SSHPasswordAndPublicKeyAuth(jmsService *service.JMService) SSHAuthFunc { }, }} return func(ctx ssh.Context, password, publicKey string) error { + ctx.SetValue(ContextKeyJMService, jmsService) if password == "" && publicKey == "" { logger.Errorf("SSH conn[%s] no password and publickey", ctx.SessionID()) return authErr } remoteAddr, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) username := ctx.User() + action := actionAccepted + var res error if req, ok := parseDirectLoginReq(jmsService, ctx); ok { if req.IsToken() { if req.Authenticate(password) { ctx.SetValue(ContextKeyUser, &req.ConnectToken.User) + res = completeSSHAuth(ctx) + switch { + case res == nil: + action = actionAccepted + case isPartialSuccess(res): + action = actionPartialAccepted + default: + action = actionFailed + } logger.Infof("SSH conn[%s] %s for %s from %s", ctx.SessionID(), - actionAccepted, username, remoteAddr) - return nil + action, username, remoteAddr) + return res } else { logger.Errorf("SSH conn[%s] token %s auth failed", ctx.SessionID(), req.ConnectToken.Id) return authErr @@ -53,8 +65,6 @@ func SSHPasswordAndPublicKeyAuth(jmsService *service.JMService) SSHAuthFunc { username = req.User() } authMethod := "publickey" - action := actionAccepted - var res error if password != "" { authMethod = "password" } @@ -114,10 +124,20 @@ func SSHPasswordAndPublicKeyAuth(jmsService *service.JMService) SSHAuthFunc { res = needPasswordAuthErr ctx.SetValue(ContextKeyCurrentAuth, "password") action = actionPartialAccepted + } else { + res = completeSSHAuth(ctx) + switch { + case res == nil: + case isPartialSuccess(res): + action = actionPartialAccepted + default: + action = actionFailed + } } case authConfirmRequired, authMFARequired: action = actionPartialAccepted ctx.SetValue(ContextKeyAuthStatus, authStatus) + ctx.SetValue(ContextKeyAuthPhase, AuthPhaseUserKeyboardInteractive) res = &ssh.PartialSuccessError{Next: ssh.ServerAuthCallbacks{ KeyboardInteractiveCallback: SSHKeyboardInteractiveAuth, }} @@ -142,6 +162,9 @@ func SSHKeyboardInteractiveAuth(ctx ssh.Context, challenger gossh.KeyboardIntera if value, ok := ctx.Value(ContextKeyAuthFailed).(*bool); ok && *value { return authErr } + if phase, ok := ctx.Value(ContextKeyAuthPhase).(AuthPhase); ok && phase == AuthPhaseDirectSFTPAssetInteractive { + return continueDirectSFTPAssetAuth(ctx, challenger) + } username := GetUsernameFromSSHCtx(ctx) client, ok := ctx.Value(ContextKeyClient).(*UserAuthClient) @@ -165,7 +188,7 @@ func SSHKeyboardInteractiveAuth(ctx ssh.Context, challenger gossh.KeyboardIntera return authErr } if checkAuth != nil && checkAuth(ctx, challenger) { - return nil + return completeSSHAuth(ctx) } return authErr } @@ -185,6 +208,14 @@ const ( ContextKeyCurrentAuth = "CONTEXT_CURRENT_AUTH" ContextKeyAuthCount = "CONTEXT_AUTH_COUNT" + + ContextKeyJMService = "CONTEXT_JMS_SERVICE" + + ContextKeyAuthPhase = "CONTEXT_AUTH_PHASE" + + ContextKeyPreparedDirectSFTP = "CONTEXT_PREPARED_DIRECT_SFTP" + + ContextKeyDirectSFTPAssetAuthState = "CONTEXT_DIRECT_SFTP_ASSET_AUTH_STATE" ) type DirectLoginAssetReq struct { diff --git a/pkg/handler/direct_handler.go b/pkg/handler/direct_handler.go index bce2ff99d..0d4e2607e 100644 --- a/pkg/handler/direct_handler.go +++ b/pkg/handler/direct_handler.go @@ -53,7 +53,8 @@ type directOpt struct { User *model.User terminalConf *model.TerminalConfig - tokenInfo *model.ConnectToken + tokenInfo *model.ConnectToken + preparedDirectSFTP *srvconn.PreparedDirectSFTP sftpMode bool @@ -100,6 +101,12 @@ func DirectConnectToken(tokenInfo *model.ConnectToken) DirectOpt { } } +func DirectPreparedSFTP(prepared *srvconn.PreparedDirectSFTP) DirectOpt { + return func(opts *directOpt) { + opts.preparedDirectSFTP = prepared + } +} + func DirectConnectProtocol(protocol string) DirectOpt { return func(opts *directOpt) { opts.protocol = protocol @@ -188,6 +195,9 @@ func (d *DirectHandler) NewSFTPHandler() *SftpHandler { opts = append(opts, srvconn.WithAccountUsername(d.opts.targetAccount)) } else { opts = append(opts, srvconn.WithConnectToken(d.opts.tokenInfo)) + if d.opts.preparedDirectSFTP != nil && d.opts.preparedDirectSFTP.IsValid() { + opts = append(opts, srvconn.WithPreparedDirectSFTP(d.opts.preparedDirectSFTP)) + } } return &SftpHandler{ UserSftpConn: srvconn.NewUserSftpConn(d.jmsService, opts...), diff --git a/pkg/handler/server_ssh.go b/pkg/handler/server_ssh.go index 0f99c2874..514230c8b 100644 --- a/pkg/handler/server_ssh.go +++ b/pkg/handler/server_ssh.go @@ -64,26 +64,35 @@ func (s *Server) SFTPHandler(sess ssh.Session) { } addr, _, _ := net.SplitHostPort(sess.RemoteAddr().String()) directReq := sess.Context().Value(auth.ContextKeyDirectLoginFormat) + preparedDirectSFTP, _ := sess.Context().Value(auth.ContextKeyPreparedDirectSFTP).(*srvconn.PreparedDirectSFTP) var sftpHandler *SftpHandler termConf := s.GetTerminalConfig() if directRequest, ok2 := directReq.(*auth.DirectLoginAssetReq); ok2 { - selectedAssets, err := s.getMatchedAssetsByDirectReq(currentUser, directRequest) - if err != nil { - logger.Errorf("Get matched assets failed: %s", err) - return - } - if directRequest.IsToken() && config.GetConf().ConnectionTokenReusable { - tokenInfo := directRequest.ConnectToken - key := cache.CreateAddrCacheKey(sess.RemoteAddr(), tokenInfo.Id) - // 缓存 token 信息 - cache.TokenCacheInstance.Save(key, tokenInfo) - defer cache.TokenCacheInstance.Recycle(key) - logger.Infof("SFTP token key %s cached", key) - } opts := buildDirectRequestOptions(currentUser, directRequest) opts = append(opts, DirectConnectSftpMode(true)) - opts = append(opts, DirectAssets(selectedAssets)) opts = append(opts, DirectTerminalConf(&termConf)) + switch { + case preparedDirectSFTP != nil && preparedDirectSFTP.IsValid(): + opts = append(opts, DirectFormatType(FormatToken)) + opts = append(opts, DirectConnectToken(preparedDirectSFTP.Token)) + opts = append(opts, DirectPreparedSFTP(preparedDirectSFTP)) + case directRequest.IsToken(): + if config.GetConf().ConnectionTokenReusable { + tokenInfo := directRequest.ConnectToken + key := cache.CreateAddrCacheKey(sess.RemoteAddr(), tokenInfo.Id) + // 缓存 token 信息 + cache.TokenCacheInstance.Save(key, tokenInfo) + defer cache.TokenCacheInstance.Recycle(key) + logger.Infof("SFTP token key %s cached", key) + } + default: + selectedAssets, err := s.getMatchedAssetsByDirectReq(currentUser, directRequest) + if err != nil { + logger.Errorf("Get matched assets failed: %s", err) + return + } + opts = append(opts, DirectAssets(selectedAssets)) + } directSrv := NewDirectHandler(sess, s.jmsService, opts...) sftpHandler = directSrv.NewSFTPHandler() } else { @@ -701,33 +710,12 @@ func buildSSHClientOptions(asset *model.Asset, account *model.Account, } func (s *Server) getMatchedAssetsByDirectReq(user *model.User, req *auth.DirectLoginAssetReq) ([]model.PermAsset, error) { - var getUserPermAssets func() ([]model.PermAsset, error) - if common.IsUUID(req.AssetTarget) { - getUserPermAssets = func() ([]model.PermAsset, error) { - return s.jmsService.GetUserPermAssetsById(user.ID, req.AssetTarget) - } - } else { - getUserPermAssets = func() ([]model.PermAsset, error) { - return s.jmsService.GetUserPermAssetsByIP(user.ID, req.AssetTarget) - } - } - i18nLang := i18n.NewLang(user.Language) - assets, err := getUserPermAssets() - if err != nil { - logger.Errorf("Get user %s perm asset failed: %s", user.String(), err) - return nil, fmt.Errorf("match asset failed: %s", i18nLang.T("Core API failed")) - } - if len(assets) == 0 { - logger.Infof("User %s no perm for asset %s", user.String(), req.AssetTarget) - return nil, fmt.Errorf("match asset failed: %s", i18nLang.T("No found asset")) - } - return assets, nil + return auth.GetMatchedAssetsByDirectReq(s.jmsService, user, req) } func (s *Server) getMatchedAccounts(user *model.User, req *auth.DirectLoginAssetReq, permAssetDetail model.PermAssetDetail) ([]model.PermAccount, error) { - matched := GetMatchedAccounts(permAssetDetail.PermedAccounts, req.AccountUsername) - return matched, nil + return auth.GetMatchedAccounts(req, permAssetDetail) } func buildDirectRequestOptions(user *model.User, directRequest *auth.DirectLoginAssetReq) []DirectOpt { @@ -743,65 +731,7 @@ func buildDirectRequestOptions(user *model.User, directRequest *auth.DirectLogin } func (s *Server) buildConnectToken(ctx ssh.Context, user *model.User, req *auth.DirectLoginAssetReq) (*model.ConnectToken, error) { - selectedAssets, err := s.getMatchedAssetsByDirectReq(user, req) - if err != nil { - return nil, err - } - i18nLang := i18n.NewLang(user.Language) - if len(selectedAssets) != 1 { - msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), req.AssetTarget) - return nil, errors.New(msg) - } - permAssetDetail, err := s.jmsService.GetUserPermAssetDetailById(user.ID, selectedAssets[0].ID) - if err != nil { - msg := fmt.Sprintf(i18nLang.T("Must be unique asset for %s"), req.AssetTarget) - logger.Errorf("Get permAssetDetail failed: %s", err) - return nil, errors.New(msg) - } - - matchedProtocol := req.Protocol == model.ProtocolSSH - assetSupportedSSH := permAssetDetail.SupportProtocol(model.ProtocolSSH) - if !matchedProtocol || !assetSupportedSSH { - msg := "not ssh asset connection" - logger.Errorf("Direct Request ssh failed: %s", msg) - return nil, errors.New(msg) - } - - selectAccounts, err := s.getMatchedAccounts(user, req, permAssetDetail) - if err != nil { - return nil, err - } - if len(selectAccounts) != 1 { - msg := fmt.Sprintf(i18nLang.T("Must be unique account for %s"), req.AccountUsername) - logger.Error(msg) - return nil, errors.New(msg) - } - selectAccount := selectAccounts[0] - remoteAddr, _, _ := net.SplitHostPort(ctx.RemoteAddr().String()) - sessReq := &service.SuperConnectTokenReq{ - UserId: user.ID, - AssetId: permAssetDetail.ID, - Account: selectAccount.Alias, - Protocol: model.ProtocolSSH, - ConnectMethod: model.ProtocolSSH, - RemoteAddr: remoteAddr, - } - // ssh 非交互式的直连格式,不支持资产的登录复核 - tokenInfo, err := s.jmsService.CreateSuperConnectToken(sessReq) - if err != nil { - msg := err.Error() - if tokenInfo.Detail != "" { - msg = tokenInfo.Detail - } - logger.Errorf("Create super connect token failed: %s", msg) - return nil, err - } - connectToken, err := s.jmsService.GetConnectTokenInfo(tokenInfo.ID, true) - if err != nil { - logger.Errorf("Create super connect token err: %s", err) - return nil, err - } - return &connectToken, nil + return auth.BuildDirectConnectToken(ctx, s.jmsService, user, req) } func (s *Server) buildSSHClient(tokenInfo *model.ConnectToken) (*srvconn.SSHClient, error) { diff --git a/pkg/srvconn/prepared_sftp.go b/pkg/srvconn/prepared_sftp.go new file mode 100644 index 000000000..0ed1cf31c --- /dev/null +++ b/pkg/srvconn/prepared_sftp.go @@ -0,0 +1,25 @@ +package srvconn + +import "github.com/jumpserver-dev/sdk-go/model" + +type PreparedDirectSFTP struct { + Token *model.ConnectToken + Client *SSHClient + DisableIdleRecycle bool +} + +func (p *PreparedDirectSFTP) IsValid() bool { + return p != nil && p.Token != nil && p.Client != nil +} + +func WithPreparedDirectSFTP(prepared *PreparedDirectSFTP) UserSftpOption { + return func(o *userSftpOption) { + o.preparedDirectSFTP = prepared + } +} + +func WithPreparedDirectSFTPAsset(prepared *PreparedDirectSFTP) FolderBuilderOption { + return func(info *folderOptions) { + info.preparedDirectSFTP = prepared + } +} diff --git a/pkg/srvconn/sftp_asset.go b/pkg/srvconn/sftp_asset.go index ad6949ba0..bba722788 100644 --- a/pkg/srvconn/sftp_asset.go +++ b/pkg/srvconn/sftp_asset.go @@ -681,10 +681,18 @@ func (ad *AssetDir) getNewSftpConn(connectToken *model.ConnectToken, return nil, errNoSelectAsset } timeout := config.GlobalConfig.SSHTimeout - sshClient, err := NewSSHClientWithToken(connectToken, timeout) - if err != nil { - logger.Errorf("Get new SSH client err: %s", err) - return nil, err + sshClient := (*SSHClient)(nil) + disableIdleRecycle := false + if prepared := ad.opts.preparedDirectSFTP; prepared != nil && prepared.IsValid() { + sshClient = prepared.Client + disableIdleRecycle = prepared.DisableIdleRecycle + logger.Infof("Reuse prepared direct sftp client %s", sshClient) + } else { + sshClient, err = NewSSHClientWithToken(connectToken, timeout) + if err != nil { + logger.Errorf("Get new SSH client err: %s", err) + return nil, err + } } sess, err := sshClient.AcquireSession() if err != nil { @@ -729,14 +737,15 @@ func (ad *AssetDir) getNewSftpConn(connectToken *model.ConnectToken, } maxIdleInt := ad.opts.terminalCfg.MaxIdleTime conn = &SftpConn{ - sshClient: sshClient, - sshSession: sess, - permAccount: su, - rootDirPath: sftpRoot, - client: sftpClient, - HomeDirPath: homeDirPath, - token: connectToken, - maxIdleTime: time.Duration(maxIdleInt) * time.Minute, + sshClient: sshClient, + sshSession: sess, + permAccount: su, + rootDirPath: sftpRoot, + client: sftpClient, + HomeDirPath: homeDirPath, + token: connectToken, + maxIdleTime: time.Duration(maxIdleInt) * time.Minute, + disableIdleRecycle: disableIdleRecycle, } return conn, nil } diff --git a/pkg/srvconn/sftpconn.go b/pkg/srvconn/sftpconn.go index fdebccb1a..877c0ea1c 100644 --- a/pkg/srvconn/sftpconn.go +++ b/pkg/srvconn/sftpconn.go @@ -395,6 +395,9 @@ func (u *UserSftpConn) generateSubFoldersFromToken(token *model.ConnectToken) ma opts = append(opts, WithToken(token)) opts = append(opts, WithFromType(u.loginFrom)) opts = append(opts, WithTerminalConfig(u.opts.terminalCfg)) + if u.opts.preparedDirectSFTP != nil && u.opts.preparedDirectSFTP.IsValid() { + opts = append(opts, WithPreparedDirectSFTPAsset(u.opts.preparedDirectSFTP)) + } assetDir := NewAssetDir(u.jmsService, u.User, opts...) assetDir.isFromWebTerminal = true assetDir.loadSystemUsers() @@ -459,7 +462,8 @@ type userSftpOption struct { accountUsername string - terminalCfg *model.TerminalConfig + terminalCfg *model.TerminalConfig + preparedDirectSFTP *PreparedDirectSFTP } type UserSftpOption func(*userSftpOption) diff --git a/pkg/srvconn/sftpfile.go b/pkg/srvconn/sftpfile.go index ac4be7d6c..1e74c5dc7 100644 --- a/pkg/srvconn/sftpfile.go +++ b/pkg/srvconn/sftpfile.go @@ -106,7 +106,8 @@ type folderOptions struct { accountUsername string - terminalCfg *model.TerminalConfig + terminalCfg *model.TerminalConfig + preparedDirectSFTP *PreparedDirectSFTP } func WithFolderUsername(username string) FolderBuilderOption { @@ -220,10 +221,11 @@ type SftpConn struct { isClosed bool rootDirPath string - nextExpiredTime time.Time - refs atomic.Int32 - lock sync.Mutex - maxIdleTime time.Duration + nextExpiredTime time.Time + refs atomic.Int32 + lock sync.Mutex + maxIdleTime time.Duration + disableIdleRecycle bool } func (s *SftpConn) IsExpired() bool { @@ -234,6 +236,9 @@ func (s *SftpConn) IsExpired() bool { s.lock.Lock() defer s.lock.Unlock() now := time.Now() + if s.disableIdleRecycle { + return s.token.ExpireAt.IsExpired(now) + } return now.Sub(s.nextExpiredTime) > 0 || s.token.ExpireAt.IsExpired(now) } From ad8771b690917c2e18f2869d4ceeb1c2177dacbd Mon Sep 17 00:00:00 2001 From: halo Date: Thu, 26 Mar 2026 15:53:44 +0800 Subject: [PATCH 2/7] fix: reorder password authentication logic in SSH client options --- pkg/srvconn/ssh.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/srvconn/ssh.go b/pkg/srvconn/ssh.go index a6740f06f..7e6007852 100644 --- a/pkg/srvconn/ssh.go +++ b/pkg/srvconn/ssh.go @@ -56,12 +56,12 @@ func (cfg *SSHClientOptions) AuthMethods() []gossh.AuthMethod { if cfg.PrivateAuth != nil { authMethods = append(authMethods, gossh.PublicKeys(cfg.PrivateAuth)) } - if cfg.Password != "" { - authMethods = append(authMethods, gossh.Password(cfg.Password)) - } if cfg.keyboardAuth != nil { authMethods = append(authMethods, gossh.KeyboardInteractive(cfg.keyboardAuth)) } + if cfg.Password != "" { + authMethods = append(authMethods, gossh.Password(cfg.Password)) + } if cfg.keyboardAuth == nil && cfg.Password != "" { cfg.keyboardAuth = func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { if len(questions) == 0 { From 1bdbbe1308078f998043e944ded2c25e74b91aea Mon Sep 17 00:00:00 2001 From: halo Date: Thu, 26 Mar 2026 16:27:26 +0800 Subject: [PATCH 3/7] feat: implement direct SFTP asset authentication with interactive prompts --- pkg/auth/direct_sftp_asset.go | 213 +++++++++++++++++++++++------ pkg/auth/direct_sftp_asset_test.go | 192 ++++++++++++++++++++++++++ 2 files changed, 362 insertions(+), 43 deletions(-) create mode 100644 pkg/auth/direct_sftp_asset_test.go diff --git a/pkg/auth/direct_sftp_asset.go b/pkg/auth/direct_sftp_asset.go index 92ef1f7a1..f5022b0c7 100644 --- a/pkg/auth/direct_sftp_asset.go +++ b/pkg/auth/direct_sftp_asset.go @@ -4,6 +4,7 @@ import ( "errors" "strconv" "strings" + "sync" "github.com/gliderlabs/ssh" gossh "golang.org/x/crypto/ssh" @@ -24,7 +25,33 @@ const ( ) type directSFTPAssetAuthState struct { - Token *model.ConnectToken + Token *model.ConnectToken + ctxDone <-chan struct{} + sessionID string + + mu sync.Mutex + currentPrompt *directSFTPAssetPrompt + events chan directSFTPAssetAuthEvent +} + +type directSFTPAssetAuthEvent struct { + prompt *directSFTPAssetPrompt + prepared *srvconn.PreparedDirectSFTP + err error +} + +type directSFTPAssetPrompt struct { + User string + Instruction string + Questions []string + Echos []bool + + answerCh chan directSFTPAssetPromptAnswer +} + +type directSFTPAssetPromptAnswer struct { + answers []string + err error } func completeSSHAuth(ctx ssh.Context) error { @@ -38,17 +65,35 @@ func continueDirectSFTPAssetAuth(ctx ssh.Context, challenger gossh.KeyboardInter logger.Errorf("SSH conn[%s] direct sftp asset auth state not found", ctx.SessionID()) return authErr } - prepared, err := newPreparedDirectSFTP(state.Token, srvconn.SSHClientKeyboardAuth( - buildDirectSFTPChallengeBridge(state.Token, challenger), - )) - if err != nil { - logger.Errorf("SSH conn[%s] direct sftp asset keyboard auth failed: %s", ctx.SessionID(), err) - return authErr + for { + prompt := state.currentPendingPrompt() + if prompt == nil { + event := state.waitForNextEvent() + switch { + case event.prompt != nil: + prompt = event.prompt + case event.prepared != nil: + storePreparedDirectSFTP(ctx, event.prepared) + ctx.SetValue(ContextKeyDirectSFTPAssetAuthState, nil) + ctx.SetValue(ContextKeyAuthPhase, AuthPhaseNone) + return nil + default: + logger.Errorf("SSH conn[%s] direct sftp asset keyboard auth failed: %s", + ctx.SessionID(), event.err) + return authErr + } + } + + answers, err := challenger(prompt.User, prompt.Instruction, prompt.Questions, prompt.Echos) + if submitErr := state.submitPromptAnswers(answers, err); submitErr != nil { + logger.Errorf("SSH conn[%s] direct sftp submit client answers failed: %s", ctx.SessionID(), submitErr) + return authErr + } + if err != nil { + logger.Errorf("SSH conn[%s] direct sftp interactive client answer failed: %s", ctx.SessionID(), err) + return authErr + } } - storePreparedDirectSFTP(ctx, prepared) - ctx.SetValue(ContextKeyDirectSFTPAssetAuthState, nil) - ctx.SetValue(ContextKeyAuthPhase, AuthPhaseNone) - return nil } func prepareDirectSFTPAssetIfNeeded(ctx ssh.Context) error { @@ -74,48 +119,33 @@ func prepareDirectSFTPAssetIfNeeded(ctx ssh.Context) error { } token, err := BuildDirectConnectToken(ctx, jmsService, user, req) if err != nil { - logger.Errorf("SSH conn[%s] build direct sftp token failed: %s", ctx.SessionID(), err) + logger.Errorf("SSH conn[%s] build direct sftp token failed: %s", + ctx.SessionID(), err) return authErr } - detector := &directSFTPChallengeDetector{password: token.Account.Secret} - prepared, err := newPreparedDirectSFTP(token, srvconn.SSHClientKeyboardAuth(detector.challenge)) - if err == nil { - storePreparedDirectSFTP(ctx, prepared) + state := newDirectSFTPAssetAuthState(ctx.SessionID(), token, ctx.Done()) + state.startHandshake() + event := state.waitForNextEvent() + switch { + case event.prepared != nil: + storePreparedDirectSFTP(ctx, event.prepared) ctx.SetValue(ContextKeyAuthPhase, AuthPhaseNone) return nil - } - if detector.needInteractive { - ctx.SetValue(ContextKeyDirectSFTPAssetAuthState, &directSFTPAssetAuthState{Token: token}) + case event.prompt != nil: + logger.Infof("SSH conn[%s] direct sftp prepare requires keyboard-interactive", ctx.SessionID()) + ctx.SetValue(ContextKeyDirectSFTPAssetAuthState, state) ctx.SetValue(ContextKeyAuthPhase, AuthPhaseDirectSFTPAssetInteractive) return &ssh.PartialSuccessError{Next: ssh.ServerAuthCallbacks{ KeyboardInteractiveCallback: SSHKeyboardInteractiveAuth, }} + default: + logger.Errorf("SSH conn[%s] prepare direct sftp asset failed: %s", + ctx.SessionID(), event.err) + return authErr } - logger.Errorf("SSH conn[%s] prepare direct sftp asset failed: %s", ctx.SessionID(), err) - return authErr -} - -type directSFTPChallengeDetector struct { - password string - needInteractive bool -} - -func (d *directSFTPChallengeDetector) challenge(user, instruction string, questions []string, echos []bool) (answers []string, err error) { - if len(questions) == 0 { - return []string{}, nil - } - answers = make([]string, len(questions)) - for i := range questions { - if isPasswordQuestion(questions[i]) && d.password != "" { - answers[i] = d.password - continue - } - d.needInteractive = true - } - return answers, nil } -func buildDirectSFTPChallengeBridge(token *model.ConnectToken, +func buildDirectSFTPChallengeBridge(sessionID string, token *model.ConnectToken, challenger gossh.KeyboardInteractiveChallenge) gossh.KeyboardInteractiveChallenge { password := "" if token != nil && !token.Account.IsSSHKey() { @@ -147,6 +177,7 @@ func buildDirectSFTPChallengeBridge(token *model.ConnectToken, pendingQuestions = append(pendingQuestions, questions[i]) pendingEchos = append(pendingEchos, echo) } + autoFilledCount := len(questions) - len(pendingIndexes) if len(pendingIndexes) == 0 { return answers, nil } @@ -155,8 +186,12 @@ func buildDirectSFTPChallengeBridge(token *model.ConnectToken, if challengeUser == "" { challengeUser = displayUser } + logger.Infof("SSH conn[%s] direct sftp interactive forwarding %d/%d questions to client (auto-filled %d)", + sessionID, len(pendingIndexes), len(questions), autoFilledCount) pendingAnswers, err := challenger(challengeUser, instruction, pendingQuestions, pendingEchos) if err != nil { + logger.Errorf("SSH conn[%s] direct sftp interactive client challenge failed: %s", + sessionID, err) return nil, err } if len(pendingAnswers) != len(pendingIndexes) { @@ -169,6 +204,98 @@ func buildDirectSFTPChallengeBridge(token *model.ConnectToken, } } +func newDirectSFTPAssetAuthState(sessionID string, token *model.ConnectToken, ctxDone <-chan struct{}) *directSFTPAssetAuthState { + return &directSFTPAssetAuthState{ + Token: token, + ctxDone: ctxDone, + sessionID: sessionID, + events: make(chan directSFTPAssetAuthEvent, 1), + } +} + +func (s *directSFTPAssetAuthState) startHandshake() { + go func() { + prepared, err := newPreparedDirectSFTP(s.Token, srvconn.SSHClientKeyboardAuth( + buildDirectSFTPChallengeBridge(s.sessionID, s.Token, s.awaitClientAnswers), + )) + select { + case s.events <- directSFTPAssetAuthEvent{prepared: prepared, err: err}: + case <-s.ctxDone: + if prepared != nil && prepared.Client != nil { + _ = prepared.Client.Close() + } + } + }() +} + +func (s *directSFTPAssetAuthState) awaitClientAnswers(user, instruction string, questions []string, echos []bool) ([]string, error) { + prompt := &directSFTPAssetPrompt{ + User: user, + Instruction: instruction, + Questions: append([]string(nil), questions...), + Echos: append([]bool(nil), echos...), + answerCh: make(chan directSFTPAssetPromptAnswer, 1), + } + s.setCurrentPendingPrompt(prompt) + select { + case s.events <- directSFTPAssetAuthEvent{prompt: prompt}: + case <-s.ctxDone: + s.setCurrentPendingPrompt(nil) + return nil, errors.New("direct sftp auth canceled") + } + + select { + case answer := <-prompt.answerCh: + s.setCurrentPendingPrompt(nil) + return answer.answers, answer.err + case <-s.ctxDone: + s.setCurrentPendingPrompt(nil) + return nil, errors.New("direct sftp auth canceled") + } +} + +func (s *directSFTPAssetAuthState) waitForNextEvent() directSFTPAssetAuthEvent { + select { + case event := <-s.events: + return event + case <-s.ctxDone: + return directSFTPAssetAuthEvent{err: errors.New("direct sftp auth canceled")} + } +} + +func (s *directSFTPAssetAuthState) currentPendingPrompt() *directSFTPAssetPrompt { + s.mu.Lock() + defer s.mu.Unlock() + return s.currentPrompt +} + +func (s *directSFTPAssetAuthState) setCurrentPendingPrompt(prompt *directSFTPAssetPrompt) { + s.mu.Lock() + defer s.mu.Unlock() + s.currentPrompt = prompt +} + +func (s *directSFTPAssetAuthState) takeCurrentPendingPrompt() *directSFTPAssetPrompt { + s.mu.Lock() + defer s.mu.Unlock() + prompt := s.currentPrompt + s.currentPrompt = nil + return prompt +} + +func (s *directSFTPAssetAuthState) submitPromptAnswers(answers []string, err error) error { + prompt := s.takeCurrentPendingPrompt() + if prompt == nil { + return errors.New("direct sftp prompt not found") + } + select { + case prompt.answerCh <- directSFTPAssetPromptAnswer{answers: answers, err: err}: + return nil + case <-s.ctxDone: + return errors.New("direct sftp auth canceled") + } +} + func isPasswordQuestion(question string) bool { normalized := strings.ToLower(strings.TrimSpace(question)) return strings.Contains(normalized, "password") @@ -197,7 +324,7 @@ func storePreparedDirectSFTP(ctx ssh.Context, prepared *srvconn.PreparedDirectSF func buildSSHClientOptionsFromToken(token *model.ConnectToken, extraOpts ...srvconn.SSHClientOption) []srvconn.SSHClientOption { asset := token.Asset account := token.Account - timeout := config.GlobalConfig.SSHTimeout + timeout := config.GetConf().SSHTimeout sshAuthOpts := make([]srvconn.SSHClientOption, 0, 8+len(extraOpts)) sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientUsername(account.Username)) sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientHost(asset.Address)) diff --git a/pkg/auth/direct_sftp_asset_test.go b/pkg/auth/direct_sftp_asset_test.go new file mode 100644 index 000000000..b7b45fbd7 --- /dev/null +++ b/pkg/auth/direct_sftp_asset_test.go @@ -0,0 +1,192 @@ +package auth + +import ( + "context" + "errors" + "net" + "reflect" + "sync/atomic" + "testing" + "time" + + "github.com/gliderlabs/ssh" + "github.com/jumpserver-dev/sdk-go/model" + gossh "golang.org/x/crypto/ssh" +) + +func TestBuildDirectSFTPChallengeBridgeAutoFillsPasswordAndForwardsOTP(t *testing.T) { + token := &model.ConnectToken{ + Account: model.Account{ + BaseAccount: model.BaseAccount{ + Username: "halo123", + Secret: "diaosi1234dsa", + }, + }, + } + + var ( + gotUser string + gotInstruction string + gotQuestions []string + gotEchos []bool + ) + bridge := buildDirectSFTPChallengeBridge("session-1", token, func(user, instruction string, questions []string, echos []bool) ([]string, error) { + gotUser = user + gotInstruction = instruction + gotQuestions = append([]string(nil), questions...) + gotEchos = append([]bool(nil), echos...) + return []string{"831248"}, nil + }) + + answers, err := bridge("", "otp required", []string{"Password:", "Verification code:"}, []bool{false, false}) + if err != nil { + t.Fatalf("bridge returned error: %v", err) + } + + if want := []string{"diaosi1234dsa", "831248"}; !reflect.DeepEqual(answers, want) { + t.Fatalf("answers mismatch: got %v want %v", answers, want) + } + if gotUser != "halo123" { + t.Fatalf("challenge user mismatch: got %q want %q", gotUser, "halo123") + } + if gotInstruction != "otp required" { + t.Fatalf("instruction mismatch: got %q want %q", gotInstruction, "otp required") + } + if want := []string{"Verification code:"}; !reflect.DeepEqual(gotQuestions, want) { + t.Fatalf("forwarded questions mismatch: got %v want %v", gotQuestions, want) + } + if want := []bool{false}; !reflect.DeepEqual(gotEchos, want) { + t.Fatalf("forwarded echos mismatch: got %v want %v", gotEchos, want) + } +} + +func TestDirectSFTPAssetAuthStateSubmitPromptAnswersClearsPrompt(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + state := newDirectSFTPAssetAuthState("session-1", &model.ConnectToken{}, ctx.Done()) + resultCh := make(chan directSFTPAssetPromptAnswer, 1) + go func() { + answers, err := state.awaitClientAnswers("halo123", "otp required", []string{"Verification code:"}, []bool{false}) + resultCh <- directSFTPAssetPromptAnswer{answers: answers, err: err} + }() + + event := state.waitForNextEvent() + if event.prompt == nil { + t.Fatal("expected prompt event") + } + if got := state.currentPendingPrompt(); got != event.prompt { + t.Fatal("expected current prompt to match prompt event") + } + + if err := state.submitPromptAnswers([]string{"797131"}, nil); err != nil { + t.Fatalf("submit answers failed: %v", err) + } + if got := state.currentPendingPrompt(); got != nil { + t.Fatal("expected prompt to be cleared immediately after submit") + } + + result := <-resultCh + if result.err != nil { + t.Fatalf("await answers returned error: %v", result.err) + } + if want := []string{"797131"}; !reflect.DeepEqual(result.answers, want) { + t.Fatalf("answer mismatch: got %v want %v", result.answers, want) + } +} + +func TestDirectSFTPAssetAuthStateUsesSingleOutboundHandshake(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer listener.Close() + + var handshakeCount int32 + server := &ssh.Server{ + ConnCallback: func(ctx ssh.Context, conn net.Conn) net.Conn { + atomic.AddInt32(&handshakeCount, 1) + return conn + }, + KeyboardInteractiveHandler: func(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) error { + answers, err := challenger(ctx.User(), "otp required", []string{"Password:", "Verification code:"}, []bool{false, false}) + if err != nil { + return err + } + if len(answers) != 2 { + return errors.New("answers mismatch") + } + if answers[0] != "diaosi1234dsa" { + return errors.New("password mismatch") + } + if answers[1] != "831248" { + return errors.New("otp mismatch") + } + return nil + }, + } + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- server.Serve(listener) + }() + defer func() { + _ = server.Close() + select { + case serveErr := <-serverErrCh: + if serveErr != nil && !errors.Is(serveErr, ssh.ErrServerClosed) { + t.Fatalf("server exited with error: %v", serveErr) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for test ssh server to exit") + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + addr := listener.Addr().(*net.TCPAddr) + state := newDirectSFTPAssetAuthState("session-1", &model.ConnectToken{ + Protocol: model.ProtocolSFTP, + Account: model.Account{ + BaseAccount: model.BaseAccount{ + Username: "halo123", + Secret: "diaosi1234dsa", + }, + }, + Asset: model.Asset{ + Address: "127.0.0.1", + Protocols: []model.Protocol{ + {Name: model.ProtocolSFTP, Port: addr.Port, Public: true}, + }, + }, + }, ctx.Done()) + + state.startHandshake() + event := state.waitForNextEvent() + if event.err != nil { + t.Fatalf("unexpected prompt wait error: %v", event.err) + } + if event.prompt == nil { + t.Fatal("expected interactive prompt event") + } + if want := []string{"Verification code:"}; !reflect.DeepEqual(event.prompt.Questions, want) { + t.Fatalf("prompt questions mismatch: got %v want %v", event.prompt.Questions, want) + } + + if err := state.submitPromptAnswers([]string{"831248"}, nil); err != nil { + t.Fatalf("submit prompt answers failed: %v", err) + } + + event = state.waitForNextEvent() + if event.err != nil { + t.Fatalf("unexpected prepared wait error: %v", event.err) + } + if event.prepared == nil || event.prepared.Client == nil { + t.Fatal("expected prepared direct sftp client") + } + _ = event.prepared.Client.Close() + + if got := atomic.LoadInt32(&handshakeCount); got != 1 { + t.Fatalf("handshake count mismatch: got %d want %d", got, 1) + } +} From 5eb176f11b01b980ef6c78e42b9ad33e1d0f523b Mon Sep 17 00:00:00 2001 From: halo Date: Thu, 26 Mar 2026 18:03:02 +0800 Subject: [PATCH 4/7] perf: streamline SSH client creation and account matching logic --- pkg/auth/direct_sftp_asset.go | 41 +---------------------------------- pkg/auth/direct_token.go | 16 +++++++++----- pkg/handler/direct_handler.go | 16 +++----------- pkg/handler/server_ssh.go | 2 +- pkg/srvconn/sftp_asset.go | 5 +++-- 5 files changed, 18 insertions(+), 62 deletions(-) diff --git a/pkg/auth/direct_sftp_asset.go b/pkg/auth/direct_sftp_asset.go index f5022b0c7..5be5faa2d 100644 --- a/pkg/auth/direct_sftp_asset.go +++ b/pkg/auth/direct_sftp_asset.go @@ -2,7 +2,6 @@ package auth import ( "errors" - "strconv" "strings" "sync" @@ -302,7 +301,7 @@ func isPasswordQuestion(question string) bool { } func newPreparedDirectSFTP(token *model.ConnectToken, extraOpts ...srvconn.SSHClientOption) (*srvconn.PreparedDirectSFTP, error) { - sshClient, err := srvconn.NewSSHClient(buildSSHClientOptionsFromToken(token, extraOpts...)...) + sshClient, err := srvconn.NewSSHClientWithToken(token, config.GetConf().SSHTimeout, extraOpts...) if err != nil { return nil, err } @@ -321,44 +320,6 @@ func storePreparedDirectSFTP(ctx ssh.Context, prepared *srvconn.PreparedDirectSF }(ctx.Done(), prepared.Client) } -func buildSSHClientOptionsFromToken(token *model.ConnectToken, extraOpts ...srvconn.SSHClientOption) []srvconn.SSHClientOption { - asset := token.Asset - account := token.Account - timeout := config.GetConf().SSHTimeout - sshAuthOpts := make([]srvconn.SSHClientOption, 0, 8+len(extraOpts)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientUsername(account.Username)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientHost(asset.Address)) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPort(asset.ProtocolPort(token.Protocol))) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientTimeout(timeout)) - if account.IsSSHKey() { - if signer, err := gossh.ParsePrivateKey([]byte(account.Secret)); err == nil { - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPrivateAuth(signer)) - } - } else { - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientPassword(account.Secret)) - } - if token.Gateway != nil { - gateway := token.Gateway - proxyArgs := make([]srvconn.SSHClientOptions, 0, 1) - loginAccount := gateway.Account - proxyArg := srvconn.SSHClientOptions{ - Host: gateway.Address, - Port: strconv.Itoa(gateway.Protocols.GetProtocolPort(model.ProtocolSSH)), - Username: loginAccount.Username, - Timeout: timeout, - } - if loginAccount.IsSSHKey() { - proxyArg.PrivateKey = loginAccount.Secret - } else { - proxyArg.Password = loginAccount.Secret - } - proxyArgs = append(proxyArgs, proxyArg) - sshAuthOpts = append(sshAuthOpts, srvconn.SSHClientProxyClient(proxyArgs...)) - } - sshAuthOpts = append(sshAuthOpts, extraOpts...) - return sshAuthOpts -} - func isPartialSuccess(err error) bool { var partialErr *ssh.PartialSuccessError return errors.As(err, &partialErr) diff --git a/pkg/auth/direct_token.go b/pkg/auth/direct_token.go index 75b4931ed..8891bd17c 100644 --- a/pkg/auth/direct_token.go +++ b/pkg/auth/direct_token.go @@ -38,15 +38,19 @@ func GetMatchedAssetsByDirectReq(jmsService *service.JMService, user *model.User return assets, nil } -func GetMatchedAccounts(req *DirectLoginAssetReq, permAssetDetail model.PermAssetDetail) ([]model.PermAccount, error) { - matched := make([]model.PermAccount, 0, len(permAssetDetail.PermedAccounts)) - for i := range permAssetDetail.PermedAccounts { - account := permAssetDetail.PermedAccounts[i] - if account.Username == req.AccountUsername { +func MatchAccountsByUsername(accounts []model.PermAccount, username string) []model.PermAccount { + matched := make([]model.PermAccount, 0, len(accounts)) + for i := range accounts { + account := accounts[i] + if account.Username == username { matched = append(matched, account) } } - return matched, nil + return matched +} + +func GetMatchedAccounts(req *DirectLoginAssetReq, permAssetDetail model.PermAssetDetail) ([]model.PermAccount, error) { + return MatchAccountsByUsername(permAssetDetail.PermedAccounts, req.AccountUsername), nil } func BuildDirectConnectToken(ctx ssh.Context, jmsService *service.JMService, user *model.User, diff --git a/pkg/handler/direct_handler.go b/pkg/handler/direct_handler.go index 0d4e2607e..c97fb293a 100644 --- a/pkg/handler/direct_handler.go +++ b/pkg/handler/direct_handler.go @@ -13,6 +13,7 @@ import ( "github.com/jumpserver-dev/sdk-go/model" "github.com/jumpserver-dev/sdk-go/service" + "github.com/jumpserver/koko/pkg/auth" "github.com/jumpserver/koko/pkg/common" "github.com/jumpserver/koko/pkg/i18n" "github.com/jumpserver/koko/pkg/logger" @@ -172,7 +173,7 @@ func (d *DirectHandler) NewSFTPHandler() *SftpHandler { if len(d.opts.assets) == 1 { asset := d.opts.assets[0] if permAssetDetail, err := d.jmsService.GetUserPermAssetDetailById(d.opts.User.ID, asset.ID); err == nil { - matchedAccount := GetMatchedAccounts(permAssetDetail.PermedAccounts, d.opts.targetAccount) + matchedAccount := auth.MatchAccountsByUsername(permAssetDetail.PermedAccounts, d.opts.targetAccount) if len(matchedAccount) == 1 { selectAccount := &matchedAccount[0] req := service.SuperConnectTokenReq{ @@ -405,7 +406,7 @@ func (d *DirectHandler) Proxy(asset model.PermAsset) { utils.IgnoreErrWriteString(d.term, lang.T("Core API failed")) return } - matched := GetMatchedAccounts(permAssetDetail.PermedAccounts, d.opts.targetAccount) + matched := auth.MatchAccountsByUsername(permAssetDetail.PermedAccounts, d.opts.targetAccount) if len(matched) == 0 { msg := fmt.Sprintf(lang.T("not found matched username %s"), d.opts.targetAccount) utils.IgnoreErrWriteString(d.term, msg+"\r\n") @@ -488,14 +489,3 @@ func (d *DirectHandler) Proxy(asset model.PermAsset) { } d.LoginConnectToken(&connectToken) } - -func GetMatchedAccounts(accounts []model.PermAccount, username string) []model.PermAccount { - matched := make([]model.PermAccount, 0, len(accounts)) - for i := range accounts { - account := accounts[i] - if account.Username == username { - matched = append(matched, account) - } - } - return matched -} diff --git a/pkg/handler/server_ssh.go b/pkg/handler/server_ssh.go index 514230c8b..2d4b47ad7 100644 --- a/pkg/handler/server_ssh.go +++ b/pkg/handler/server_ssh.go @@ -715,7 +715,7 @@ func (s *Server) getMatchedAssetsByDirectReq(user *model.User, req *auth.DirectL func (s *Server) getMatchedAccounts(user *model.User, req *auth.DirectLoginAssetReq, permAssetDetail model.PermAssetDetail) ([]model.PermAccount, error) { - return auth.GetMatchedAccounts(req, permAssetDetail) + return auth.MatchAccountsByUsername(permAssetDetail.PermedAccounts, req.AccountUsername), nil } func buildDirectRequestOptions(user *model.User, directRequest *auth.DirectLoginAssetReq) []DirectOpt { diff --git a/pkg/srvconn/sftp_asset.go b/pkg/srvconn/sftp_asset.go index bba722788..c56927262 100644 --- a/pkg/srvconn/sftp_asset.go +++ b/pkg/srvconn/sftp_asset.go @@ -750,13 +750,13 @@ func (ad *AssetDir) getNewSftpConn(connectToken *model.ConnectToken, return conn, nil } -func NewSSHClientWithToken(connectToken *model.ConnectToken, timeout int) (*SSHClient, error) { +func NewSSHClientWithToken(connectToken *model.ConnectToken, timeout int, extraOpts ...SSHClientOption) (*SSHClient, error) { asset := connectToken.Asset account := connectToken.Account username := account.Username protocol := connectToken.Protocol - sshAuthOpts := make([]SSHClientOption, 0, 6) + sshAuthOpts := make([]SSHClientOption, 0, 6+len(extraOpts)) sshAuthOpts = append(sshAuthOpts, SSHClientUsername(username)) sshAuthOpts = append(sshAuthOpts, SSHClientHost(asset.Address)) sshAuthOpts = append(sshAuthOpts, SSHClientPort(asset.ProtocolPort(protocol))) @@ -790,6 +790,7 @@ func NewSSHClientWithToken(connectToken *model.ConnectToken, timeout int) (*SSHC proxyArgs = append(proxyArgs, proxyArg) sshAuthOpts = append(sshAuthOpts, SSHClientProxyClient(proxyArgs...)) } + sshAuthOpts = append(sshAuthOpts, extraOpts...) return NewSSHClient(sshAuthOpts...) } From 689643fe447dc6fe08fb9dfe1cf1d525a8ac040e Mon Sep 17 00:00:00 2001 From: halo Date: Fri, 27 Mar 2026 22:44:52 +0800 Subject: [PATCH 5/7] fix: reorder keyboard authentication logic in SSH client options --- pkg/srvconn/ssh.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/srvconn/ssh.go b/pkg/srvconn/ssh.go index 7e6007852..a6740f06f 100644 --- a/pkg/srvconn/ssh.go +++ b/pkg/srvconn/ssh.go @@ -56,12 +56,12 @@ func (cfg *SSHClientOptions) AuthMethods() []gossh.AuthMethod { if cfg.PrivateAuth != nil { authMethods = append(authMethods, gossh.PublicKeys(cfg.PrivateAuth)) } - if cfg.keyboardAuth != nil { - authMethods = append(authMethods, gossh.KeyboardInteractive(cfg.keyboardAuth)) - } if cfg.Password != "" { authMethods = append(authMethods, gossh.Password(cfg.Password)) } + if cfg.keyboardAuth != nil { + authMethods = append(authMethods, gossh.KeyboardInteractive(cfg.keyboardAuth)) + } if cfg.keyboardAuth == nil && cfg.Password != "" { cfg.keyboardAuth = func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { if len(questions) == 0 { From 0f732f26ff7da449cb85179402e27318cf1e6548 Mon Sep 17 00:00:00 2001 From: halo Date: Sat, 28 Mar 2026 16:28:03 +0800 Subject: [PATCH 6/7] Revert "fix: reorder keyboard authentication logic in SSH client options" This reverts commit 689643fe447dc6fe08fb9dfe1cf1d525a8ac040e. --- pkg/srvconn/ssh.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/srvconn/ssh.go b/pkg/srvconn/ssh.go index a6740f06f..7e6007852 100644 --- a/pkg/srvconn/ssh.go +++ b/pkg/srvconn/ssh.go @@ -56,12 +56,12 @@ func (cfg *SSHClientOptions) AuthMethods() []gossh.AuthMethod { if cfg.PrivateAuth != nil { authMethods = append(authMethods, gossh.PublicKeys(cfg.PrivateAuth)) } - if cfg.Password != "" { - authMethods = append(authMethods, gossh.Password(cfg.Password)) - } if cfg.keyboardAuth != nil { authMethods = append(authMethods, gossh.KeyboardInteractive(cfg.keyboardAuth)) } + if cfg.Password != "" { + authMethods = append(authMethods, gossh.Password(cfg.Password)) + } if cfg.keyboardAuth == nil && cfg.Password != "" { cfg.keyboardAuth = func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { if len(questions) == 0 { From 8f04c348678b5aeec000dd093d5aa2c9fd0cd756 Mon Sep 17 00:00:00 2001 From: halo Date: Tue, 9 Jun 2026 17:38:56 +0800 Subject: [PATCH 7/7] chore: remove direct SFTP asset test file --- pkg/auth/direct_sftp_asset_test.go | 192 ----------------------------- 1 file changed, 192 deletions(-) delete mode 100644 pkg/auth/direct_sftp_asset_test.go diff --git a/pkg/auth/direct_sftp_asset_test.go b/pkg/auth/direct_sftp_asset_test.go deleted file mode 100644 index b7b45fbd7..000000000 --- a/pkg/auth/direct_sftp_asset_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package auth - -import ( - "context" - "errors" - "net" - "reflect" - "sync/atomic" - "testing" - "time" - - "github.com/gliderlabs/ssh" - "github.com/jumpserver-dev/sdk-go/model" - gossh "golang.org/x/crypto/ssh" -) - -func TestBuildDirectSFTPChallengeBridgeAutoFillsPasswordAndForwardsOTP(t *testing.T) { - token := &model.ConnectToken{ - Account: model.Account{ - BaseAccount: model.BaseAccount{ - Username: "halo123", - Secret: "diaosi1234dsa", - }, - }, - } - - var ( - gotUser string - gotInstruction string - gotQuestions []string - gotEchos []bool - ) - bridge := buildDirectSFTPChallengeBridge("session-1", token, func(user, instruction string, questions []string, echos []bool) ([]string, error) { - gotUser = user - gotInstruction = instruction - gotQuestions = append([]string(nil), questions...) - gotEchos = append([]bool(nil), echos...) - return []string{"831248"}, nil - }) - - answers, err := bridge("", "otp required", []string{"Password:", "Verification code:"}, []bool{false, false}) - if err != nil { - t.Fatalf("bridge returned error: %v", err) - } - - if want := []string{"diaosi1234dsa", "831248"}; !reflect.DeepEqual(answers, want) { - t.Fatalf("answers mismatch: got %v want %v", answers, want) - } - if gotUser != "halo123" { - t.Fatalf("challenge user mismatch: got %q want %q", gotUser, "halo123") - } - if gotInstruction != "otp required" { - t.Fatalf("instruction mismatch: got %q want %q", gotInstruction, "otp required") - } - if want := []string{"Verification code:"}; !reflect.DeepEqual(gotQuestions, want) { - t.Fatalf("forwarded questions mismatch: got %v want %v", gotQuestions, want) - } - if want := []bool{false}; !reflect.DeepEqual(gotEchos, want) { - t.Fatalf("forwarded echos mismatch: got %v want %v", gotEchos, want) - } -} - -func TestDirectSFTPAssetAuthStateSubmitPromptAnswersClearsPrompt(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - state := newDirectSFTPAssetAuthState("session-1", &model.ConnectToken{}, ctx.Done()) - resultCh := make(chan directSFTPAssetPromptAnswer, 1) - go func() { - answers, err := state.awaitClientAnswers("halo123", "otp required", []string{"Verification code:"}, []bool{false}) - resultCh <- directSFTPAssetPromptAnswer{answers: answers, err: err} - }() - - event := state.waitForNextEvent() - if event.prompt == nil { - t.Fatal("expected prompt event") - } - if got := state.currentPendingPrompt(); got != event.prompt { - t.Fatal("expected current prompt to match prompt event") - } - - if err := state.submitPromptAnswers([]string{"797131"}, nil); err != nil { - t.Fatalf("submit answers failed: %v", err) - } - if got := state.currentPendingPrompt(); got != nil { - t.Fatal("expected prompt to be cleared immediately after submit") - } - - result := <-resultCh - if result.err != nil { - t.Fatalf("await answers returned error: %v", result.err) - } - if want := []string{"797131"}; !reflect.DeepEqual(result.answers, want) { - t.Fatalf("answer mismatch: got %v want %v", result.answers, want) - } -} - -func TestDirectSFTPAssetAuthStateUsesSingleOutboundHandshake(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen failed: %v", err) - } - defer listener.Close() - - var handshakeCount int32 - server := &ssh.Server{ - ConnCallback: func(ctx ssh.Context, conn net.Conn) net.Conn { - atomic.AddInt32(&handshakeCount, 1) - return conn - }, - KeyboardInteractiveHandler: func(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) error { - answers, err := challenger(ctx.User(), "otp required", []string{"Password:", "Verification code:"}, []bool{false, false}) - if err != nil { - return err - } - if len(answers) != 2 { - return errors.New("answers mismatch") - } - if answers[0] != "diaosi1234dsa" { - return errors.New("password mismatch") - } - if answers[1] != "831248" { - return errors.New("otp mismatch") - } - return nil - }, - } - serverErrCh := make(chan error, 1) - go func() { - serverErrCh <- server.Serve(listener) - }() - defer func() { - _ = server.Close() - select { - case serveErr := <-serverErrCh: - if serveErr != nil && !errors.Is(serveErr, ssh.ErrServerClosed) { - t.Fatalf("server exited with error: %v", serveErr) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for test ssh server to exit") - } - }() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - addr := listener.Addr().(*net.TCPAddr) - state := newDirectSFTPAssetAuthState("session-1", &model.ConnectToken{ - Protocol: model.ProtocolSFTP, - Account: model.Account{ - BaseAccount: model.BaseAccount{ - Username: "halo123", - Secret: "diaosi1234dsa", - }, - }, - Asset: model.Asset{ - Address: "127.0.0.1", - Protocols: []model.Protocol{ - {Name: model.ProtocolSFTP, Port: addr.Port, Public: true}, - }, - }, - }, ctx.Done()) - - state.startHandshake() - event := state.waitForNextEvent() - if event.err != nil { - t.Fatalf("unexpected prompt wait error: %v", event.err) - } - if event.prompt == nil { - t.Fatal("expected interactive prompt event") - } - if want := []string{"Verification code:"}; !reflect.DeepEqual(event.prompt.Questions, want) { - t.Fatalf("prompt questions mismatch: got %v want %v", event.prompt.Questions, want) - } - - if err := state.submitPromptAnswers([]string{"831248"}, nil); err != nil { - t.Fatalf("submit prompt answers failed: %v", err) - } - - event = state.waitForNextEvent() - if event.err != nil { - t.Fatalf("unexpected prepared wait error: %v", event.err) - } - if event.prepared == nil || event.prepared.Client == nil { - t.Fatal("expected prepared direct sftp client") - } - _ = event.prepared.Client.Close() - - if got := atomic.LoadInt32(&handshakeCount); got != 1 { - t.Fatalf("handshake count mismatch: got %d want %d", got, 1) - } -}