@@ -426,6 +426,35 @@ func (l ServerAuthError) Error() string {
426426 return "[" + strings .Join (errs , ", " ) + "]"
427427}
428428
429+ // ServerAuthCallbacks defines server-side authentication callbacks.
430+ type ServerAuthCallbacks struct {
431+ // PasswordCallback behaves like [ServerConfig.PasswordCallback].
432+ PasswordCallback func (conn ConnMetadata , password []byte ) (* Permissions , error )
433+
434+ // PublicKeyCallback behaves like [ServerConfig.PublicKeyCallback].
435+ PublicKeyCallback func (conn ConnMetadata , key PublicKey ) (* Permissions , error )
436+
437+ // KeyboardInteractiveCallback behaves like [ServerConfig.KeyboardInteractiveCallback].
438+ KeyboardInteractiveCallback func (conn ConnMetadata , client KeyboardInteractiveChallenge ) (* Permissions , error )
439+
440+ // GSSAPIWithMICConfig behaves like [ServerConfig.GSSAPIWithMICConfig].
441+ GSSAPIWithMICConfig * GSSAPIWithMICConfig
442+ }
443+
444+ // PartialSuccessError can be returned by any of the [ServerConfig]
445+ // authentication callbacks to indicate to the client that authentication has
446+ // partially succeeded, but further steps are required.
447+ type PartialSuccessError struct {
448+ // Next defines the authentication callbacks to apply to further steps. The
449+ // available methods communicated to the client are based on the non-nil
450+ // ServerAuthCallbacks fields.
451+ Next ServerAuthCallbacks
452+ }
453+
454+ func (p * PartialSuccessError ) Error () string {
455+ return "ssh: authenticated with partial success"
456+ }
457+
429458// ErrNoAuth is the error value returned if no
430459// authentication method has been passed yet. This happens as a normal
431460// part of the authentication loop, since the client first tries
@@ -441,6 +470,15 @@ func (s *connection) serverAuthenticate(config *ServerConfig) (*Permissions, err
441470 authFailures := 0
442471 var authErrs []error
443472 var displayedBanner bool
473+ partialSuccessReturned := false
474+ // Set the initial authentication callbacks from the config. They can be
475+ // changed if a PartialSuccessError is returned.
476+ authConfig := ServerAuthCallbacks {
477+ PasswordCallback : config .PasswordCallback ,
478+ PublicKeyCallback : config .PublicKeyCallback ,
479+ KeyboardInteractiveCallback : config .KeyboardInteractiveCallback ,
480+ GSSAPIWithMICConfig : config .GSSAPIWithMICConfig ,
481+ }
444482
445483userAuthLoop:
446484 for {
@@ -471,6 +509,11 @@ userAuthLoop:
471509 return nil , errors .New ("ssh: client attempted to negotiate for unknown service: " + userAuthReq .Service )
472510 }
473511
512+ if s .user != userAuthReq .User && partialSuccessReturned {
513+ return nil , fmt .Errorf ("ssh: client changed the user after a partial success authentication, previous user %q, current user %q" ,
514+ s .user , userAuthReq .User )
515+ }
516+
474517 s .user = userAuthReq .User
475518
476519 if ! displayedBanner && config .BannerCallback != nil {
@@ -491,20 +534,17 @@ userAuthLoop:
491534
492535 switch userAuthReq .Method {
493536 case "none" :
494- if config .NoClientAuth {
537+ // We don't allow none authentication after a partial success
538+ // response.
539+ if config .NoClientAuth && ! partialSuccessReturned {
495540 if config .NoClientAuthCallback != nil {
496541 perms , authErr = config .NoClientAuthCallback (s )
497542 } else {
498543 authErr = nil
499544 }
500545 }
501-
502- // allow initial attempt of 'none' without penalty
503- if authFailures == 0 {
504- authFailures --
505- }
506546 case "password" :
507- if config .PasswordCallback == nil {
547+ if authConfig .PasswordCallback == nil {
508548 authErr = errors .New ("ssh: password auth not configured" )
509549 break
510550 }
@@ -518,17 +558,17 @@ userAuthLoop:
518558 return nil , parseError (msgUserAuthRequest )
519559 }
520560
521- perms , authErr = config .PasswordCallback (s , password )
561+ perms , authErr = authConfig .PasswordCallback (s , password )
522562 case "keyboard-interactive" :
523- if config .KeyboardInteractiveCallback == nil {
563+ if authConfig .KeyboardInteractiveCallback == nil {
524564 authErr = errors .New ("ssh: keyboard-interactive auth not configured" )
525565 break
526566 }
527567
528568 prompter := & sshClientKeyboardInteractive {s }
529- perms , authErr = config .KeyboardInteractiveCallback (s , prompter .Challenge )
569+ perms , authErr = authConfig .KeyboardInteractiveCallback (s , prompter .Challenge )
530570 case "publickey" :
531- if config .PublicKeyCallback == nil {
571+ if authConfig .PublicKeyCallback == nil {
532572 authErr = errors .New ("ssh: publickey auth not configured" )
533573 break
534574 }
@@ -562,11 +602,18 @@ userAuthLoop:
562602 if ! ok {
563603 candidate .user = s .user
564604 candidate .pubKeyData = pubKeyData
565- candidate .perms , candidate .result = config .PublicKeyCallback (s , pubKey )
566- if candidate .result == nil && candidate .perms != nil && candidate .perms .CriticalOptions != nil && candidate .perms .CriticalOptions [sourceAddressCriticalOption ] != "" {
567- candidate .result = checkSourceAddress (
605+ candidate .perms , candidate .result = authConfig .PublicKeyCallback (s , pubKey )
606+ _ , isPartialSuccessError := candidate .result .(* PartialSuccessError )
607+
608+ if (candidate .result == nil || isPartialSuccessError ) &&
609+ candidate .perms != nil &&
610+ candidate .perms .CriticalOptions != nil &&
611+ candidate .perms .CriticalOptions [sourceAddressCriticalOption ] != "" {
612+ if err := checkSourceAddress (
568613 s .RemoteAddr (),
569- candidate .perms .CriticalOptions [sourceAddressCriticalOption ])
614+ candidate .perms .CriticalOptions [sourceAddressCriticalOption ]); err != nil {
615+ candidate .result = err
616+ }
570617 }
571618 cache .add (candidate )
572619 }
@@ -578,8 +625,8 @@ userAuthLoop:
578625 if len (payload ) > 0 {
579626 return nil , parseError (msgUserAuthRequest )
580627 }
581-
582- if candidate .result == nil {
628+ _ , isPartialSuccessError := candidate . result .( * PartialSuccessError )
629+ if candidate .result == nil || isPartialSuccessError {
583630 okMsg := userAuthPubKeyOkMsg {
584631 Algo : algo ,
585632 PubKey : pubKeyData ,
@@ -629,11 +676,11 @@ userAuthLoop:
629676 perms = candidate .perms
630677 }
631678 case "gssapi-with-mic" :
632- if config .GSSAPIWithMICConfig == nil {
679+ if authConfig .GSSAPIWithMICConfig == nil {
633680 authErr = errors .New ("ssh: gssapi-with-mic auth not configured" )
634681 break
635682 }
636- gssapiConfig := config .GSSAPIWithMICConfig
683+ gssapiConfig := authConfig .GSSAPIWithMICConfig
637684 userAuthRequestGSSAPI , err := parseGSSAPIPayload (userAuthReq .Payload )
638685 if err != nil {
639686 return nil , parseError (msgUserAuthRequest )
@@ -689,49 +736,70 @@ userAuthLoop:
689736 break userAuthLoop
690737 }
691738
692- authFailures ++
693- if config .MaxAuthTries > 0 && authFailures >= config .MaxAuthTries {
694- // If we have hit the max attempts, don't bother sending the
695- // final SSH_MSG_USERAUTH_FAILURE message, since there are
696- // no more authentication methods which can be attempted,
697- // and this message may cause the client to re-attempt
698- // authentication while we send the disconnect message.
699- // Continue, and trigger the disconnect at the start of
700- // the loop.
701- //
702- // The SSH specification is somewhat confusing about this,
703- // RFC 4252 Section 5.1 requires each authentication failure
704- // be responded to with a respective SSH_MSG_USERAUTH_FAILURE
705- // message, but Section 4 says the server should disconnect
706- // after some number of attempts, but it isn't explicit which
707- // message should take precedence (i.e. should there be a failure
708- // message than a disconnect message, or if we are going to
709- // disconnect, should we only send that message.)
710- //
711- // Either way, OpenSSH disconnects immediately after the last
712- // failed authnetication attempt, and given they are typically
713- // considered the golden implementation it seems reasonable
714- // to match that behavior.
715- continue
739+ var failureMsg userAuthFailureMsg
740+
741+ if partialSuccess , ok := authErr .(* PartialSuccessError ); ok {
742+ // After a partial success error we don't allow changing the user
743+ // name and execute the NoClientAuthCallback.
744+ partialSuccessReturned = true
745+
746+ // In case a partial success is returned, the server may send
747+ // a new set of authentication methods.
748+ authConfig = partialSuccess .Next
749+
750+ // Reset pubkey cache, as the new PublicKeyCallback might
751+ // accept a different set of public keys.
752+ cache = pubKeyCache {}
753+
754+ // Send back a partial success message to the user.
755+ failureMsg .PartialSuccess = true
756+ } else {
757+ // Allow initial attempt of 'none' without penalty.
758+ if authFailures > 0 || userAuthReq .Method != "none" {
759+ authFailures ++
760+ }
761+ if config .MaxAuthTries > 0 && authFailures >= config .MaxAuthTries {
762+ // If we have hit the max attempts, don't bother sending the
763+ // final SSH_MSG_USERAUTH_FAILURE message, since there are
764+ // no more authentication methods which can be attempted,
765+ // and this message may cause the client to re-attempt
766+ // authentication while we send the disconnect message.
767+ // Continue, and trigger the disconnect at the start of
768+ // the loop.
769+ //
770+ // The SSH specification is somewhat confusing about this,
771+ // RFC 4252 Section 5.1 requires each authentication failure
772+ // be responded to with a respective SSH_MSG_USERAUTH_FAILURE
773+ // message, but Section 4 says the server should disconnect
774+ // after some number of attempts, but it isn't explicit which
775+ // message should take precedence (i.e. should there be a failure
776+ // message than a disconnect message, or if we are going to
777+ // disconnect, should we only send that message.)
778+ //
779+ // Either way, OpenSSH disconnects immediately after the last
780+ // failed authnetication attempt, and given they are typically
781+ // considered the golden implementation it seems reasonable
782+ // to match that behavior.
783+ continue
784+ }
716785 }
717786
718- var failureMsg userAuthFailureMsg
719- if config .PasswordCallback != nil {
787+ if authConfig .PasswordCallback != nil {
720788 failureMsg .Methods = append (failureMsg .Methods , "password" )
721789 }
722- if config .PublicKeyCallback != nil {
790+ if authConfig .PublicKeyCallback != nil {
723791 failureMsg .Methods = append (failureMsg .Methods , "publickey" )
724792 }
725- if config .KeyboardInteractiveCallback != nil {
793+ if authConfig .KeyboardInteractiveCallback != nil {
726794 failureMsg .Methods = append (failureMsg .Methods , "keyboard-interactive" )
727795 }
728- if config .GSSAPIWithMICConfig != nil && config .GSSAPIWithMICConfig .Server != nil &&
729- config .GSSAPIWithMICConfig .AllowLogin != nil {
796+ if authConfig .GSSAPIWithMICConfig != nil && authConfig .GSSAPIWithMICConfig .Server != nil &&
797+ authConfig .GSSAPIWithMICConfig .AllowLogin != nil {
730798 failureMsg .Methods = append (failureMsg .Methods , "gssapi-with-mic" )
731799 }
732800
733801 if len (failureMsg .Methods ) == 0 {
734- return nil , errors .New ("ssh: no authentication methods configured but NoClientAuth is also false " )
802+ return nil , errors .New ("ssh: no authentication methods available " )
735803 }
736804
737805 if err := s .transport .writePacket (Marshal (& failureMsg )); err != nil {
0 commit comments