diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 0d599c2f..f683ad17 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -87,7 +87,7 @@ jobs: - name: Run go test run: | set -euo pipefail - go test -json -p 1 -v ./... 2>&1 | tee /tmp/gotest.log + go test -coverprofile=coverage.txt -covermode=atomic -json -p 1 -v ./... 2>&1 | tee /tmp/gotest.log - name: Format log output if: always() run: | @@ -100,3 +100,7 @@ jobs: name: test-log path: /tmp/gotest.log if-no-files-found: error + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/cmd/containerssh-testauthconfigserver/main.go b/cmd/containerssh-testauthconfigserver/main.go index 6e5c4b0d..b83ced47 100644 --- a/cmd/containerssh-testauthconfigserver/main.go +++ b/cmd/containerssh-testauthconfigserver/main.go @@ -3,16 +3,16 @@ // This OpenAPI document describes the API endpoints that are required for implementing an authentication // and configuration server for ContainerSSH. (See https://github.com/containerssh/libcontainerssh for details.) // -// Schemes: http, https -// Host: localhost -// BasePath: / -// Version: 0.5.0 +// Schemes: http, https +// Host: localhost +// BasePath: / +// Version: 0.5.0 // -// Consumes: -// - application/json +// Consumes: +// - application/json // -// Produces: -// - application/json +// Produces: +// - application/json // // swagger:meta package main @@ -40,19 +40,21 @@ type authHandler struct { // swagger:operation POST /password Authentication authPassword // -// Password authentication +// # Password authentication // // --- // parameters: -// - name: request -// in: body -// description: The authentication request -// required: true -// schema: +// - name: request +// in: body +// description: The authentication request +// required: true +// schema: // "$ref": "#/definitions/PasswordAuthRequest" +// // responses: -// "200": -// "$ref": "#/responses/AuthResponse" +// +// "200": +// "$ref": "#/responses/AuthResponse" func (a *authHandler) OnPassword(meta metadata.ConnectionAuthPendingMetadata, Password []byte) ( bool, metadata.ConnectionAuthenticatedMetadata, @@ -60,53 +62,57 @@ func (a *authHandler) OnPassword(meta metadata.ConnectionAuthPendingMetadata, Pa ) { if os.Getenv("CONTAINERSSH_ALLOW_ALL") == "1" || meta.Username == "foo" || - meta.Username == "busybox" { - return true, meta.Authenticated(meta.Username), nil + meta.Username == "busybox" || meta.Username == "foonoauthz" { + return string(Password) == "bar", meta.Authenticated(meta.Username), nil } return false, meta.AuthFailed(), nil } // swagger:operation POST /pubkey Authentication authPubKey // -// Public key authentication +// # Public key authentication // // --- // parameters: -// - name: request -// in: body -// description: The authentication request -// required: true -// schema: +// - name: request +// in: body +// description: The authentication request +// required: true +// schema: // "$ref": "#/definitions/PublicKeyAuthRequest" +// // responses: -// "200": -// "$ref": "#/responses/AuthResponse" +// +// "200": +// "$ref": "#/responses/AuthResponse" func (a *authHandler) OnPubKey(meta metadata.ConnectionAuthPendingMetadata, publicKey publicAuth.PublicKey) ( bool, metadata.ConnectionAuthenticatedMetadata, error, ) { - if meta.Username == "foo" || meta.Username == "busybox" { - return true, meta.Authenticated(meta.Username), nil + if meta.Username == "foo" || meta.Username == "busybox" || meta.Username == "foonoauthz" { + return false, meta.Authenticated(meta.Username), nil } return false, meta.AuthFailed(), nil } // swagger:operation POST /authz Authentication authz // -// Authorization +// # Authorization // // --- // parameters: -// - name: request -// in: body -// description: The authorization request -// required: true -// schema: +// - name: request +// in: body +// description: The authorization request +// required: true +// schema: // "$ref": "#/definitions/AuthorizationRequest" +// // responses: -// "200": -// "$ref": "#/responses/AuthResponse" +// +// "200": +// "$ref": "#/responses/AuthResponse" func (a *authHandler) OnAuthorization(meta metadata.ConnectionAuthenticatedMetadata) ( bool, metadata.ConnectionAuthenticatedMetadata, @@ -127,14 +133,16 @@ type configHandler struct { // // --- // parameters: -// - name: request -// in: body -// description: The configuration request -// schema: +// - name: request +// in: body +// description: The configuration request +// schema: // "$ref": "#/definitions/ConfigRequest" +// // responses: -// "200": -// "$ref": "#/responses/ConfigResponse" +// +// "200": +// "$ref": "#/responses/ConfigResponse" func (c *configHandler) OnConfig(request config.Request) (config.AppConfig, error) { cfg := config.AppConfig{} @@ -156,6 +164,8 @@ type handler struct { func (h *handler) ServeHTTP(writer goHttp.ResponseWriter, request *goHttp.Request) { switch request.URL.Path { + case "/authz": + fallthrough case "/password": fallthrough case "/pubkey": diff --git a/config/auth.go b/config/auth.go index e02552e4..b3ae85de 100644 --- a/config/auth.go +++ b/config/auth.go @@ -672,7 +672,7 @@ func (o *AuthGenericConfig) Validate() error { type AuthzConfig struct { Method AuthzMethod `json:"method" yaml:"method" default:""` - AuthWebhookClientConfig `json:",inline" yaml:",inline"` + Webhook AuthWebhookClientConfig `json:"webhook" yaml:"webhook"` } // Validate validates the authorization configuration. @@ -684,7 +684,7 @@ func (k *AuthzConfig) Validate() error { case AuthzMethodDisabled: return nil case AuthzMethodWebhook: - return wrap(k.AuthWebhookClientConfig.Validate(), "webhook") + return wrap(k.Webhook.Validate(), "webhook") default: return newError("method", "BUG: invalid value for method for authorization: %s", k.Method) } diff --git a/internal/auth/authentication_factory.go b/internal/auth/authentication_factory.go index f8aeb08b..c9d769af 100644 --- a/internal/auth/authentication_factory.go +++ b/internal/auth/authentication_factory.go @@ -114,7 +114,7 @@ func NewAuthorizationProvider( case config.AuthzMethodDisabled: return nil, nil, nil case config.AuthzMethodWebhook: - cli, err := NewWebhookClient(AuthenticationTypeAuthz, cfg.AuthWebhookClientConfig, logger, metrics) + cli, err := NewWebhookClient(AuthenticationTypeAuthz, cfg.Webhook, logger, metrics) return cli, nil, err default: return nil, nil, fmt.Errorf("unsupported method: %s", cfg.Method) diff --git a/internal/auth/webhook_client_impl.go b/internal/auth/webhook_client_impl.go index 0717f06e..721beec3 100644 --- a/internal/auth/webhook_client_impl.go +++ b/internal/auth/webhook_client_impl.go @@ -43,7 +43,13 @@ func (client *webhookClient) Authorize( client.logger.Debug(err) return &webhookClientContext{meta.AuthFailed(), false, err} } - return client.processAuthzWithRetry(meta) + + url := client.endpoint + "/authz" + authzRequest := auth.AuthorizationRequest{ + ConnectionAuthenticatedMetadata: meta, + } + + return client.processAuthzWithRetry(meta, url, authzRequest) } func (client *webhookClient) Password( @@ -268,10 +274,9 @@ func (client *webhookClient) authServerRequest(endpoint string, requestObject in func (client *webhookClient) processAuthzWithRetry( meta metadata.ConnectionAuthenticatedMetadata, + url string, + authzRequest interface{}, ) AuthenticationContext { - url := client.endpoint + "/authz" - authzRequest := auth.AuthorizationRequest{} - ctx, cancel := context.WithTimeout(context.Background(), client.timeout) defer cancel() var lastError error diff --git a/internal/auth/webhook_test.go b/internal/auth/webhook_test.go index b322d1ff..61b59189 100644 --- a/internal/auth/webhook_test.go +++ b/internal/auth/webhook_test.go @@ -70,6 +70,19 @@ func (h *handler) OnAuthorization(meta metadata.ConnectionAuthenticatedMetadata) metadata.ConnectionAuthenticatedMetadata, error, ) { + if meta.RemoteAddress.IP.String() != "127.0.0.1" { + return false, meta.AuthFailed(), fmt.Errorf("invalid IP: %s", meta.RemoteAddress.IP.String()) + } + if meta.ConnectionID != "0123456789ABCDEF" { + return false, meta.AuthFailed(), fmt.Errorf("invalid connection ID: %s", meta.ConnectionID) + } + if meta.AuthenticatedUsername == "foo" { + return true, meta.AuthFailed(), nil + } + if meta.Username == "crash" { + // Simulate a database failure + return false, meta.AuthFailed(), fmt.Errorf("database error") + } return false, meta.AuthFailed(), nil } @@ -143,6 +156,24 @@ func TestAuth(t *testing.T) { ) assert.NotEqual(t, nil, authenticationContext.Error()) assert.Equal(t, false, authenticationContext.Success()) + + authenticationContext = client.Authorize( + metadata.NewTestAuthenticatingMetadata("foo").Authenticated("foo"), + ) + assert.Equal(t, nil, authenticationContext.Error()) + assert.Equal(t, true, authenticationContext.Success()) + + authenticationContext = client.Authorize( + metadata.NewTestAuthenticatingMetadata("foo").Authenticated("foonoauthz"), + ) + assert.Equal(t, nil, authenticationContext.Error()) + assert.Equal(t, false, authenticationContext.Success()) + + authenticationContext = client.Authorize( + metadata.NewTestAuthenticatingMetadata("crash").Authenticated("crash"), + ) + assert.NotEqual(t, nil, authenticationContext.Error()) + assert.Equal(t, false, authenticationContext.Success()) }, ) } diff --git a/internal/authintegration/handler.go b/internal/authintegration/handler.go index 61a1d770..09d0b777 100644 --- a/internal/authintegration/handler.go +++ b/internal/authintegration/handler.go @@ -4,11 +4,11 @@ import ( "context" "net" - auth2 "go.containerssh.io/libcontainerssh/auth" - "go.containerssh.io/libcontainerssh/internal/auth" - "go.containerssh.io/libcontainerssh/internal/sshserver" - "go.containerssh.io/libcontainerssh/message" - "go.containerssh.io/libcontainerssh/metadata" + auth2 "go.containerssh.io/libcontainerssh/auth" + "go.containerssh.io/libcontainerssh/internal/auth" + "go.containerssh.io/libcontainerssh/internal/sshserver" + "go.containerssh.io/libcontainerssh/message" + "go.containerssh.io/libcontainerssh/metadata" ) // Behavior dictates how when the authentication requests are passed to the backends. @@ -76,7 +76,8 @@ func (h *handler) OnNetworkConnection(meta metadata.ConnectionMetadata) ( return nil, meta, err } } - return &networkConnectionHandler{ + + authHandler := networkConnectionHandler{ connectionID: meta.ConnectionID, ip: meta.RemoteAddress.IP, backend: backend, @@ -86,7 +87,20 @@ func (h *handler) OnNetworkConnection(meta metadata.ConnectionMetadata) ( gssapiAuthenticator: h.gssapiAuthenticator, keyboardInteractiveAuthenticator: h.keyboardInteractiveAuthenticator, authorizationProvider: h.authorizationProvider, - }, meta, nil + } + + if h.authorizationProvider != nil { + // We inject the authz handler before the normal authentication handler in the chain as we need the authenticated metadata the handler returns. + // Authentications request will first hit the authz handler which will pass it through to the authHandler, once it returns we can perform authorization. + authzHandler := authzNetworkConnectionHandler{ + connectionID: meta.ConnectionID, + ip: meta.RemoteAddress.IP, + authorizationProvider: h.authorizationProvider, + backend: &authHandler, + } + return &authzHandler, meta, nil + } + return &authHandler, meta, nil } type networkConnectionHandler struct { @@ -263,3 +277,142 @@ func (h *networkConnectionHandler) OnDisconnect() { } h.backend.OnDisconnect() } + +type authzNetworkConnectionHandler struct { + backend sshserver.NetworkConnectionHandler + ip net.IP + connectionID string + authorizationProvider auth.AuthzProvider +} + +// genericAuthorization is a helper function that takes the response of an authentication call (e.g. OnAuthPassword) and performs authorization. +func (a *authzNetworkConnectionHandler) genericAuthorization( + meta metadata.ConnectionAuthPendingMetadata, + authResponse sshserver.AuthResponse, + authenticatedMeta metadata.ConnectionAuthenticatedMetadata, + err error, +) (sshserver.AuthResponse, metadata.ConnectionAuthenticatedMetadata, error) { + if authResponse != sshserver.AuthResponseSuccess { + return authResponse, authenticatedMeta, err + } + + authzResponse := a.authorizationProvider.Authorize(authenticatedMeta) + if authzResponse.Success() { + return sshserver.AuthResponseSuccess, authzResponse.Metadata(), err + } + return sshserver.AuthResponseFailure, authzResponse.Metadata(), authzResponse.Error() +} + +// 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 *authzNetworkConnectionHandler) OnAuthPassword(meta metadata.ConnectionAuthPendingMetadata, password []byte) (sshserver.AuthResponse, metadata.ConnectionAuthenticatedMetadata, error) { + authResponse, authenticatedMeta, err := a.backend.OnAuthPassword(meta, password) + return a.genericAuthorization(meta, authResponse, authenticatedMeta, err) +} + +// OnAuthPubKey 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 *authzNetworkConnectionHandler) OnAuthPubKey(meta metadata.ConnectionAuthPendingMetadata, pubKey auth2.PublicKey) (sshserver.AuthResponse, metadata.ConnectionAuthenticatedMetadata, error) { + authResponse, authenticatedMeta, err := a.backend.OnAuthPubKey(meta, pubKey) + return a.genericAuthorization(meta, authResponse, authenticatedMeta, err) +} + +// 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 +// questions. +func (a *authzNetworkConnectionHandler) OnAuthKeyboardInteractive(meta metadata.ConnectionAuthPendingMetadata, challenge func( + instruction string, + questions sshserver.KeyboardInteractiveQuestions) (answers sshserver.KeyboardInteractiveAnswers, err error)) (sshserver.AuthResponse, metadata.ConnectionAuthenticatedMetadata, error) { + authResponse, authenticatedMeta, err := a.backend.OnAuthKeyboardInteractive(meta, challenge) + return a.genericAuthorization(meta, authResponse, authenticatedMeta, err) +} + +// OnAuthGSSAPI returns a GSSAPIServer which can perform a GSSAPI authentication. +func (a *authzNetworkConnectionHandler) OnAuthGSSAPI(metadata metadata.ConnectionMetadata) auth.GSSAPIServer { + gssApiServer := a.backend.OnAuthGSSAPI(metadata) + authzGssApiServer := authzGssApiServer{ + backend: gssApiServer, + } + return &authzGssApiServer +} + +// 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. +func (a *authzNetworkConnectionHandler) OnHandshakeFailed(metadata metadata.ConnectionMetadata, reason error) { + a.backend.OnHandshakeFailed(metadata, reason) +} + +// OnHandshakeSuccess is called when the SSH handshake was successful. It returns metadata to process +// requests, or failureReason to indicate that a backend error has happened. In this case, the +// metadata will be closed and OnDisconnect will be called. +func (a *authzNetworkConnectionHandler) OnHandshakeSuccess(metadata metadata.ConnectionAuthenticatedMetadata) (connection sshserver.SSHConnectionHandler, meta metadata.ConnectionAuthenticatedMetadata, failureReason error) { + return a.backend.OnHandshakeSuccess(metadata) +} + +// OnDisconnect is called when the network connection is closed. +func (a *authzNetworkConnectionHandler) OnDisconnect() { + a.backend.OnDisconnect() +} + +// OnShutdown is called when a shutdown of the SSH server is desired. The shutdownContext is passed as a deadline +// for the shutdown, after which the server should abort all running connections and return as fast as +// possible. +func (a *authzNetworkConnectionHandler) OnShutdown(shutdownContext context.Context) { + a.backend.OnShutdown(shutdownContext) +} + +type authzGssApiServer struct { + backend auth.GSSAPIServer + authorizationProvider auth.AuthzProvider + authzResponse auth.AuthorizationResponse +} + +// Success must return true or false of the authentication was successful / unsuccessful. +func (g *authzGssApiServer) Success() bool { + backendResponse := g.backend.Success() + if !backendResponse || g.authzResponse == nil { + return false + } + return g.authzResponse.Success() +} + +// Error returns the error that happened during the authentication. +func (g *authzGssApiServer) Error() error { + backendErr := g.backend.Error() + if backendErr != nil || g.authzResponse == nil { + return backendErr + } + return g.authzResponse.Error() +} + +// AcceptSecContext is the GSSAPI function to verify the tokens. +func (g *authzGssApiServer) AcceptSecContext(token []byte) (outputToken []byte, srcName string, needContinue bool, err error) { + return g.backend.AcceptSecContext(token) +} + +// VerifyMIC is the GSSAPI function to verify the MIC (Message Integrity Code). +func (g *authzGssApiServer) VerifyMIC(micField []byte, micToken []byte) error { + return g.backend.VerifyMIC(micField, micToken) +} + +// DeleteSecContext is the GSSAPI function to free all resources bound as part of an authentication attempt. +func (g *authzGssApiServer) DeleteSecContext() error { + return g.backend.DeleteSecContext() +} + +// 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. +func (g *authzGssApiServer) AllowLogin(username string, meta metadata.ConnectionAuthPendingMetadata) (metadata.ConnectionAuthenticatedMetadata, error) { + authenticatedMetadata, err := g.backend.AllowLogin(username, meta) + if err != nil { + return authenticatedMetadata, err + } + + authzResponse := g.authorizationProvider.Authorize(authenticatedMetadata) + g.authzResponse = authzResponse + return authzResponse.Metadata(), authzResponse.Error() +} diff --git a/internal/authintegration/integration_test.go b/internal/authintegration/integration_test.go index 25e7c594..3478dbdc 100644 --- a/internal/authintegration/integration_test.go +++ b/internal/authintegration/integration_test.go @@ -34,8 +34,11 @@ func TestAuthentication(t *testing.T) { sshServerConfig, lifecycle := startSSHServer(t, logger, authServerPort) defer lifecycle.Stop(context.Background()) - testConnection(t, ssh.Password("bar"), sshServerConfig, true) - testConnection(t, ssh.Password("baz"), sshServerConfig, false) + testConnection(t, "foo", ssh.Password("bar"), sshServerConfig, true) + testConnection(t, "foo", ssh.Password("baz"), sshServerConfig, false) + + testConnection(t, "foonoauthz", ssh.Password("bar"), sshServerConfig, false) + testConnection(t, "foonoauthz", ssh.Password("baz"), sshServerConfig, false) } func startAuthServer(t *testing.T, logger log.Logger, authServerPort int) service.Lifecycle { @@ -66,17 +69,22 @@ func startAuthServer(t *testing.T, logger log.Logger, authServerPort int) servic func startSSHServer(t *testing.T, logger log.Logger, authServerPort int) (config.SSHConfig, service.Lifecycle) { backend := &testBackend{} collector := metrics.New(dummy.New()) + webhookConfig := config.AuthWebhookClientConfig{ + HTTPClientConfiguration: config.HTTPClientConfiguration{ + URL: fmt.Sprintf("http://127.0.0.1:%d", authServerPort), + Timeout: 10 * time.Second, + }, + AuthTimeout: 30 * time.Second, + } handler, _, err := authintegration.New( config.AuthConfig{ PasswordAuth: config.PasswordAuthConfig{ - Method: config.PasswordAuthMethodWebhook, - Webhook: config.AuthWebhookClientConfig{ - HTTPClientConfiguration: config.HTTPClientConfiguration{ - URL: fmt.Sprintf("http://127.0.0.1:%d", authServerPort), - Timeout: 10 * time.Second, - }, - AuthTimeout: 30 * time.Second, - }, + Method: config.PasswordAuthMethodWebhook, + Webhook: webhookConfig, + }, + Authz: config.AuthzConfig{ + Method: config.AuthzMethodWebhook, + Webhook: webhookConfig, }, }, backend, @@ -112,10 +120,10 @@ func startSSHServer(t *testing.T, logger log.Logger, authServerPort int) (config return sshServerConfig, lifecycle } -func testConnection(t *testing.T, authMethod ssh.AuthMethod, sshServerConfig config.SSHConfig, success bool) { +func testConnection(t *testing.T, username string, authMethod ssh.AuthMethod, sshServerConfig config.SSHConfig, success bool) { clientConfig := ssh.ClientConfig{ Config: ssh.Config{}, - User: "foo", + User: username, Auth: []ssh.AuthMethod{authMethod}, // We don't care about host key verification for this test. HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec @@ -234,7 +242,7 @@ func (h *authHandler) OnPassword( meta metadata.ConnectionAuthPendingMetadata, Password []byte, ) (bool, metadata.ConnectionAuthenticatedMetadata, error) { - if meta.Username == "foo" && string(Password) == "bar" { + if (meta.Username == "foo" || meta.Username == "foonoauthz") && string(Password) == "bar" { return true, meta.Authenticated(meta.Username), nil } if meta.Username == "crash" { @@ -254,6 +262,9 @@ func (h *authHandler) OnPubKey( func (h *authHandler) OnAuthorization( meta metadata.ConnectionAuthenticatedMetadata, ) (bool, metadata.ConnectionAuthenticatedMetadata, error) { + if meta.AuthenticatedUsername == "foo" { + return true, meta, nil + } return false, meta.AuthFailed(), nil }