diff --git a/auth/metadata.go b/auth/metadata.go new file mode 100644 index 00000000..682d29e4 --- /dev/null +++ b/auth/metadata.go @@ -0,0 +1,77 @@ +package auth + +type ConnectionMetadata struct { + // Metadata is a set of key-value pairs that can be returned and + // either consumed by the configuration server or exposed in the + // backend as environment variables. + Metadata map[string]string `json:"metadata,omitempty"` + // Environment is a set of key-value pairs that will be exposed to the + // container as environment variables + Environment map[string]string `json:"environment,omitempty"` + // Files is a key-value pair of files to be placed inside containers. + // The key represents the path to the file while the value is the + // binary content. + Files map[string][]byte `json:"files,omitempty"` +} + +// Transmit returns a copy of the Metadata containing only the metadata map for transmission to external servers (file and environment maps are considered sensitive by default) +func (m *ConnectionMetadata) Transmit() *ConnectionMetadata { + if m == nil { + return nil + } + return &ConnectionMetadata{ + Metadata: m.Metadata, + } +} + +// Merge merges a metadata object into the current one. In case of duplicated keys the one in the new struct take precedence +func (m *ConnectionMetadata) Merge(newmeta *ConnectionMetadata) { + if m == nil { + return + } + if newmeta == nil { + return + } + for k, v := range newmeta.GetMetadata() { + m.GetMetadata()[k] = v + } + for k, v := range newmeta.GetFiles() { + m.GetFiles()[k] = v + } + for k, v := range newmeta.GetEnvironment() { + m.GetEnvironment()[k] = v + } +} + +// GetMetadata returns an editable metadata map +func (m *ConnectionMetadata) GetMetadata() map[string]string { + if m == nil { + return nil + } + if m.Metadata == nil { + m.Metadata = make(map[string]string) + } + return m.Metadata +} + +// GetFiles returns an editable files map +func (m *ConnectionMetadata) GetFiles() map[string][]byte { + if m == nil { + return nil + } + if m.Files == nil { + m.Files = make(map[string][]byte) + } + return m.Files +} + +// GetFiles returns an editable files map +func (m *ConnectionMetadata) GetEnvironment() map[string]string { + if m == nil { + return nil + } + if m.Environment == nil { + m.Environment = make(map[string]string) + } + return m.Environment +} diff --git a/auth/protocol.go b/auth/protocol.go index e92b0094..b0dcb02e 100644 --- a/auth/protocol.go +++ b/auth/protocol.go @@ -60,6 +60,36 @@ type PublicKeyAuthRequest struct { PublicKey string `json:"publicKey"` } +// AuthorizationRequest is the authorization request used after some +// authentication methods (e.g. kerberos) to determine whether users are +// allowed to access the service +// +// swagger:model AuthorizationRequest +type AuthorizationRequest struct { + // PrincipalUsername is the authenticated username of the user, this + // username has been verified to be correct and to correspond to the + // user that is connecting. + // + // required: true + PrincipalUsername string `json:"principalUsername"` + // LoginUsername is the username the user wishes to log in as. In + // general, the authorization must check that this matches the + // PrincipalUsername, however in some cases it may be beneficial to let + // some users log in as others (e.g. administrators logging in as + // normal users to debug) + // + // required: true + LoginUsername string `json:"loginUsername"` + // RemoteAddress is the address the user is connecting from + // + // required: true + RemoteAddress string `json:"remoteAddress"` + // ConnectionID is an opaque ID to identify the SSH connection + // + // required: true + ConnectionID string `json:"connectionId"` +} + // ResponseBody is a response to authentication requests. // // swagger:model AuthResponseBody @@ -69,11 +99,13 @@ type ResponseBody struct { // required: true Success bool `json:"success"` - // Metadata is a set of key-value pairs that can be returned and either consumed by the configuration server or + // Metadata is a set of key-value pairs that can be returned and either + // consumed by the configuration server or // exposed in the backend as environment variables. + // They can also be used to deploy files in the container // // required: false - Metadata map[string]string `json:"metadata,omitempty"` + Metadata *ConnectionMetadata `json:"metadata,omitempty"` } // Response is the full HTTP authentication response. diff --git a/auth/webhook/client.go b/auth/webhook/client.go index c73371df..da3fe91f 100644 --- a/auth/webhook/client.go +++ b/auth/webhook/client.go @@ -3,6 +3,7 @@ package webhook import ( "net" + auth2 "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/internal/geoip/dummy" @@ -37,7 +38,7 @@ type AuthenticationContext interface { // Error returns the error that happened during the authentication. Error() error // Metadata returns a set of metadata entries that have been obtained during the authentication. - Metadata() map[string]string + Metadata() *auth2.ConnectionMetadata } // NewTestClient creates a new copy of a client usable for testing purposes. diff --git a/auth/webhook/handler_factory.go b/auth/webhook/handler_factory.go new file mode 100644 index 00000000..643b21b5 --- /dev/null +++ b/auth/webhook/handler_factory.go @@ -0,0 +1,13 @@ +package webhook + +import ( + "net/http" + + "github.com/containerssh/libcontainerssh/internal/auth" + "github.com/containerssh/libcontainerssh/log" +) + +// NewHandler creates a HTTP handler that forwards calls to the provided h config request handler. +func NewHandler(h AuthRequestHandler, logger log.Logger) http.Handler { + return auth.NewHandler(h, logger) +} diff --git a/auth/webhook/server_test.go b/auth/webhook/server_test.go index e2dbe90b..b017df66 100644 --- a/auth/webhook/server_test.go +++ b/auth/webhook/server_test.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/auth/webhook" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/log" @@ -23,7 +24,7 @@ func (m *myAuthReqHandler) OnPassword( connectionID string, ) ( success bool, - metadata map[string]string, + metadata *auth.ConnectionMetadata, err error, ) { return true, nil, nil @@ -37,7 +38,21 @@ func (m *myAuthReqHandler) OnPubKey( connectionID string, ) ( success bool, - metadata map[string]string, + metadata *auth.ConnectionMetadata, + err error, +) { + return true, nil, nil +} + +// OnAuthorization will be called after login in non-webhook auth handlers to verify the user is authorized to login +func (m *myAuthReqHandler) OnAuthorization( + principalUsername string, + loginUsername string, + remoteAddress string, + connectionID string, +) ( + success bool, + metadata *auth.ConnectionMetadata, err error, ) { return true, nil, nil diff --git a/cmd/containerssh-testauthconfigserver/main.go b/cmd/containerssh-testauthconfigserver/main.go index c72f0a97..7876b955 100644 --- a/cmd/containerssh-testauthconfigserver/main.go +++ b/cmd/containerssh-testauthconfigserver/main.go @@ -24,6 +24,7 @@ import ( "os/signal" "syscall" + auth2 "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" configWebhook "github.com/containerssh/libcontainerssh/config/webhook" "github.com/containerssh/libcontainerssh/http" @@ -53,7 +54,7 @@ type authHandler struct { // "$ref": "#/responses/AuthResponse" func (a *authHandler) OnPassword(Username string, _ []byte, _ string, _ string) ( bool, - map[string]string, + *auth2.ConnectionMetadata, error, ) { if os.Getenv("CONTAINERSSH_ALLOW_ALL") == "1" || @@ -81,7 +82,7 @@ func (a *authHandler) OnPassword(Username string, _ []byte, _ string, _ string) // "$ref": "#/responses/AuthResponse" func (a *authHandler) OnPubKey(Username string, _ string, _ string, _ string) ( bool, - map[string]string, + *auth2.ConnectionMetadata, error, ) { if Username == "foo" || Username == "busybox" { @@ -90,6 +91,32 @@ func (a *authHandler) OnPubKey(Username string, _ string, _ string, _ string) ( return false, nil, nil } +// swagger:operation POST /authz Authentication authz +// +// Authorization +// +// --- +// parameters: +// - name: request +// in: body +// description: The authorization request +// required: true +// schema: +// "$ref": "#/definitions/AuthorizationRequest" +// responses: +// "200": +// "$ref": "#/responses/AuthResponse" +func (a *authHandler) OnAuthorization(PrincipalUsername string, _ string,_ string, _ string) ( + bool, + *auth2.ConnectionMetadata, + error, +) { + if PrincipalUsername == "foo" || PrincipalUsername == "busybox" { + return true, nil, nil + } + return false, nil, nil +} + type configHandler struct { } diff --git a/config/auth.go b/config/auth.go index cf092c83..01bd3a37 100644 --- a/config/auth.go +++ b/config/auth.go @@ -19,6 +19,14 @@ type AuthConfig struct { // OAuth2 is the configuration for OAuth2 authentication via Keyboard-Interactive. OAuth2 AuthOAuth2ClientConfig `json:"oauth2" yaml:"oauth2"` + // Kerberos is the configuration for Kerberos authentication via GSSAPI and/or password. + Kerberos AuthKerberosClientConfig `json:"kerberos" yaml:"kerberos"` + + // Authz is the authorization configuration. The authorization server + // will receive a webhook after successful user authentication to + // determine whether the specified user has access to the service. + Authz AuthzConfig `json:"authz" yaml:"authz"` + // AuthTimeout is the timeout for the overall authentication call (e.g. verifying a password). If the server // responds with a non-200 response the call will be retried until this timeout is reached. This timeout // should be increased to ~180s for OAuth2 login. @@ -56,14 +64,20 @@ func (c *AuthConfig) Validate() error { err = c.Webhook.Validate() case AuthMethodOAuth2: err = c.OAuth2.Validate() + case AuthMethodKerberos: + err = c.Kerberos.Validate() default: return fmt.Errorf("invalid method: %s", c.Method) } - if err != nil { return fmt.Errorf("invalid %s client configuration (%w)", c.Method, err) } + err = c.Authz.Validate() + if err != nil { + return fmt.Errorf("Invalid authz configuration (%w)", err) + } + return nil } @@ -71,7 +85,7 @@ type AuthMethod string // Validate checks if the provided method is valid or not. func (m AuthMethod) Validate() error { - if m == "webhook" || m == "oauth2" { + if m == "webhook" || m == "oauth2" || m == "kerberos" { return nil } return fmt.Errorf("invalid value for method: %s", m) @@ -83,6 +97,8 @@ const AuthMethodWebhook AuthMethod = "webhook" // AuthMethodOAuth2 authenticates by sending the user to a web interface using the keyboard-interactive facility. const AuthMethodOAuth2 AuthMethod = "oauth2" +const AuthMethodKerberos AuthMethod = "kerberos" + // AuthWebhookClientConfig is the configuration for webhook authentication. type AuthWebhookClientConfig struct { HTTPClientConfiguration `json:",inline" yaml:",inline"` @@ -293,3 +309,60 @@ func (o *AuthOIDCConfig) Validate() error { } return o.HTTPClientConfiguration.Validate() } + +// AuthzConfig is the configuration for the authorization flow +type AuthzConfig struct { + HTTPClientConfiguration `json:",inline" yaml:",inline"` + // Controls whether the authorization flow is enabled. If set to false + // all authenticated users are allowed in the service. + Enable bool `json:"enable" yaml:"enable" default:"false"` +} + +func (k *AuthzConfig) Validate() error { + if k.Enable { + return k.HTTPClientConfiguration.Validate() + } + return nil +} + +// AuthKerberosClientConfig is the configuration for the Kerberos authentication method. +type AuthKerberosClientConfig struct { + // Keytab is the path to the kerberos keytab. If unset it defaults to + // the default of /etc/krb5.keytab. If this file doesn't exist and + // kerberos authentication is requested ContainerSSH will fail to start + Keytab string `json:"keytab" yaml:"keytab" default:"/etc/krb5.keytab"` + // Acceptor is the name of the keytab entry to authenticate against. + // The value of this field needs to be in the form of `service/name`. + // + // The special value of `host` will authenticate clients only against + // the service `host/hostname` where hostname is the system hostname + // The special value of 'any' will authenticate against all keytab + // entries regardless of name + Acceptor string `json:"acceptor" yaml:"acceptor" default:"any"` + // EnforceUsername specifies whether to check that the username of the + // authenticated user matches the SSH username entered. If set to false + // the authorization server must be responsible for ensuring proper + // access control. + // + // WARNING: If authorization is unset and this is set to false all + // authenticated users can log in to any account! + EnforceUsername bool `json:"enforceUsername" yaml:"enforceUsername" default:"true"` + // CredentialCachePath is the path in which the kerberos credentials + // will be written inside the user containers. + CredentialCachePath string `json:"credentialCachePath" yaml:"credentialCachePath" default:"/tmp/krb5cc"` + // AllowPassword controls whether kerberos-based password + // authentication should be allowed. If set to false only GSSAPI + // authentication will be permitted + AllowPassword bool `json:"allowPassword" yaml:"allowPassword" default:"true"` + // ConfigPath is the path of the kerberos configuration file. This is + // only used for password authentication. + ConfigPath string `json:"configPath" yaml:"configPath" default:"/etc/containerssh/krb5.conf"` + // ClockSkew is the maximum allowed clock skew for kerberos messages, + // any messages older than this will be rejected. This value is also + // used for the replay cache. + ClockSkew time.Duration `json:"clockSkew" yaml:"clockSkew" default:"5m"` +} + +func (k *AuthKerberosClientConfig) Validate() error { + return nil +} diff --git a/config/protocol.go b/config/protocol.go index e3d5d01f..47de916d 100644 --- a/config/protocol.go +++ b/config/protocol.go @@ -1,5 +1,9 @@ package config +import ( + "github.com/containerssh/libcontainerssh/auth" +) + // Request is the request object passed from the client to the config server. // // swagger:model Request @@ -23,7 +27,7 @@ type Request struct { // Metadata is the metadata received from the authentication server. // // required: false - Metadata map[string]string `json:"metadata"` + Metadata *auth.ConnectionMetadata `json:"metadata"` } // ResponseBody is the structure representing the JSON HTTP response. diff --git a/e2e_framework_test.go b/e2e_framework_test.go index 3b9f3c76..091ba4e6 100644 --- a/e2e_framework_test.go +++ b/e2e_framework_test.go @@ -18,6 +18,7 @@ import ( containerssh "github.com/containerssh/libcontainerssh" "github.com/containerssh/libcontainerssh/auth/webhook" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" internalssh "github.com/containerssh/libcontainerssh/internal/ssh" "github.com/containerssh/libcontainerssh/internal/test" @@ -404,7 +405,7 @@ type authHandler struct { func (a *authHandler) OnPassword(Username string, Password []byte, RemoteAddress string, ConnectionID string) ( bool, - map[string]string, + *auth.ConnectionMetadata, error, ) { user, err := a.userdb.GetUser(Username) @@ -419,7 +420,7 @@ func (a *authHandler) OnPassword(Username string, Password []byte, RemoteAddress func (a *authHandler) OnPubKey(Username string, PublicKey string, RemoteAddress string, ConnectionID string) ( bool, - map[string]string, + *auth.ConnectionMetadata, error, ) { user, err := a.userdb.GetUser(Username) @@ -434,6 +435,14 @@ func (a *authHandler) OnPubKey(Username string, PublicKey string, RemoteAddress return false, nil, fmt.Errorf("authentication failed") } +func (a *authHandler) OnAuthorization(PrincipalUsername string, LoginUsername string, RemoteAddress string, ConnectionID string) ( + bool, + *auth.ConnectionMetadata, + error, +){ + return true, nil, nil +} + func (c *testContext) authServer(t *testing.T, userdb AuthUserStorage, port int) { srv, err := webhook.NewServer( config.HTTPServerConfiguration{ diff --git a/go.mod b/go.mod index a3e68661..fccd1543 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/aws/aws-sdk-go v1.42.21 + github.com/containerssh/gokrb5/v8 v8.4.3-0.20211214150832-4bf8b91123af github.com/creasty/defaults v1.5.2 github.com/docker/distribution v2.7.1+incompatible github.com/docker/docker v20.10.11+incompatible @@ -13,7 +14,6 @@ require ( github.com/google/uuid v1.3.0 github.com/gorilla/schema v1.2.0 github.com/imdario/mergo v0.3.12 - github.com/jcmturner/gokrb5/v8 v8.4.2 github.com/mattn/go-shellwords v1.0.12 github.com/mitchellh/mapstructure v1.4.3 github.com/opencontainers/image-spec v1.0.2 @@ -22,6 +22,7 @@ require ( github.com/stretchr/testify v1.7.0 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e + gopkg.in/jcmturner/goidentity.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b k8s.io/api v0.23.0 k8s.io/apimachinery v0.23.0 @@ -48,6 +49,7 @@ require ( github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.0.0 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 75344215..d378d808 100644 --- a/go.sum +++ b/go.sum @@ -216,6 +216,8 @@ github.com/containernetworking/plugins v0.9.1/go.mod h1:xP/idU2ldlzN6m4p5LmGiwRD github.com/containers/ocicrypt v1.0.1/go.mod h1:MeJDzk1RJHv89LjsH0Sp5KTY3ZYkjXO/C+bKAeWFIrc= github.com/containers/ocicrypt v1.1.0/go.mod h1:b8AOe0YR67uU8OqfVNcznfFpAzu3rdgUV4GP9qXPfu4= github.com/containers/ocicrypt v1.1.1/go.mod h1:Dm55fwWm1YZAjYRaJ94z2mfZikIyIN4B0oB3dj3jFxY= +github.com/containerssh/gokrb5/v8 v8.4.3-0.20211214150832-4bf8b91123af h1:zX9MRWT3+n/EssD/tlGgD0hiS/nWja2Q6VNL92ExRz8= +github.com/containerssh/gokrb5/v8 v8.4.3-0.20211214150832-4bf8b91123af/go.mod h1:NwSygCr+mQtAFt0TTYQvAzx3CLRlsytGaLtb6BqVDfY= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -420,7 +422,9 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -475,8 +479,6 @@ github.com/jcmturner/gofork v1.0.0 h1:J7uCkflzTEhUZ64xqKnkDxq3kzc96ajM1Gli5ktUem github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= -github.com/jcmturner/gokrb5/v8 v8.4.2 h1:6ZIM6b/JJN0X8UM43ZOM6Z4SJzla+a/u7scXFJzodkA= -github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -1190,6 +1192,8 @@ gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKW gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/jcmturner/goidentity.v3 v3.0.0 h1:1duIyWiTaYvVx3YX2CYtpJbUFd7/UuPYCfgXtQ3VTbI= +gopkg.in/jcmturner/goidentity.v3 v3.0.0/go.mod h1:oG2kH0IvSYNIu80dVAyu/yoefjq1mNfM5bm88whjWx4= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= diff --git a/internal/auditlogintegration/handler_networkconnection.go b/internal/auditlogintegration/handler_networkconnection.go index f6ff7010..e60a4045 100644 --- a/internal/auditlogintegration/handler_networkconnection.go +++ b/internal/auditlogintegration/handler_networkconnection.go @@ -3,6 +3,8 @@ package auditlogintegration import ( "context" + auth2 "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/auditlog/message" "github.com/containerssh/libcontainerssh/internal/auditlog" "github.com/containerssh/libcontainerssh/internal/sshserver" @@ -20,7 +22,7 @@ func (n *networkConnectionHandler) OnAuthKeyboardInteractive( questions sshserver.KeyboardInteractiveQuestions, ) (answers sshserver.KeyboardInteractiveAnswers, err error), clientVersion string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { return n.backend.OnAuthKeyboardInteractive( user, func( @@ -65,7 +67,7 @@ func (n *networkConnectionHandler) OnAuthPassword( username string, password []byte, clientVersion string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { n.audit.OnAuthPassword(username, password) response, metadata, reason = n.backend.OnAuthPassword(username, password, clientVersion) switch response { @@ -89,7 +91,7 @@ func (n *networkConnectionHandler) OnAuthPubKey( clientVersion string, ) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { n.audit.OnAuthPubKey(username, pubKey) @@ -109,6 +111,10 @@ func (n *networkConnectionHandler) OnAuthPubKey( return response, metadata, reason } +func (n *networkConnectionHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return n.backend.OnAuthGSSAPI() +} + func (n *networkConnectionHandler) OnHandshakeFailed(reason error) { n.backend.OnHandshakeFailed(reason) n.audit.OnHandshakeFailed(reason.Error()) @@ -117,7 +123,7 @@ func (n *networkConnectionHandler) OnHandshakeFailed(reason error) { func (n *networkConnectionHandler) OnHandshakeSuccess( username string, clientVersion string, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, ) ( connection sshserver.SSHConnectionHandler, failureReason error, diff --git a/internal/auditlogintegration/integration_test.go b/internal/auditlogintegration/integration_test.go index 2adc70e9..06cc0155 100644 --- a/internal/auditlogintegration/integration_test.go +++ b/internal/auditlogintegration/integration_test.go @@ -11,7 +11,9 @@ import ( "time" "github.com/containerssh/libcontainerssh/auditlog/message" + auth2 "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" + "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/internal/auditlog/codec/binary" "github.com/containerssh/libcontainerssh/internal/auditlog/storage/file" "github.com/containerssh/libcontainerssh/internal/geoip" @@ -209,7 +211,7 @@ func (b *backendHandler) OnAuthKeyboardInteractive( questions sshserver.KeyboardInteractiveQuestions, ) (answers sshserver.KeyboardInteractiveAnswers, err error), _ string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { answers, err := challenge( "Test", sshserver.KeyboardInteractiveQuestions{{ @@ -307,7 +309,7 @@ func (b *backendHandler) OnSessionChannel(_ uint64, _ []byte, session sshserver. func (b *backendHandler) OnAuthPassword(username string, _ []byte, _ string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { if username == "test" { @@ -318,15 +320,19 @@ func (b *backendHandler) OnAuthPassword(username string, _ []byte, _ string) ( func (b *backendHandler) OnAuthPubKey(_ string, _ string, _ string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return sshserver.AuthResponseFailure, nil, nil } +func (b *backendHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return nil +} + func (b *backendHandler) OnHandshakeFailed(_ error) {} -func (b *backendHandler) OnHandshakeSuccess(_ string, _ string, _ map[string]string) ( +func (b *backendHandler) OnHandshakeSuccess(_ string, _ string, _ *auth2.ConnectionMetadata) ( connection sshserver.SSHConnectionHandler, failureReason error, ) { diff --git a/internal/auth/client.go b/internal/auth/client.go index 34913cdf..96902f5c 100644 --- a/internal/auth/client.go +++ b/internal/auth/client.go @@ -1,6 +1,7 @@ package auth import ( + "github.com/containerssh/libcontainerssh/auth" "net" ) @@ -11,7 +12,7 @@ type AuthenticationContext interface { // Error returns the error that happened during the authentication. Error() error // Metadata returns a set of metadata entries that have been obtained during the authentication. - Metadata() map[string]string + Metadata() *auth.ConnectionMetadata // OnDisconnect is called when the client disconnects, or if the authentication fails due to a different reason. OnDisconnect() } @@ -50,6 +51,12 @@ type Client interface { connectionID string, remoteAddr net.IP, ) AuthenticationContext + + // GSSAPIConfig is a method to generate and retrieve a GSSAPIServer interface for GSSAPI authentication + GSSAPIConfig( + connectionId string, + addr net.IP, + ) GSSAPIServer } // KeyboardInteractiveQuestions is a list of questions for keyboard-interactive authentication @@ -69,4 +76,24 @@ type KeyboardInteractiveQuestion struct { type KeyboardInteractiveAnswers struct { // KeyboardInteractiveQuestion is the original question that was answered. Answers map[string]string -} \ No newline at end of file +} + +// GSSAPIServer is the interface for GSSAPI authentication +type GSSAPIServer interface { + AuthenticationContext + + // AcceptSecContext is the GSSAPI function to verify the tokens + AcceptSecContext(token []byte) (outputToken []byte, srcName string, needContinue bool, err error) + + // VerifyMIC is the GSSAPI function to verify the MIC (Message Integrity Code) + VerifyMIC(micField []byte, micToken []byte) error + + // DeleteSecContext is the GSSAPI function to free all resources bound as part of an authentication attempt + DeleteSecContext() error + + // AllowLogin is the authorization function. The username parameter + // specifies the user that the authenticated user is trying to log in + // as. Note! This is different from the gossh AllowLogin function in + // which the username field is the authenticated username. + AllowLogin(username string) error +} diff --git a/internal/auth/client_authz.go b/internal/auth/client_authz.go new file mode 100644 index 00000000..5932f31d --- /dev/null +++ b/internal/auth/client_authz.go @@ -0,0 +1,316 @@ +package auth + +import ( + "context" + "errors" + "net" + "time" + + "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/http" + "github.com/containerssh/libcontainerssh/internal/metrics" + "github.com/containerssh/libcontainerssh/log" + "github.com/containerssh/libcontainerssh/message" +) + +type httpAuthzClient struct { + backend Client + timeout time.Duration + httpClient http.Client + endpoint string + logger log.Logger + metrics metrics.Collector + backendRequestsMetric metrics.SimpleCounter + backendFailureMetric metrics.SimpleCounter + authSuccessMetric metrics.GeoCounter + authFailureMetric metrics.GeoCounter +} + +type authzContext struct { + client *httpAuthzClient + backend GSSAPIServer + princUsername string + connectionID string + remoteAddr net.IP + + success bool + err error +} + +func (h authzContext) Success() bool { + if h.success { + return true + } + return h.backend.Success() +} + +func (h authzContext) Error() error { + if h.err != nil { + return h.err + } + return h.backend.Error() +} + +func (h authzContext) Metadata() *auth.ConnectionMetadata { + return h.backend.Metadata() +} + +func (h authzContext) OnDisconnect() { +} + +func (client *httpAuthzClient) KeyboardInteractive( + username string, + challenge func(instruction string, questions KeyboardInteractiveQuestions) ( + answers KeyboardInteractiveAnswers, + err error, + ), + connectionID string, + remoteAddr net.IP, +) AuthenticationContext { + auth := client.backend.KeyboardInteractive(username, challenge, connectionID, remoteAddr) + if !auth.Success() { + return auth + } + + return client.processAuthzWithRetry(username, username, connectionID, remoteAddr) +} + +func (client *httpAuthzClient) Password( + username string, + password []byte, + connectionID string, + remoteAddr net.IP, +) AuthenticationContext { + auth := client.backend.Password(username, password, connectionID, remoteAddr) + if !auth.Success() { + return auth + } + + return client.processAuthzWithRetry(username, username, connectionID, remoteAddr) +} + +func (client *httpAuthzClient) PubKey( + username string, + pubKey string, + connectionID string, + remoteAddr net.IP, +) AuthenticationContext { + auth := client.backend.PubKey(username, pubKey, connectionID, remoteAddr) + if !auth.Success() { + return auth + } + + return client.processAuthzWithRetry(username, username, connectionID, remoteAddr) +} + +func (s *authzContext) AcceptSecContext(token []byte) (outputToken []byte, srcName string, needContinue bool, err error) { + outputToken, srcName, needContinue, err = s.backend.AcceptSecContext(token) + s.princUsername = srcName + return outputToken, srcName, needContinue, err +} + + +func (s *authzContext) VerifyMIC(micField []byte, micToken []byte) error { + return s.backend.VerifyMIC(micField, micToken) +} + +func (s *authzContext) DeleteSecContext() error { + return s.backend.DeleteSecContext() +} + + +func (s *authzContext) AllowLogin(username string) error { + err := s.backend.AllowLogin(username) + if err != nil { + return err + } + + authz := s.client.processAuthzWithRetry(s.princUsername, username, s.connectionID, s.remoteAddr) + if authz.Error() != nil { + return authz.Error() + } + if !authz.Success() { + return message.NewMessage( + message.EAuthzFailed, + "Authorization failed for principal %s trying to log in as %s", + s.princUsername, + username, + ) + } + return nil +} + +func (client *httpAuthzClient) GSSAPIConfig(connectionId string, addr net.IP) GSSAPIServer { + backend := client.backend.GSSAPIConfig(connectionId, addr) + if backend == nil { + return nil + } + return &authzContext{ + client: client, + backend: backend, + connectionID: connectionId, + remoteAddr: addr, + } +} + +func (client *httpAuthzClient) processAuthzWithRetry( + princUsername string, + loginUsername string, + connectionID string, + remoteAddr net.IP, +) AuthenticationContext { + authRequest := auth.AuthorizationRequest{ + PrincipalUsername: princUsername, + LoginUsername: loginUsername, + RemoteAddress: remoteAddr.String(), + ConnectionID: connectionID, + } + + ctx, cancel := context.WithTimeout(context.Background(), client.timeout) + defer cancel() + var lastError error + var lastLabels []metrics.MetricLabel + logger := client.logger. + WithLabel("connectionId", connectionID). + WithLabel("princUsername", princUsername). + WithLabel("loginUsername", loginUsername). + WithLabel("url", client.endpoint) +loop: + for { + lastLabels = []metrics.MetricLabel{ + metrics.Label("authtype", "authorization"), + } + if lastError != nil { + lastLabels = append( + lastLabels, + metrics.Label("retry", "1"), + ) + } else { + lastLabels = append( + lastLabels, + metrics.Label("retry", "0"), + ) + } + client.logAttempt(logger, lastLabels) + + authResponse := &auth.ResponseBody{} + lastError = client.authServerRequest(client.endpoint, authRequest, authResponse) + if lastError == nil { + client.logAuthResponse(logger, authResponse, lastLabels, remoteAddr) + return &httpAuthContext{authResponse.Success, authResponse.Metadata, nil} + } + reason := client.getReason(lastError) + lastLabels = append(lastLabels, metrics.Label("reason", reason)) + client.logTemporaryFailure(logger, lastError, reason, lastLabels) + select { + case <-ctx.Done(): + break loop + case <-time.After(10 * time.Second): + } + } + return client.logAndReturnPermanentFailure(lastError, lastLabels, logger) +} + +func (client *httpAuthzClient) logAttempt(logger log.Logger, lastLabels []metrics.MetricLabel) { + logger.Debug( + message.NewMessage( + message.MAuth, + "Authorization request", + ), + ) + client.backendRequestsMetric.Increment(lastLabels...) +} + +func (client *httpAuthzClient) logAndReturnPermanentFailure( + lastError error, + lastLabels []metrics.MetricLabel, + logger log.Logger, +) AuthenticationContext { + err := message.Wrap( + lastError, + message.EAuthBackendError, + "Backend request for authorization failed, giving up", + ) + client.backendFailureMetric.Increment( + append( + []metrics.MetricLabel{ + metrics.Label("type", "hard"), + }, lastLabels..., + )..., + ) + logger.Error(err) + return &httpAuthContext{false, nil, err} +} + +func (client *httpAuthzClient) logTemporaryFailure( + logger log.Logger, + lastError error, + reason string, + lastLabels []metrics.MetricLabel, +) { + logger.Debug( + message.Wrap( + lastError, + message.EAuthBackendError, + "authorization request to backend failed, retrying in 10 seconds", + ). + Label("reason", reason), + ) + client.backendFailureMetric.Increment( + append( + []metrics.MetricLabel{ + metrics.Label("type", "soft"), + }, lastLabels..., + )..., + ) +} + +func (client *httpAuthzClient) getReason(lastError error) string { + var typedErr message.Message + reason := message.EUnknownError + if errors.As(lastError, &typedErr) { + reason = typedErr.Code() + } + return reason +} + +func (client *httpAuthzClient) logAuthResponse( + logger log.Logger, + authResponse *auth.ResponseBody, + labels []metrics.MetricLabel, + remoteAddr net.IP, +) { + if authResponse.Success { + logger.Debug( + message.NewMessage( + message.MAuthSuccessful, + "authorization successful", + ), + ) + client.authSuccessMetric.Increment(remoteAddr, labels...) + } else { + logger.Debug( + message.NewMessage( + message.EAuthFailed, + "authorization failed", + ), + ) + client.authFailureMetric.Increment(remoteAddr, labels...) + } +} + +func (client *httpAuthzClient) authServerRequest(endpoint string, requestObject interface{}, response interface{}) error { + statusCode, err := client.httpClient.Post(endpoint, requestObject, response) + if err != nil { + return err + } + if statusCode != 200 { + return message.UserMessage( + message.EAuthInvalidStatus, + "Cannot authenticate at this time.", + "auth server responded with an invalid status code: %d", + statusCode, + ) + } + return nil +} diff --git a/internal/auth/client_authz_factory.go b/internal/auth/client_authz_factory.go new file mode 100644 index 00000000..9c3265fb --- /dev/null +++ b/internal/auth/client_authz_factory.go @@ -0,0 +1,76 @@ +package auth + +import ( + "fmt" + + "github.com/containerssh/libcontainerssh/config" + "github.com/containerssh/libcontainerssh/http" + "github.com/containerssh/libcontainerssh/internal/metrics" + "github.com/containerssh/libcontainerssh/log" +) + +// NewHttpAuthClient creates a new HTTP authentication client +//goland:noinspection GoUnusedExportedFunction +func NewHttpAuthzClient( + backend Client, + cfg config.AuthConfig, + logger log.Logger, + metrics metrics.Collector, +) (Client, error) { + if !cfg.Authz.Enable{ + return nil, fmt.Errorf("authorization is disabled") + } + if err := cfg.Validate(); err != nil { + return nil, err + } + + httpClient, err := http.NewClient( + cfg.Authz.HTTPClientConfiguration, + logger, + ) + if err != nil { + return nil, err + } + + backendRequestsMetric, backendFailureMetric, authSuccessMetric, authFailureMetric := createAuthzMetrics(metrics) + return &httpAuthzClient{ + backend: backend, + timeout: cfg.AuthTimeout, + httpClient: httpClient, + logger: logger, + metrics: metrics, + backendRequestsMetric: backendRequestsMetric, + backendFailureMetric: backendFailureMetric, + authSuccessMetric: authSuccessMetric, + authFailureMetric: authFailureMetric, + }, nil +} + +func createAuthzMetrics(metrics metrics.Collector) ( + metrics.Counter, + metrics.Counter, + metrics.GeoCounter, + metrics.GeoCounter, +) { + backendRequestsMetric := metrics.MustCreateCounter( + MetricNameAuthBackendRequests, + "requests", + "The number of requests sent to the configuration server.", + ) + backendFailureMetric := metrics.MustCreateCounter( + MetricNameAuthBackendFailure, + "requests", + "The number of request failures to the configuration server.", + ) + authSuccessMetric := metrics.MustCreateCounterGeo( + MetricNameAuthSuccess, + "requests", + "The number of successful authorizations.", + ) + authFailureMetric := metrics.MustCreateCounterGeo( + MetricNameAuthFailure, + "requests", + "The number of failed authorizations.", + ) + return backendRequestsMetric, backendFailureMetric, authSuccessMetric, authFailureMetric +} diff --git a/internal/auth/client_factory.go b/internal/auth/client_factory.go index 35248589..70354eaa 100644 --- a/internal/auth/client_factory.go +++ b/internal/auth/client_factory.go @@ -17,13 +17,25 @@ func NewClient( if err := cfg.Validate(); err != nil { return nil, nil, err } + var client Client + var service service.Service + var err error switch cfg.Method { case config.AuthMethodWebhook: - client, err := NewHttpAuthClient(cfg, logger, metrics) - return client, nil, err + client, err = NewHttpAuthClient(cfg, logger, metrics) case config.AuthMethodOAuth2: - return NewOAuth2Client(cfg, logger, metrics) + client, service, err = NewOAuth2Client(cfg, logger, metrics) + case config.AuthMethodKerberos: + client, err = NewKerberosClient(cfg, logger, metrics) default: return nil, nil, fmt.Errorf("unsupported method: %s", cfg.Method) } + if err != nil { + return nil, nil, err + } + + if cfg.Authz.Enable { + client, err = NewHttpAuthzClient(client, cfg, logger, metrics) + } + return client, service, err } diff --git a/internal/auth/client_http.go b/internal/auth/client_http.go index a860c381..ea980724 100644 --- a/internal/auth/client_http.go +++ b/internal/auth/client_http.go @@ -31,7 +31,7 @@ type httpAuthClient struct { type httpAuthContext struct { success bool - metadata map[string]string + metadata *auth.ConnectionMetadata err error } @@ -43,7 +43,7 @@ func (h httpAuthContext) Error() error { return h.err } -func (h httpAuthContext) Metadata() map[string]string { +func (h httpAuthContext) Metadata() *auth.ConnectionMetadata { return h.metadata } @@ -124,6 +124,10 @@ func (client *httpAuthClient) PubKey( return client.processAuthWithRetry(username, method, authType, connectionID, url, authRequest, remoteAddr) } +func (client *httpAuthClient) GSSAPIConfig(connectionId string, addr net.IP) GSSAPIServer { + return nil +} + func (client *httpAuthClient) processAuthWithRetry( username string, method string, diff --git a/internal/auth/client_kerberos.go b/internal/auth/client_kerberos.go new file mode 100644 index 00000000..e5397cd6 --- /dev/null +++ b/internal/auth/client_kerberos.go @@ -0,0 +1,409 @@ +package auth + +import ( + "fmt" + "net" + + "github.com/containerssh/libcontainerssh/internal/ssh" + "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/log" + "github.com/containerssh/libcontainerssh/message" + "github.com/containerssh/libcontainerssh/config" + internalSsh "github.com/containerssh/libcontainerssh/internal/ssh" + + "github.com/containerssh/gokrb5/v8/gssapi" + "gopkg.in/jcmturner/goidentity.v3" + "github.com/containerssh/gokrb5/v8/keytab" + "github.com/containerssh/gokrb5/v8/credentials" + "github.com/containerssh/gokrb5/v8/client" + krbconf "github.com/containerssh/gokrb5/v8/config" + krbmsg "github.com/containerssh/gokrb5/v8/messages" + krb5svc "github.com/containerssh/gokrb5/v8/service" + "github.com/containerssh/gokrb5/v8/spnego" + "github.com/containerssh/gokrb5/v8/types" + "github.com/containerssh/gokrb5/v8/iana/keyusage" + "github.com/containerssh/gokrb5/v8/asn1tools" +) + +type kerberosAuthContext struct { + client *kerberosAuthClient + + connectionId string + remoteAddr net.IP + + key types.EncryptionKey + principalUsername string + loginUsername string + credentials []byte + + metadata *auth.ConnectionMetadata + success bool + err error +} + +type kerberosAuthClient struct { + logger log.Logger + config config.AuthKerberosClientConfig + keytab *keytab.Keytab + clientConf *krbconf.Config + acceptor *types.PrincipalName +} + +func (k kerberosAuthContext) Success() bool { + return k.success +} + +func (k kerberosAuthContext) Error() error { + if !k.success && k.err == nil { + k.err = fmt.Errorf("Unknown error happened during kerberos authentication") + } + return k.err +} + +func (k kerberosAuthContext) Metadata() *auth.ConnectionMetadata { + if k.metadata == nil { + k.metadata = &auth.ConnectionMetadata{} + } + if k.client == nil { + return nil + } + if k.client.config.CredentialCachePath != "" && k.credentials != nil { + path := k.client.config.CredentialCachePath + k.metadata.GetFiles()[path] = k.credentials + k.metadata.GetEnvironment()["KRB5CCNAME"] = "FILE:" + k.client.config.CredentialCachePath + } + return k.metadata +} + +func (k kerberosAuthContext) OnDisconnect() { + +} + +func (c *kerberosAuthClient) KeyboardInteractive( + _ string, + _ func(instruction string, questions KeyboardInteractiveQuestions) ( + answers KeyboardInteractiveAnswers, + err error, + ), + _ string, + _ net.IP, +) AuthenticationContext { + return &kerberosAuthContext{ + client: c, + err: message.UserMessage( + message.EAuthUnsupported, + "Keyboard-interactive authentication is not available.", + "Kerberos authentication doesn't support keyboard-interactive.", + ), + } +} + +func (c *kerberosAuthClient) Password( + username string, + password []byte, + connectionID string, + remoteAddr net.IP, +) AuthenticationContext { + if !c.config.AllowPassword { + return &kerberosAuthContext{ + client: c, + success: false, + err: fmt.Errorf("Password authentication disabled for kerberos backend"), + } + } + + cl := client.NewWithPassword( + username, + c.clientConf.LibDefaults.DefaultRealm, + string(password), + c.clientConf, + client.DisablePAFXFAST(true), //Breaks Active-Directory + ) + + err := cl.Login() + if err != nil { + return kerberosAuthContext{ + client: c, + success: false, + err: err, + } + } + + ccache, err := cl.GetCCache() + if err != nil { + return kerberosAuthContext{ + client: c, + success: false, + err: err, + } + } + + ccacheMar, err := ccache.Marshal() + if err != nil { + return kerberosAuthContext{ + client: c, + success: false, + err: err, + } + } + + ctx := kerberosAuthContext{ + client: c, + principalUsername: username, + loginUsername: username, + credentials: ccacheMar, + connectionId: connectionID, + remoteAddr: remoteAddr, + success: true, + err: nil, + } + + if err := ctx.AllowLogin(username); err != nil { + return kerberosAuthContext{ + client: c, + success: false, + err: err, + } + } + + return ctx +} + +func (c *kerberosAuthClient) PubKey( + _ string, + _ string, + _ string, + _ net.IP, +) AuthenticationContext { + return &kerberosAuthContext{ + client: c, + err: message.UserMessage( + message.EAuthUnsupported, + "Keyboard-interactive authentication is not available.", + "Kerberos authentication doesn't support keyboard-interactive.", + ), + } +} + +func (k *kerberosAuthContext) AcceptSecContext(token []byte) (outputToken []byte, srcName string, needContinue bool, err error) { + var st spnego.KRB5Token + err = st.Unmarshal(token) + if err != nil { + return nil, "", false, message.Wrap( + err, + message.EAuthKerberosVerificationFailed, + "Failed to unmarshal the intial KRB token", + ) + } + st.Settings = krb5svc.NewSettings( + k.client.keytab, + krb5svc.MaxClockSkew(k.client.config.ClockSkew), + ) + verified, _ := st.Verify() + + if verified { + ctx := st.Context() + id := ctx.Value(spnego.CtxCredentials).(goidentity.Identity) + k.principalUsername = id.UserName() + + a := st.APReq + + hostAddr := types.HostAddressFromNetIP(k.remoteAddr) + + ok, err := a.Verify(k.client.keytab, k.client.config.ClockSkew, hostAddr, k.client.acceptor) + if err != nil { + return nil, "", false, message.Wrap( + err, + message.EAuthKerberosVerificationFailed, + "Failed to verify the AP_REQ packet (is the acceptor correct?)", + ) + } + if !ok { + return nil, "", false, message.Wrap( + err, + message.EAuthKerberosVerificationFailed, + "Couldn't verify AP_REQ packet", + ) + } + + k.key = a.Authenticator.SubKey + + ticket := a.Ticket + rep, err := krbmsg.NewAPRep(ticket, a.Authenticator) + if err != nil { + return nil, "", false, message.Wrap( + err, + message.EAuthKerberosVerificationFailed, + "Failed to generate an AP_REP packet", + ) + } + + repToken := spnego.NewKRB5TokenAPREP() + repToken.APRep = rep + mar2, err:= repToken.Marshal() + asn1tools.AddASNAppTag(mar2, 060) + if err != nil { + return nil, "", false, message.Wrap( + err, + message.EAuthKerberosVerificationFailed, + "Failed to marshal the AP_REP packet", + ) + } + + authCred, err := a.Authenticator.GetCredDelegation() + if err != nil { + k.client.logger.Info(message.Wrap( + err, + message.EAuthKerberosVerificationFailed, + "Failed to unmarshal the Authenticator delegation packet", + )) + // Accept but no cred delegation + return mar2, k.principalUsername, false, nil + } + + if authCred != nil && authCred.HasDelegation() { + var cred krbmsg.KRBCred + err = cred.Unmarshal(authCred.Deleg) + if err != nil { + k.client.logger.Info(message.Wrap( + err, + message.EAuthKerberosVerificationFailed, + "Failed to marshal the KRB_CRED packet", + )) + return mar2, k.principalUsername, false, nil + } + err = cred.DecryptEncPart(ticket.DecryptedEncPart.Key) + if err != nil { + return nil, "", false, err + } + + cacheCreds, err := cred.ToCredentials() + if err != nil { + return nil, "", false, err + } + cache := credentials.CCacheFromCredentials(cacheCreds) + mar, err := cache.Marshal() + if err != nil { + return nil, "", false, err + } + k.credentials = mar + } + + return mar2, k.principalUsername, false, nil + } + + return nil, "", false, fmt.Errorf("Invalid token") +} + +// GSSAPIMicField is described in RFC4462 Section 3.5 +type GSSAPIMicField struct { + // SessionIdentifier is a random string identifying the ssh connection + // (different from containerSSHs identifier) + SessionIdentifier string + // Request is the action that's being requested (50: SSH_MSG_USERAUTH_REQUEST) + Request byte + // UserName is the username that the user requests to log in as + UserName string + // Service is the service being used ('ssh-connection') + Service string + // Method is the authentication method in use ('gssapi-with-mic') + Method string +} + +func (mic *GSSAPIMicField) unmarshal(b []byte) error { + err := ssh.Unmarshal(b, mic) + + if err != nil { + return err + } + return nil +} + +func (k *kerberosAuthContext) VerifyMIC(micField []byte, micToken []byte) error { + var t gssapi.MICToken + err := t.Unmarshal(micToken, false) + if err != nil { + return err + } + t.Payload = micField + verified, err := t.Verify(k.key, keyusage.GSSAPI_INITIATOR_SIGN) + if err != nil { + return err + } + if !verified { + return message.NewMessage( + message.EAuthKerberosVerificationFailed, + "Verify() returned unverified but no error", + ) + } + + // MIC is verified, but need to ensure usernames match + var field GSSAPIMicField + err = field.unmarshal(micField) + if err != nil { + return err + } + + if field.Request != internalSsh.SSH_MSG_USERAUTH_REQUEST || field.Service != "ssh-connection" || field.Method != "gssapi-with-mic" { + return message.NewMessage( + message.EAuthKerberosVerificationFailed, + "Received MIC packet with unexpected values", + ) + } + + if k.client.config.EnforceUsername && field.UserName != k.principalUsername { + return message.UserMessage( + message.EAuthKerberosVerificationFailed, + "Unable to login with the requested username", + "Cannot login to account %s using principal %s", + field.UserName, + k.principalUsername, + ) + } + + k.loginUsername = field.UserName + k.success = true + + return nil +} + +func (k *kerberosAuthContext) DeleteSecContext() error { + return nil +} + +func (k *kerberosAuthContext) AllowLogin(username string) error { + if !k.Success() { + return k.Error() + } + + if k.loginUsername != username { + return message.NewMessage( + message.EAuthKerberosVerificationFailed, + "Tried to authorize on a username %s different from the one in the MIC %s", + username, + k.loginUsername, + ) + } + + // Note: this is a redundant check as VerifyMIC already checks this + // case, but it never hurts to be paranoid + if k.client.config.EnforceUsername && username != k.principalUsername { + return message.UserMessage( + message.EAuthKerberosVerificationFailed, + "Unable login with the requested username", + "Cannot login to account %s using principal %s", + username, + k.principalUsername, + ) + } + + return nil +} + +func (c *kerberosAuthClient) GSSAPIConfig(connectionId string, remoteAddr net.IP) GSSAPIServer { + return &kerberosAuthContext{ + client: c, + connectionId: connectionId, + remoteAddr: remoteAddr, + } +} diff --git a/internal/auth/client_kerberos_factory.go b/internal/auth/client_kerberos_factory.go new file mode 100644 index 00000000..50069d8a --- /dev/null +++ b/internal/auth/client_kerberos_factory.go @@ -0,0 +1,86 @@ +package auth + +import ( + "fmt" + "os" + + "github.com/containerssh/libcontainerssh/config" + "github.com/containerssh/libcontainerssh/message" + "github.com/containerssh/libcontainerssh/internal/metrics" + "github.com/containerssh/libcontainerssh/log" + + "github.com/containerssh/gokrb5/v8/keytab" + krb5cfg "github.com/containerssh/gokrb5/v8/config" + "github.com/containerssh/gokrb5/v8/types" + "github.com/containerssh/gokrb5/v8/iana/nametype" +) + +func NewKerberosClient( + cfg config.AuthConfig, + logger log.Logger, + metrics metrics.Collector, +) (Client, error) { + if cfg.Method != config.AuthMethodKerberos { + return nil, fmt.Errorf("authentication is not set to kerberos") + } + + if err := cfg.Validate(); err != nil { + return nil, message.Wrap( + err, + message.EAuthConfigError, + "Kerberos configuration failed to validate", + ) + } + + kt, err := keytab.Load(cfg.Kerberos.Keytab) + if err != nil { + return nil, message.Wrap( + err, + message.EAuthConfigError, + "Failed to load kerberos keytab from %s", + cfg.Kerberos.Keytab, + ) + } + + var conf *krb5cfg.Config + if cfg.Kerberos.AllowPassword { + conf, err = krb5cfg.Load(cfg.Kerberos.ConfigPath) + if err != nil { + return nil, message.Wrap( + err, + message.EAuthConfigError, + "Failed to load kerberos configuration file from %s", + cfg.Kerberos.ConfigPath, + ) + } + } + + var acceptor *types.PrincipalName + if cfg.Kerberos.Acceptor == "any" { + acceptor = nil + } else if cfg.Kerberos.Acceptor == "host" { + hostname, err := os.Hostname() + if err != nil { + return nil, message.Wrap( + err, + message.EAuthConfigError, + "Failed to get hostname from OS", + ) + } + acceptor = &types.PrincipalName{ + NameType: nametype.KRB_NT_PRINCIPAL, + NameString: []string{"host", hostname}, + } + } else { + a := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, cfg.Kerberos.Acceptor) + acceptor = &a + } + + return &kerberosAuthClient{ + logger: logger, + config: cfg.Kerberos, + keytab: kt, + acceptor: acceptor, + clientConf: conf, + }, nil +} diff --git a/internal/auth/client_kerberos_test.go b/internal/auth/client_kerberos_test.go new file mode 100644 index 00000000..b744c34b --- /dev/null +++ b/internal/auth/client_kerberos_test.go @@ -0,0 +1,354 @@ +package auth_test + +import ( + "fmt" + "net" + "testing" + "io/ioutil" + "os" + + configuration "github.com/containerssh/libcontainerssh/config" + "github.com/containerssh/libcontainerssh/internal/auth" + "github.com/containerssh/libcontainerssh/internal/geoip/dummy" + "github.com/containerssh/libcontainerssh/internal/metrics" + "github.com/containerssh/libcontainerssh/internal/structutils" + "github.com/containerssh/libcontainerssh/internal/test" + "github.com/containerssh/libcontainerssh/log" + "github.com/stretchr/testify/assert" + + "golang.org/x/crypto/ssh" + + "github.com/containerssh/gokrb5/v8/client" + krb5cfg "github.com/containerssh/gokrb5/v8/config" + "github.com/containerssh/gokrb5/v8/crypto" + "github.com/containerssh/gokrb5/v8/gssapi" + "github.com/containerssh/gokrb5/v8/iana/flags" + "github.com/containerssh/gokrb5/v8/messages" + "github.com/containerssh/gokrb5/v8/spnego" + "github.com/containerssh/gokrb5/v8/types" +) + +func tempFile(t *testing.T) *os.File { + file, err := ioutil.TempFile(t.TempDir(), "krb5.keytab-*") + if err != nil { + panic(err) + } + return file +} + +func setupKerberosClient(t *testing.T, config configuration.AuthConfig) auth.Client { + krb := test.Kerberos(t) + kt := tempFile(t) + n, err := kt.Write(krb.Keytab()) + if err != nil { + t.Fatal(err) + } + defer func() { + err := os.Remove(kt.Name()) + if err != nil { + t.Fatal("Failed to remove keytab file", err) + } + }() + if n != len(krb.Keytab()) { + t.Fatal("Failed to write keytab") + } + config.Kerberos.Keytab = kt.Name() + err = kt.Close() + if err != nil { + t.Fatal("Failed to close keytab file", err) + } + + c, err := auth.NewKerberosClient( + config, + log.NewTestLogger(t), + metrics.New(dummy.New()), + ) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestKerberosPasswordAuth(t *testing.T) { + config := configuration.AuthConfig{ + Method: configuration.AuthMethodKerberos, + Kerberos: configuration.AuthKerberosClientConfig{ + EnforceUsername: true, + AllowPassword: true, + ConfigPath: "testdata/krb5.conf", + }, + } + c := setupKerberosClient(t, config) + + ctx := c.Password("foo", []byte("test"), "asdf", net.ParseIP("127.0.0.1")) + if !ctx.Success() { + assert.Fail(t, "Failed to login as foo", ctx.Error()) + } + + ctx = c.Password("foo", []byte("wrongpass"), "asdf", net.ParseIP("127.0.0.1")) + if ctx.Success() { + assert.Fail(t, "Logged in as foo with wrong password") + } +} + +func TestKerberosNoPassword(t *testing.T) { + config := configuration.AuthConfig{ + Method: configuration.AuthMethodKerberos, + Kerberos: configuration.AuthKerberosClientConfig{ + EnforceUsername: true, + AllowPassword: false, + ConfigPath: "testdata/krb5.conf", + }, + } + c := setupKerberosClient(t, config) + + ctx := c.Password("foo", []byte("test"), "asdf", net.ParseIP("127.0.0.1")) + if ctx.Success() { + assert.Fail(t, "Logged in as foo even though password is disallowed") + } + + ctx = c.Password("foo", []byte("wrongpass"), "asdf", net.ParseIP("127.0.0.1")) + if ctx.Success() { + assert.Fail(t, "Logged in as foo with wrong password") + } +} + +func TestKerberosGSSAPIAuth(t *testing.T) { + config := configuration.AuthConfig{} + structutils.Defaults(&config) + config.Method = configuration.AuthMethodKerberos + config.Kerberos.ConfigPath = "testdata/krb5.conf" + + authCl := setupKerberosClient(t, config) + userCl := krbAuth(t, "foo", "test") + + gssClient := testGssApiClient{ + client: userCl, + } + + + name, err := doGssAuth( + t, + "foo", + "host/testing.containerssh.io", + false, + authCl.GSSAPIConfig( + "asdf", + net.IPv4(127, 0, 0, 1), + ), + gssClient, + ) + if err != nil { + assert.Fail(t, "GSSAPI Failure", err) + } + if name != "foo" { + assert.Fail(t, "Logged in with the wrong name", name) + } + + name, err = doGssAuth( + t, + "foo", + "host/testing.containerssh.io", + true, + authCl.GSSAPIConfig( + "asdf", + net.IPv4(127, 0, 0, 1), + ), + gssClient, + ) + if err != nil { + assert.Fail(t, "GSSAPI Failure", err) + } + if name != "foo" { + assert.Fail(t, "Logged in with the wrong name", name) + } + + _, err = doGssAuth( + t, + "bar", + "host/testing.containerssh.io", + false, + authCl.GSSAPIConfig( + "asdf", + net.IPv4(127, 0, 0, 1), + ), + gssClient, + ) + if err == nil { + assert.Fail(t, "Invalid username was successfully authenticated") + } +} + + +func krbAuth(t *testing.T, username string, password string) *client.Client { + conf, err := krb5cfg.Load("testdata/krb5.conf") + if err != nil { + panic (err) + } + + cl := client.NewWithPassword( + username, + conf.LibDefaults.DefaultRealm, + password, + conf, + client.DisablePAFXFAST(true), + ) + + err = cl.Login() + if err != nil { + t.Fatal("Failed to authenticate to kerbers server as foo", err) + } + return cl +} + +func doGssAuth(t *testing.T, username string, principal string, deleg bool, server auth.GSSAPIServer, client testGssApiClient) (string, error) { + tok, cont, err := client.InitSecContext(principal, nil, deleg) + if err != nil { + return "", fmt.Errorf("Client failed to initialize context (%w)", err) + } + if !cont { + return "", fmt.Errorf("Client did not ask for any token exchanges (%w)", err) + } + + _, name, cont, err := server.AcceptSecContext(tok) + if err != nil { + return "", fmt.Errorf("Server failed to initialize context (%w)", err) + } + if cont { + return "", fmt.Errorf("Server asked for a second token exchange but we don't support this yet") + } + + micField := buildMic(username) + mic, err := client.GetMIC(micField) + if err != nil { + return "", fmt.Errorf("Failed to get MIC from client (%w)", err) + } + + err = server.VerifyMIC(micField, mic) + if err != nil { + return "", fmt.Errorf("Server failed to verify mic (%w)", err) + } + + err = server.DeleteSecContext() + if err != nil { + return "", fmt.Errorf("Server failed to delete sec context (%w)", err) + } + + err = client.DeleteSecContext() + if err != nil { + return "", fmt.Errorf("Client failed to delete sec context (%w)", err) + } + + return name, nil +} + +func buildMic(username string) []byte { + mic := auth.GSSAPIMicField{ + SessionIdentifier: "abcdefg", + Request: 50, + UserName: username, + Service: "ssh-connection", + Method: "gssapi-with-mic", + } + + return ssh.Marshal(mic) +} + +type testGssApiClient struct { + client *client.Client + key types.EncryptionKey +} + +func (c *testGssApiClient) generateContext(target string, deleg bool) (outputToken []byte, needContinue bool, err error) { + tkt, key, err := c.client.GetServiceTicket(target) + if err != nil { + return nil, false, err + } + + tok, err := spnego.NewKRB5TokenAPREQ( + c.client, + tkt, + key, + []int{ + gssapi.ContextFlagMutual, + gssapi.ContextFlagInteg, + }, + []int{ + flags.APOptionMutualRequired, + }, + ) + if err != nil { + return nil, false, err + } + + err = tok.APReq.DecryptAuthenticator(key) + if err != nil { + return nil, false, err + } + + etype, err := crypto.GetEtype(key.KeyType) + if err != nil { + return nil, false, err + } + + err = tok.APReq.Authenticator.GenerateSeqNumberAndSubKey(key.KeyType, etype.GetKeyByteSize()) + if err != nil { + return nil, false, err + } + + c.key = tok.APReq.Authenticator.SubKey + + req, err := messages.NewAPReq(tkt, key, tok.APReq.Authenticator) + if err != nil { + return nil, false, err + } + + tok.APReq = req + + mar, err := tok.Marshal() + if err != nil { + return nil, false, err + } + + return mar, true, nil +} + +func (c *testGssApiClient) InitSecContext(target string, token []byte, isGSSDelegCreds bool) (outputToken []byte, needContinue bool, err error) { + if token == nil { + // Initial setup + return c.generateContext(target, isGSSDelegCreds) + } else { + // Got a response + var rep spnego.KRB5Token + err := rep.Unmarshal(token) + if err != nil { + return nil, false, err + } + + if rep.IsKRBError() { + return nil, false, fmt.Errorf("Received error response") + } + + if !rep.IsAPRep() { + return nil, false, fmt.Errorf("Did not receive expected response packet AP_REP") + } + return nil, false, nil + } +} + +func (c *testGssApiClient) GetMIC(micField []byte) ([]byte, error) { + token, err := gssapi.NewInitiatorMICToken(micField, c.key) + if err != nil { + return nil, err + } + + mar, err := token.Marshal() + if err != nil { + return nil, err + } + return mar, nil +} + +func (c *testGssApiClient) DeleteSecContext() error { + return nil +} diff --git a/internal/auth/client_oauth2.go b/internal/auth/client_oauth2.go index 9e4decfa..c96a359a 100644 --- a/internal/auth/client_oauth2.go +++ b/internal/auth/client_oauth2.go @@ -6,6 +6,7 @@ import ( "net" "strings" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/log" "github.com/containerssh/libcontainerssh/message" ) @@ -17,7 +18,7 @@ type oauth2Client struct { type oauth2Context struct { success bool - metadata map[string]string + metadata *auth.ConnectionMetadata err error flow OAuth2Flow } @@ -30,7 +31,7 @@ func (o *oauth2Context) Error() error { return o.err } -func (o *oauth2Context) Metadata() map[string]string { +func (o *oauth2Context) Metadata() *auth.ConnectionMetadata { return o.metadata } @@ -56,6 +57,10 @@ func (o *oauth2Client) PubKey(_ string, _ string, _ string, _ net.IP) Authentica ), nil} } +func (client *oauth2Client) GSSAPIConfig(connectionId string, addr net.IP) GSSAPIServer { + return nil +} + func (o *oauth2Client) KeyboardInteractive( username string, challenge func( @@ -69,7 +74,7 @@ func (o *oauth2Client) KeyboardInteractive( _ net.IP, ) AuthenticationContext { ctx := context.TODO() - var metadata map[string]string + metadata := &auth.ConnectionMetadata{} var err error if o.provider.SupportsDeviceFlow() { deviceFlow, err := o.provider.GetDeviceFlow(connectionID, username) @@ -89,7 +94,7 @@ func (o *oauth2Client) KeyboardInteractive( } verifyContext, cancelFunc := context.WithTimeout(ctx, expiration) defer cancelFunc() - metadata, err = deviceFlow.Verify(verifyContext) + metadata.Metadata, err = deviceFlow.Verify(verifyContext) // TODO fallback to authorization code flow if the device flow rate limit is exceeded. if err != nil { deviceFlow.Deauthorize(ctx) @@ -130,7 +135,7 @@ func (o *oauth2Client) KeyboardInteractive( "Authentication failed because the return code did not contain the requisite state and code.", ), authCodeFlow} } - metadata, err = authCodeFlow.Verify(ctx, parts[0], parts[1]) + metadata.Metadata, err = authCodeFlow.Verify(ctx, parts[0], parts[1]) if err != nil { return &oauth2Context{false, metadata, err, authCodeFlow} } else { diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 856dd339..b05e8947 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -1,5 +1,9 @@ package auth +import ( + "github.com/containerssh/libcontainerssh/auth" +) + type Handler interface { // OnPassword is called if the client requests a password authentication. // @@ -16,7 +20,7 @@ type Handler interface { Password []byte, RemoteAddress string, ConnectionID string, - ) (bool, map[string]string, error) + ) (bool, *auth.ConnectionMetadata, error) // OnPubKey is called when the client requests a public key authentication. // @@ -33,5 +37,18 @@ type Handler interface { PublicKey string, RemoteAddress string, ConnectionID string, - ) (bool, map[string]string, error) + ) (bool, *auth.ConnectionMetadata, error) + + // OnAuthorization is called when the client requests user authorization. + // + // - PrincipalUsername is the authenticated username of the user, this is the real identity + // - LoginUsername is the username the user requests to log in as + // - RemoteAddress is the IP address of the user. + // - SessionID is an opaque identifier for the current session. + OnAuthorization( + PrincipalUsername string, + LoginUsername string, + RemoteAddress string, + ConnectionID string, + ) (bool, *auth.ConnectionMetadata, error) } diff --git a/internal/auth/handler_factory.go b/internal/auth/handler_factory.go index fc95c234..fa380e36 100644 --- a/internal/auth/handler_factory.go +++ b/internal/auth/handler_factory.go @@ -10,6 +10,10 @@ import ( // NewHandler creates a handler that is compatible with the Go HTTP server. func NewHandler(h Handler, logger log.Logger) goHttp.Handler { return &handler{ + authzHandler: http.NewServerHandler(&authzHandler{ + backend: h, + logger: logger, + }, logger), passwordHandler: http.NewServerHandler(&passwordHandler{ backend: h, logger: logger, diff --git a/internal/auth/handler_impl.go b/internal/auth/handler_impl.go index f7ff1a81..a61de0df 100644 --- a/internal/auth/handler_impl.go +++ b/internal/auth/handler_impl.go @@ -13,6 +13,7 @@ import ( ) type handler struct { + authzHandler goHttp.Handler passwordHandler goHttp.Handler pubkeyHandler goHttp.Handler } @@ -20,6 +21,8 @@ type handler struct { func (h handler) ServeHTTP(writer goHttp.ResponseWriter, request *goHttp.Request) { parts := strings.Split(request.URL.Path, "/") switch parts[len(parts)-1] { + case "authz": + h.authzHandler.ServeHTTP(writer, request) case "password": h.passwordHandler.ServeHTTP(writer, request) case "pubkey": @@ -29,6 +32,41 @@ func (h handler) ServeHTTP(writer goHttp.ResponseWriter, request *goHttp.Request } } +type authzHandler struct { + backend Handler + logger log.Logger +} + +func (p *authzHandler) OnRequest(request http.ServerRequest, response http.ServerResponse) error { + requestObject := auth.AuthorizationRequest{} + if err := request.Decode(&requestObject); err != nil { + return err + } + success, metadata, err := p.backend.OnAuthorization( + requestObject.PrincipalUsername, + requestObject.LoginUsername, + requestObject.RemoteAddress, + requestObject.ConnectionID, + ) + if err != nil { + p.logger.Debug(message.Wrap(err, message.EAuthRequestDecodeFailed, "failed to execute authorization request")) + response.SetStatus(500) + response.SetBody( + auth.ResponseBody{ + Success: false, + Metadata: metadata, + }) + return nil + } else { + response.SetBody( + auth.ResponseBody{ + Success: success, + Metadata: metadata, + }) + } + return nil +} + type passwordHandler struct { backend Handler logger log.Logger diff --git a/internal/auth/integration_test.go b/internal/auth/integration_test.go index 7d74e409..df7d9be8 100644 --- a/internal/auth/integration_test.go +++ b/internal/auth/integration_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + auth2 "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/internal/geoip/dummy" @@ -26,7 +27,7 @@ func (h *handler) OnPassword( password []byte, remoteAddress string, connectionID string, -) (bool, map[string]string, error) { +) (bool, *auth2.ConnectionMetadata, error) { if remoteAddress != "127.0.0.1" { return false, nil, fmt.Errorf("invalid IP: %s", remoteAddress) } @@ -48,7 +49,7 @@ func (h *handler) OnPubKey( publicKey string, remoteAddress string, connectionID string, -) (bool, map[string]string, error) { +) (bool, *auth2.ConnectionMetadata, error) { if remoteAddress != "127.0.0.1" { return false, nil, fmt.Errorf("invalid IP: %s", remoteAddress) } @@ -65,6 +66,15 @@ func (h *handler) OnPubKey( return false, nil, nil } +func (h *handler) OnAuthorization( + principalUsername string, + loginUsername string, + remoteAddress string, + connectionID string, +) (bool, *auth2.ConnectionMetadata, error) { + return false, nil, nil +} + func TestAuth(t *testing.T) { logger := log.NewTestLogger(t) logger.Info( diff --git a/internal/auth/testdata/krb5.conf b/internal/auth/testdata/krb5.conf new file mode 100644 index 00000000..1e4733eb --- /dev/null +++ b/internal/auth/testdata/krb5.conf @@ -0,0 +1,15 @@ +[libdefaults] + default_realm = TESTING.CONTAINERSSH.IO + dns_lookup_realm = false + dns_lookup_kdc = false + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + +[realms] +TESTING.CONTAINERSSH.IO = { + kdc = 127.0.0.1 +} + +[domain_realm] + diff --git a/internal/authintegration/handler.go b/internal/authintegration/handler.go index 639ba7a0..a620d3db 100644 --- a/internal/authintegration/handler.go +++ b/internal/authintegration/handler.go @@ -4,6 +4,8 @@ import ( "context" "net" + auth2 "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/internal/sshserver" "github.com/containerssh/libcontainerssh/internal/auth" @@ -88,7 +90,7 @@ func (h *networkConnectionHandler) OnShutdown(shutdownContext context.Context) { h.backend.OnShutdown(shutdownContext) } -func (h *networkConnectionHandler) OnAuthPassword(username string, password []byte, clientVersion string) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +func (h *networkConnectionHandler) OnAuthPassword(username string, password []byte, clientVersion string) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { if h.authContext != nil { h.authContext.OnDisconnect() } @@ -113,7 +115,7 @@ func (h *networkConnectionHandler) OnAuthPassword(username string, password []by } } -func (h *networkConnectionHandler) OnAuthPubKey(username string, pubKey string, clientVersion string) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +func (h *networkConnectionHandler) OnAuthPubKey(username string, pubKey string, clientVersion string) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { if h.authContext != nil { h.authContext.OnDisconnect() } @@ -144,7 +146,7 @@ func (h *networkConnectionHandler) OnAuthKeyboardInteractive( questions sshserver.KeyboardInteractiveQuestions, ) (answers sshserver.KeyboardInteractiveAnswers, err error), clientVersion string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { if h.authContext != nil { h.authContext.OnDisconnect() } @@ -192,11 +194,15 @@ func (h *networkConnectionHandler) OnAuthKeyboardInteractive( return sshserver.AuthResponseSuccess, authContext.Metadata(), authContext.Error() } +func (h *networkConnectionHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return h.authClient.GSSAPIConfig(h.connectionID, h.ip) +} + func (h *networkConnectionHandler) OnHandshakeFailed(reason error) { h.backend.OnHandshakeFailed(reason) } -func (h *networkConnectionHandler) OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) (connection sshserver.SSHConnectionHandler, failureReason error) { +func (h *networkConnectionHandler) OnHandshakeSuccess(username string, clientVersion string, metadata *auth2.ConnectionMetadata) (connection sshserver.SSHConnectionHandler, failureReason error) { return h.backend.OnHandshakeSuccess(username, clientVersion, metadata) } diff --git a/internal/authintegration/integration_test.go b/internal/authintegration/integration_test.go index 75a0c84d..8670d598 100644 --- a/internal/authintegration/integration_test.go +++ b/internal/authintegration/integration_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + auth2 "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/internal/authintegration" @@ -147,7 +148,7 @@ func (t *testBackend) OnSessionChannel(_ uint64, _ []byte, _ sshserver.SessionCh func (t *testBackend) OnHandshakeFailed(_ error) {} -func (t *testBackend) OnHandshakeSuccess(_ string, _ string, _ map[string]string) ( +func (t *testBackend) OnHandshakeSuccess(_ string, _ string, _ *auth2.ConnectionMetadata) ( connection sshserver.SSHConnectionHandler, failureReason error, ) { @@ -184,7 +185,7 @@ func (h *authHandler) OnPassword( Password []byte, _ string, _ string, -) (bool, map[string]string, error) { +) (bool, *auth2.ConnectionMetadata, error) { if Username == "foo" && string(Password) == "bar" { return true, nil, nil } @@ -200,7 +201,16 @@ func (h *authHandler) OnPubKey( _ string, _ string, _ string, -) (bool, map[string]string, error) { +) (bool, *auth2.ConnectionMetadata, error) { + return false, nil, nil +} + +func (h *authHandler) OnAuthorization( + _ string, + _ string, + _ string, + _ string, +) (bool, *auth2.ConnectionMetadata, error) { return false, nil, nil } diff --git a/internal/backend/handler.go b/internal/backend/handler.go index b3ed36c1..780c7c5d 100644 --- a/internal/backend/handler.go +++ b/internal/backend/handler.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" internalConfig "github.com/containerssh/libcontainerssh/internal/config" "github.com/containerssh/libcontainerssh/internal/docker" @@ -59,11 +60,11 @@ type networkHandler struct { logger log.Logger } -func (n *networkHandler) OnAuthPassword(_ string, _ []byte, _ string) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +func (n *networkHandler) OnAuthPassword(_ string, _ []byte, _ string) (response sshserver.AuthResponse, metadata *auth.ConnectionMetadata, reason error) { return n.authResponse() } -func (n *networkHandler) authResponse() (sshserver.AuthResponse, map[string]string, error) { +func (n *networkHandler) authResponse() (sshserver.AuthResponse, *auth.ConnectionMetadata, error) { switch n.rootHandler.authResponse { case sshserver.AuthResponseUnavailable: return sshserver.AuthResponseUnavailable, nil, fmt.Errorf("the backend handler does not support authentication") @@ -72,14 +73,14 @@ func (n *networkHandler) authResponse() (sshserver.AuthResponse, map[string]stri } } -func (n *networkHandler) OnAuthPubKey(_ string, _ string, _ string) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +func (n *networkHandler) OnAuthPubKey(_ string, _ string, _ string) (response sshserver.AuthResponse, metadata *auth.ConnectionMetadata, reason error) { return n.authResponse() } func (n *networkHandler) OnHandshakeFailed(_ error) { } -func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) ( +func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata *auth.ConnectionMetadata) ( connection sshserver.SSHConnectionHandler, failureReason error, ) { @@ -98,7 +99,7 @@ func (n *networkHandler) initBackend( appConfig config.AppConfig, backendLogger log.Logger, version string, - metadata map[string]string, + metadata *auth.ConnectionMetadata, ) (sshserver.SSHConnectionHandler, error) { backend, failureReason := n.getConfiguredBackend( appConfig, @@ -162,7 +163,7 @@ func (n *networkHandler) getConfiguredBackend( func (n *networkHandler) loadConnectionSpecificConfig( username string, - metadata map[string]string, + metadata *auth.ConnectionMetadata, ) ( config.AppConfig, error, diff --git a/internal/config/client.go b/internal/config/client.go index 5a1edf76..9c4ee46d 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -4,6 +4,7 @@ import ( "context" "net" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" ) @@ -15,6 +16,6 @@ type Client interface { username string, remoteAddr net.TCPAddr, connectionID string, - metadata map[string]string, + metadata *auth.ConnectionMetadata, ) (config.AppConfig, error) } diff --git a/internal/config/client_impl.go b/internal/config/client_impl.go index 9ba65a16..2e8451e5 100644 --- a/internal/config/client_impl.go +++ b/internal/config/client_impl.go @@ -6,6 +6,7 @@ import ( "net" "time" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/http" "github.com/containerssh/libcontainerssh/internal/metrics" @@ -25,7 +26,7 @@ func (c *client) Get( username string, remoteAddr net.TCPAddr, connectionID string, - metadata map[string]string, + metadata *auth.ConnectionMetadata, ) (config.AppConfig, error) { if c.httpClient == nil { return config.AppConfig{}, nil @@ -33,7 +34,7 @@ func (c *client) Get( logger := c.logger. WithLabel("connectionId", connectionID). WithLabel("username", username) - request, response := c.createRequestResponse(username, remoteAddr, connectionID, metadata) + request, response := c.createRequestResponse(username, remoteAddr, connectionID, metadata.Transmit()) var lastError error = nil var lastLabels []metrics.MetricLabel loop: @@ -73,7 +74,7 @@ func (c *client) createRequestResponse( username string, remoteAddr net.TCPAddr, connectionID string, - metadata map[string]string, + metadata *auth.ConnectionMetadata, ) (config.Request, config.ResponseBody) { request := config.Request{ Username: username, diff --git a/internal/config/loader.go b/internal/config/loader.go index 93f476bc..1ceff062 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -4,6 +4,7 @@ import ( "context" "net" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" ) @@ -30,7 +31,7 @@ type Loader interface { username string, remoteAddr net.TCPAddr, connectionID string, - metadata map[string]string, + metadata *auth.ConnectionMetadata, config *config.AppConfig, ) error } diff --git a/internal/config/loader_http.go b/internal/config/loader_http.go index 71e3650f..d7b57357 100644 --- a/internal/config/loader_http.go +++ b/internal/config/loader_http.go @@ -4,6 +4,7 @@ import ( "context" "net" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/metrics" "github.com/containerssh/libcontainerssh/internal/structutils" @@ -39,7 +40,7 @@ func (h *httpLoader) LoadConnection( username string, remoteAddr net.TCPAddr, connectionID string, - metadata map[string]string, + metadata *auth.ConnectionMetadata, config *config.AppConfig, ) error { newAppConfig, err := h.client.Get(ctx, username, remoteAddr, connectionID, metadata) diff --git a/internal/config/loader_reader.go b/internal/config/loader_reader.go index 3238bfba..eff416ad 100644 --- a/internal/config/loader_reader.go +++ b/internal/config/loader_reader.go @@ -7,6 +7,7 @@ import ( "io" "net" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/log" "gopkg.in/yaml.v3" @@ -57,7 +58,7 @@ func (y *readerLoader) LoadConnection( _ string, _ net.TCPAddr, _ string, - _ map[string]string, + _ *auth.ConnectionMetadata, _ *config.AppConfig, ) error { return nil diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 11021a63..9f76f6d0 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -51,6 +51,8 @@ type dockerContainer interface { // createExec creates an execution process for the given program with the given parameters. The passed context is // the start context. createExec(ctx context.Context, program []string, env map[string]string, tty bool) (dockerExecution, error) + // writeFile writes a file at the given path inside the container + writeFile(path string, content []byte) error // remove removes the container within the given context. remove(ctx context.Context) error diff --git a/internal/docker/docker_impl.go b/internal/docker/docker_impl.go index 99bb3ce0..3c1504f0 100644 --- a/internal/docker/docker_impl.go +++ b/internal/docker/docker_impl.go @@ -358,6 +358,70 @@ loop: return err } +func (d *dockerV20Container) writeFile(path string, content []byte) error { + if d.config.Execution.DisableAgent { + return message.NewMessage( + message.EDockerWriteFileFailed, + "The ContainerSSH guest agent is disabled. Failed to write file %s in the container", + path, + ) + } + + ctx, cancelFunc := context.WithTimeout( + context.Background(), + d.config.Timeouts.CommandStart, + ) + d.logger.Debug(message.NewMessage( + message.MDockerFileWrite, + "Writing to file %s", + path, + )) + defer cancelFunc() + + exec, err := d.createExec( + ctx, + []string{d.config.Execution.AgentPath, "write-file", path}, + nil, + false, + ) + if err != nil { + return message.Wrap( + err, + message.EDockerWriteFileFailed, + "Failed to write file inside container", + ) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + stdin := bytes.NewReader(content) + exitCode := -1 + done := make(chan int) + exec.run( + stdin, + &stdout, + &stderr, + func() error { + return nil + }, + func(exitStatus int) { + exitCode = exitStatus + close(done) + }, + ) + <-done + if exitCode != 0 { + return message.NewMessage( + message.EDockerWriteFileFailed, + "Agent exited with status %d error output: %s", + exitCode, + stderr.String(), + ) + } + + return nil +} + func (d *dockerV20Container) remove(ctx context.Context) error { d.removeLock.Lock() defer d.removeLock.Unlock() diff --git a/internal/docker/handler_network.go b/internal/docker/handler_network.go index f68a5af1..29144458 100644 --- a/internal/docker/handler_network.go +++ b/internal/docker/handler_network.go @@ -7,6 +7,7 @@ import ( "strings" "sync" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/sshserver" "github.com/containerssh/libcontainerssh/log" @@ -30,17 +31,17 @@ type networkHandler struct { done chan struct{} } -func (n *networkHandler) OnAuthPassword(_ string, _ []byte, _ string) (sshserver.AuthResponse, map[string]string, error) { +func (n *networkHandler) OnAuthPassword(_ string, _ []byte, _ string) (sshserver.AuthResponse, *auth.ConnectionMetadata, error) { return sshserver.AuthResponseUnavailable, nil, fmt.Errorf("docker does not support authentication") } -func (n *networkHandler) OnAuthPubKey(_ string, _ string, _ string) (sshserver.AuthResponse, map[string]string, error) { +func (n *networkHandler) OnAuthPubKey(_ string, _ string, _ string) (sshserver.AuthResponse, *auth.ConnectionMetadata, error) { return sshserver.AuthResponseUnavailable, nil, fmt.Errorf("docker does not support authentication") } func (n *networkHandler) OnHandshakeFailed(_ error) {} -func (n *networkHandler) OnHandshakeSuccess(username string, _ string, metadata map[string]string) ( +func (n *networkHandler) OnHandshakeSuccess(username string, _ string, metadata *auth.ConnectionMetadata) ( connection sshserver.SSHConnectionHandler, failureReason error, ) { @@ -53,7 +54,12 @@ func (n *networkHandler) OnHandshakeSuccess(username string, _ string, metadata n.username = username var env map[string]string if n.config.Execution.ExposeAuthMetadataAsEnv { - env = metadata + env = metadata.GetMetadata() + } else { + env = make(map[string]string) + } + for k, v := range metadata.GetEnvironment() { + env[k] = v } if err := n.setupDockerClient(ctx, n.config); err != nil { @@ -77,6 +83,17 @@ func (n *networkHandler) OnHandshakeSuccess(username string, _ string, metadata if err := n.container.start(ctx); err != nil { return nil, err } + + for path, content := range metadata.GetFiles() { + err := cnt.writeFile(path, content) + if err != nil { + n.logger.Warning(message.Wrap( + err, + message.EDockerWriteFileFailed, + "Failed to write file", + )) + } + } } return &sshConnectionHandler{ diff --git a/internal/kubernetes/channelHandler.go b/internal/kubernetes/channelHandler.go index dbff0015..c7ea4757 100644 --- a/internal/kubernetes/channelHandler.go +++ b/internal/kubernetes/channelHandler.go @@ -18,6 +18,7 @@ type channelHandler struct { networkHandler *networkHandler username string env map[string]string + files map[string][]byte pty bool columns uint32 rows uint32 @@ -157,6 +158,29 @@ func (c *channelHandler) handleExecModeSession( if err != nil { return nil, err } + + for path, content := range c.files { + ctx, cancelFunc := context.WithTimeout( + context.Background(), + c.networkHandler.config.Timeouts.CommandStart, + ) + c.networkHandler.logger.Debug(message.NewMessage( + message.MKubernetesFileModification, + "Writing to file %s", + path, + )) + defer cancelFunc() + err := pod.writeFile(ctx, path, content) + if err != nil { + c.networkHandler.logger.Warning(message.Wrap( + err, + message.EKubernetesFileModificationFailed, + "Failed to write to %s", + path, + )) + } + } + c.exec, err = pod.attach(ctx) if err != nil { c.removePod(pod) diff --git a/internal/kubernetes/kubernetesPod.go b/internal/kubernetes/kubernetesPod.go index e1b31e34..83bd3f27 100644 --- a/internal/kubernetes/kubernetesPod.go +++ b/internal/kubernetes/kubernetesPod.go @@ -13,6 +13,8 @@ type kubernetesPod interface { // the start context. createExec(ctx context.Context, program []string, env map[string]string, tty bool) (kubernetesExecution, error) + writeFile(ctx context.Context, path string, content []byte) error + // remove removes the Pod within the given context. remove(ctx context.Context) error } diff --git a/internal/kubernetes/kubernetesPodImpl.go b/internal/kubernetes/kubernetesPodImpl.go index 29a76886..331dc39b 100644 --- a/internal/kubernetes/kubernetesPodImpl.go +++ b/internal/kubernetes/kubernetesPodImpl.go @@ -1,6 +1,7 @@ package kubernetes import ( + "bytes" "context" "fmt" "sync" @@ -227,6 +228,42 @@ func (k *kubernetesPodImpl) createExecLocked( }, nil } +func (k *kubernetesPodImpl) writeFile(ctx context.Context, path string, content []byte) error { + writeCmd := []string{k.config.Pod.AgentPath, "write-file", path} + if k.config.Pod.Mode == config2.KubernetesExecutionModeSession { + writeCmd = append( + []string{k.config.Pod.AgentPath, "console", "--wait", "--"}, + writeCmd..., + ) + } + exec, err := k.createExec( + ctx, + writeCmd, + map[string]string{}, + false, + ) + if err != nil { + return err + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + stdin := bytes.NewReader(content) + + exec.run( + stdin, + &stdout, + &stderr, + func() error { + return nil + }, + func(exitStatus int) { + }, + ) + return nil +} + func (k *kubernetesPodImpl) remove(ctx context.Context) error { k.removeLock.Lock() defer k.removeLock.Unlock() diff --git a/internal/kubernetes/networkHandler.go b/internal/kubernetes/networkHandler.go index 2b495758..97b6995f 100644 --- a/internal/kubernetes/networkHandler.go +++ b/internal/kubernetes/networkHandler.go @@ -7,6 +7,8 @@ import ( "strings" "sync" + "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/message" config2 "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/sshserver" "github.com/containerssh/libcontainerssh/log" @@ -29,18 +31,18 @@ type networkHandler struct { done chan struct{} } -func (n *networkHandler) OnAuthPassword(_ string, _ []byte, _ string) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +func (n *networkHandler) OnAuthPassword(_ string, _ []byte, _ string) (response sshserver.AuthResponse, metadata *auth.ConnectionMetadata, reason error) { return sshserver.AuthResponseUnavailable, nil, fmt.Errorf("the backend handler does not support authentication") } -func (n *networkHandler) OnAuthPubKey(_ string, _ string, _ string) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +func (n *networkHandler) OnAuthPubKey(_ string, _ string, _ string) (response sshserver.AuthResponse, metadata *auth.ConnectionMetadata, reason error) { return sshserver.AuthResponseUnavailable, nil, fmt.Errorf("the backend handler does not support authentication") } func (n *networkHandler) OnHandshakeFailed(_ error) { } -func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) (connection sshserver.SSHConnectionHandler, failureReason error) { +func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata *auth.ConnectionMetadata) (connection sshserver.SSHConnectionHandler, failureReason error) { n.mutex.Lock() if n.pod != nil { n.mutex.Unlock() @@ -57,10 +59,13 @@ func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion strin env := map[string]string{} for authMetadataName, envName := range n.config.Pod.ExposeAuthMetadataAsEnv { - if value, ok := metadata[authMetadataName]; ok { + if value, ok := metadata.GetMetadata()[authMetadataName]; ok { env[envName] = value } } + for k, v := range metadata.GetEnvironment() { + env[k] = v + } spec.Containers[n.config.Pod.ConsoleContainerNumber].Command = n.config.Pod.IdleCommand n.labels = map[string]string{ @@ -68,7 +73,7 @@ func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion strin "containerssh_username": username, } for authMetadataName, labelName := range n.config.Pod.ExposeAuthMetadataAsLabels { - if value, ok := metadata[authMetadataName]; ok { + if value, ok := metadata.GetMetadata()[authMetadataName]; ok { n.labels[labelName] = value } } @@ -77,7 +82,7 @@ func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion strin "containerssh_ip": strings.ReplaceAll(n.client.IP.String(), ":", "-"), } for authMetadataName, annotationName := range n.config.Pod.ExposeAuthMetadataAsAnnotations { - if value, ok := metadata[authMetadataName]; ok { + if value, ok := metadata.GetMetadata()[authMetadataName]; ok { n.annotations[annotationName] = value } } @@ -87,12 +92,34 @@ func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion strin if n.pod, err = n.cli.createPod(ctx, n.labels, n.annotations, env, nil, nil); err != nil { return nil, err } + for path, content := range metadata.GetFiles() { + ctx, cancelFunc := context.WithTimeout( + context.Background(), + n.config.Timeouts.CommandStart, + ) + n.logger.Debug(message.NewMessage( + message.MKubernetesFileModification, + "Writing to file %s", + path, + )) + defer cancelFunc() + err := n.pod.writeFile(ctx, path, content) + if err != nil { + n.logger.Warning(message.Wrap( + err, + message.EKubernetesFileModificationFailed, + "Failed to write to %s", + path, + )) + } + } } return &sshConnectionHandler{ networkHandler: n, username: username, env: env, + files: metadata.GetFiles(), }, nil } diff --git a/internal/kubernetes/sshConnectionHandler.go b/internal/kubernetes/sshConnectionHandler.go index 639d395d..18ee3941 100644 --- a/internal/kubernetes/sshConnectionHandler.go +++ b/internal/kubernetes/sshConnectionHandler.go @@ -10,6 +10,7 @@ type sshConnectionHandler struct { networkHandler *networkHandler username string env map[string]string + files map[string][]byte } func (s *sshConnectionHandler) OnUnsupportedGlobalRequest(_ uint64, _ string, _ []byte) { @@ -35,5 +36,6 @@ func (s *sshConnectionHandler) OnSessionChannel(channelID uint64, _ []byte, sess networkHandler: s.networkHandler, username: s.username, env: env, + files: s.files, }, nil } diff --git a/internal/metricsintegration/handler.go b/internal/metricsintegration/handler.go index 4842132f..7e428007 100644 --- a/internal/metricsintegration/handler.go +++ b/internal/metricsintegration/handler.go @@ -5,6 +5,8 @@ import ( "net" "sync" + auth2 "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/internal/metrics" "github.com/containerssh/libcontainerssh/internal/sshserver" ) @@ -58,7 +60,7 @@ func (m *metricsNetworkHandler) OnShutdown(shutdownContext context.Context) { func (m *metricsNetworkHandler) OnAuthPassword(username string, password []byte, clientVersion string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return m.backend.OnAuthPassword(username, password, clientVersion) @@ -66,7 +68,7 @@ func (m *metricsNetworkHandler) OnAuthPassword(username string, password []byte, func (m *metricsNetworkHandler) OnAuthPubKey(username string, pubKey string, clientVersion string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return m.backend.OnAuthPubKey(username, pubKey, clientVersion) @@ -81,18 +83,22 @@ func (m *metricsNetworkHandler) OnAuthKeyboardInteractive( clientVersion string, ) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return m.backend.OnAuthKeyboardInteractive(user, challenge, clientVersion) } +func (m *metricsNetworkHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return m.backend.OnAuthGSSAPI() +} + func (m *metricsNetworkHandler) OnHandshakeFailed(reason error) { m.handler.handshakeFailedMetric.Increment(m.client.IP) m.backend.OnHandshakeFailed(reason) } -func (m *metricsNetworkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) ( +func (m *metricsNetworkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata *auth2.ConnectionMetadata) ( connection sshserver.SSHConnectionHandler, failureReason error, ) { diff --git a/internal/metricsintegration/integration_test.go b/internal/metricsintegration/integration_test.go index cef6e9df..1ffc0551 100644 --- a/internal/metricsintegration/integration_test.go +++ b/internal/metricsintegration/integration_test.go @@ -6,7 +6,9 @@ import ( "net" "testing" + auth2 "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" + "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/internal/geoip/dummy" "github.com/containerssh/libcontainerssh/internal/metrics" "github.com/containerssh/libcontainerssh/internal/sshserver" @@ -137,7 +139,7 @@ func (d *dummyBackendHandler) OnDisconnect() { func (d *dummyBackendHandler) OnAuthPassword(_ string, _ []byte, _ string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return d.authResponse, nil, nil @@ -145,7 +147,7 @@ func (d *dummyBackendHandler) OnAuthPassword(_ string, _ []byte, _ string) ( func (d *dummyBackendHandler) OnAuthPubKey(_ string, _ string, _ string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return d.authResponse, nil, nil @@ -158,15 +160,19 @@ func (d *dummyBackendHandler) OnAuthKeyboardInteractive( questions sshserver.KeyboardInteractiveQuestions, ) (answers sshserver.KeyboardInteractiveAnswers, err error), _ string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { return d.authResponse, nil, nil } +func (d *dummyBackendHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return nil +} + func (d *dummyBackendHandler) OnHandshakeFailed(_ error) { } -func (d *dummyBackendHandler) OnHandshakeSuccess(_ string, _ string, _ map[string]string) ( +func (d *dummyBackendHandler) OnHandshakeSuccess(_ string, _ string, _ *auth2.ConnectionMetadata) ( connection sshserver.SSHConnectionHandler, failureReason error, ) { diff --git a/internal/security/handler_network.go b/internal/security/handler_network.go index f93d8148..5baa90c1 100644 --- a/internal/security/handler_network.go +++ b/internal/security/handler_network.go @@ -4,6 +4,8 @@ import ( "context" "sync" + auth2 "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/internal/auth" config2 "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/sshserver" "github.com/containerssh/libcontainerssh/log" @@ -22,7 +24,7 @@ func (n *networkHandler) OnAuthKeyboardInteractive( questions sshserver.KeyboardInteractiveQuestions, ) (answers sshserver.KeyboardInteractiveAnswers, err error), clientVersion string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { return n.backend.OnAuthKeyboardInteractive( user, challenge, @@ -36,7 +38,7 @@ func (n *networkHandler) OnShutdown(shutdownContext context.Context) { func (n *networkHandler) OnAuthPassword(username string, password []byte, clientVersion string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return n.backend.OnAuthPassword(username, password, clientVersion) @@ -44,17 +46,21 @@ func (n *networkHandler) OnAuthPassword(username string, password []byte, client func (n *networkHandler) OnAuthPubKey(username string, pubKey string, clientVersion string) ( response sshserver.AuthResponse, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, reason error, ) { return n.backend.OnAuthPubKey(username, pubKey, clientVersion) } +func (n *networkHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return n.backend.OnAuthGSSAPI() +} + func (n *networkHandler) OnHandshakeFailed(reason error) { n.backend.OnHandshakeFailed(reason) } -func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) ( +func (n *networkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata *auth2.ConnectionMetadata) ( connection sshserver.SSHConnectionHandler, failureReason error, ) { diff --git a/internal/ssh/enums.go b/internal/ssh/enums.go index 35a12c17..d6b8b303 100644 --- a/internal/ssh/enums.go +++ b/internal/ssh/enums.go @@ -11,3 +11,7 @@ const ( RequestTypeWindow RequestType = "window-change" RequestTypeSignal RequestType = "signal" ) + +const ( + SSH_MSG_USERAUTH_REQUEST = 50 +) diff --git a/internal/ssh/messages.go b/internal/ssh/messages.go index b929cac8..7c38edae 100644 --- a/internal/ssh/messages.go +++ b/internal/ssh/messages.go @@ -1,5 +1,9 @@ package ssh +import ( + "golang.org/x/crypto/ssh" +) + type EnvRequestPayload struct { Name string Value string @@ -46,3 +50,7 @@ type WindowRequestPayload struct { Width uint32 Height uint32 } + +func Unmarshal(data []byte, out interface{}) error { + return ssh.Unmarshal(data, out) +} diff --git a/internal/sshproxy/networkConnectionHandler.go b/internal/sshproxy/networkConnectionHandler.go index a51b3e37..4a499bff 100644 --- a/internal/sshproxy/networkConnectionHandler.go +++ b/internal/sshproxy/networkConnectionHandler.go @@ -7,10 +7,13 @@ import ( "sync" "time" + auth2 "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/internal/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/metrics" "github.com/containerssh/libcontainerssh/log" "github.com/containerssh/libcontainerssh/message" + "golang.org/x/crypto/ssh" "github.com/containerssh/libcontainerssh/internal/sshserver" @@ -33,7 +36,7 @@ type networkConnectionHandler struct { func (s *networkConnectionHandler) OnAuthPassword(_ string, _ []byte, _ string) ( _ sshserver.AuthResponse, - _ map[string]string, + _ *auth2.ConnectionMetadata, _ error, ) { return sshserver.AuthResponseUnavailable, nil, fmt.Errorf( @@ -43,7 +46,7 @@ func (s *networkConnectionHandler) OnAuthPassword(_ string, _ []byte, _ string) func (s *networkConnectionHandler) OnAuthPubKey(_ string, _ string, _ string) ( sshserver.AuthResponse, - map[string]string, + *auth2.ConnectionMetadata, error, ) { return sshserver.AuthResponseUnavailable, nil, fmt.Errorf( @@ -58,18 +61,22 @@ func (s *networkConnectionHandler) OnAuthKeyboardInteractive( _ sshserver.KeyboardInteractiveQuestions, ) (answers sshserver.KeyboardInteractiveAnswers, err error), _ string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { return sshserver.AuthResponseUnavailable, nil, fmt.Errorf( "ssh proxy does not support authentication", ) } +func (s *networkConnectionHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return nil +} + func (s *networkConnectionHandler) OnHandshakeFailed(_ error) {} func (s *networkConnectionHandler) OnHandshakeSuccess( username string, clientVersion string, - metadata map[string]string, + metadata *auth2.ConnectionMetadata, ) ( connection sshserver.SSHConnectionHandler, failureReason error, diff --git a/internal/sshserver/AbstractNetworkConnectionHandler.go b/internal/sshserver/AbstractNetworkConnectionHandler.go index c526cb35..9fdd9812 100644 --- a/internal/sshserver/AbstractNetworkConnectionHandler.go +++ b/internal/sshserver/AbstractNetworkConnectionHandler.go @@ -3,6 +3,9 @@ package sshserver import ( "context" "fmt" + + auth2 "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/internal/auth" ) // AbstractNetworkConnectionHandler is an empty implementation for the NetworkConnectionHandler interface. @@ -11,14 +14,14 @@ type AbstractNetworkConnectionHandler struct { // OnAuthPassword is called when a user attempts a password authentication. The implementation must always supply // AuthResponse and may supply error as a reason description. -func (a *AbstractNetworkConnectionHandler) OnAuthPassword(_ string, _ []byte, _ string) (response AuthResponse, metadata map[string]string, reason error) { +func (a *AbstractNetworkConnectionHandler) OnAuthPassword(_ string, _ []byte, _ string) (response AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { return AuthResponseUnavailable, nil, nil } // OnAuthPassword is called when a user attempts a pubkey authentication. The implementation must always supply // AuthResponse and may supply error as a reason description. The pubKey parameter is an SSH key in // the form of "ssh-rsa KEY HERE". -func (a *AbstractNetworkConnectionHandler) OnAuthPubKey(_ string, _ string, _ string) (response AuthResponse, metadata map[string]string, reason error) { +func (a *AbstractNetworkConnectionHandler) OnAuthPubKey(_ string, _ string, _ string) (response AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { return AuthResponseUnavailable, nil, nil } @@ -32,10 +35,14 @@ func (a *AbstractNetworkConnectionHandler) OnAuthKeyboardInteractive( questions KeyboardInteractiveQuestions, ) (answers KeyboardInteractiveAnswers, err error), _ string, -) (response AuthResponse, metadata map[string]string, reason error) { +) (response AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { return AuthResponseUnavailable, nil, nil } +func (a *AbstractNetworkConnectionHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return nil +} + // OnHandshakeFailed is called when the SSH handshake failed. This method is also called after an authentication // failure. After this method is the connection will be closed and the OnDisconnect method will be // called. diff --git a/internal/sshserver/Server_test.go b/internal/sshserver/Server_test.go index 51c3dcb8..bde94938 100644 --- a/internal/sshserver/Server_test.go +++ b/internal/sshserver/Server_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + auth2 "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" "github.com/containerssh/libcontainerssh/internal/sshserver" "github.com/containerssh/libcontainerssh/internal/structutils" @@ -132,6 +133,80 @@ func TestAuthKeyboardInteractive(t *testing.T) { defer srv.Stop(10 * time.Second) } +type gssApiClient struct { + username string +} + +func (c *gssApiClient) InitSecContext(target string, token []byte, isGSSDelegCreds bool) (outputToken []byte, needContinue bool, err error) { + if token == nil { + return []byte(c.username), true, nil + } else { + if string(token) != c.username { + return []byte{}, false, fmt.Errorf("Invalid test token, expecting username") + } + return []byte{}, false, nil + } +} + +func (c *gssApiClient) GetMIC(micField []byte) ([]byte, error) { + return append(micField, []byte(c.username)...), nil +} + +func (c *gssApiClient) DeleteSecContext() error { + return nil +} + +// Test the GSSAPI plumbing within the sshserver +func TestAuthGSSAPI(t *testing.T) { + user2 := sshserver.NewTestUser("foo") + logger := log.NewTestLogger(t) + + sshconf := config.SSHConfig{} + structutils.Defaults(&sshconf) + + srv := sshserver.NewTestServer( + t, + sshserver.NewTestAuthenticationHandler( + sshserver.NewTestHandler(), + user2, + ), + logger, + nil, + ) + srv.Start() + + gssClient := gssApiClient{ + username: "foo", + } + sshConfig := &ssh.ClientConfig{ + User: "foo", + Auth: []ssh.AuthMethod{ssh.GSSAPIWithMICAuthMethod(&gssClient, "testing.containerssh.io")}, + } + sshConfig.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { + marshaledKey := key.Marshal() + private, err := ssh.ParsePrivateKey([]byte(srv.GetHostKey())) + if err != nil { + panic(err) + } + if bytes.Equal(marshaledKey, private.PublicKey().Marshal()) { + return nil + } + return fmt.Errorf("invalid host") + } + + sshConnection, err := ssh.Dial("tcp", srv.GetListen(), sshConfig) + if err != nil { + if !strings.Contains(err.Error(), "unable to authenticate") { + assert.Fail(t, "handshake failed for non-auth reasons", err) + } + } else { + _ = sshConnection.Close() + assert.Fail(t, "authentication succeeded", err) + } + + defer srv.Stop(10 * time.Second) +} + func TestSessionSuccess(t *testing.T) { //t.Parallel()() port := test.GetNextPort(t, "SSH") @@ -596,21 +671,21 @@ func (f *fullNetworkConnectionHandler) OnAuthPassword( username string, password []byte, _ string, -) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { if storedPassword, ok := f.handler.passwords[username]; ok && bytes.Equal(storedPassword, password) { return sshserver.AuthResponseSuccess, nil, nil } return sshserver.AuthResponseFailure, nil, fmt.Errorf("authentication failed") } -func (f *fullNetworkConnectionHandler) OnAuthPubKey(username string, pubKey string, _ string) (response sshserver.AuthResponse, metadata map[string]string, reason error) { +func (f *fullNetworkConnectionHandler) OnAuthPubKey(username string, pubKey string, _ string) (response sshserver.AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { if storedPubKey, ok := f.handler.pubKeys[username]; ok && storedPubKey == pubKey { return sshserver.AuthResponseSuccess, nil, nil } return sshserver.AuthResponseFailure, nil, fmt.Errorf("authentication failed") } -func (f *fullNetworkConnectionHandler) OnHandshakeSuccess(_ string, _ string, _ map[string]string) (connection sshserver.SSHConnectionHandler, failureReason error) { +func (f *fullNetworkConnectionHandler) OnHandshakeSuccess(_ string, _ string, _ *auth2.ConnectionMetadata) (connection sshserver.SSHConnectionHandler, failureReason error) { return &fullSSHConnectionHandler{ handler: f.handler, }, nil diff --git a/internal/sshserver/handler.go b/internal/sshserver/handler.go index 615c1e2e..498da02c 100644 --- a/internal/sshserver/handler.go +++ b/internal/sshserver/handler.go @@ -6,7 +6,10 @@ import ( "io" "net" + "github.com/containerssh/libcontainerssh/auth" + auth2 "github.com/containerssh/libcontainerssh/internal/auth" message2 "github.com/containerssh/libcontainerssh/message" + "golang.org/x/crypto/ssh" ) @@ -96,12 +99,12 @@ func (k *KeyboardInteractiveAnswers) GetByQuestionText(question string) (string, type NetworkConnectionHandler interface { // OnAuthPassword is called when a user attempts a password authentication. The implementation must always supply // AuthResponse and may supply error as a reason description. - OnAuthPassword(username string, password []byte, clientVersion string) (response AuthResponse, metadata map[string]string, reason error) + OnAuthPassword(username string, password []byte, clientVersion string) (response AuthResponse, metadata *auth.ConnectionMetadata, reason error) // OnAuthPassword is called when a user attempts a pubkey authentication. The implementation must always supply // AuthResponse and may supply error as a reason description. The pubKey parameter is an SSH key in // the form of "ssh-rsa KEY HERE". - OnAuthPubKey(username string, pubKey string, clientVersion string) (response AuthResponse, metadata map[string]string, reason error) + OnAuthPubKey(username string, pubKey string, clientVersion string) (response AuthResponse, metadata *auth.ConnectionMetadata, reason error) // OnAuthKeyboardInteractive is a callback for interactive authentication. The implementer will be passed a callback // function that can be used to issue challenges to the user. These challenges can, but do not have to contain @@ -113,7 +116,9 @@ type NetworkConnectionHandler interface { questions KeyboardInteractiveQuestions, ) (answers KeyboardInteractiveAnswers, err error), clientVersion string, - ) (response AuthResponse, metadata map[string]string, reason error) + ) (response AuthResponse, metadata *auth.ConnectionMetadata, reason error) + + OnAuthGSSAPI() auth2.GSSAPIServer // OnHandshakeFailed is called when the SSH handshake failed. This method is also called after an authentication // failure. After this method is the connection will be closed and the OnDisconnect method will be @@ -123,7 +128,7 @@ type NetworkConnectionHandler interface { // OnHandshakeSuccess is called when the SSH handshake was successful. It returns connection to process // requests, or failureReason to indicate that a backend error has happened. In this case, the // connection will be closed and OnDisconnect will be called. - OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) (connection SSHConnectionHandler, failureReason error) + OnHandshakeSuccess(username string, clientVersion string, metadata *auth.ConnectionMetadata) (connection SSHConnectionHandler, failureReason error) // OnDisconnect is called when the network connection is closed. OnDisconnect() diff --git a/internal/sshserver/serverImpl.go b/internal/sshserver/serverImpl.go index 9122e471..e85c423f 100644 --- a/internal/sshserver/serverImpl.go +++ b/internal/sshserver/serverImpl.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/containerssh/libcontainerssh/auth" "github.com/containerssh/libcontainerssh/config" ssh2 "github.com/containerssh/libcontainerssh/internal/ssh" "github.com/containerssh/libcontainerssh/log" @@ -129,8 +130,8 @@ func (s *serverImpl) disconnectClients(lifecycle service.Lifecycle, allClientsEx func (s *serverImpl) createPasswordAuthenticator( handlerNetworkConnection NetworkConnectionHandler, logger log.Logger, -) func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, map[string]string, error) { - return func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, map[string]string, error) { +) func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, *auth.ConnectionMetadata, error) { + return func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, *auth.ConnectionMetadata, error) { authResponse, metadata, err := handlerNetworkConnection.OnAuthPassword( conn.User(), password, @@ -221,8 +222,8 @@ func (s *serverImpl) logAuthSuccessful(logger log.Logger, conn ssh.ConnMetadata, func (s *serverImpl) createPubKeyAuthenticator( handlerNetworkConnection NetworkConnectionHandler, logger log.Logger, -) func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, map[string]string, error) { - return func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, map[string]string, error) { +) func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, *auth.ConnectionMetadata, error) { + return func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, *auth.ConnectionMetadata, error) { authorizedKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubKey))) authResponse, metadata, err := handlerNetworkConnection.OnAuthPubKey( conn.User(), @@ -249,11 +250,11 @@ func (s *serverImpl) createPubKeyAuthenticator( func (s *serverImpl) createKeyboardInteractiveHandler( handlerNetworkConnection *networkConnectionWrapper, logger log.Logger, -) func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, map[string]string, error) { +) func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, *auth.ConnectionMetadata, error) { return func( conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge, - ) (*ssh.Permissions, map[string]string, error) { + ) (*ssh.Permissions, *auth.ConnectionMetadata, error) { challengeWrapper := func( instruction string, questions KeyboardInteractiveQuestions, @@ -301,10 +302,11 @@ func (s *serverImpl) createConfiguration( handlerNetworkConnection *networkConnectionWrapper, logger log.Logger, ) *ssh.ServerConfig { - passwordCallback, pubkeyCallback, keyboardInteractiveCallback := s.createAuthenticators( + passwordCallback, pubkeyCallback, keyboardInteractiveCallback, gssConfig := s.createAuthenticators( handlerNetworkConnection, logger, ) + serverConfig := &ssh.ServerConfig{ Config: ssh.Config{ KeyExchanges: s.cfg.KexAlgorithms.StringList(), @@ -316,6 +318,7 @@ func (s *serverImpl) createConfiguration( PasswordCallback: passwordCallback, PublicKeyCallback: pubkeyCallback, KeyboardInteractiveCallback: keyboardInteractiveCallback, + GSSAPIWithMICConfig: gssConfig, ServerVersion: s.cfg.ServerVersion.String(), BannerCallback: func(conn ssh.ConnMetadata) string { return s.cfg.Banner }, } @@ -332,11 +335,71 @@ func (s *serverImpl) createAuthenticators( func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error), func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error), func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error), + *ssh.GSSAPIWithMICConfig, ) { passwordCallback := s.createPasswordCallback(handlerNetworkConnection, logger) pubkeyCallback := s.createPubKeyCallback(handlerNetworkConnection, logger) keyboardInteractiveCallback := s.createKeyboardInteractiveCallback(handlerNetworkConnection, logger) - return passwordCallback, pubkeyCallback, keyboardInteractiveCallback + gssConfig := s.createGSSAPIConfig(handlerNetworkConnection, logger) + return passwordCallback, pubkeyCallback, keyboardInteractiveCallback, gssConfig +} + +func (s *serverImpl) createGSSAPIConfig( + handlerNetworkConnection *networkConnectionWrapper, + logger log.Logger, +) (*ssh.GSSAPIWithMICConfig){ + var gssConfig *ssh.GSSAPIWithMICConfig + + gssServer := handlerNetworkConnection.OnAuthGSSAPI() + if gssServer != nil { + gssConfig = &ssh.GSSAPIWithMICConfig{ + AllowLogin: func(conn ssh.ConnMetadata, srcName string) (*ssh.Permissions, error) { + if !gssServer.Success() { + if gssServer.Error() == nil { + return nil, messageCodes.NewMessage( + messageCodes.ESSHAuthFailed, + "Authentication failed", + ) + } + return nil, gssServer.Error() + } + + if err := gssServer.AllowLogin(conn.User()); err != nil { + return nil, err + } + + metadata := gssServer.Metadata() + + s.logAuthSuccessful(logger, conn, "GSSAPI") + sshConnectionHandler, err := handlerNetworkConnection.OnHandshakeSuccess( + conn.User(), + string(conn.ClientVersion()), + metadata, + ) + if err != nil { + err = messageCodes.WrapUser( + err, + messageCodes.ESSHBackendRejected, + "Authentication currently unavailable, please try again later.", + "The backend has rejected the user after successful authentication.", + ) + logger.Error(err) + return nil, err + } + handlerNetworkConnection.sshConnectionHandler = sshConnectionHandler + return &ssh.Permissions{}, nil + }, + Server: gssServer, + } + } else { + logger.Info( + messageCodes.NewMessage( + messageCodes.ESSHAuthUnavailable, + "GSSAPI Authentication unsupported with current authentication method", + ), + ) + } + return gssConfig } func (s *serverImpl) createKeyboardInteractiveCallback( diff --git a/internal/sshserver/testAuthenticationNetworkHandler.go b/internal/sshserver/testAuthenticationNetworkHandler.go index c7dc3fa8..675a9304 100644 --- a/internal/sshserver/testAuthenticationNetworkHandler.go +++ b/internal/sshserver/testAuthenticationNetworkHandler.go @@ -1,8 +1,13 @@ package sshserver import ( + "bytes" "context" "fmt" + "net" + + auth2 "github.com/containerssh/libcontainerssh/auth" + "github.com/containerssh/libcontainerssh/internal/auth" ) type testAuthenticationNetworkHandler struct { @@ -17,7 +22,7 @@ func (t *testAuthenticationNetworkHandler) OnAuthKeyboardInteractive( questions KeyboardInteractiveQuestions, ) (answers KeyboardInteractiveAnswers, err error), _ string, -) (response AuthResponse, metadata map[string]string, reason error) { +) (response AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { var foundUser *TestUser for _, user := range t.rootHandler.users { if user.Username() == username { @@ -64,7 +69,7 @@ func (t *testAuthenticationNetworkHandler) OnShutdown(shutdownContext context.Co t.backend.OnShutdown(shutdownContext) } -func (t *testAuthenticationNetworkHandler) OnAuthPassword(username string, password []byte, clientVersion string) (response AuthResponse, metadata map[string]string, reason error) { +func (t *testAuthenticationNetworkHandler) OnAuthPassword(username string, password []byte, clientVersion string) (response AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { for _, user := range t.rootHandler.users { if user.username == username && user.password == string(password) { return AuthResponseSuccess, nil, nil @@ -73,7 +78,7 @@ func (t *testAuthenticationNetworkHandler) OnAuthPassword(username string, passw return AuthResponseFailure, nil, ErrAuthenticationFailed } -func (t *testAuthenticationNetworkHandler) OnAuthPubKey(username string, pubKey string, clientVersion string) (response AuthResponse, metadata map[string]string, reason error) { +func (t *testAuthenticationNetworkHandler) OnAuthPubKey(username string, pubKey string, clientVersion string) (response AuthResponse, metadata *auth2.ConnectionMetadata, reason error) { for _, user := range t.rootHandler.users { if user.username == username { for _, authorizedKey := range user.authorizedKeys { @@ -86,11 +91,74 @@ func (t *testAuthenticationNetworkHandler) OnAuthPubKey(username string, pubKey return AuthResponseFailure, nil, ErrAuthenticationFailed } +// NOTE: This is a dummy implementation to test the plumbing, by no means how +// GSSAPI is supposed to work :) +type gssApiServer struct { + username string + success bool +} + + +func (s *gssApiServer) AcceptSecContext(token []byte) (outputToken []byte, srcName string, needContinue bool, err error) { + s.username = string(token) + s.success = true + return token, s.username, false, nil +} + +func (s *gssApiServer) VerifyMIC(micField []byte, micToken []byte) error { + if bytes.Equal(micField, []byte(s.username)) { + s.success = true + return nil + } + return fmt.Errorf("Invalid username") +} + +func (s *gssApiServer) DeleteSecContext() error { + return nil +} + +func (s *gssApiServer) AllowLogin(username string) error { + return nil +} + +func (s *gssApiServer) Error() error { + return nil +} + +func (s *gssApiServer) Success() bool { + return s.success +} + +func (s *gssApiServer) Metadata() *auth2.ConnectionMetadata { + return nil +} + +func (s *gssApiServer) OnDisconnect() { +} + +func (s *gssApiServer) Password(_ string, _ []byte, _ string, _ net.IP) auth.AuthenticationContext { + s.success = false + return s +} + +func (s *gssApiServer) PubKey(_ string, _ string, _ string, _ net.IP) auth.AuthenticationContext { + s.success = false + return s +} + +func (s *gssApiServer) GSSAPIConfig(connectionId string, addr net.IP) auth.GSSAPIServer { + return s +} + +func (t *testAuthenticationNetworkHandler) OnAuthGSSAPI() auth.GSSAPIServer { + return &gssApiServer{} +} + func (t *testAuthenticationNetworkHandler) OnHandshakeFailed(err error) { t.backend.OnHandshakeFailed(err) } -func (t *testAuthenticationNetworkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) ( +func (t *testAuthenticationNetworkHandler) OnHandshakeSuccess(username string, clientVersion string, metadata *auth2.ConnectionMetadata) ( connection SSHConnectionHandler, failureReason error, ) { diff --git a/internal/sshserver/testNetworkHandlerImpl.go b/internal/sshserver/testNetworkHandlerImpl.go index 3bef73c4..bbf271f8 100644 --- a/internal/sshserver/testNetworkHandlerImpl.go +++ b/internal/sshserver/testNetworkHandlerImpl.go @@ -3,6 +3,8 @@ package sshserver import ( "context" "net" + + "github.com/containerssh/libcontainerssh/auth" ) type testNetworkHandlerImpl struct { @@ -14,7 +16,7 @@ type testNetworkHandlerImpl struct { shutdown bool } -func (t *testNetworkHandlerImpl) OnHandshakeSuccess(username string, clientVersion string, metadata map[string]string) ( +func (t *testNetworkHandlerImpl) OnHandshakeSuccess(username string, clientVersion string, metadata *auth.ConnectionMetadata) ( connection SSHConnectionHandler, failureReason error, ) { diff --git a/internal/test/README.md b/internal/test/README.md index a8695e8f..08366528 100644 --- a/internal/test/README.md +++ b/internal/test/README.md @@ -60,8 +60,8 @@ import ( "testing" "github.com/containerssh/test" - "github.com/jcmturner/gokrb5/v8/client" - "github.com/jcmturner/gokrb5/v8/config" + "github.com/containerssh/gokrb5/v8/client" + "github.com/containerssh/gokrb5/v8/config" ) var krbConf = ` diff --git a/internal/test/kerberos.go b/internal/test/kerberos.go index 193f8c42..cf68363f 100644 --- a/internal/test/kerberos.go +++ b/internal/test/kerberos.go @@ -95,6 +95,8 @@ type KerberosHelper interface { AdminUsername() string // AdminPassword returns the password for the admin username from AdminUsername. AdminPassword() string + // Get service keytab + Keytab() []byte } type kerberosHelper struct { @@ -124,6 +126,10 @@ func (k *kerberosHelper) KDCPort() int { return 88 } +func (k *kerberosHelper) Keytab() []byte { + return k.cnt.extractFile("/test.keytab") +} + func (k *kerberosHelper) wait() { k.t.Log("Waiting for the KDC to come up...") tries := 0 diff --git a/internal/test/kerberos_test.go b/internal/test/kerberos_test.go index e368e0b6..1e1645e6 100644 --- a/internal/test/kerberos_test.go +++ b/internal/test/kerberos_test.go @@ -5,8 +5,8 @@ import ( "testing" "github.com/containerssh/libcontainerssh/internal/test" - "github.com/jcmturner/gokrb5/v8/client" - "github.com/jcmturner/gokrb5/v8/config" + "github.com/containerssh/gokrb5/v8/client" + "github.com/containerssh/gokrb5/v8/config" ) var krbConf = ` diff --git a/internal/test/krb/krb5.keytab b/internal/test/krb/krb5.keytab new file mode 100644 index 00000000..5a926f24 Binary files /dev/null and b/internal/test/krb/krb5.keytab differ diff --git a/internal/test/krb/root/etc/krb5.conf b/internal/test/krb/root/etc/krb5.conf index 7763b89f..deed1f84 100644 --- a/internal/test/krb/root/etc/krb5.conf +++ b/internal/test/krb/root/etc/krb5.conf @@ -12,6 +12,9 @@ forwardable = true [realms] +TESTING.CONTAINERSSH.IO = { + kdc = 127.0.0.1 +} [domain_realm] diff --git a/internal/test/krb/root/usr/local/bin/init.sh b/internal/test/krb/root/usr/local/bin/init.sh index 413f893e..74f34df9 100644 --- a/internal/test/krb/root/usr/local/bin/init.sh +++ b/internal/test/krb/root/usr/local/bin/init.sh @@ -25,6 +25,18 @@ kadmin.local -q "addprinc -pw ${KERBEROS_PASSWORD} ${KERBEROS_USERNAME}" echo -n "" >/etc/krb5kdc/kadm5.acl echo "${KERBEROS_USERNAME}@TESTING.CONTAINERSSH.IO *" >>/etc/krb5kdc/kadm5.acl +echo -e "\e[32mAdding host principal testing.containerssh.io ...\e[0m" +kadmin.local -q "addprinc -randkey host/testing.containerssh.io" + +echo -e "\e[32mGenerating keytab...\e[0m" +kadmin.local -q "ktadd -k /test.keytab host/testing.containerssh.io" + +echo -e "\e[32mCreating sample user...\e[0m" +kadmin.local -q "addprinc -policy users -pw test foo" + +echo -e "\e[32mCreating secondary user...\e[0m" +kadmin.local -q "addprinc -policy users -pw pwbar bar" + trap finish SIGCHLD EXIT finish() { echo -e "\e[33mSignal received, exiting...\e[0m" diff --git a/message/auth.go b/message/auth.go index 2721c343..7fdbe700 100644 --- a/message/auth.go +++ b/message/auth.go @@ -96,3 +96,15 @@ const EAuthDeviceFlowRateLimitExceeded = "OAUTH_DEVICE_FLOW_RATE_LIMIT_EXCEEDED" // EAuthGitHubUsernameDoesNotMatch indicates that the user specified a username other than their GitHub login and // enforceUsername was set to on. const EAuthGitHubUsernameDoesNotMatch = "GITHUB_USERNAME_DOES_NOT_MATCH" + +// EAuthKerberosVerificationFailed indicates that there was an error verifying the kerberos ticket sent by the client +const EAuthKerberosVerificationFailed = "KRB_VERIFY_ERROR" + +// EAuthKerberosUsernameDoesNotMatch indicates that the user tried to a user other than their own and enforceUsername was set to on +const EAuthKerberosUsernameDoesNotMatch = "KRB_USERNAME_DOES_NOT_MATCH" + +// EAuthKerberosBackendError indicates that there was an error contacting the authorization server +const EAuthKerberosBackendError = "KRB_BACKEND_ERROR" + +// EAuthzFailed indicates that the authorization server rejected the user +const EAuthzFailed = "AUTHZ_FAILED" diff --git a/message/docker.go b/message/docker.go index 414ec5ec..fbfba92f 100644 --- a/message/docker.go +++ b/message/docker.go @@ -183,3 +183,9 @@ const EDockerProgramNotRunning = "DOCKER_PROGRAM_NOT_RUNNING" // removed since ContainerSSH 0.5. To fix this error please remove the dockerrun segment from your configuration or // configuration server response. For details please see https://containerssh.io/deprecations/dockerrun/ . const EDockerRunRemoved = "DOCKER_RUN_REMOVED" + +// EDockerWriteFileFailed indicates that the ContainerSSH docker backend failed to write to a file in the container +const EDockerWriteFileFailed = "DOCKER_FILE_WRITE_FAILED" + +// EDockerFileWrite indicates that the ContainerSSH docker backend wrote a file inside the container +const MDockerFileWrite = "DOCKER_FILE_WRITE" diff --git a/message/kubernetes.go b/message/kubernetes.go index 048c1367..244b6805 100644 --- a/message/kubernetes.go +++ b/message/kubernetes.go @@ -65,6 +65,12 @@ const EKubernetesCannotSendSignalNoAgent = "KUBERNETES_EXEC_SIGNAL_FAILED_NO_AGE // MKubernetesExecSignalSuccessful indicates that the ContainerSSH Kubernetes module successfully delivered the requested signal. const MKubernetesExecSignalSuccessful = "KUBERNETES_EXEC_SIGNAL_SUCCESSFUL" +// MKubernetesFileModification indicates that the ContainerSSH Kubernetes module is modifying a file on the container based on connection metadata +const MKubernetesFileModification = "KUBERNETES_FILE_WRITE" + +// EKubernetesFileModificationFailed indicates that the ContainerSSH Kubernetes module failed to modify a file on the container +const EKubernetesFileModificationFailed = "KUBERNETES_FILE_WRITE_FAILED" + // EKubernetesFetchingExitCodeFailed indicates that the ContainerSSH Kubernetes module has failed to fetch the exit code of the // program. const EKubernetesFetchingExitCodeFailed = "KUBERNETES_EXIT_CODE_FAILED"