mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
* Move the reexec parts of lib/srv to session/reexec * Update references to the moved bits of lib/srv * Avoid testutils in session/reexec * Shuffle some constants around to avoid imports in session/reexec * Vendor in the relevant parts of logutils in session/reexec * Remove unnecessary symlink checks when opening files * Inline the last two things from lib/utils in session/reexec * Inline moved constants * Deprecate group name consts and fix missed inlines * Add missing session/reexec.TestMain with reexec check * Reuse existing constants from the log constants package in session * Fix broken godoc link
11634 lines
365 KiB
Go
11634 lines
365 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2023 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package web
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coreos/go-semver/semver"
|
|
"github.com/gogo/protobuf/proto"
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/gravitational/roundtrip"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/julienschmidt/httprouter"
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
commonv1 "go.opentelemetry.io/proto/otlp/common/v1"
|
|
resourcev1 "go.opentelemetry.io/proto/otlp/resource/v1"
|
|
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
|
|
"golang.org/x/crypto/ssh"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
"google.golang.org/protobuf/testing/protocmp"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
|
"k8s.io/apimachinery/pkg/util/yaml"
|
|
authztypes "k8s.io/client-go/kubernetes/typed/authorization/v1"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
|
|
|
"github.com/gravitational/teleport"
|
|
"github.com/gravitational/teleport/api/breaker"
|
|
authproto "github.com/gravitational/teleport/api/client/proto"
|
|
"github.com/gravitational/teleport/api/client/webclient"
|
|
"github.com/gravitational/teleport/api/constants"
|
|
apidefaults "github.com/gravitational/teleport/api/defaults"
|
|
componentfeaturesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/componentfeatures/v1"
|
|
devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1"
|
|
kubeproto "github.com/gravitational/teleport/api/gen/proto/go/teleport/kube/v1"
|
|
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
|
|
transportpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/transport/v1"
|
|
"github.com/gravitational/teleport/api/mfa"
|
|
"github.com/gravitational/teleport/api/types"
|
|
apievents "github.com/gravitational/teleport/api/types/events"
|
|
apiutils "github.com/gravitational/teleport/api/utils"
|
|
"github.com/gravitational/teleport/api/utils/clientutils"
|
|
"github.com/gravitational/teleport/api/utils/grpc/interceptors"
|
|
"github.com/gravitational/teleport/api/utils/keys"
|
|
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
|
|
"github.com/gravitational/teleport/entitlements"
|
|
"github.com/gravitational/teleport/lib/agentless"
|
|
"github.com/gravitational/teleport/lib/auth"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/auth/authtest"
|
|
tlsutils "github.com/gravitational/teleport/lib/auth/keygen"
|
|
"github.com/gravitational/teleport/lib/auth/mocku2f"
|
|
"github.com/gravitational/teleport/lib/auth/state"
|
|
"github.com/gravitational/teleport/lib/auth/testauthority"
|
|
wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes"
|
|
"github.com/gravitational/teleport/lib/authz"
|
|
"github.com/gravitational/teleport/lib/automaticupgrades"
|
|
"github.com/gravitational/teleport/lib/bpf"
|
|
"github.com/gravitational/teleport/lib/client"
|
|
"github.com/gravitational/teleport/lib/client/conntest"
|
|
dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
|
|
"github.com/gravitational/teleport/lib/client/sso"
|
|
"github.com/gravitational/teleport/lib/componentfeatures"
|
|
"github.com/gravitational/teleport/lib/cryptosuites"
|
|
"github.com/gravitational/teleport/lib/cryptosuites/cryptosuitestest"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/events/eventstest"
|
|
"github.com/gravitational/teleport/lib/healthcheck"
|
|
"github.com/gravitational/teleport/lib/httplib"
|
|
"github.com/gravitational/teleport/lib/httplib/csrf"
|
|
"github.com/gravitational/teleport/lib/inventory"
|
|
kubeproxy "github.com/gravitational/teleport/lib/kube/proxy"
|
|
"github.com/gravitational/teleport/lib/limiter"
|
|
"github.com/gravitational/teleport/lib/modules"
|
|
"github.com/gravitational/teleport/lib/modules/modulestest"
|
|
"github.com/gravitational/teleport/lib/multiplexer"
|
|
"github.com/gravitational/teleport/lib/observability/tracing"
|
|
"github.com/gravitational/teleport/lib/player"
|
|
"github.com/gravitational/teleport/lib/proxy"
|
|
"github.com/gravitational/teleport/lib/reversetunnel"
|
|
"github.com/gravitational/teleport/lib/reversetunnelclient"
|
|
"github.com/gravitational/teleport/lib/secret"
|
|
"github.com/gravitational/teleport/lib/service/servicecfg"
|
|
"github.com/gravitational/teleport/lib/services"
|
|
"github.com/gravitational/teleport/lib/session"
|
|
"github.com/gravitational/teleport/lib/srv"
|
|
"github.com/gravitational/teleport/lib/srv/desktop"
|
|
"github.com/gravitational/teleport/lib/srv/desktop/tdp"
|
|
"github.com/gravitational/teleport/lib/srv/desktop/tdp/protocol/legacy"
|
|
"github.com/gravitational/teleport/lib/srv/regular"
|
|
"github.com/gravitational/teleport/lib/srv/transport/transportv1"
|
|
"github.com/gravitational/teleport/lib/sshutils"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
"github.com/gravitational/teleport/lib/ui"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
utilsaws "github.com/gravitational/teleport/lib/utils/aws"
|
|
"github.com/gravitational/teleport/lib/utils/log/logtest"
|
|
"github.com/gravitational/teleport/lib/utils/set"
|
|
"github.com/gravitational/teleport/lib/utils/testutils"
|
|
"github.com/gravitational/teleport/lib/web/app"
|
|
websession "github.com/gravitational/teleport/lib/web/session"
|
|
"github.com/gravitational/teleport/lib/web/terminal"
|
|
webui "github.com/gravitational/teleport/lib/web/ui"
|
|
"github.com/gravitational/teleport/session/pam/pamcfg"
|
|
"github.com/gravitational/teleport/session/reexec"
|
|
)
|
|
|
|
const hostID = "00000000-0000-0000-0000-000000000000"
|
|
|
|
type WebSuite struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
node *regular.Server
|
|
proxy *regular.Server
|
|
proxyTunnel reversetunnelclient.Server
|
|
srvID string
|
|
|
|
user string
|
|
webServer *httptest.Server
|
|
webHandler *APIHandler
|
|
|
|
mockU2F *mocku2f.Key
|
|
server *authtest.Server
|
|
proxyClient *authclient.Client
|
|
clock *clockwork.FakeClock
|
|
}
|
|
|
|
// TestMain will re-execute Teleport to run a command if "exec" is passed to
|
|
// it as an argument. Otherwise, it will run tests as normal.
|
|
func TestMain(m *testing.M) {
|
|
logtest.InitLogger(testing.Verbose)
|
|
modules.SetInsecureTestMode(true)
|
|
// If the test is re-executing itself, execute the command that comes over
|
|
// the pipe.
|
|
if reexec.IsReexec() {
|
|
reexec.RunAndExit(os.Args[1])
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cryptosuitestest.PrecomputeRSAKeys(ctx)
|
|
|
|
// Otherwise run tests as normal.
|
|
code := m.Run()
|
|
cancel()
|
|
os.Exit(code)
|
|
}
|
|
|
|
func newWebSuite(t *testing.T) *WebSuite {
|
|
return newWebSuiteWithConfig(t, webSuiteConfig{})
|
|
}
|
|
|
|
type webSuiteConfig struct {
|
|
// AuthPreferenceSpec is custom initial AuthPreference spec for the test.
|
|
authPreferenceSpec *types.AuthPreferenceSpecV2
|
|
|
|
disableDiskBasedRecording bool
|
|
|
|
onSessionRecordEvent func(ctx context.Context, sid session.ID, pe apievents.PreparedSessionEvent) error
|
|
|
|
uiConfig webclient.UIConfig
|
|
|
|
// Custom "HealthCheckAppServer" function. Can be used to avoid dialing app
|
|
// services.
|
|
HealthCheckAppServer healthCheckAppServerFunc
|
|
|
|
// ClusterFeatures allows overriding default auth server features
|
|
ClusterFeatures *authproto.Features
|
|
|
|
// presenceChecker executes presence prompts for tests
|
|
presenceChecker PresenceChecker
|
|
|
|
// clock to use for all server components
|
|
clock *clockwork.FakeClock
|
|
|
|
// databaseREPLGetter allows setting custom database REPLs.
|
|
databaseREPLGetter dbrepl.REPLRegistry
|
|
|
|
// alpnHandler allows setting custom alpnHandler.
|
|
alpnHandler ConnectionHandler
|
|
|
|
// modules to inject into components.
|
|
modules *modulestest.Modules
|
|
|
|
// middleware adds optional middleware to the test handler.
|
|
middleware func(next http.Handler) http.Handler
|
|
}
|
|
|
|
func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite {
|
|
t.Helper()
|
|
mockU2F, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, mockU2F)
|
|
|
|
u, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
if cfg.clock == nil {
|
|
cfg.clock = clockwork.NewFakeClock()
|
|
}
|
|
|
|
if cfg.modules == nil {
|
|
cfg.modules = modulestest.OSSModules()
|
|
} else {
|
|
// TODO(tross) remove this when modules are injected into the auth server
|
|
modulestest.SetTestModules(t, *cfg.modules)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s := &WebSuite{
|
|
mockU2F: mockU2F,
|
|
clock: cfg.clock,
|
|
user: u.Username,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
networkingConfig, err := types.NewClusterNetworkingConfigFromConfigFile(types.ClusterNetworkingConfigSpecV2{
|
|
KeepAliveInterval: types.Duration(10 * time.Second),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
authCfg := authtest.ServerConfig{
|
|
Auth: authtest.AuthServerConfig{
|
|
ClusterName: "localhost",
|
|
Dir: t.TempDir(),
|
|
Clock: s.clock,
|
|
ClusterNetworkingConfig: networkingConfig,
|
|
AuthPreferenceSpec: cfg.authPreferenceSpec,
|
|
},
|
|
}
|
|
|
|
if cfg.disableDiskBasedRecording {
|
|
authCfg.Auth.AuditLog = events.NewDiscardAuditLog()
|
|
if cfg.onSessionRecordEvent != nil {
|
|
streamer, err := events.NewCallbackStreamer(
|
|
events.CallbackStreamerConfig{
|
|
// Inner emits events to the underlying store
|
|
Inner: events.NewDiscardStreamer(),
|
|
OnRecordEvent: cfg.onSessionRecordEvent,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
authCfg.Auth.Streamer = streamer
|
|
}
|
|
}
|
|
|
|
s.server, err = authtest.NewTestServer(authCfg)
|
|
require.NoError(t, err)
|
|
|
|
if cfg.disableDiskBasedRecording {
|
|
// use a sync recording mode because the disk-based uploader
|
|
// that runs in the background introduces races with test cleanup
|
|
recConfig := types.DefaultSessionRecordingConfig()
|
|
recConfig.SetMode(types.RecordAtNodeSync)
|
|
_, err := s.server.AuthServer.AuthServer.UpsertSessionRecordingConfig(context.Background(), recConfig)
|
|
require.NoError(t, err)
|
|
|
|
}
|
|
|
|
// Register the auth server, since test auth server doesn't start its own
|
|
// heartbeat.
|
|
err = s.server.Auth().UpsertAuthServer(ctx, &types.ServerV2{
|
|
Kind: types.KindAuthServer,
|
|
Version: types.V2,
|
|
Metadata: types.Metadata{
|
|
Namespace: apidefaults.Namespace,
|
|
Name: "auth",
|
|
},
|
|
Spec: types.ServerSpecV2{
|
|
Addr: s.server.TLS.Addr().String(),
|
|
Hostname: "localhost",
|
|
Version: teleport.Version,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
priv, pub, err := testauthority.GenerateKeyPair()
|
|
require.NoError(t, err)
|
|
|
|
tlsPub, err := authtest.PrivateKeyToPublicKeyTLS(priv)
|
|
require.NoError(t, err)
|
|
|
|
nodeID := "node"
|
|
|
|
// start node
|
|
certs, err := s.server.Auth().GenerateHostCerts(s.ctx,
|
|
auth.HostCertsParams{
|
|
Req: &authproto.HostCertsRequest{
|
|
HostID: hostID,
|
|
NodeName: nodeID,
|
|
Role: types.RoleNode,
|
|
PublicSSHKey: pub,
|
|
PublicTLSKey: tlsPub,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
signer, err := sshutils.NewSigner(priv, certs.SSH)
|
|
require.NoError(t, err)
|
|
|
|
nodeClient, err := s.server.NewClient(authtest.TestIdentity{
|
|
I: authz.BuiltinRole{
|
|
Role: types.RoleNode,
|
|
Username: nodeID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
nodeLockWatcher, err := services.NewLockWatcher(s.ctx, services.LockWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentNode,
|
|
Client: nodeClient,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
nodeSessionController, err := srv.NewSessionController(srv.SessionControllerConfig{
|
|
Semaphores: nodeClient,
|
|
AccessPoint: nodeClient,
|
|
LockEnforcer: nodeLockWatcher,
|
|
Emitter: nodeClient,
|
|
Component: teleport.ComponentNode,
|
|
ServerID: nodeID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// create SSH service:
|
|
nodeDataDir := t.TempDir()
|
|
node, err := regular.New(
|
|
ctx,
|
|
utils.NetAddr{AddrNetwork: "tcp", Addr: "127.0.0.1:0"},
|
|
nodeID,
|
|
sshutils.StaticHostSigners(signer),
|
|
nodeClient,
|
|
nodeDataDir,
|
|
"",
|
|
utils.NetAddr{},
|
|
nodeClient,
|
|
regular.SetUUID(nodeID),
|
|
regular.SetNamespace(apidefaults.Namespace),
|
|
regular.SetEmitter(nodeClient),
|
|
regular.SetPAMConfig(&pamcfg.PAMConfig{Enabled: false}),
|
|
regular.SetBPF(&bpf.NOP{}),
|
|
regular.SetClock(s.clock),
|
|
regular.SetLockWatcher(nodeLockWatcher),
|
|
regular.SetSessionController(nodeSessionController),
|
|
regular.SetConnectedProxyGetter(reversetunnel.NewConnectedProxyGetter()),
|
|
)
|
|
require.NoError(t, err)
|
|
s.node = node
|
|
s.srvID = node.ID()
|
|
require.NoError(t, s.node.Start())
|
|
|
|
// create reverse tunnel service:
|
|
proxyID := "proxy"
|
|
s.proxyClient, err = s.server.NewClient(authtest.TestIdentity{
|
|
I: authz.BuiltinRole{
|
|
Role: types.RoleProxy,
|
|
Username: proxyID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
revTunListener, err := net.Listen("tcp", fmt.Sprintf("%v:0", s.server.ClusterName()))
|
|
require.NoError(t, err)
|
|
|
|
proxyLockWatcher, err := services.NewLockWatcher(s.ctx, services.LockWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: s.proxyClient,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
proxyNodeWatcher, err := services.NewNodeWatcher(s.ctx, services.NodeWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: s.proxyClient,
|
|
},
|
|
NodesGetter: s.proxyClient,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
//nolint:staticcheck // SA1019 This should be updated to use [services.NewCertAuthorityWatcher]
|
|
caWatcher, err := services.DeprecatedNewCertAuthorityWatcher(s.ctx, services.CertAuthorityWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: s.proxyClient,
|
|
},
|
|
Types: []types.CertAuthType{types.HostCA, types.UserCA},
|
|
})
|
|
require.NoError(t, err)
|
|
defer caWatcher.Close()
|
|
|
|
proxyGitServerWatcher, err := services.NewGitServerWatcher(ctx, services.GitServerWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: s.proxyClient,
|
|
},
|
|
GitServerGetter: s.proxyClient.GitServerReadOnlyClient(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(proxyGitServerWatcher.Close)
|
|
|
|
appServerWatcher, err := services.NewAppServersWatcher(ctx, services.AppServersWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: s.proxyClient,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(appServerWatcher.Close)
|
|
|
|
databaseServerWatcher, err := services.NewDatabaseServerWatcher(ctx, services.DatabaseServerWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: s.proxyClient,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(databaseServerWatcher.Close)
|
|
|
|
revTunServer, err := reversetunnel.NewServer(reversetunnel.Config{
|
|
ID: node.ID(),
|
|
Listener: revTunListener,
|
|
GetClientTLSCertificate: func() (*tls.Certificate, error) {
|
|
return &s.proxyClient.TLSConfig().Certificates[0], nil
|
|
},
|
|
ClusterName: s.server.ClusterName(),
|
|
GetHostSigners: sshutils.StaticHostSigners(signer),
|
|
LocalAuthClient: s.proxyClient,
|
|
LocalAccessPoint: s.proxyClient,
|
|
Emitter: s.proxyClient,
|
|
NewCachingAccessPoint: noCache,
|
|
DataDir: t.TempDir(),
|
|
LockWatcher: proxyLockWatcher,
|
|
NodeWatcher: proxyNodeWatcher,
|
|
GitServerWatcher: proxyGitServerWatcher,
|
|
AppServerWatcher: appServerWatcher,
|
|
DatabaseServerWatcher: databaseServerWatcher,
|
|
CertAuthorityWatcher: caWatcher,
|
|
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
|
|
LocalAuthAddresses: []string{s.server.TLS.Addr().String()},
|
|
Clock: s.clock,
|
|
EICESigner: func(ctx context.Context, target types.Server, integration types.Integration, login, token string, ap cryptosuites.AuthPreferenceGetter) (ssh.Signer, error) {
|
|
return nil, errors.New("eice disabled in tests")
|
|
},
|
|
EICEDialer: func(ctx context.Context, target types.Server, integration types.Integration, token string) (net.Conn, error) {
|
|
return nil, errors.New("eice disabled in tests")
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
s.proxyTunnel = revTunServer
|
|
|
|
router, err := proxy.NewRouter(proxy.RouterConfig{
|
|
ClusterName: s.server.ClusterName(),
|
|
LocalAccessPoint: s.proxyClient,
|
|
ClusterGetter: revTunServer,
|
|
TracerProvider: tracing.NoopProvider(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
proxySessionController, err := srv.NewSessionController(srv.SessionControllerConfig{
|
|
Semaphores: s.proxyClient,
|
|
AccessPoint: s.proxyClient,
|
|
LockEnforcer: proxyLockWatcher,
|
|
Emitter: s.proxyClient,
|
|
Component: teleport.ComponentProxy,
|
|
ServerID: proxyID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// proxy server:
|
|
s.proxy, err = regular.New(
|
|
ctx,
|
|
utils.NetAddr{AddrNetwork: "tcp", Addr: "127.0.0.1:0"},
|
|
s.server.ClusterName(),
|
|
sshutils.StaticHostSigners(signer),
|
|
s.proxyClient,
|
|
t.TempDir(),
|
|
"",
|
|
utils.NetAddr{},
|
|
s.proxyClient,
|
|
regular.SetUUID(proxyID),
|
|
regular.SetProxyMode("", revTunServer, s.proxyClient, router),
|
|
regular.SetEmitter(s.proxyClient),
|
|
regular.SetNamespace(apidefaults.Namespace),
|
|
regular.SetBPF(&bpf.NOP{}),
|
|
regular.SetClock(s.clock),
|
|
regular.SetLockWatcher(proxyLockWatcher),
|
|
regular.SetSessionController(proxySessionController),
|
|
regular.SetConnectedProxyGetter(reversetunnel.NewConnectedProxyGetter()),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Expired sessions are purged immediately
|
|
var sessionLingeringThreshold time.Duration
|
|
|
|
features := *cfg.modules.Features().ToProto() // safe to dereference because ToProto creates a struct and return a pointer to it
|
|
if cfg.ClusterFeatures != nil {
|
|
features = *cfg.ClusterFeatures
|
|
}
|
|
authID := state.IdentityID{
|
|
Role: types.RoleProxy,
|
|
HostUUID: proxyID,
|
|
}
|
|
dns := []string{"localhost", "127.0.0.1"}
|
|
proxyIdentity, err := auth.LocalRegister(authID, s.server.Auth(), nil, dns, "", nil)
|
|
require.NoError(t, err)
|
|
proxyClientCert, err := keys.X509KeyPair(proxyIdentity.TLSCertBytes, proxyIdentity.KeyBytes)
|
|
require.NoError(t, err)
|
|
getProxyClientCert := func() (*tls.Certificate, error) {
|
|
return &proxyClientCert, nil
|
|
}
|
|
|
|
proxySigner, err := multiplexer.NewPROXYSigner(s.server.ClusterName(), getProxyClientCert, s.clock, false)
|
|
require.NoError(t, err)
|
|
|
|
handlerConfig := Config{
|
|
ClusterFeatures: features,
|
|
Modules: cfg.modules,
|
|
Proxy: revTunServer,
|
|
AuthServers: utils.FromAddr(s.server.TLS.Addr()),
|
|
ProxyClient: s.proxyClient,
|
|
CipherSuites: utils.DefaultCipherSuites(),
|
|
AccessPoint: s.proxyClient,
|
|
Context: s.ctx,
|
|
HostUUID: proxyID,
|
|
Emitter: s.proxyClient,
|
|
CachedSessionLingeringThreshold: &sessionLingeringThreshold,
|
|
ProxySettings: &ProxySettings{
|
|
ServiceConfig: servicecfg.MakeDefaultConfig(),
|
|
ProxySSHAddr: "127.0.0.1",
|
|
AccessPoint: s.server.Auth(),
|
|
},
|
|
SessionControl: SessionControllerFunc(func(ctx context.Context, sctx *SessionContext, login, localAddr, remoteAddr string) (context.Context, error) {
|
|
controller := srv.WebSessionController(proxySessionController)
|
|
ctx, err := controller(ctx, sctx, login, localAddr, remoteAddr)
|
|
return ctx, trace.Wrap(err)
|
|
}),
|
|
Router: router,
|
|
HealthCheckAppServer: cfg.HealthCheckAppServer,
|
|
UI: cfg.uiConfig,
|
|
PresenceChecker: cfg.presenceChecker,
|
|
GetProxyClientCertificate: getProxyClientCert,
|
|
IntegrationAppHandler: &mockIntegrationAppHandler{},
|
|
DatabaseREPLRegistry: cfg.databaseREPLGetter,
|
|
ALPNHandler: cfg.alpnHandler,
|
|
PROXYSigner: proxySigner,
|
|
}
|
|
|
|
if handlerConfig.HealthCheckAppServer == nil {
|
|
handlerConfig.HealthCheckAppServer = func(context.Context, string, string) error { return nil }
|
|
}
|
|
|
|
handler, err := NewHandler(handlerConfig, SetClock(s.clock))
|
|
require.NoError(t, err)
|
|
|
|
var httpTestHandler http.Handler = handler
|
|
if cfg.middleware != nil {
|
|
httpTestHandler = cfg.middleware(handler)
|
|
}
|
|
|
|
s.webServer = httptest.NewUnstartedServer(httpTestHandler)
|
|
s.webHandler = handler
|
|
s.webServer.StartTLS()
|
|
err = s.proxy.Start()
|
|
require.NoError(t, err)
|
|
|
|
// Wait for proxy to fully register before starting the test.
|
|
for start := time.Now(); ; {
|
|
//nolint:staticcheck // TODO(kiosion) DELETE IN 21.0.0
|
|
proxies, err := s.proxyClient.GetProxies()
|
|
require.NoError(t, err)
|
|
if len(proxies) != 0 {
|
|
break
|
|
}
|
|
if time.Since(start) > 5*time.Second {
|
|
t.Fatal("proxy didn't register within 5s after startup")
|
|
}
|
|
}
|
|
|
|
proxyAddr := utils.MustParseAddr(s.proxy.Addr())
|
|
|
|
addr := utils.MustParseAddr(s.webServer.Listener.Addr().String())
|
|
handler.handler.cfg.ProxyWebAddr = *addr
|
|
handler.handler.cfg.ProxySSHAddr = *proxyAddr
|
|
_, sshPort, err := net.SplitHostPort(proxyAddr.String())
|
|
require.NoError(t, err)
|
|
handler.handler.sshPort = sshPort
|
|
|
|
t.Cleanup(func() {
|
|
// In particular close the lock watchers by canceling the context.
|
|
s.cancel()
|
|
|
|
s.webServer.Close()
|
|
|
|
var errors []error
|
|
if err := s.proxyTunnel.Close(); err != nil {
|
|
errors = append(errors, err)
|
|
}
|
|
if err := s.node.Close(); err != nil {
|
|
errors = append(errors, err)
|
|
}
|
|
s.webServer.Close()
|
|
if err := s.proxy.Close(); err != nil {
|
|
errors = append(errors, err)
|
|
}
|
|
if err := s.server.Shutdown(context.Background()); err != nil {
|
|
errors = append(errors, err)
|
|
}
|
|
require.Empty(t, errors)
|
|
})
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *WebSuite) addNode(t *testing.T, uuid string, hostname string, address string) *regular.Server {
|
|
priv, pub, err := testauthority.GenerateKeyPair()
|
|
require.NoError(t, err)
|
|
|
|
tlsPub, err := authtest.PrivateKeyToPublicKeyTLS(priv)
|
|
require.NoError(t, err)
|
|
|
|
// start node
|
|
certs, err := s.server.Auth().GenerateHostCerts(s.ctx,
|
|
auth.HostCertsParams{
|
|
Req: &authproto.HostCertsRequest{
|
|
HostID: uuid,
|
|
NodeName: hostname,
|
|
Role: types.RoleNode,
|
|
PublicSSHKey: pub,
|
|
PublicTLSKey: tlsPub,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
signer, err := sshutils.NewSigner(priv, certs.SSH)
|
|
require.NoError(t, err)
|
|
|
|
nodeClient, err := s.server.NewClient(authtest.TestIdentity{
|
|
I: authz.BuiltinRole{
|
|
Role: types.RoleNode,
|
|
Username: uuid,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
nodeLockWatcher, err := services.NewLockWatcher(s.ctx, services.LockWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentNode,
|
|
Client: nodeClient,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
nodeSessionController, err := srv.NewSessionController(srv.SessionControllerConfig{
|
|
Semaphores: nodeClient,
|
|
AccessPoint: nodeClient,
|
|
LockEnforcer: nodeLockWatcher,
|
|
Emitter: nodeClient,
|
|
Component: teleport.ComponentNode,
|
|
ServerID: uuid,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// create SSH service:
|
|
node, err := regular.New(
|
|
context.Background(),
|
|
utils.NetAddr{AddrNetwork: "tcp", Addr: address},
|
|
hostname,
|
|
sshutils.StaticHostSigners(signer),
|
|
nodeClient,
|
|
t.TempDir(),
|
|
"",
|
|
utils.NetAddr{},
|
|
nodeClient,
|
|
regular.SetUUID(uuid),
|
|
regular.SetNamespace(apidefaults.Namespace),
|
|
regular.SetEmitter(nodeClient),
|
|
regular.SetPAMConfig(&pamcfg.PAMConfig{Enabled: false}),
|
|
regular.SetBPF(&bpf.NOP{}),
|
|
regular.SetClock(s.clock),
|
|
regular.SetLockWatcher(nodeLockWatcher),
|
|
regular.SetSessionController(nodeSessionController),
|
|
regular.SetConnectedProxyGetter(reversetunnel.NewConnectedProxyGetter()),
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, node.Start())
|
|
|
|
t.Cleanup(func() {
|
|
require.NoError(t, node.Close())
|
|
node.Wait()
|
|
})
|
|
|
|
return node
|
|
}
|
|
|
|
func noCache(clt authclient.ClientI, cacheName []string) (authclient.RemoteProxyAccessPoint, error) {
|
|
return clt, nil
|
|
}
|
|
|
|
func (r *authPack) renewSession(ctx context.Context, t *testing.T) *roundtrip.Response {
|
|
resp, err := r.clt.PostJSON(ctx, r.clt.Endpoint("webapi", "sessions", "web", "renew"), nil)
|
|
require.NoError(t, err)
|
|
return resp
|
|
}
|
|
|
|
func (r *authPack) validateAPI(ctx context.Context, t *testing.T) {
|
|
_, err := r.clt.Get(ctx, r.clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
type authPack struct {
|
|
otpSecret string
|
|
user string
|
|
login string
|
|
password string
|
|
session *CreateSessionResponse
|
|
clt *TestWebClient
|
|
cookies []*http.Cookie
|
|
device *authtest.Device
|
|
}
|
|
|
|
// authPack returns new authenticated package consisting of created valid
|
|
// user, otp token, created web session and authenticated client.
|
|
func (s *WebSuite) authPack(t *testing.T, user string, roles ...string) *authPack {
|
|
login := s.user
|
|
pass := "abcdef123456"
|
|
otpSecret := newOTPSharedSecret()
|
|
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertAuthPreference(s.ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
s.createUser(t, user, login, pass, otpSecret, roles...)
|
|
|
|
sessionResp, httpResp := loginWebOTP(t, t.Context(), loginWebOTPParams{
|
|
webClient: s.client(t),
|
|
clock: s.clock,
|
|
user: user,
|
|
password: pass,
|
|
otpSecret: otpSecret,
|
|
})
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
clt := s.client(t, roundtrip.BearerAuth(sessionResp.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(s.url(), httpResp.Cookies())
|
|
|
|
return &authPack{
|
|
otpSecret: otpSecret,
|
|
user: user,
|
|
login: login,
|
|
session: sessionResp,
|
|
clt: clt,
|
|
cookies: httpResp.Cookies(),
|
|
}
|
|
}
|
|
|
|
func (s *WebSuite) authPackWithMFA(t *testing.T, name string, roles ...types.Role) *authPack {
|
|
const password = "testing12345"
|
|
user, err := types.NewUser(name)
|
|
require.NoError(t, err)
|
|
|
|
userRole := services.RoleForUser(user)
|
|
userRole.SetLogins(types.Allow, []string{s.user})
|
|
userRole, err = s.server.Auth().UpsertRole(s.ctx, userRole)
|
|
require.NoError(t, err)
|
|
|
|
for _, role := range roles {
|
|
role, err = s.server.Auth().UpsertRole(s.ctx, role)
|
|
require.NoError(t, err)
|
|
user.AddRole(role.GetName())
|
|
}
|
|
|
|
user.AddRole(userRole.GetName())
|
|
_, err = s.server.Auth().CreateUser(s.ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
clt := s.client(t)
|
|
|
|
// create register challenge
|
|
token, err := s.server.Auth().CreateResetPasswordToken(s.ctx, authclient.CreateUserTokenRequest{
|
|
Name: name,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
res, err := s.server.Auth().CreateRegisterChallenge(s.ctx, &authproto.CreateRegisterChallengeRequest{
|
|
TokenID: token.GetName(),
|
|
DeviceType: authproto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
DeviceUsage: authproto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cc := wantypes.CredentialCreationFromProto(res.GetWebauthn())
|
|
|
|
// use passwordless as auth method
|
|
device, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
|
|
device.SetPasswordless()
|
|
|
|
const rpID = "localhost"
|
|
ccr, err := device.SignCredentialCreation("https://"+rpID, cc)
|
|
require.NoError(t, err)
|
|
|
|
_, err = s.server.Auth().ChangeUserAuthentication(s.ctx, &authproto.ChangeUserAuthenticationRequest{
|
|
TokenID: token.GetName(),
|
|
NewPassword: []byte(password),
|
|
NewMFARegisterResponse: &authproto.MFARegisterResponse{
|
|
Response: &authproto.MFARegisterResponse_Webauthn{
|
|
Webauthn: wantypes.CredentialCreationResponseToProto(ccr),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ctx := context.Background()
|
|
sessionResp, httpResp := loginWebMFA(ctx, t, loginWebMFAParams{
|
|
webClient: clt,
|
|
rpID: rpID,
|
|
user: name,
|
|
password: password,
|
|
authenticator: device,
|
|
})
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
clt = s.client(t, roundtrip.BearerAuth(sessionResp.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(s.url(), httpResp.Cookies())
|
|
|
|
return &authPack{
|
|
user: name,
|
|
login: s.user,
|
|
session: sessionResp,
|
|
clt: clt,
|
|
cookies: httpResp.Cookies(),
|
|
device: &authtest.Device{Key: device},
|
|
}
|
|
}
|
|
|
|
func (s *WebSuite) createUser(t *testing.T, user string, login string, pass string, otpSecret string, roles ...string) {
|
|
teleUser, err := types.NewUser(user)
|
|
require.NoError(t, err)
|
|
role := services.RoleForUser(teleUser)
|
|
role.SetLogins(types.Allow, []string{login})
|
|
options := role.GetOptions()
|
|
options.ForwardAgent = types.NewBool(true)
|
|
role.SetOptions(options)
|
|
role, err = s.server.Auth().UpsertRole(s.ctx, role)
|
|
require.NoError(t, err)
|
|
teleUser.AddRole(role.GetName())
|
|
|
|
for _, r := range roles {
|
|
teleUser.AddRole(r)
|
|
}
|
|
|
|
teleUser.SetCreatedBy(types.CreatedBy{
|
|
User: types.UserRef{Name: "some-auth-user"},
|
|
})
|
|
_, err = s.server.Auth().CreateUser(s.ctx, teleUser)
|
|
require.NoError(t, err)
|
|
|
|
err = s.server.Auth().UpsertPassword(user, []byte(pass))
|
|
require.NoError(t, err)
|
|
|
|
if otpSecret != "" {
|
|
dev, err := services.NewTOTPDevice("otp", otpSecret, s.clock.Now())
|
|
require.NoError(t, err)
|
|
err = s.server.Auth().UpsertMFADevice(context.Background(), user, dev)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
func verifySecurityResponseHeaders(t *testing.T, h http.Header) {
|
|
t.Helper()
|
|
cases := []struct {
|
|
header string
|
|
expectedValue string
|
|
}{
|
|
{
|
|
header: "X-Content-Type-Options",
|
|
expectedValue: "nosniff",
|
|
},
|
|
{
|
|
header: "Referrer-Policy",
|
|
expectedValue: "strict-origin",
|
|
},
|
|
{
|
|
header: "X-Frame-Options",
|
|
expectedValue: "SAMEORIGIN",
|
|
},
|
|
{
|
|
header: "Strict-Transport-Security",
|
|
expectedValue: "max-age=31536000; includeSubDomains",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
require.Contains(t, h, tc.header)
|
|
require.Equal(t, tc.expectedValue, h.Get(tc.header))
|
|
}
|
|
}
|
|
|
|
func TestValidRedirectURL(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tt := range []struct {
|
|
desc, url string
|
|
valid bool
|
|
}{
|
|
{"valid absolute https url", "https://example.com?a=1", true},
|
|
{"valid absolute http url", "http://example.com?a=1", true},
|
|
{"valid relative url", "/path/to/something", true},
|
|
{"garbage", "fjoiewjwpods302j09", false},
|
|
{"empty string", "", false},
|
|
{"block bad protocol", "javascript:alert('xss')", false},
|
|
} {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
require.Equal(t, tt.valid, IsValidRedirectURL(tt.url))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMetaRedirect(t *testing.T) {
|
|
t.Parallel()
|
|
h := &Handler{}
|
|
redirectHandler := h.WithMetaRedirect(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) string {
|
|
return "https://example.com"
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/some/route", nil)
|
|
resp := httptest.NewRecorder()
|
|
redirectHandler(resp, req, nil)
|
|
targetElement := `<meta http-equiv="refresh" content="0;URL='https://example.com'" />`
|
|
require.Equal(t, http.StatusOK, resp.Code)
|
|
body := resp.Body.String()
|
|
require.Contains(t, body, targetElement)
|
|
}
|
|
|
|
func Test_clientMetaFromReq(t *testing.T) {
|
|
ua := "foobar"
|
|
r := httptest.NewRequest(
|
|
http.MethodGet, "https://example.com/webapi/foo", nil,
|
|
)
|
|
r.Header.Set("User-Agent", ua)
|
|
r.Header.Set("Max-Touch-Points", "5")
|
|
|
|
got := clientMetaFromReq(r)
|
|
require.Equal(t, &authclient.ForwardedClientMetadata{
|
|
UserAgent: ua,
|
|
RemoteAddr: "192.0.2.1:1234",
|
|
MaxTouchPoints: 5,
|
|
}, got)
|
|
}
|
|
|
|
func TestWebSessionsCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
pack := s.authPack(t, "foo")
|
|
|
|
// make sure we can use client to make authenticated requests
|
|
re, err := pack.clt.Get(context.Background(), pack.clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
var clusters []webui.Cluster
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &clusters))
|
|
|
|
// now delete session
|
|
_, err = pack.clt.Delete(
|
|
context.Background(),
|
|
pack.clt.Endpoint("webapi", "sessions", "web"))
|
|
require.NoError(t, err)
|
|
|
|
// subsequent requests trying to use this session will fail
|
|
_, err = pack.clt.Get(context.Background(), pack.clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
}
|
|
|
|
func TestCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
|
|
// create a valid user
|
|
user := "csrfuser"
|
|
pass := "abcdef123456"
|
|
otpSecret := newOTPSharedSecret()
|
|
s.createUser(t, user, user, pass, otpSecret)
|
|
|
|
clt := s.client(t)
|
|
ctx := context.Background()
|
|
|
|
// valid
|
|
validReq := loginWebOTPParams{
|
|
webClient: clt,
|
|
clock: s.clock,
|
|
user: user,
|
|
password: pass,
|
|
otpSecret: otpSecret,
|
|
}
|
|
loginWebOTP(t, ctx, validReq)
|
|
|
|
// invalid - wrong content-type header
|
|
invalidReq := validReq
|
|
invalidReq.overrideContentType = "multipart/form-data"
|
|
httpResp, _, err := rawLoginWebOTP(ctx, invalidReq)
|
|
require.NoError(t, err, "Login via /webapi/sessions/new failed unexpectedly")
|
|
require.Equal(t, http.StatusBadRequest, httpResp.StatusCode, "HTTP status code mismatch")
|
|
}
|
|
|
|
func TestPasswordChange(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
pack := s.authPack(t, "foo")
|
|
|
|
// invalidate the token
|
|
s.clock.Advance(1 * time.Minute)
|
|
validToken, err := totp.GenerateCode(pack.otpSecret, s.clock.Now())
|
|
require.NoError(t, err)
|
|
|
|
req := changePasswordReq{
|
|
OldPassword: []byte("abcdef123456"),
|
|
NewPassword: []byte("fedcba654321"),
|
|
SecondFactorToken: validToken,
|
|
}
|
|
|
|
_, err = pack.clt.PutJSON(context.Background(), pack.clt.Endpoint("webapi", "users", "password"), req)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestValidateBearerToken tests that the bearer token's user name
|
|
// matches the user name on the cookie.
|
|
func TestValidateBearerToken(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack1 := proxy.authPack(t, "user1", nil /* roles */)
|
|
pack2 := proxy.authPack(t, "user2", nil /* roles */)
|
|
|
|
// Swap pack1's session token with pack2's sessionToken
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
pack1.clt = proxy.newClient(t, roundtrip.BearerAuth(pack2.session.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(&proxy.webURL, pack1.cookies)
|
|
|
|
// Auth protected endpoint.
|
|
req := changePasswordReq{}
|
|
_, err = pack1.clt.PutJSON(context.Background(), pack1.clt.Endpoint("webapi", "users", "password"), req)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
require.True(t, strings.Contains(err.Error(), "bad bearer token"))
|
|
}
|
|
|
|
func TestWebSessionsBadInput(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
|
|
authServer := s.server.Auth()
|
|
clock := s.clock
|
|
ctx := context.Background()
|
|
|
|
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
})
|
|
require.NoError(t, err, "NewAuthPreference failed")
|
|
_, err = authServer.UpsertAuthPreference(ctx, authPref)
|
|
require.NoError(t, err, "UpsertAuthPreference failed")
|
|
|
|
const user = "bob"
|
|
const pass = "abcdef123456"
|
|
otpSecret := newOTPSharedSecret()
|
|
badSecret := newOTPSharedSecret()
|
|
|
|
u, err := types.NewUser(user)
|
|
require.NoError(t, err)
|
|
_, err = authServer.CreateUser(ctx, u)
|
|
require.NoError(t, err)
|
|
|
|
err = authServer.UpsertPassword(user, []byte(pass))
|
|
require.NoError(t, err)
|
|
|
|
dev, err := services.NewTOTPDevice("otp", otpSecret, s.clock.Now())
|
|
require.NoError(t, err)
|
|
err = authServer.UpsertMFADevice(context.Background(), user, dev)
|
|
require.NoError(t, err)
|
|
|
|
clt := s.client(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
user, pass, otpSecret string
|
|
}{
|
|
{
|
|
name: "empty request",
|
|
},
|
|
{
|
|
name: "missing user",
|
|
pass: pass,
|
|
otpSecret: otpSecret,
|
|
},
|
|
{
|
|
name: "missing pass",
|
|
user: user,
|
|
otpSecret: otpSecret,
|
|
},
|
|
{
|
|
name: "bad pass",
|
|
user: user,
|
|
pass: "bla bla",
|
|
otpSecret: otpSecret,
|
|
},
|
|
{
|
|
name: "bad otp token",
|
|
user: user,
|
|
pass: pass,
|
|
otpSecret: badSecret,
|
|
},
|
|
{
|
|
name: "missing otp token",
|
|
user: user,
|
|
pass: pass,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
clock.Advance(1 * time.Minute) // Avoid OTP clashes.
|
|
|
|
httpResp, body, err := rawLoginWebOTP(ctx, loginWebOTPParams{
|
|
webClient: clt,
|
|
clock: clock,
|
|
user: test.user,
|
|
password: test.pass,
|
|
otpSecret: test.otpSecret,
|
|
})
|
|
require.NoError(t, err, "HTTP request errored unexpectedly")
|
|
|
|
// Assert HTTP response code.
|
|
assert.Equal(t, http.StatusForbidden, httpResp.StatusCode, "HTTP status mismatch")
|
|
|
|
// Assert body error message.
|
|
var resp httpErrorResponse
|
|
require.NoError(t,
|
|
json.Unmarshal(body, &resp),
|
|
"HTTP error response unmarshal",
|
|
)
|
|
const invalidCredentialsMessage = "invalid credentials"
|
|
assert.Contains(t, resp.Error.Message, invalidCredentialsMessage, "HTTP error message mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
type clusterNodesGetResponse struct {
|
|
Items []webui.Server `json:"items"`
|
|
StartKey string `json:"startKey"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
func TestClusterNodesGet(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
// Get the node already added by `newWebPack`
|
|
servers, err := env.server.Auth().GetNodes(context.Background(), apidefaults.Namespace)
|
|
require.NoError(t, err)
|
|
require.Len(t, servers, 1)
|
|
server1 := servers[0]
|
|
|
|
// Add another node.
|
|
server2, err := types.NewServerWithLabels("server2", types.KindNode, types.ServerSpecV2{Hostname: "server2"}, map[string]string{"test-field": "test-value"})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertNode(context.Background(), server2)
|
|
require.NoError(t, err)
|
|
|
|
// Get nodes from endpoint.
|
|
clusterName := env.server.ClusterName()
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "nodes")
|
|
|
|
query := url.Values{"sort": []string{"name"}}
|
|
|
|
// Get nodes.
|
|
re, err := pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
|
|
// Test response.
|
|
res := clusterNodesGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &res))
|
|
require.Len(t, res.Items, 2)
|
|
require.Equal(t, 2, res.TotalCount)
|
|
require.ElementsMatch(t, res.Items, []webui.Server{
|
|
{
|
|
Kind: types.KindNode,
|
|
SubKind: types.SubKindTeleportNode,
|
|
ClusterName: clusterName,
|
|
Name: server1.GetName(),
|
|
Hostname: server1.GetHostname(),
|
|
Tunnel: server1.GetUseTunnel(),
|
|
Addr: server1.GetAddr(),
|
|
Labels: []ui.Label{},
|
|
SSHLogins: []string{pack.login},
|
|
SSHLoginDetails: []webui.SSHLogin{{Login: pack.login}},
|
|
},
|
|
{
|
|
Kind: types.KindNode,
|
|
SubKind: types.SubKindTeleportNode,
|
|
ClusterName: clusterName,
|
|
Name: server2.GetName(),
|
|
Hostname: server2.GetHostname(),
|
|
Labels: []ui.Label{{Name: "test-field", Value: "test-value"}},
|
|
Tunnel: false,
|
|
SSHLogins: []string{pack.login},
|
|
SSHLoginDetails: []webui.SSHLogin{{Login: pack.login}},
|
|
},
|
|
})
|
|
|
|
// Get nodes using shortcut.
|
|
re, err = pack.clt.Get(context.Background(), pack.clt.Endpoint("webapi", "sites", currentSiteShortcut, "nodes"), query)
|
|
require.NoError(t, err)
|
|
|
|
res2 := clusterNodesGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &res2))
|
|
require.Len(t, res.Items, 2)
|
|
require.Equal(t, res, res2)
|
|
}
|
|
|
|
func TestUserGroupsGet(t *testing.T) {
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
type testResponse struct {
|
|
Items []webui.UserGroup `json:"items"`
|
|
StartKey string `json:"startKey"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
// add a user group
|
|
ug, err := types.NewUserGroup(types.Metadata{
|
|
Name: "ug1", Description: "ug1-description",
|
|
Labels: map[string]string{"test-field": "test-value"},
|
|
},
|
|
types.UserGroupSpecV1{Applications: []string{"appnameonly"}})
|
|
require.NoError(t, err)
|
|
err = env.server.Auth().CreateUserGroup(ctx, ug)
|
|
require.NoError(t, err)
|
|
|
|
resource := &types.AppServerV3{
|
|
Metadata: types.Metadata{Name: "test-app-server"},
|
|
Kind: types.KindApp,
|
|
Version: types.V2,
|
|
Spec: types.AppServerSpecV3{
|
|
HostID: "hostid",
|
|
App: &types.AppV3{
|
|
Metadata: types.Metadata{
|
|
Name: "appnameonly",
|
|
Description: "app-description",
|
|
},
|
|
Spec: types.AppSpecV3{
|
|
URI: "appname-uri",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Register app
|
|
_, err = env.server.Auth().UpsertApplicationServer(ctx, resource)
|
|
require.NoError(t, err)
|
|
|
|
// Make the call.
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "user-groups")
|
|
re, err := pack.clt.Get(ctx, endpoint, url.Values{"sort": []string{"name"}})
|
|
require.NoError(t, err)
|
|
|
|
// The correct response should include application names (not app server names)
|
|
var resp testResponse
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Items, 1)
|
|
require.Equal(t, 1, resp.TotalCount)
|
|
require.ElementsMatch(t, resp.Items, []webui.UserGroup{{
|
|
Name: "ug1",
|
|
Description: ug.GetMetadata().Description,
|
|
Labels: []ui.Label{{Name: "test-field", Value: "test-value"}},
|
|
Applications: []webui.ApplicationAndFriendlyName{
|
|
{Name: "appnameonly", FriendlyName: ""},
|
|
},
|
|
}})
|
|
}
|
|
|
|
func TestUnifiedResourcesGet_AppComponentFeatures(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
|
|
ctx := t.Context()
|
|
username := "[email protected]"
|
|
|
|
u, err := user.Current()
|
|
require.NoError(t, err)
|
|
loginUser := u.Username
|
|
|
|
role := defaultRoleForNewUser(&types.UserV2{Metadata: types.Metadata{Name: username}}, loginUser)
|
|
role.SetAppLabels(types.Allow, types.Labels{"env": []string{"prod"}})
|
|
|
|
pack := proxy.authPack(t, username, []types.Role{role})
|
|
|
|
// - cluster (auth/proxy) support `feature1`
|
|
// - app has 2 appservers, both support `feature1` + `feature2`
|
|
// - aggregated appserver features should = {feature1, feature2}
|
|
// - final app supportedFeatureIDs should = {feature1}
|
|
feature1 := componentfeatures.FeatureID(9998)
|
|
feature2 := componentfeatures.FeatureID(9999)
|
|
|
|
{
|
|
for srv := range clientutils.Resources(ctx, env.server.Auth().ListProxyServers) {
|
|
srv.SetComponentFeatures(componentfeatures.New(feature1))
|
|
err := env.server.Auth().UpsertProxy(ctx, srv)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
{
|
|
for srv := range clientutils.Resources(ctx, env.server.Auth().ListAuthServers) {
|
|
srv.SetComponentFeatures(componentfeatures.New(feature1))
|
|
err := env.server.Auth().UpsertAuthServer(ctx, srv)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
// AWS Console app + 2 app servers serving it
|
|
appName := "aws-console-app"
|
|
awsApp, err := types.NewAppV3(
|
|
types.Metadata{
|
|
Name: appName,
|
|
Labels: map[string]string{
|
|
"env": "prod",
|
|
},
|
|
},
|
|
types.AppSpecV3{
|
|
URI: "https://console.aws.amazon.com",
|
|
Cloud: "AWS",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
awsSrv1, err := types.NewAppServerV3FromApp(awsApp, "localhost", "host-1")
|
|
require.NoError(t, err)
|
|
awsSrv1.SetComponentFeatures(componentfeatures.New(feature1, feature2))
|
|
_, err = env.server.Auth().UpsertApplicationServer(ctx, awsSrv1)
|
|
require.NoError(t, err)
|
|
|
|
awsSrv2, err := types.NewAppServerV3FromApp(awsApp, "localhost", "host-2")
|
|
require.NoError(t, err)
|
|
awsSrv2.SetComponentFeatures(componentfeatures.New(feature1, feature2))
|
|
_, err = env.server.Auth().UpsertApplicationServer(ctx, awsSrv2)
|
|
require.NoError(t, err)
|
|
|
|
clusterName := env.server.ClusterName()
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "resources")
|
|
query := url.Values{"kinds": []string{types.KindApp}}
|
|
|
|
require.Eventually(t, func() bool {
|
|
return env.server.Auth().UnifiedResourceCache.IsInitialized()
|
|
}, 5*time.Second, 50*time.Millisecond, "unified resource cache failed to initialize")
|
|
|
|
// Ensure aggregated AppServers' features are intersected as expected
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
var got []types.AppServer
|
|
|
|
for srv, err := range env.server.Auth().UnifiedResourceCache.AppServers(
|
|
ctx, services.UnifiedResourcesIterateParams{},
|
|
) {
|
|
require.NoError(t, err)
|
|
if srv.GetApp() == nil || srv.GetApp().GetName() != appName {
|
|
continue
|
|
}
|
|
got = append(got, srv)
|
|
}
|
|
|
|
require.Len(t, got, 1)
|
|
|
|
cf := got[0].GetComponentFeatures()
|
|
require.NotNil(t, cf)
|
|
require.ElementsMatch(t, componentfeatures.New(feature1, feature2).GetFeatures(), cf.GetFeatures())
|
|
}, 5*time.Second, 50*time.Millisecond, "unified resource cache did not expose aggregated AppServer")
|
|
|
|
// Ensure returned UnifiedResource's features are intersected as expected (against all auth/proxy)
|
|
re, err := pack.clt.Get(ctx, endpoint, query)
|
|
require.NoError(t, err)
|
|
|
|
var resp struct {
|
|
Items []webui.App `json:"items"`
|
|
}
|
|
err = json.Unmarshal(re.Bytes(), &resp)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Items, 1)
|
|
|
|
app := resp.Items[0]
|
|
require.True(t, app.AWSConsole)
|
|
|
|
require.ElementsMatch(t, []componentfeaturesv1.ComponentFeatureID{
|
|
componentfeaturesv1.ComponentFeatureID(feature1),
|
|
}, app.SupportedFeatureIDs)
|
|
}
|
|
|
|
func TestUnifiedResourcesGet(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
username := "[email protected]"
|
|
|
|
u, err := user.Current()
|
|
require.NoError(t, err)
|
|
loginUser := u.Username
|
|
|
|
role := defaultRoleForNewUser(&types.UserV2{Metadata: types.Metadata{Name: username}}, loginUser)
|
|
role.SetAWSRoleARNs(types.Allow, []string{"arn:aws:iam::999999999999:role/ProdInstance"})
|
|
role.SetAppLabels(types.Allow, types.Labels{"env": []string{"prod"}})
|
|
role.SetGitHubPermissions(types.Allow, []types.GitHubPermission{{Organizations: []string{types.Wildcard}}})
|
|
|
|
// This role is used to test that DevInstance AWS Role is only available to AppServices that have env:dev label.
|
|
roleForDev, err := types.NewRole("dev-access", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
AWSRoleARNs: []string{"arn:aws:iam::999999999999:role/DevInstance"},
|
|
AppLabels: types.Labels{"env": []string{"dev"}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
pack := proxy.authPack(t, username, []types.Role{role, roleForDev})
|
|
|
|
// add aws AppServer
|
|
awsApp, err := types.NewAppV3(
|
|
types.Metadata{
|
|
Name: "my-aws-app",
|
|
Labels: map[string]string{
|
|
"env": "prod",
|
|
},
|
|
},
|
|
types.AppSpecV3{
|
|
URI: "localhost:8080",
|
|
Cloud: "AWS",
|
|
})
|
|
require.NoError(t, err)
|
|
awsAppServer, err := types.NewAppServerV3FromApp(
|
|
awsApp,
|
|
"localhost",
|
|
"host-id",
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertApplicationServer(context.Background(), awsAppServer)
|
|
require.NoError(t, err)
|
|
|
|
app, err := types.NewAppV3(
|
|
types.Metadata{
|
|
Name: "my-app",
|
|
Labels: map[string]string{
|
|
"env": "prod",
|
|
},
|
|
},
|
|
types.AppSpecV3{
|
|
URI: "localhost:8080",
|
|
})
|
|
require.NoError(t, err)
|
|
appServer, err := types.NewAppServerV3FromApp(
|
|
app,
|
|
"localhost",
|
|
"host-id",
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertApplicationServer(context.Background(), appServer)
|
|
require.NoError(t, err)
|
|
|
|
// add a SAMLIdPServiceProvider
|
|
samlapp, err := types.NewSAMLIdPServiceProvider(
|
|
types.Metadata{
|
|
Name: "sp1",
|
|
Labels: map[string]string{
|
|
"env": "prod",
|
|
},
|
|
},
|
|
types.SAMLIdPServiceProviderSpecV1{
|
|
EntityID: "https://example.com",
|
|
ACSURL: "https://example.com/acs",
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
err = env.server.Auth().CreateSAMLIdPServiceProvider(context.Background(), samlapp)
|
|
require.NoError(t, err)
|
|
|
|
// Add nodes
|
|
for i := range 20 {
|
|
name := fmt.Sprintf("server-%d", i)
|
|
node, err := types.NewServer(name, types.KindNode, types.ServerSpecV2{
|
|
Hostname: name,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertNode(context.Background(), node)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// add HA dbs
|
|
for _, healthStatus := range []string{"healthy", "unhealthy", "unknown"} {
|
|
db, err := types.NewDatabaseV3(types.Metadata{
|
|
Name: "dbdb",
|
|
Labels: map[string]string{
|
|
"env": "prod",
|
|
},
|
|
}, types.DatabaseSpecV3{
|
|
Protocol: "test-protocol",
|
|
URI: "test-uri",
|
|
})
|
|
require.NoError(t, err)
|
|
dbServer, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: db.GetName(),
|
|
}, types.DatabaseServerSpecV3{
|
|
Hostname: "dddb1",
|
|
HostID: uuid.NewString(),
|
|
Database: db,
|
|
})
|
|
require.NoError(t, err)
|
|
dbServer.SetTargetHealth(types.TargetHealth{Status: healthStatus})
|
|
_, err = env.server.Auth().UpsertDatabaseServer(context.Background(), dbServer)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// add windows desktop
|
|
win, err := types.NewWindowsDesktopV3(
|
|
"zzzz9",
|
|
nil,
|
|
types.WindowsDesktopSpecV3{Addr: "localhost", HostID: "win1-host-id"},
|
|
)
|
|
require.NoError(t, err)
|
|
err = env.server.Auth().UpsertWindowsDesktop(context.Background(), win)
|
|
require.NoError(t, err)
|
|
|
|
// add git server
|
|
gitServer, err := types.NewGitHubServer(types.GitHubServerMetadata{
|
|
Organization: "org1",
|
|
Integration: "org1",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().GitServers.UpsertGitServer(context.Background(), gitServer)
|
|
require.NoError(t, err)
|
|
|
|
clusterName := env.server.ClusterName()
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "resources")
|
|
|
|
// test sort type ascend
|
|
query := url.Values{"sort": []string{"kind:asc"}}
|
|
re, err := pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
res := clusterNodesGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &res))
|
|
require.Equal(t, types.KindApp, res.Items[0].Kind)
|
|
require.Equal(t, types.KindApp, res.Items[1].Kind)
|
|
require.Equal(t, types.KindApp, res.Items[2].Kind)
|
|
require.Equal(t, types.KindDatabase, res.Items[3].Kind)
|
|
|
|
// test sort type desc
|
|
query = url.Values{"sort": []string{"kind:desc"}}
|
|
re, err = pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
res = clusterNodesGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &res))
|
|
require.Equal(t, types.KindWindowsDesktop, res.Items[0].Kind)
|
|
|
|
// test with no access
|
|
noAccessRole, err := types.NewRole(services.RoleNameForUser("[email protected]"), types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
noAccessPack := proxy.authPack(t, "[email protected]", []types.Role{noAccessRole})
|
|
|
|
// shouldnt get any results with no access
|
|
query = url.Values{"sort": []string{"name:asc"}}
|
|
re, err = noAccessPack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
res = clusterNodesGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &res))
|
|
require.Empty(t, res.Items)
|
|
|
|
// query only databases
|
|
type dbResponse struct {
|
|
Items []webui.Database `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
query = url.Values{"sort": []string{"name"}, "limit": []string{"1"}, "kinds": []string{types.KindDatabase}, "query": []string{`health.status == "mixed"`}}
|
|
re, err = pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
dbRes := dbResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &dbRes))
|
|
require.Len(t, dbRes.Items, 1)
|
|
require.ElementsMatch(t, dbRes.Items, []webui.Database{{
|
|
Kind: types.KindDatabase,
|
|
Name: "dbdb",
|
|
Type: types.DatabaseTypeSelfHosted,
|
|
Labels: []ui.Label{{Name: "env", Value: "prod"}},
|
|
Protocol: "test-protocol",
|
|
Hostname: "test-uri",
|
|
URI: "test-uri",
|
|
TargetHealth: types.TargetHealth{Status: "mixed"},
|
|
}})
|
|
|
|
// should return first page and have a second page
|
|
query = url.Values{"sort": []string{"name"}, "limit": []string{"15"}}
|
|
re, err = pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
res = clusterNodesGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &res))
|
|
require.Len(t, res.Items, 15)
|
|
require.NotEmpty(t, res.StartKey)
|
|
|
|
// should return second page and have no third page
|
|
query = url.Values{"sort": []string{"name"}, "limit": []string{"15"}}
|
|
query.Add("startKey", res.StartKey)
|
|
re, err = pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
res = clusterNodesGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &res))
|
|
require.Len(t, res.Items, 12)
|
|
require.Empty(t, res.StartKey)
|
|
|
|
// Only list valid AWS Roles for AWS Apps
|
|
query = url.Values{
|
|
"search": []string{"my-aws-app"},
|
|
"sort": []string{"name"},
|
|
}
|
|
re, err = pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
listResp := struct {
|
|
Items []webui.App `json:"Items"`
|
|
}{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &listResp))
|
|
require.Len(t, listResp.Items, 1)
|
|
expectedRoles := []utilsaws.Role{
|
|
{Name: "ProdInstance", Display: "ProdInstance", ARN: "arn:aws:iam::999999999999:role/ProdInstance", AccountID: "999999999999"},
|
|
}
|
|
require.Equal(t, expectedRoles, listResp.Items[0].AWSRoles)
|
|
t.Log(string(re.Bytes()), listResp)
|
|
|
|
t.Run("saml_idp_service_provider is included with app kinds", func(t *testing.T) {
|
|
type appResponse struct {
|
|
Items []webui.App `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
query := url.Values{"kinds": []string{types.KindApp}}
|
|
re, err := pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
appRes := appResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &appRes))
|
|
|
|
awsRoleSet := set.New("arn:aws:iam::999999999999:role/ProdInstance")
|
|
appConfig := webui.MakeAppsConfig{
|
|
LocalClusterName: clusterName,
|
|
LocalProxyDNSName: "proxy-1.example.com",
|
|
AppClusterName: clusterName,
|
|
RequiresRequest: false,
|
|
AWSRoles: &webui.PrincipalSet{All: awsRoleSet, Granted: awsRoleSet},
|
|
}
|
|
|
|
expectedApps := []webui.App{
|
|
webui.MakeApp(app, appConfig),
|
|
webui.MakeApp(awsApp, appConfig),
|
|
webui.MakeAppTypeFromSAMLApp(samlapp, appConfig),
|
|
}
|
|
|
|
// marshal & unmarshal to match API response (i.e. omitempty)
|
|
expectedJSON, err := json.Marshal(expectedApps)
|
|
require.NoError(t, err)
|
|
var expected []webui.App
|
|
require.NoError(t, json.Unmarshal(expectedJSON, &expected))
|
|
|
|
require.ElementsMatch(t, appRes.Items, expected)
|
|
})
|
|
}
|
|
|
|
type clusterAlertsGetResponse struct {
|
|
Alerts []types.ClusterAlert `json:"alerts"`
|
|
}
|
|
|
|
func TestClusterAlertsGet(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
|
|
// generate alert
|
|
alert, err := types.NewClusterAlert(
|
|
"test-alert",
|
|
"test alert message",
|
|
types.WithAlertSeverity(0),
|
|
types.WithAlertLabel(types.AlertOnLogin, "yes"),
|
|
// AlertPermitAll is necessary because the alert is only shown to
|
|
// admin clients by default.
|
|
types.WithAlertLabel(types.AlertPermitAll, "yes"),
|
|
)
|
|
require.NoError(t, err)
|
|
err = env.server.Auth().UpsertClusterAlert(context.Background(), alert)
|
|
require.NoError(t, err)
|
|
|
|
// get alerts.
|
|
clusterName := env.server.ClusterName()
|
|
pack := env.proxies[0].authPack(t, "[email protected]", nil)
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "alerts")
|
|
re, err := pack.clt.Get(context.Background(), endpoint, nil)
|
|
require.NoError(t, err)
|
|
|
|
alerts := clusterAlertsGetResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &alerts))
|
|
require.Len(t, alerts.Alerts, 1)
|
|
}
|
|
|
|
func TestSiteNodeConnectInvalidSessionID(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
result := make(chan error)
|
|
|
|
_, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "foo"),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
sessionID: "/../../../foo",
|
|
handlers: map[string]terminal.WSHandlerFunc{
|
|
defaults.WebsocketError: func(ctx context.Context, e terminal.Envelope) {
|
|
if e.Payload == "/../../../foo is not a valid UUID" {
|
|
result <- errors.New(e.Payload)
|
|
}
|
|
close(result)
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
res := <-result
|
|
require.Error(t, res)
|
|
}
|
|
|
|
func TestResolveServerHostPort(t *testing.T) {
|
|
t.Parallel()
|
|
sampleNode := types.ServerV2{}
|
|
sampleNode.SetName("eca53e45-86a9-11e7-a893-0242ac0a0101")
|
|
sampleNode.Spec.Hostname = "nodehostname"
|
|
|
|
// valid cases
|
|
validCases := []struct {
|
|
server string
|
|
nodes []types.Server
|
|
expectedHost string
|
|
expectedPort int
|
|
}{
|
|
{
|
|
server: "localhost",
|
|
expectedHost: "localhost",
|
|
expectedPort: 0,
|
|
},
|
|
{
|
|
server: "localhost:8080",
|
|
expectedHost: "localhost",
|
|
expectedPort: 8080,
|
|
},
|
|
{
|
|
server: "eca53e45-86a9-11e7-a893-0242ac0a0101",
|
|
nodes: []types.Server{&sampleNode},
|
|
expectedHost: "nodehostname",
|
|
expectedPort: 0,
|
|
},
|
|
}
|
|
|
|
// invalid cases
|
|
invalidCases := []struct {
|
|
server string
|
|
expectedErr string
|
|
}{
|
|
{
|
|
server: ":22",
|
|
expectedErr: "empty hostname",
|
|
},
|
|
{
|
|
server: ":",
|
|
expectedErr: "empty hostname",
|
|
},
|
|
{
|
|
server: "",
|
|
expectedErr: "empty server name",
|
|
},
|
|
{
|
|
server: "host:",
|
|
expectedErr: "invalid port",
|
|
},
|
|
{
|
|
server: "host:port",
|
|
expectedErr: "invalid port",
|
|
},
|
|
}
|
|
|
|
for _, testCase := range validCases {
|
|
host, port, err := resolveServerHostPort(testCase.server, testCase.nodes)
|
|
require.NoError(t, err, testCase.server)
|
|
require.Equal(t, testCase.expectedHost, host, testCase.server)
|
|
require.Equal(t, testCase.expectedPort, port, testCase.server)
|
|
}
|
|
|
|
for _, testCase := range invalidCases {
|
|
_, _, err := resolveServerHostPort(testCase.server, nil)
|
|
require.Error(t, err, testCase.server)
|
|
require.Regexp(t, ".*"+testCase.expectedErr+".*", err.Error(), testCase.server)
|
|
}
|
|
}
|
|
|
|
func isFileTransferRequest(e *terminal.Envelope) bool {
|
|
if e.GetType() != defaults.WebsocketAudit {
|
|
return false
|
|
}
|
|
var ef events.EventFields
|
|
if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil {
|
|
return false
|
|
}
|
|
return ef.GetType() == string(srv.FileTransferUpdate)
|
|
}
|
|
|
|
func isFileTransferDecision(e *terminal.Envelope) bool {
|
|
if e.GetType() != defaults.WebsocketAudit {
|
|
return false
|
|
}
|
|
var ef events.EventFields
|
|
if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil {
|
|
return false
|
|
}
|
|
return ef.GetType() == string(srv.FileTransferApproved)
|
|
}
|
|
|
|
func getRequestId(e *terminal.Envelope) (string, error) {
|
|
var ef events.EventFields
|
|
if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil {
|
|
return "", err
|
|
}
|
|
return ef.GetString("requestID"), nil
|
|
}
|
|
|
|
func TestFileTransferEvents(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{disableDiskBasedRecording: true})
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
// Create a new user "foo", open a terminal to a new session
|
|
wsMessages := make(chan *terminal.Envelope)
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "foo"),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
handlers: map[string]terminal.WSHandlerFunc{
|
|
defaults.WebsocketAudit: func(ctx context.Context, envelope terminal.Envelope) {
|
|
wsMessages <- &envelope
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term.Close()) })
|
|
|
|
// Create file transfer event
|
|
data, err := json.Marshal(events.EventFields{
|
|
"download": true,
|
|
"location": "~/myfile.txt",
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
envelope := &terminal.Envelope{
|
|
Version: defaults.WebsocketVersion,
|
|
Type: defaults.WebsocketFileTransferRequest,
|
|
Payload: string(data),
|
|
}
|
|
envelopeBytes, err := proto.Marshal(envelope)
|
|
require.NoError(t, err)
|
|
err = term.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)
|
|
require.NoError(t, err)
|
|
|
|
done := time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case <-done:
|
|
require.FailNow(t, "expected to receive a file transfer event")
|
|
case e := <-wsMessages:
|
|
if isFileTransferRequest(e) {
|
|
requestId, err := getRequestId(e)
|
|
require.NoError(t, err)
|
|
data, err := json.Marshal(events.EventFields{
|
|
"requestId": requestId,
|
|
"approved": true,
|
|
})
|
|
require.NoError(t, err)
|
|
envelope := &terminal.Envelope{
|
|
Version: defaults.WebsocketVersion,
|
|
Type: defaults.WebsocketFileTransferDecision,
|
|
Payload: string(data),
|
|
}
|
|
envelopeBytes, err := proto.Marshal(envelope)
|
|
require.NoError(t, err)
|
|
err = term.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
if isFileTransferDecision(e) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewTerminalHandler(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
invalidCases := []struct {
|
|
expectedErr string
|
|
cfg TerminalHandlerConfig
|
|
}{
|
|
{
|
|
expectedErr: "sid: invalid session id",
|
|
cfg: TerminalHandlerConfig{
|
|
SessionData: session.Session{
|
|
ID: session.ID("not a uuid"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
expectedErr: "login: missing login",
|
|
cfg: TerminalHandlerConfig{
|
|
SessionData: session.Session{
|
|
ID: session.NewID(),
|
|
Login: "",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
expectedErr: "server: missing server",
|
|
cfg: TerminalHandlerConfig{
|
|
SessionData: session.Session{
|
|
ID: session.NewID(),
|
|
Login: "root",
|
|
ServerID: "",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
expectedErr: "term: bad dimensions(-1x25)",
|
|
cfg: TerminalHandlerConfig{
|
|
SessionData: session.Session{
|
|
ID: session.NewID(),
|
|
Login: "root",
|
|
ServerID: uuid.New().String(),
|
|
},
|
|
Term: session.TerminalParams{
|
|
W: -1,
|
|
H: 0,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
expectedErr: "term: bad dimensions(80x-1)",
|
|
cfg: TerminalHandlerConfig{
|
|
SessionData: session.Session{
|
|
ID: session.NewID(),
|
|
Login: "root",
|
|
ServerID: uuid.New().String(),
|
|
},
|
|
Term: session.TerminalParams{
|
|
W: 0,
|
|
H: -1,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
expectedErr: "term: bad dimensions(1x4097)",
|
|
cfg: TerminalHandlerConfig{
|
|
SessionData: session.Session{
|
|
ID: session.NewID(),
|
|
Login: "root",
|
|
ServerID: uuid.New().String(),
|
|
},
|
|
Term: session.TerminalParams{
|
|
W: 1,
|
|
H: 4097,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, testCase := range invalidCases {
|
|
_, err := NewTerminal(ctx, testCase.cfg)
|
|
require.Equal(t, testCase.expectedErr, err.Error())
|
|
}
|
|
|
|
validNode := types.ServerV2{}
|
|
validNode.SetName("eca53e45-86a9-11e7-a893-0242ac0a0101")
|
|
validNode.Spec.Hostname = "nodehostname"
|
|
|
|
// Valid Case
|
|
validCfg := TerminalHandlerConfig{
|
|
Term: session.TerminalParams{
|
|
W: 100,
|
|
H: 100,
|
|
},
|
|
SessionCtx: &SessionContext{},
|
|
UserAuthClient: authProviderMock{
|
|
server: validNode,
|
|
},
|
|
LocalAccessPoint: authProviderMock{},
|
|
SessionData: session.Session{
|
|
ID: session.NewID(),
|
|
Login: "root",
|
|
ServerID: uuid.New().String(),
|
|
},
|
|
KeepAliveInterval: time.Duration(100),
|
|
ProxyHostPort: "1234",
|
|
InteractiveCommand: make([]string, 1),
|
|
DisplayLogin: "tree",
|
|
Router: &proxy.Router{},
|
|
}
|
|
|
|
term, err := NewTerminal(ctx, validCfg)
|
|
require.NoError(t, err)
|
|
// passed through
|
|
require.Equal(t, validCfg.SessionCtx, term.ctx)
|
|
require.Equal(t, validCfg.UserAuthClient, term.userAuthClient)
|
|
require.Equal(t, validCfg.SessionData, term.sessionData)
|
|
require.Equal(t, validCfg.KeepAliveInterval, term.keepAliveInterval)
|
|
require.Equal(t, validCfg.ProxyHostPort, term.proxyHostPort)
|
|
require.Equal(t, validCfg.InteractiveCommand, term.interactiveCommand)
|
|
require.Equal(t, validCfg.Term, term.term)
|
|
require.Equal(t, validCfg.DisplayLogin, term.displayLogin)
|
|
// newly added
|
|
require.NotNil(t, term.logger)
|
|
}
|
|
|
|
func TestUIConfig(t *testing.T) {
|
|
uiConfig := webclient.UIConfig{
|
|
ScrollbackLines: 555,
|
|
ShowResources: constants.ShowResourcesaccessibleOnly,
|
|
}
|
|
t.Parallel()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{uiConfig: uiConfig})
|
|
clt := s.client(t)
|
|
endpoint := clt.Endpoint("web", "config.js")
|
|
re, err := clt.Get(ctx, endpoint, nil)
|
|
require.NoError(t, err)
|
|
require.True(t, strings.HasPrefix(string(re.Bytes()), "var GRV_CONFIG"))
|
|
t.Cleanup(cancel)
|
|
|
|
// Response is type application/javascript, we need to strip off the variable name
|
|
// and the semicolon at the end, then we are left with json like object.
|
|
var cfg webclient.WebConfig
|
|
str := strings.ReplaceAll(string(re.Bytes()), "var GRV_CONFIG = ", "")
|
|
err = json.Unmarshal([]byte(str[:len(str)-1]), &cfg)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uiConfig, cfg.UI)
|
|
}
|
|
|
|
func TestResizeTerminal(t *testing.T) {
|
|
t.Parallel()
|
|
const fooUser = "foo"
|
|
|
|
eventsChan := make(chan apievents.AuditEvent, 10)
|
|
isInitialResizeComplete := make(chan struct{})
|
|
go func() {
|
|
var once sync.Once
|
|
for {
|
|
select {
|
|
case <-t.Context().Done():
|
|
return
|
|
case evt, ok := <-eventsChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
// we are only interested in resize events
|
|
if evt.GetType() != events.ResizeEvent {
|
|
continue
|
|
}
|
|
resize, ok := evt.(*apievents.Resize)
|
|
assert.True(t, ok)
|
|
|
|
// we are only interested in resize events from foo user with 100x100 terminal size
|
|
// which is the initial size the client connects with.
|
|
if resize.GetUser() != fooUser || resize.TerminalSize != "100:100" {
|
|
continue
|
|
}
|
|
|
|
// signal that the initial resize events have been processed
|
|
// by the server
|
|
once.Do(func() {
|
|
close(isInitialResizeComplete)
|
|
})
|
|
|
|
}
|
|
}
|
|
}()
|
|
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
disableDiskBasedRecording: true,
|
|
onSessionRecordEvent: func(ctx context.Context, sid session.ID, pe apievents.PreparedSessionEvent) error {
|
|
select {
|
|
case eventsChan <- pe.GetAuditEvent():
|
|
case <-ctx.Done():
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
sid := session.NewID()
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
ws1Messages := make(chan *terminal.Envelope)
|
|
ws1Raw := make(chan []byte)
|
|
ws2Messages := make(chan *terminal.Envelope)
|
|
// Create a new user "foo", open a terminal to a new session
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, fooUser),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
handlers: map[string]terminal.WSHandlerFunc{
|
|
defaults.WebsocketAudit: func(ctx context.Context, envelope terminal.Envelope) {
|
|
ws1Messages <- &envelope
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term.Close()) })
|
|
|
|
// The client connects to the server with an initial terminal size of 100x100.
|
|
// We must ensure the server processes this initial resize event before proceeding
|
|
// with bar joining the session and sending a new resize event.
|
|
// This is done by waiting for the resize event to be recorded
|
|
// by the onSessionRecordEvent callback above.
|
|
// If this does not happen within 10 seconds, the test fails.
|
|
// This ensures that the bar user joining never receives any possible
|
|
// pending resize events from foo creating the session.
|
|
select {
|
|
case <-isInitialResizeComplete:
|
|
case <-time.After(10 * time.Second):
|
|
require.FailNow(t, "", "timeout waiting for initial resize events")
|
|
case <-t.Context().Done():
|
|
require.FailNow(t, "", "test context done: %v", t.Context().Err())
|
|
}
|
|
|
|
sess := term.GetSession()
|
|
// Wait for session to have started
|
|
require.Eventually(t, func() bool {
|
|
_, err := s.server.Auth().GetSessionTracker(context.Background(), string(sess.ID))
|
|
return err == nil
|
|
}, 3*time.Second, 200*time.Millisecond, "session not available")
|
|
|
|
// Create a new user "bar" and join the session created above
|
|
term2, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "bar"),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
sessionID: sess.ID,
|
|
participantMode: types.SessionPeerMode,
|
|
handlers: map[string]terminal.WSHandlerFunc{
|
|
defaults.WebsocketAudit: func(ctx context.Context, envelope terminal.Envelope) {
|
|
ws2Messages <- &envelope
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term2.Close()) })
|
|
|
|
require.Equal(t, sess.ID, term2.GetSession().ID)
|
|
|
|
go func() {
|
|
read, err := io.ReadAll(io.LimitReader(term, 10))
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
ws1Raw <- read
|
|
}()
|
|
|
|
// Consume events from the first terminal. We expect to see 2 resize events from the second user
|
|
// joining the session (one for the default size, and one for the manual resize request). We also
|
|
// validate at least one raw event with PTY data (indicating terminal ready) came through.
|
|
done := time.After(10 * time.Second)
|
|
t1ResizeEvents, t1RawEvents := 0, 0
|
|
t1ready:
|
|
for {
|
|
select {
|
|
case <-done:
|
|
require.FailNowf(t, "", "expected to receive 2 resize events (got %d)", t1ResizeEvents)
|
|
case <-ws1Raw:
|
|
t1RawEvents++
|
|
case e := <-ws1Messages:
|
|
if isResizeEventEnvelope(e) {
|
|
t1ResizeEvents++
|
|
}
|
|
}
|
|
|
|
if t1ResizeEvents == 2 && t1RawEvents > 0 {
|
|
break t1ready
|
|
}
|
|
}
|
|
|
|
// we should not expect to see a resize event on terminal 2,
|
|
// since they are not broadcast back to the originator
|
|
select {
|
|
case e := <-ws2Messages:
|
|
if isResizeEventEnvelope(e) {
|
|
require.FailNow(t, "terminal 2 should not have received a resize event: %v", e)
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
}
|
|
|
|
// Resize the second terminal. This should only be reflected in the first terminal
|
|
// because resize events are sent to participants but not the originator.
|
|
params, err := session.NewTerminalParamsFromInt(300, 120)
|
|
require.NoError(t, err)
|
|
data, err := json.Marshal(events.EventFields{
|
|
events.EventType: events.ResizeEvent,
|
|
events.EventNamespace: apidefaults.Namespace,
|
|
events.SessionEventID: sid.String(),
|
|
events.TerminalSize: params.Serialize(),
|
|
})
|
|
require.NoError(t, err)
|
|
envelope := &terminal.Envelope{
|
|
Version: defaults.WebsocketVersion,
|
|
Type: defaults.WebsocketResize,
|
|
Payload: string(data),
|
|
}
|
|
envelopeBytes, err := proto.Marshal(envelope)
|
|
require.NoError(t, err)
|
|
err = term2.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)
|
|
require.NoError(t, err)
|
|
|
|
// the first terminal should see the resize event
|
|
done = time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case <-done:
|
|
require.FailNow(t, "expected to receive a final resize event")
|
|
case e := <-ws1Messages:
|
|
if isResizeEventEnvelope(e) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func isResizeEventEnvelope(e *terminal.Envelope) bool {
|
|
if e.GetType() != defaults.WebsocketAudit {
|
|
return false
|
|
}
|
|
var ef events.EventFields
|
|
if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil {
|
|
return false
|
|
}
|
|
return ef.GetType() == events.ResizeEvent
|
|
}
|
|
|
|
// TestTerminalPing tests that the server sends continuous ping control messages.
|
|
func TestTerminalPing(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
closed := false
|
|
done := make(chan struct{})
|
|
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "foo"),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
keepAliveInterval: time.Second,
|
|
pingHandler: func(ws terminal.WSConn, message string) error {
|
|
if closed == false {
|
|
close(done)
|
|
closed = true
|
|
}
|
|
|
|
err := ws.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(time.Second))
|
|
if errors.Is(err, websocket.ErrCloseSent) {
|
|
return nil
|
|
} else {
|
|
var e net.Error
|
|
if errors.As(err, &e) && e.Timeout() {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term.Close()) })
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(6 * time.Second):
|
|
t.Fatal("timeout waiting for ping")
|
|
}
|
|
}
|
|
|
|
func TestTerminal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
recordingConfig types.SessionRecordingConfigV2
|
|
}{
|
|
{
|
|
name: "node recording mode",
|
|
recordingConfig: types.SessionRecordingConfigV2{
|
|
Spec: types.SessionRecordingConfigSpecV2{
|
|
Mode: types.RecordAtNodeSync,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "proxy recording mode",
|
|
recordingConfig: types.SessionRecordingConfigV2{
|
|
Spec: types.SessionRecordingConfigSpecV2{
|
|
Mode: types.RecordAtProxySync,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
|
|
// Set the recording config
|
|
_, err := s.server.Auth().UpsertSessionRecordingConfig(context.Background(), &tt.recordingConfig)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
// Create a new session
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "foo"),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term.Close()) })
|
|
|
|
// Send a command and validate the output
|
|
validateTerminal(t, term)
|
|
|
|
// Validate that the session is active on the node
|
|
require.Equal(t, int32(1), s.node.ActiveConnections())
|
|
|
|
// Close the web socket to emulate a user closing the browser window
|
|
require.NoError(t, term.Close())
|
|
|
|
// Validate that the node terminates the session
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
require.Zero(t, s.node.ActiveConnections())
|
|
}, 30*time.Second, 250*time.Millisecond)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTerminalRouting(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{disableDiskBasedRecording: true})
|
|
|
|
// add nodes with conflicting hostnames
|
|
llama := s.addNode(t, uuid.NewString(), "llama", "127.0.0.1:0")
|
|
s.addNode(t, uuid.NewString(), "llamas", "127.0.0.1:0")
|
|
alpaca1 := s.addNode(t, uuid.NewString(), "alpaca", "127.0.0.1:0")
|
|
s.addNode(t, uuid.NewString(), "alpaca", "127.0.0.1:0")
|
|
|
|
closeNoError := func(t *testing.T, err error) {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
target *regular.Server
|
|
output string
|
|
wsCloseAssertion func(t *testing.T, err error)
|
|
}{
|
|
{
|
|
name: "exact match by uuid",
|
|
target: llama,
|
|
output: "teleport",
|
|
wsCloseAssertion: closeNoError,
|
|
},
|
|
{
|
|
name: "connect by uuid successful when multiple hostnames match",
|
|
target: alpaca1,
|
|
output: "teleport",
|
|
wsCloseAssertion: closeNoError,
|
|
},
|
|
}
|
|
|
|
for i, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, fmt.Sprintf("foo-%d", i)),
|
|
host: tt.target.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { tt.wsCloseAssertion(t, term.Close()) })
|
|
|
|
sess := term.GetSession()
|
|
|
|
metadata := tt.target.EventMetadata()
|
|
require.Equal(t, metadata.ServerID, sess.ServerID)
|
|
require.Equal(t, metadata.ServerHostname, sess.ServerHostname)
|
|
|
|
// here we intentionally run a command where the output we're looking
|
|
// for is not present in the command itself
|
|
_, err = io.WriteString(term, "echo txlxport | sed 's/x/e/g'\r\n")
|
|
require.NoError(t, err)
|
|
require.NoError(t, waitForOutput(term, tt.output))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTerminalRequireSessionMFA(t *testing.T) {
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
|
|
const username = "llama2999"
|
|
pack := proxy.authPack(t, username, nil /* roles */)
|
|
|
|
userClient, err := env.server.NewClient(authtest.TestUser(username))
|
|
require.NoError(t, err)
|
|
|
|
cases := []struct {
|
|
name string
|
|
getAuthPreference func(t *testing.T) types.AuthPreference
|
|
registerDevice func(t *testing.T) *authtest.Device
|
|
getChallengeResponseBytes func(t *testing.T, chal client.MFAAuthenticateChallenge, testDev *authtest.Device) []byte
|
|
}{
|
|
{
|
|
name: "with webauthn",
|
|
getAuthPreference: func(t *testing.T) types.AuthPreference {
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorWebauthn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
RequireMFAType: types.RequireMFAType_SESSION,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return ap
|
|
},
|
|
registerDevice: func(t *testing.T) *authtest.Device {
|
|
webauthnDev, err := authtest.RegisterTestDevice(
|
|
ctx,
|
|
userClient,
|
|
"webauthn", authproto.DeviceType_DEVICE_TYPE_WEBAUTHN, pack.device /* authenticator */)
|
|
require.NoError(t, err)
|
|
|
|
return webauthnDev
|
|
},
|
|
getChallengeResponseBytes: func(t *testing.T, chal client.MFAAuthenticateChallenge, testDev *authtest.Device) []byte {
|
|
res, err := testDev.SolveAuthn(&authproto.MFAAuthenticateChallenge{
|
|
WebauthnChallenge: wantypes.CredentialAssertionToProto(chal.WebauthnChallenge),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
webauthnResBytes, err := json.Marshal(wantypes.CredentialAssertionResponseFromProto(res.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
|
|
envelope := &terminal.Envelope{
|
|
Version: defaults.WebsocketVersion,
|
|
Type: defaults.WebsocketMFAChallenge,
|
|
Payload: string(webauthnResBytes),
|
|
}
|
|
protoBytes, err := proto.Marshal(envelope)
|
|
require.NoError(t, err)
|
|
|
|
return protoBytes
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, tc.getAuthPreference(t))
|
|
require.NoError(t, err)
|
|
|
|
dev := tc.registerDevice(t)
|
|
|
|
termCtx, cancel := context.WithCancel(ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
// Open a terminal to a new session.
|
|
term, err := connectToHost(termCtx, connectConfig{
|
|
pack: pack,
|
|
host: proxy.node.ID(),
|
|
proxy: proxy.webURL.Host,
|
|
mfaCeremony: func(challenge client.MFAAuthenticateChallenge) []byte {
|
|
return tc.getChallengeResponseBytes(t, challenge, dev)
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Test we can write.
|
|
_, err = io.WriteString(term, "echo txlxport | sed 's/x/e/g'\r\n")
|
|
require.NoError(t, err)
|
|
require.NoError(t, waitForOutput(term, "teleport"))
|
|
})
|
|
}
|
|
}
|
|
|
|
type windowsDesktopServiceMock struct {
|
|
listener net.Listener
|
|
}
|
|
|
|
func mustStartWindowsDesktopMock(t *testing.T, authClient *auth.Server) *windowsDesktopServiceMock {
|
|
l, err := net.Listen("tcp", "localhost:0")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
require.NoError(t, l.Close())
|
|
})
|
|
authID := state.IdentityID{
|
|
Role: types.RoleWindowsDesktop,
|
|
HostUUID: "windows_server",
|
|
NodeName: "windows_server",
|
|
}
|
|
n, err := authClient.GetClusterName(t.Context())
|
|
require.NoError(t, err)
|
|
dns := []string{"localhost", "127.0.0.1", desktop.WildcardServiceDNS}
|
|
identity, err := auth.LocalRegister(authID, authClient, nil, dns, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
tlsConfig, err := identity.TLSConfig(nil)
|
|
require.NoError(t, err)
|
|
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
|
require.NoError(t, err)
|
|
|
|
ca, err := authClient.GetCertAuthority(context.Background(), types.CertAuthID{Type: types.UserCA, DomainName: n.GetClusterName()}, false)
|
|
require.NoError(t, err)
|
|
|
|
for _, kp := range services.GetTLSCerts(ca) {
|
|
require.True(t, tlsConfig.ClientCAs.AppendCertsFromPEM(kp))
|
|
}
|
|
|
|
wd := &windowsDesktopServiceMock{
|
|
listener: l,
|
|
}
|
|
go func() {
|
|
conn, err := l.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
tlsConn := tls.Server(conn, tlsConfig)
|
|
if err := tlsConn.HandshakeContext(context.Background()); err != nil {
|
|
t.Errorf("Unexpected error %v", err)
|
|
return
|
|
}
|
|
wd.handleConn(t, tlsConn)
|
|
}()
|
|
|
|
return wd
|
|
}
|
|
|
|
func (w *windowsDesktopServiceMock) handleConn(t *testing.T, conn *tls.Conn) {
|
|
tdpConn := tdp.NewConn(conn, legacy.Decode)
|
|
|
|
// Ensure that incoming connection is MFAVerified.
|
|
require.NotEmpty(t, conn.ConnectionState().PeerCertificates)
|
|
cert := conn.ConnectionState().PeerCertificates[0]
|
|
identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, identity.MFAVerified)
|
|
|
|
msg, err := tdpConn.ReadMessage()
|
|
require.NoError(t, err)
|
|
require.IsType(t, legacy.ClientUsername{}, msg)
|
|
|
|
msg, err = tdpConn.ReadMessage()
|
|
require.NoError(t, err)
|
|
require.IsType(t, legacy.ClientScreenSpec{}, msg)
|
|
|
|
msg, err = tdpConn.ReadMessage()
|
|
require.NoError(t, err)
|
|
require.IsType(t, legacy.ClientKeyboardLayout{}, msg, "%v", msg)
|
|
|
|
err = tdpConn.WriteMessage(legacy.Alert{Message: "test", Severity: legacy.SeverityWarning})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestDesktopAccessMFA(t *testing.T) {
|
|
t.Setenv("TELEPORT_DISABLE_DESKTOP_LATENCY_DETECTOR_PING", "true")
|
|
|
|
tests := []struct {
|
|
name string
|
|
authPref types.AuthPreferenceSpecV2
|
|
mfaHandler func(t *testing.T, ws *websocket.Conn, dev *authtest.Device)
|
|
registerDevice func(t *testing.T, ctx context.Context, clt *authclient.Client) *authtest.Device
|
|
}{
|
|
{
|
|
name: "webauthn",
|
|
authPref: types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorWebauthn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
RequireMFAType: types.RequireMFAType_SESSION,
|
|
},
|
|
mfaHandler: handleDesktopMFAWebauthnChallenge,
|
|
registerDevice: func(t *testing.T, ctx context.Context, clt *authclient.Client) *authtest.Device {
|
|
webauthnDev, err := authtest.RegisterTestDevice(ctx, clt, "webauthn", authproto.DeviceType_DEVICE_TYPE_WEBAUTHN, nil /* authenticator */)
|
|
require.NoError(t, err)
|
|
return webauthnDev
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "llama", nil /* roles */)
|
|
|
|
clt, err := env.server.NewClient(authtest.TestUser("llama"))
|
|
require.NoError(t, err)
|
|
wdID := uuid.New().String()
|
|
|
|
wdMock := mustStartWindowsDesktopMock(t, env.server.Auth())
|
|
wd, err := types.NewWindowsDesktopV3("desktop1", nil, types.WindowsDesktopSpecV3{
|
|
Addr: wdMock.listener.Addr().String(),
|
|
Domain: "CORP",
|
|
HostID: wdID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = env.server.Auth().UpsertWindowsDesktop(context.Background(), wd)
|
|
require.NoError(t, err)
|
|
wds, err := types.NewWindowsDesktopServiceV3(types.Metadata{Name: wdID}, types.WindowsDesktopServiceSpecV3{
|
|
Addr: wdMock.listener.Addr().String(),
|
|
TeleportVersion: teleport.Version,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = env.server.Auth().UpsertWindowsDesktopService(context.Background(), wds)
|
|
require.NoError(t, err)
|
|
|
|
ap, err := types.NewAuthPreference(tc.authPref)
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
dev := tc.registerDevice(t, ctx, clt)
|
|
|
|
ws := proxy.makeDesktopSession(t, pack)
|
|
|
|
// Before the session can proceed to the MFA ceremony, the proxy expects the
|
|
// web UI to send both the screen size and keyboard layout over the websocket.
|
|
ss, err := legacy.ClientScreenSpec{
|
|
Width: 1920,
|
|
Height: 1080,
|
|
}.Encode()
|
|
require.NoError(t, err)
|
|
require.NoError(t, ws.WriteMessage(websocket.BinaryMessage, ss))
|
|
kl, err := legacy.ClientKeyboardLayout{}.Encode()
|
|
require.NoError(t, err)
|
|
require.NoError(t, ws.WriteMessage(websocket.BinaryMessage, kl))
|
|
|
|
tc.mfaHandler(t, ws, dev)
|
|
|
|
tdpClient := tdp.NewConn(&WebsocketIO{Conn: ws}, legacy.Decode)
|
|
|
|
msg, err := tdpClient.ReadMessage()
|
|
require.NoError(t, err)
|
|
|
|
// sometimes LatencyStats will be sent before we get Alert, in such case just skip it and get next one
|
|
if _, ok := msg.(legacy.LatencyStats); ok {
|
|
msg, err = tdpClient.ReadMessage()
|
|
require.NoError(t, err)
|
|
}
|
|
require.IsType(t, legacy.Alert{}, msg)
|
|
})
|
|
}
|
|
}
|
|
|
|
func handleDesktopMFAWebauthnChallenge(t *testing.T, ws *websocket.Conn, dev *authtest.Device) {
|
|
wsrwc := &WebsocketIO{Conn: ws}
|
|
tdpConn := tdp.NewConn(wsrwc, legacy.Decode)
|
|
|
|
br := bufio.NewReader(wsrwc)
|
|
mt, err := br.ReadByte()
|
|
require.NoError(t, err)
|
|
require.Equal(t, legacy.TypeMFA, legacy.MessageType(mt))
|
|
|
|
mfaChallange, err := legacy.DecodeMFAChallenge(br)
|
|
require.NoError(t, err)
|
|
res, err := dev.SolveAuthn(&authproto.MFAAuthenticateChallenge{
|
|
WebauthnChallenge: wantypes.CredentialAssertionToProto(mfaChallange.WebauthnChallenge),
|
|
})
|
|
require.NoError(t, err)
|
|
err = tdpConn.WriteMessage(legacy.MFA{
|
|
Type: defaults.WebsocketMFAChallenge[0],
|
|
MFAAuthenticateResponse: &authproto.MFAAuthenticateResponse{
|
|
Response: &authproto.MFAAuthenticateResponse_Webauthn{
|
|
Webauthn: res.GetWebauthn(),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestWebAgentForward(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{disableDiskBasedRecording: true})
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "foo"),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term.Close()) })
|
|
|
|
_, err = io.WriteString(term, "echo $SSH_AUTH_SOCK\r\n")
|
|
require.NoError(t, err)
|
|
|
|
err = waitForOutput(term, "/")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestActiveSessions(t *testing.T) {
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{modules: modulestest.EnterpriseModules()})
|
|
pack := s.authPack(t, "foo")
|
|
|
|
start := time.Now()
|
|
kinds := []types.SessionKind{
|
|
types.SSHSessionKind,
|
|
types.KubernetesSessionKind,
|
|
types.WindowsDesktopSessionKind,
|
|
types.DatabaseSessionKind,
|
|
types.AppSessionKind,
|
|
}
|
|
ids := make(map[string]struct{})
|
|
|
|
for _, kind := range kinds {
|
|
tracker, err := types.NewSessionTracker(types.SessionTrackerSpecV1{
|
|
SessionID: string(session.NewID()),
|
|
ClusterName: s.server.ClusterName(),
|
|
Kind: string(kind),
|
|
State: types.SessionState_SessionStateRunning,
|
|
Created: start,
|
|
Expires: start.Add(1 * time.Hour),
|
|
Hostname: s.node.GetInfo().GetHostname(),
|
|
DesktopName: s.node.GetInfo().GetHostname(),
|
|
AppName: s.node.GetInfo().GetHostname(),
|
|
DatabaseName: s.node.GetInfo().GetHostname(),
|
|
Address: s.srvID,
|
|
Login: pack.login,
|
|
Participants: []types.Participant{
|
|
{ID: "id", User: "user-1", LastActive: start},
|
|
},
|
|
HostPolicies: []*types.SessionTrackerPolicySet{
|
|
{
|
|
Name: "foo",
|
|
Version: "5",
|
|
RequireSessionJoin: []*types.SessionRequirePolicy{
|
|
{
|
|
Name: "foo",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
ids[tracker.GetSessionID()] = struct{}{}
|
|
|
|
_, err = s.server.Auth().CreateSessionTracker(context.Background(), tracker)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// create an inactive session, which should not show up
|
|
inactive, err := types.NewSessionTracker(types.SessionTrackerSpecV1{
|
|
SessionID: string(session.NewID()),
|
|
ClusterName: s.server.ClusterName(),
|
|
Kind: string(types.SSHSessionKind),
|
|
State: types.SessionState_SessionStateTerminated,
|
|
Created: time.Now(),
|
|
Expires: time.Now().Add(1 * time.Hour),
|
|
Hostname: s.node.GetInfo().GetHostname(),
|
|
Address: s.srvID,
|
|
Login: pack.login,
|
|
Participants: nil,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().CreateSessionTracker(context.Background(), inactive)
|
|
require.NoError(t, err)
|
|
|
|
re, err := pack.clt.Get(s.ctx, pack.clt.Endpoint("webapi", "sites", s.server.ClusterName(), "sessions"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
var sessResp siteSessionsGetResponse
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &sessResp))
|
|
require.Len(t, sessResp.Sessions, len(kinds))
|
|
|
|
for _, session := range sessResp.Sessions {
|
|
require.Contains(t, ids, string(session.ID))
|
|
require.Equal(t, s.node.GetNamespace(), session.Namespace)
|
|
require.NotNil(t, session.Parties)
|
|
require.Greater(t, session.TerminalParams.H, 0)
|
|
require.Greater(t, session.TerminalParams.W, 0)
|
|
require.Equal(t, pack.login, session.Login)
|
|
require.False(t, session.Created.IsZero())
|
|
require.False(t, session.LastActive.IsZero())
|
|
require.Equal(t, s.srvID, session.ServerID)
|
|
require.Equal(t, s.node.GetInfo().GetHostname(), session.ServerHostname)
|
|
require.Equal(t, s.srvID, session.ServerAddr)
|
|
require.Equal(t, s.server.ClusterName(), session.ClusterName)
|
|
require.ElementsMatch(t, []types.SessionParticipantMode{"peer"}, session.ParticipantModes)
|
|
}
|
|
}
|
|
|
|
func TestCloseConnectionsOnLogout(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
pack := s.authPack(t, "foo")
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: pack,
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term.Close()) })
|
|
|
|
// to make sure we have a session
|
|
_, err = io.WriteString(term, "expr 137 + 39\r\n")
|
|
require.NoError(t, err)
|
|
|
|
// make sure the server has replied
|
|
out := make([]byte, 100)
|
|
_, err = term.Read(out)
|
|
require.NoError(t, err)
|
|
|
|
_, err = pack.clt.Delete(s.ctx, pack.clt.Endpoint("webapi", "sessions", "web"))
|
|
require.NoError(t, err)
|
|
|
|
// wait until timeout or detect that the connection has been closed.
|
|
after := time.After(5 * time.Second)
|
|
errC := make(chan error)
|
|
go func() {
|
|
for {
|
|
_, err := term.Read(out)
|
|
if err != nil {
|
|
errC <- err
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-after:
|
|
t.Fatalf("timeout")
|
|
case err := <-errC:
|
|
require.ErrorIs(t, err, io.EOF)
|
|
}
|
|
}
|
|
|
|
type httpErrorMessage struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type httpErrorResponse struct {
|
|
Error httpErrorMessage `json:"error"`
|
|
}
|
|
|
|
func TestLogin_PrivateKeyEnabledError(t *testing.T) {
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
modules: &modulestest.Modules{
|
|
TestBuildType: modules.BuildOSS,
|
|
MockAttestationData: &keys.AttestationData{
|
|
PrivateKeyPolicy: keys.PrivateKeyPolicyNone,
|
|
},
|
|
},
|
|
})
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOff,
|
|
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertAuthPreference(s.ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// create user
|
|
const user = "user1"
|
|
const pass = "password1234"
|
|
s.createUser(t, user, "root", pass, "")
|
|
|
|
clt := s.client(t)
|
|
ctx := context.Background()
|
|
|
|
const ua = "test-ua"
|
|
_, body, err := rawLoginWebOTP(ctx, loginWebOTPParams{
|
|
webClient: clt,
|
|
user: user,
|
|
password: pass,
|
|
userAgent: ua,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var resErr httpErrorResponse
|
|
require.NoError(t, json.Unmarshal(body, &resErr))
|
|
require.Contains(t, resErr.Error.Message, keys.PrivateKeyPolicyHardwareKeyTouch)
|
|
}
|
|
|
|
func TestLogin(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOff,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertAuthPreference(s.ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// create user
|
|
const user = "user1"
|
|
const pass = "password1234"
|
|
s.createUser(t, user, "root", pass, "")
|
|
|
|
clt := s.client(t)
|
|
ctx := context.Background()
|
|
|
|
const ua = "test-ua"
|
|
sessionResp, httpResp := loginWebOTP(t, ctx, loginWebOTPParams{
|
|
webClient: clt,
|
|
user: user,
|
|
password: pass,
|
|
userAgent: ua,
|
|
})
|
|
|
|
events, _, err := s.server.AuthServer.AuditLog.SearchEvents(ctx, events.SearchEventsRequest{
|
|
From: s.clock.Now().Add(-time.Hour),
|
|
To: s.clock.Now().Add(time.Hour),
|
|
EventTypes: []string{events.UserLoginEvent},
|
|
Limit: 1,
|
|
Order: types.EventOrderDescending,
|
|
})
|
|
require.NoError(t, err)
|
|
event := events[0].(*apievents.UserLogin)
|
|
require.True(t, event.Success)
|
|
require.Equal(t, ua, event.UserAgent)
|
|
require.True(t, strings.HasPrefix(event.RemoteAddr, "127.0.0.1:"))
|
|
|
|
cookies := httpResp.Cookies()
|
|
require.Len(t, cookies, 1)
|
|
require.NotEmpty(t, sessionResp.SessionExpires)
|
|
|
|
// now make sure we are logged in by calling authenticated method
|
|
// we need to supply both session cookie and bearer token for
|
|
// request to succeed
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
clt = s.client(t, roundtrip.BearerAuth(sessionResp.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(s.url(), cookies)
|
|
|
|
re, err := clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
var clusters []webui.Cluster
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &clusters))
|
|
|
|
// in absence of session cookie or bearer auth the same request fill fail
|
|
|
|
// no session cookie:
|
|
clt = s.client(t, roundtrip.BearerAuth(sessionResp.Token))
|
|
_, err = clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
|
|
// no bearer token:
|
|
clt = s.client(t, roundtrip.CookieJar(jar))
|
|
_, err = clt.Get(s.ctx, clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
}
|
|
|
|
// TestEmptyMotD ensures that responses returned by both /webapi/ping and
|
|
// /webapi/motd work when no MotD is set
|
|
func TestEmptyMotD(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
wc := s.client(t)
|
|
|
|
// Given an auth server configured *not* to expose a Message Of The
|
|
// Day...
|
|
|
|
// When I issue a ping request...
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
// Expect that the MotD flag in the ping response is *not* set
|
|
var pingResponse *webclient.PingResponse
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &pingResponse))
|
|
require.False(t, pingResponse.Auth.HasMessageOfTheDay)
|
|
|
|
// When I fetch the MotD...
|
|
re, err = wc.Get(s.ctx, wc.Endpoint("webapi", "motd"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
// Expect that an empty response returned
|
|
var motdResponse *webclient.MotD
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &motdResponse))
|
|
require.Empty(t, motdResponse.Text)
|
|
}
|
|
|
|
// TestMotD ensures that a response is returned by both /webapi/ping and /webapi/motd
|
|
// and that that the response bodies contain their MOTD components
|
|
func TestMotD(t *testing.T) {
|
|
t.Parallel()
|
|
const motd = "Hello. I'm a Teleport cluster!"
|
|
|
|
s := newWebSuite(t)
|
|
wc := s.client(t)
|
|
|
|
// Given an auth server configured to expose a Message Of The Day...
|
|
prefs := types.DefaultAuthPreference()
|
|
prefs.SetMessageOfTheDay(motd)
|
|
_, err := s.server.AuthServer.AuthServer.UpsertAuthPreference(s.ctx, prefs)
|
|
require.NoError(t, err)
|
|
|
|
// When I issue a ping request...
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
// Expect that the MotD flag in the ping response is set to indicate
|
|
// a MotD
|
|
var pingResponse *webclient.PingResponse
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &pingResponse))
|
|
require.True(t, pingResponse.Auth.HasMessageOfTheDay)
|
|
|
|
// When I fetch the MotD...
|
|
re, err = wc.Get(s.ctx, wc.Endpoint("webapi", "motd"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
// Expect that the text returned is the configured value
|
|
var motdResponse *webclient.MotD
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &motdResponse))
|
|
require.Equal(t, motd, motdResponse.Text)
|
|
}
|
|
|
|
// TestPingAutomaticUpgrades ensures /webapi/ping returns whether AutomaticUpgrades are enabled.
|
|
func TestPingAutomaticUpgrades(t *testing.T) {
|
|
t.Run("Automatic Upgrades are enabled", func(t *testing.T) {
|
|
// Set up
|
|
m := modulestest.Modules{
|
|
TestBuildType: modules.BuildOSS,
|
|
TestFeatures: modules.Features{
|
|
AutomaticUpgrades: true,
|
|
},
|
|
}
|
|
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{modules: &m})
|
|
wc := s.client(t)
|
|
var pingResponse *webclient.PingResponse
|
|
|
|
// Get Ping response
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &pingResponse))
|
|
require.True(t, pingResponse.AutomaticUpgrades, "expected automatic upgrades to be enabled")
|
|
})
|
|
t.Run("Automatic Upgrades are disabled", func(t *testing.T) {
|
|
// Set up
|
|
m := modulestest.Modules{
|
|
TestBuildType: modules.BuildOSS,
|
|
TestFeatures: modules.Features{
|
|
AutomaticUpgrades: false,
|
|
},
|
|
}
|
|
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{modules: &m})
|
|
wc := s.client(t)
|
|
var pingResponse *webclient.PingResponse
|
|
|
|
// Get Ping response
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &pingResponse))
|
|
require.False(t, pingResponse.AutomaticUpgrades, "expected automatic upgrades to be disabled")
|
|
})
|
|
}
|
|
|
|
// TestInstallerRepoChannel ensures the returned installer script has the proper repo channel
|
|
func TestInstallerRepoChannel(t *testing.T) {
|
|
t.Run("cloud with automatic upgrades", func(t *testing.T) {
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
authPreferenceSpec: &types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Webauthn: &types.Webauthn{RPID: "localhost"},
|
|
},
|
|
modules: &modulestest.Modules{
|
|
TestBuildType: modules.BuildEnterprise,
|
|
TestFeatures: modules.Features{
|
|
Cloud: true,
|
|
AutomaticUpgrades: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
wc := s.client(t)
|
|
t.Run("documented variables are injected", func(t *testing.T) {
|
|
// Variables documented here:
|
|
// https://goteleport.com/docs/enroll-resources/auto-discovery/servers/ec2-discovery/#step-67-optional-customize-the-default-installer-script
|
|
err := s.server.Auth().SetInstaller(s.ctx, types.MustNewInstallerV1("custom", `#!/usr/bin/env bash
|
|
echo {{ .PublicProxyAddr }}
|
|
echo Teleport-{{ .MajorVersion }}
|
|
echo Repository Channel: {{ .RepoChannel }}
|
|
echo AutomaticUpgrades: {{ .AutomaticUpgrades }}
|
|
`))
|
|
require.NoError(t, err)
|
|
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "custom"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
// Variables must be injected
|
|
require.Contains(t, responseString, "echo Teleport-v")
|
|
require.NotContains(t, responseString, "echo Repository Channel: stable/v")
|
|
require.Contains(t, responseString, "echo Repository Channel: stable/cloud")
|
|
require.Contains(t, responseString, "echo AutomaticUpgrades: true")
|
|
})
|
|
|
|
t.Run("default-installer", func(t *testing.T) {
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "default-installer"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
// The repo's channel to use is stable/cloud
|
|
require.Contains(t, responseString, "stable/cloud")
|
|
require.NotContains(t, responseString, "stable/v")
|
|
require.Contains(t, responseString, "--auto-upgrade=true")
|
|
require.Contains(t, responseString, "--teleport-package=teleport-ent")
|
|
})
|
|
|
|
t.Run("default-agentless-installer", func(t *testing.T) {
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "default-agentless-installer"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
// The repo's channel to use is stable/cloud
|
|
require.Contains(t, responseString, "stable/cloud")
|
|
require.NotContains(t, responseString, "stable/v")
|
|
require.Contains(t, responseString, ""+
|
|
" # shellcheck disable=SC2050\n"+
|
|
" if [ \"true\" = \"true\" ]; then\n"+
|
|
" # automatic upgrades\n",
|
|
)
|
|
require.Contains(t, responseString, ""+
|
|
" TELEPORT_PACKAGE=\"teleport-ent\"\n"+
|
|
" TELEPORT_UPDATER_PACKAGE=\"teleport-ent-updater\"\n",
|
|
)
|
|
})
|
|
})
|
|
|
|
t.Run("cloud without automatic upgrades", func(t *testing.T) {
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
authPreferenceSpec: &types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Webauthn: &types.Webauthn{RPID: "localhost"},
|
|
},
|
|
modules: &modulestest.Modules{
|
|
TestBuildType: modules.BuildOSS,
|
|
TestFeatures: modules.Features{
|
|
Cloud: true,
|
|
AutomaticUpgrades: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
wc := s.client(t)
|
|
|
|
t.Run("documented variables are injected", func(t *testing.T) {
|
|
// Variables documented here: https://goteleport.com/docs/enroll-resources/auto-discovery/servers/ec2-discovery/#step-67-optional-customize-the-default-installer-script
|
|
err := s.server.Auth().SetInstaller(s.ctx, types.MustNewInstallerV1("custom", `#!/usr/bin/env bash
|
|
echo {{ .PublicProxyAddr }}
|
|
echo Teleport-{{ .MajorVersion }}
|
|
echo Repository Channel: {{ .RepoChannel }}
|
|
echo AutomaticUpgrades: {{ .AutomaticUpgrades }}
|
|
`))
|
|
require.NoError(t, err)
|
|
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "custom"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
// Variables must be injected
|
|
require.Contains(t, responseString, "echo Teleport-v")
|
|
require.Contains(t, responseString, "echo Repository Channel: stable/v")
|
|
require.NotContains(t, responseString, "echo Repository Channel: stable/cloud")
|
|
require.Contains(t, responseString, "echo AutomaticUpgrades: false")
|
|
})
|
|
t.Run("default-installer", func(t *testing.T) {
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "default-installer"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
require.NotContains(t, responseString, "stable/cloud")
|
|
})
|
|
t.Run("default-agentless-installer", func(t *testing.T) {
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "default-agentless-installer"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
require.NotContains(t, responseString, "stable/cloud")
|
|
})
|
|
})
|
|
|
|
t.Run("oss or enterprise with automatic upgrades", func(t *testing.T) {
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
authPreferenceSpec: &types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Webauthn: &types.Webauthn{RPID: "localhost"},
|
|
},
|
|
modules: &modulestest.Modules{
|
|
TestBuildType: modules.BuildOSS,
|
|
TestFeatures: modules.Features{
|
|
Cloud: false,
|
|
AutomaticUpgrades: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
wc := s.client(t)
|
|
t.Run("documented variables are injected", func(t *testing.T) {
|
|
// Variables documented here: https://goteleport.com/docs/enroll-resources/auto-discovery/servers/ec2-discovery/#step-67-optional-customize-the-default-installer-script
|
|
err := s.server.Auth().SetInstaller(s.ctx, types.MustNewInstallerV1("custom", `#!/usr/bin/env bash
|
|
echo {{ .PublicProxyAddr }}
|
|
echo Teleport-{{ .MajorVersion }}
|
|
echo Repository Channel: {{ .RepoChannel }}
|
|
echo AutomaticUpgrades: {{ .AutomaticUpgrades }}
|
|
`))
|
|
require.NoError(t, err)
|
|
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "custom"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
// Variables must be injected
|
|
require.Contains(t, responseString, "echo Teleport-v")
|
|
require.Contains(t, responseString, "echo Repository Channel: stable/v")
|
|
require.NotContains(t, responseString, "echo Repository Channel: stable/cloud")
|
|
require.Contains(t, responseString, "echo AutomaticUpgrades: false")
|
|
})
|
|
t.Run("default-installer", func(t *testing.T) {
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "default-installer"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
// The repo's channel to use is stable/cloud
|
|
require.NotContains(t, responseString, "stable/cloud")
|
|
require.Contains(t, responseString, "stable/v")
|
|
require.Contains(t, responseString, "--auto-upgrade=false")
|
|
require.Contains(t, responseString, "--teleport-package=teleport ")
|
|
})
|
|
t.Run("default-agentless-installer", func(t *testing.T) {
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "scripts", "installer", "default-agentless-installer"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
responseString := string(re.Bytes())
|
|
|
|
// The repo's channel to use is stable/cloud
|
|
require.NotContains(t, responseString, "stable/cloud")
|
|
require.Contains(t, responseString, "stable/v")
|
|
require.Contains(t, responseString, ""+
|
|
" # shellcheck disable=SC2050\n"+
|
|
" if [ \"false\" = \"true\" ]; then\n"+
|
|
" # automatic upgrades\n",
|
|
)
|
|
require.Contains(t, responseString, ""+
|
|
" TELEPORT_PACKAGE=\"teleport\"\n"+
|
|
" TELEPORT_UPDATER_PACKAGE=\"teleport-updater\"\n",
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestMultipleConnectors(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
wc := s.client(t)
|
|
|
|
// create two oidc connectors, one named "foo" and another named "bar"
|
|
oidcConnectorSpec := types.OIDCConnectorSpecV3{
|
|
RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"},
|
|
ClientID: "000000000000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com",
|
|
ClientSecret: "AAAAAAAAAAAAAAAAAAAAAAAA",
|
|
IssuerURL: "https://oidc.example.com",
|
|
Display: "Login with Example",
|
|
Scope: []string{"group"},
|
|
ClaimsToRoles: []types.ClaimMapping{
|
|
{
|
|
Claim: "group",
|
|
Value: "admin",
|
|
Roles: []string{"admin"},
|
|
},
|
|
},
|
|
}
|
|
o, err := types.NewOIDCConnector("foo", oidcConnectorSpec)
|
|
require.NoError(t, err)
|
|
upserted, err := s.server.Auth().UpsertOIDCConnector(s.ctx, o)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, upserted)
|
|
o2, err := types.NewOIDCConnector("bar", oidcConnectorSpec)
|
|
require.NoError(t, err)
|
|
upserted, err = s.server.Auth().UpsertOIDCConnector(s.ctx, o2)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, upserted)
|
|
|
|
// set the auth preferences to oidc with no connector name
|
|
authPreference, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: "oidc",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertAuthPreference(s.ctx, authPreference)
|
|
require.NoError(t, err)
|
|
|
|
// hit the ping endpoint to get the auth type and connector name
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
var out *webclient.PingResponse
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &out))
|
|
|
|
// make sure the connector name we got back was the first connector
|
|
// in the backend, in this case it's "bar"
|
|
oidcConnectors, err := s.server.Auth().GetOIDCConnectors(s.ctx, false)
|
|
require.NoError(t, err)
|
|
require.Equal(t, oidcConnectors[0].GetName(), out.Auth.OIDC.Name)
|
|
|
|
// update the auth preferences and this time specify the connector name
|
|
authPreference, err = types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: "oidc",
|
|
ConnectorName: "foo",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertAuthPreference(s.ctx, authPreference)
|
|
require.NoError(t, err)
|
|
|
|
// hit the ping endpoing to get the auth type and connector name
|
|
re, err = wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &out))
|
|
|
|
// make sure the connector we get back is "foo"
|
|
require.Equal(t, "foo", out.Auth.OIDC.Name)
|
|
}
|
|
|
|
func TestPingSSHDialTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
wc := s.client(t)
|
|
|
|
// hit the ping endpoint to get the ssh dial timeout
|
|
re, err := wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
var out webclient.PingResponse
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &out))
|
|
|
|
// Validate the timeout is the default value.
|
|
require.Equal(t, apidefaults.DefaultIOTimeout, out.Proxy.SSH.DialTimeout)
|
|
|
|
// Update the timeout
|
|
cnc, err := s.server.Auth().GetClusterNetworkingConfig(s.ctx)
|
|
require.NoError(t, err)
|
|
|
|
cnc.SetSSHDialTimeout(time.Minute)
|
|
cnc, err = s.server.Auth().UpsertClusterNetworkingConfig(s.ctx, cnc)
|
|
require.NoError(t, err)
|
|
|
|
// hit the ping endpoint again to validate that updated values are returned
|
|
re, err = wc.Get(s.ctx, wc.Endpoint("webapi", "ping"), url.Values{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &out))
|
|
|
|
// Validate the timeout is the default value.
|
|
require.Equal(t, cnc.GetSSHDialTimeout(), out.Proxy.SSH.DialTimeout)
|
|
}
|
|
|
|
// TestConstructSSHResponse checks if the secret package uses AES-GCM to
|
|
// encrypt and decrypt data that passes through the ConstructSSHResponse
|
|
// function.
|
|
func TestConstructSSHResponse(t *testing.T) {
|
|
key, err := secret.NewKey()
|
|
require.NoError(t, err)
|
|
|
|
u, err := url.Parse("http://www.example.com/callback")
|
|
require.NoError(t, err)
|
|
query := u.Query()
|
|
query.Set("secret_key", key.String())
|
|
u.RawQuery = query.Encode()
|
|
|
|
rawresp, err := ConstructSSHResponse(AuthParams{
|
|
Username: "foo",
|
|
Cert: []byte{0x00},
|
|
TLSCert: []byte{0x01},
|
|
ClientRedirectURL: u.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.Empty(t, rawresp.Query().Get("secret"))
|
|
require.Empty(t, rawresp.Query().Get("secret_key"))
|
|
require.NotEmpty(t, rawresp.Query().Get("response"))
|
|
|
|
plaintext, err := key.Open([]byte(rawresp.Query().Get("response")))
|
|
require.NoError(t, err)
|
|
|
|
var resp *authclient.CLILoginResponse
|
|
err = json.Unmarshal(plaintext, &resp)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "foo", resp.Username)
|
|
require.EqualValues(t, []byte{0x00}, resp.Cert)
|
|
require.EqualValues(t, []byte{0x01}, resp.TLSCert)
|
|
}
|
|
|
|
// TestConstructSSHResponseLegacy checks that the old-style NaCl encryption with
|
|
// a secret query parameter (rather than secret_key, using AES-GCM) is not
|
|
// supported.
|
|
func TestConstructSSHResponseLegacy(t *testing.T) {
|
|
u := &url.URL{
|
|
Scheme: "http",
|
|
Host: "www.example.com",
|
|
Path: "/callback",
|
|
RawQuery: url.Values{
|
|
// the old-style NaCl key is 32 bytes in base 32
|
|
"secret": {base64.StdEncoding.EncodeToString(make([]byte, 32))},
|
|
}.Encode(),
|
|
}
|
|
|
|
_, err := ConstructSSHResponse(AuthParams{
|
|
Username: "foo",
|
|
Cert: []byte{0x00},
|
|
TLSCert: []byte{0x01},
|
|
ClientRedirectURL: u.String(),
|
|
})
|
|
require.ErrorIs(t, err, &trace.BadParameterError{Message: "missing secret_key"})
|
|
}
|
|
|
|
type byTimeAndIndex []apievents.AuditEvent
|
|
|
|
func (f byTimeAndIndex) Len() int {
|
|
return len(f)
|
|
}
|
|
|
|
func (f byTimeAndIndex) Less(i, j int) bool {
|
|
itime := f[i].GetTime()
|
|
jtime := f[j].GetTime()
|
|
if itime.Equal(jtime) && events.GetSessionID(f[i]) == events.GetSessionID(f[j]) {
|
|
return f[i].GetIndex() < f[j].GetIndex()
|
|
}
|
|
return itime.Before(jtime)
|
|
}
|
|
|
|
func (f byTimeAndIndex) Swap(i, j int) {
|
|
f[i], f[j] = f[j], f[i]
|
|
}
|
|
|
|
// ConstructSSHResponse_WebMFA tests that [sso.WebMFARedirect] is always
|
|
// transformed into a relative URL so that the sso callback will redirect
|
|
// back to the proxy.
|
|
func TestConstructSSHResponse_WebMFA(t *testing.T) {
|
|
baseURL := sso.WebMFARedirect + "?channel_id=" + uuid.NewString()
|
|
for _, clientRedirectURL := range []string{
|
|
baseURL,
|
|
"http://127.0.0.1:1234" + baseURL,
|
|
"http://localhost:1234" + baseURL,
|
|
"https://proxy.example.com" + baseURL,
|
|
"tcp://tcp.app.com" + baseURL,
|
|
} {
|
|
t.Run("clientRedirectURL: "+clientRedirectURL, func(t *testing.T) {
|
|
redirectURL, err := ConstructSSHResponse(AuthParams{
|
|
Username: "foo",
|
|
Cert: []byte{0x00},
|
|
TLSCert: []byte{0x01},
|
|
MFAToken: "token",
|
|
ClientRedirectURL: clientRedirectURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, redirectURL.Query().Get("channel_id"))
|
|
require.NotEmpty(t, redirectURL.Query().Get("response"))
|
|
withoutQueryParams, _, _ := strings.Cut(redirectURL.String(), "?")
|
|
require.Equal(t, sso.WebMFARedirect, withoutQueryParams)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchClusterEvents makes sure web API allows querying events by type.
|
|
func TestSearchClusterEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newWebSuite(t)
|
|
clock := s.clock
|
|
sessionEvents := eventstest.GenerateTestSession(eventstest.SessionParams{
|
|
PrintEvents: 3,
|
|
Clock: clock,
|
|
ServerID: s.proxy.ID(),
|
|
})
|
|
|
|
for _, e := range sessionEvents {
|
|
require.NoError(t, s.proxyClient.EmitAuditEvent(s.ctx, e))
|
|
}
|
|
|
|
sort.Sort(sort.Reverse(byTimeAndIndex(sessionEvents)))
|
|
sessionStart := sessionEvents[0]
|
|
sessionPrint := sessionEvents[1]
|
|
sessionEnd := sessionEvents[4]
|
|
|
|
fromTime := []string{clock.Now().AddDate(0, -1, 0).UTC().Format(time.RFC3339)}
|
|
toTime := []string{clock.Now().AddDate(0, 1, 0).UTC().Format(time.RFC3339)}
|
|
|
|
testCases := []struct {
|
|
// Comment is the test case description.
|
|
Comment string
|
|
// Query is the search query sent to the API.
|
|
Query url.Values
|
|
// Result is the expected returned list of events.
|
|
Result []apievents.AuditEvent
|
|
// TestStartKey is a flag to test start key value.
|
|
TestStartKey bool
|
|
// StartKeyValue is the value of start key to expect.
|
|
StartKeyValue string
|
|
}{
|
|
{
|
|
Comment: "Empty query",
|
|
Query: url.Values{
|
|
"from": fromTime,
|
|
"to": toTime,
|
|
},
|
|
Result: sessionEvents,
|
|
},
|
|
{
|
|
Comment: "Query by session start event",
|
|
Query: url.Values{
|
|
"include": []string{sessionStart.GetType()},
|
|
"from": fromTime,
|
|
"to": toTime,
|
|
},
|
|
Result: sessionEvents[:1],
|
|
},
|
|
{
|
|
Comment: "Query session start and session end events",
|
|
Query: url.Values{
|
|
"include": []string{sessionEnd.GetType() + "," + sessionStart.GetType()},
|
|
"from": fromTime,
|
|
"to": toTime,
|
|
},
|
|
Result: []apievents.AuditEvent{sessionStart, sessionEnd},
|
|
},
|
|
{
|
|
Comment: "Query events with filter by type and limit",
|
|
Query: url.Values{
|
|
"include": []string{sessionPrint.GetType() + "," + sessionEnd.GetType()},
|
|
"limit": []string{"1"},
|
|
"from": fromTime,
|
|
"to": toTime,
|
|
},
|
|
Result: []apievents.AuditEvent{sessionPrint},
|
|
},
|
|
{
|
|
Comment: "Query session start and session end events with limit and test returned start key",
|
|
Query: url.Values{
|
|
"include": []string{sessionEnd.GetType() + "," + sessionStart.GetType()},
|
|
"limit": []string{"1"},
|
|
"from": fromTime,
|
|
"to": toTime,
|
|
},
|
|
Result: []apievents.AuditEvent{sessionStart},
|
|
TestStartKey: true,
|
|
StartKeyValue: fmt.Sprintf("%s/%d", sessionStart.GetID(), sessionStart.GetTime().UnixNano()),
|
|
},
|
|
{
|
|
Comment: "Query session start and session end events with limit and given start key",
|
|
Query: url.Values{
|
|
"include": []string{sessionEnd.GetType() + "," + sessionStart.GetType()},
|
|
"startKey": []string{sessionStart.GetID()},
|
|
"from": fromTime,
|
|
"to": toTime,
|
|
},
|
|
Result: []apievents.AuditEvent{sessionEnd},
|
|
TestStartKey: true,
|
|
StartKeyValue: "",
|
|
},
|
|
}
|
|
|
|
pack := s.authPack(t, "foo")
|
|
for _, tc := range testCases {
|
|
t.Run(tc.Comment, func(t *testing.T) {
|
|
t.Parallel()
|
|
response, err := pack.clt.Get(s.ctx, pack.clt.Endpoint("webapi", "sites", s.server.ClusterName(), "events", "search"), tc.Query)
|
|
require.NoError(t, err)
|
|
var result eventsListGetResponse
|
|
require.NoError(t, json.Unmarshal(response.Bytes(), &result))
|
|
|
|
// filter out irrelvant auth events
|
|
filteredEvents := []events.EventFields{}
|
|
for _, e := range result.Events {
|
|
t := e.GetType()
|
|
if t == events.SessionStartEvent ||
|
|
t == events.SessionPrintEvent ||
|
|
t == events.SessionEndEvent {
|
|
filteredEvents = append(filteredEvents, e)
|
|
}
|
|
}
|
|
|
|
require.Len(t, filteredEvents, len(tc.Result))
|
|
for i, resultEvent := range filteredEvents {
|
|
require.Equal(t, tc.Result[i].GetType(), resultEvent.GetType())
|
|
require.Equal(t, tc.Result[i].GetID(), resultEvent.GetID())
|
|
}
|
|
|
|
// Session prints do not have IDs, only sessionStart and sessionEnd.
|
|
// When retrieving events for sessionStart and sessionEnd, sessionStart is returned first.
|
|
if tc.TestStartKey {
|
|
require.Equal(t, tc.StartKeyValue, result.StartKey)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetClusterDetails(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
cluster, err := s.proxyTunnel.Cluster(t.Context(), s.server.ClusterName())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cluster)
|
|
|
|
clusterDetails, err := webui.GetClusterDetails(s.ctx, cluster)
|
|
require.NoError(t, err)
|
|
require.Equal(t, s.server.ClusterName(), clusterDetails.Name)
|
|
require.Equal(t, teleport.Version, clusterDetails.ProxyVersion)
|
|
require.Equal(t, fmt.Sprintf("%v:%v", s.server.ClusterName(), defaults.HTTPListenPort), clusterDetails.PublicURL)
|
|
require.Equal(t, teleport.RemoteClusterStatusOnline, clusterDetails.Status)
|
|
require.NotNil(t, clusterDetails.LastConnected)
|
|
require.Equal(t, teleport.Version, clusterDetails.AuthVersion)
|
|
}
|
|
|
|
func TestTokenGeneration(t *testing.T) {
|
|
const username = "[email protected]"
|
|
// Users should be able to create Tokens even if they can't update them
|
|
roleTokenCRD, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindToken,
|
|
[]string{types.VerbCreate, types.VerbRead}),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, username, []types.Role{roleTokenCRD})
|
|
endpoint := pack.clt.Endpoint("webapi", "token")
|
|
|
|
tt := []struct {
|
|
name string
|
|
roles types.SystemRoles
|
|
shouldErr bool
|
|
joinMethod types.JoinMethod
|
|
suggestedAgentMatcherLabels types.Labels
|
|
allow []*types.TokenRule
|
|
}{
|
|
{
|
|
name: "single node role",
|
|
roles: types.SystemRoles{types.RoleNode},
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "single app role",
|
|
roles: types.SystemRoles{types.RoleApp},
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "single db role",
|
|
roles: types.SystemRoles{types.RoleDatabase},
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "multiple roles",
|
|
roles: types.SystemRoles{types.RoleNode, types.RoleApp, types.RoleDatabase},
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "return error if no role is requested",
|
|
roles: types.SystemRoles{},
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "cannot request token with IAM join method without allow field",
|
|
roles: types.SystemRoles{types.RoleNode},
|
|
joinMethod: types.JoinMethodIAM,
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "can request token with IAM join method",
|
|
roles: types.SystemRoles{types.RoleNode},
|
|
joinMethod: types.JoinMethodIAM,
|
|
allow: []*types.TokenRule{{AWSAccount: "1234"}},
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "adds the agent match labels",
|
|
roles: types.SystemRoles{types.RoleDatabase},
|
|
suggestedAgentMatcherLabels: types.Labels{
|
|
"*": apiutils.Strings{"*"},
|
|
},
|
|
shouldErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
re, err := pack.clt.PostJSON(context.Background(), endpoint, types.ProvisionTokenSpecV2{
|
|
Roles: tc.roles,
|
|
JoinMethod: tc.joinMethod,
|
|
Allow: tc.allow,
|
|
SuggestedAgentMatcherLabels: tc.suggestedAgentMatcherLabels,
|
|
})
|
|
|
|
if tc.shouldErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
var responseToken nodeJoinToken
|
|
err = json.Unmarshal(re.Bytes(), &responseToken)
|
|
require.NoError(t, err)
|
|
|
|
require.NotEmpty(t, responseToken.SuggestedLabels)
|
|
require.Condition(t, func() (success bool) {
|
|
for _, uiLabel := range responseToken.SuggestedLabels {
|
|
if uiLabel.Name == types.InternalResourceIDLabel && uiLabel.Value != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
|
|
// generated token roles should match the requested ones
|
|
generatedToken, err := proxy.auth.Auth().GetToken(context.Background(), responseToken.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.roles, generatedToken.GetRoles())
|
|
|
|
expectedJoinMethod := tc.joinMethod
|
|
if tc.joinMethod == "" {
|
|
expectedJoinMethod = types.JoinMethodToken
|
|
}
|
|
// if no joinMethod is provided, expect token method
|
|
require.Equal(t, expectedJoinMethod, generatedToken.GetJoinMethod())
|
|
|
|
require.Equal(t, tc.suggestedAgentMatcherLabels, generatedToken.GetSuggestedAgentMatcherLabels())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEndpointNotFoundHandling(t *testing.T) {
|
|
t.Parallel()
|
|
const username = "[email protected]"
|
|
// Allow user to create tokens.
|
|
roleTokenCRD, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindToken,
|
|
[]string{types.VerbCreate}),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, username, []types.Role{roleTokenCRD})
|
|
|
|
tt := []struct {
|
|
name string
|
|
endpoint string
|
|
shouldErr bool
|
|
}{
|
|
{
|
|
name: "valid endpoint without v1 prefix",
|
|
endpoint: "webapi/token",
|
|
},
|
|
{
|
|
name: "valid endpoint with v1 prefix",
|
|
endpoint: "v1/webapi/token",
|
|
},
|
|
{
|
|
name: "valid endpoint with v2 prefix",
|
|
endpoint: "v2/webapi/token",
|
|
},
|
|
{
|
|
name: "invalid double version prefixes",
|
|
endpoint: "v1/v2/webapi/token",
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "route not matched version prefix",
|
|
endpoint: "v9999999/webapi/token",
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "non api route with prefix",
|
|
endpoint: "v1/something/else",
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "invalid triple version prefixes",
|
|
endpoint: "v1/v1/v1/webapi/token",
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "invalid just prefix",
|
|
endpoint: "v1",
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "invalid prefix",
|
|
endpoint: "v1s/webapi/token",
|
|
shouldErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
re, err := pack.clt.PostJSON(context.Background(), fmt.Sprintf("%s/%s", proxy.web.URL, tc.endpoint), types.ProvisionTokenSpecV2{
|
|
Roles: []types.SystemRole{types.RoleNode},
|
|
JoinMethod: types.JoinMethodToken,
|
|
})
|
|
|
|
if tc.shouldErr {
|
|
require.True(t, trace.IsNotFound(err))
|
|
|
|
jsonResp := struct {
|
|
Error struct {
|
|
Message string
|
|
}
|
|
Fields struct {
|
|
ProxyVersion httplib.ProxyVersion
|
|
}
|
|
}{}
|
|
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &jsonResp))
|
|
require.Equal(t, "path not found", jsonResp.Error.Message)
|
|
require.Equal(t, teleport.Version, jsonResp.Fields.ProxyVersion.String)
|
|
|
|
ver, err := semver.NewVersion(teleport.Version)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ver.Major, jsonResp.Fields.ProxyVersion.Major)
|
|
require.Equal(t, ver.Minor, jsonResp.Fields.ProxyVersion.Minor)
|
|
require.Equal(t, ver.Patch, jsonResp.Fields.ProxyVersion.Patch)
|
|
require.Equal(t, string(ver.PreRelease), jsonResp.Fields.ProxyVersion.PreRelease)
|
|
|
|
} else {
|
|
require.NoError(t, err)
|
|
|
|
var responseToken nodeJoinToken
|
|
err = json.Unmarshal(re.Bytes(), &responseToken)
|
|
require.NoError(t, err)
|
|
require.Equal(t, types.JoinMethodToken, responseToken.Method)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKnownWebPathsWithAndWithoutV1Prefix(t *testing.T) {
|
|
t.Parallel()
|
|
const username = "[email protected]"
|
|
// Allow user to create tokens.
|
|
roleTokenCRD, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindToken,
|
|
[]string{types.VerbCreate}),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, username, []types.Role{roleTokenCRD})
|
|
|
|
res, err := pack.clt.PostJSON(context.Background(), pack.clt.Endpoint("webapi", "token"), types.ProvisionTokenSpecV2{
|
|
Roles: types.SystemRoles{types.RoleNode},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var responseToken nodeJoinToken
|
|
err = json.Unmarshal(res.Bytes(), &responseToken)
|
|
require.NoError(t, err)
|
|
|
|
tt := []struct {
|
|
name string
|
|
endpoint string
|
|
}{
|
|
{
|
|
name: "web path with prefix",
|
|
endpoint: "v1/web/config.js",
|
|
},
|
|
{
|
|
name: "web path without prefix",
|
|
endpoint: "web/config.js",
|
|
},
|
|
{
|
|
name: "webapi path with prefix",
|
|
endpoint: "v1/webapi/spiffe/bundle.json",
|
|
},
|
|
{
|
|
name: "webapi path without prefix",
|
|
endpoint: "webapi/spiffe/bundle.json",
|
|
},
|
|
{
|
|
name: ".well-known path with prefix",
|
|
endpoint: "v1/.well-known/jwks.json",
|
|
},
|
|
{
|
|
name: ".well-known path without prefix",
|
|
endpoint: ".well-known/jwks.json",
|
|
},
|
|
{
|
|
name: "workload-identity path with prefix",
|
|
endpoint: "v1/workload-identity/jwt-jwks.json",
|
|
},
|
|
{
|
|
name: "workload-identity path without prefix",
|
|
endpoint: "workload-identity/jwt-jwks.json",
|
|
},
|
|
{
|
|
name: "scripts path with prefix",
|
|
endpoint: fmt.Sprintf("v1/scripts/%s/install-node.sh", responseToken.ID),
|
|
},
|
|
{
|
|
name: "scripts path without prefix",
|
|
endpoint: fmt.Sprintf("scripts/%s/install-node.sh", responseToken.ID),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := pack.clt.Get(context.Background(), fmt.Sprintf("%s/%s", proxy.web.URL, tc.endpoint), url.Values{})
|
|
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInstallDatabaseScriptGeneration(t *testing.T) {
|
|
const username = "[email protected]"
|
|
|
|
// Users should be able to create Tokens even if they can't update them
|
|
roleTokenCRD, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindToken,
|
|
[]string{types.VerbCreate, types.VerbRead}),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1, withModules(&modulestest.Modules{TestBuildType: modules.BuildCommunity}))
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, username, []types.Role{roleTokenCRD})
|
|
|
|
// Create a new token with the desired SuggestedAgentMatcherLabels
|
|
endpointGenerateToken := pack.clt.Endpoint("webapi", "token")
|
|
re, err := pack.clt.PostJSON(
|
|
context.Background(),
|
|
endpointGenerateToken,
|
|
types.ProvisionTokenSpecV2{
|
|
Roles: types.SystemRoles{types.RoleDatabase},
|
|
SuggestedAgentMatcherLabels: types.Labels{
|
|
"stage": apiutils.Strings{"prod"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var responseToken nodeJoinToken
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &responseToken))
|
|
|
|
// Generating the script with the token should return the SuggestedAgentMatcherLabels provided in the first request
|
|
endpointInstallDatabase := pack.clt.Endpoint("scripts", responseToken.ID, "install-database.sh")
|
|
|
|
t.Log(responseToken, endpointInstallDatabase)
|
|
req, err := http.NewRequest(http.MethodGet, endpointInstallDatabase, nil)
|
|
require.NoError(t, err)
|
|
|
|
anonHTTPClient := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := anonHTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
scriptBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
script := string(scriptBytes)
|
|
|
|
// It contains the agenbtMatchLabels
|
|
require.Contains(t, script, "stage: prod")
|
|
}
|
|
|
|
func TestSignMTLS(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
clusterName := env.server.ClusterName()
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil)
|
|
|
|
endpoint := pack.clt.Endpoint("webapi", "token")
|
|
re, err := pack.clt.PostJSON(context.Background(), endpoint, types.ProvisionTokenSpecV2{
|
|
Roles: types.SystemRoles{types.RoleDatabase},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var responseToken nodeJoinToken
|
|
err = json.Unmarshal(re.Bytes(), &responseToken)
|
|
require.NoError(t, err)
|
|
|
|
// download mTLS files from /webapi/sites/:site/sign/db
|
|
endpointSign := pack.clt.Endpoint("webapi", "sites", clusterName, "sign", "db")
|
|
|
|
bs, err := json.Marshal(struct {
|
|
Hostname string `json:"hostname"`
|
|
TTL string `json:"ttl"`
|
|
}{
|
|
Hostname: "mypg.example.com",
|
|
TTL: "2h",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, endpointSign, bytes.NewReader(bs))
|
|
require.NoError(t, err)
|
|
req.Header.Add("Content-Type", "application/json")
|
|
req.Header.Add("Authorization", "Bearer "+responseToken.ID)
|
|
|
|
anonHTTPClient := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := anonHTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
gzipReader, err := gzip.NewReader(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
tarReader := tar.NewReader(gzipReader)
|
|
|
|
tarContentFileNames := []string{}
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, byte(tar.TypeReg), header.Typeflag)
|
|
require.Equal(t, int64(0o600), header.Mode)
|
|
tarContentFileNames = append(tarContentFileNames, header.Name)
|
|
}
|
|
|
|
expectedFileNames := []string{"server.cas", "server.key", "server.crt"}
|
|
require.ElementsMatch(t, tarContentFileNames, expectedFileNames)
|
|
|
|
// the token is no longer valid, so trying again should return an error
|
|
req, err = http.NewRequest(http.MethodPost, endpointSign, bytes.NewReader(bs))
|
|
require.NoError(t, err)
|
|
req.Header.Add("Content-Type", "application/json")
|
|
req.Header.Add("Authorization", "Bearer "+responseToken.ID)
|
|
|
|
respSecondCall, err := anonHTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer respSecondCall.Body.Close()
|
|
require.Equal(t, http.StatusForbidden, respSecondCall.StatusCode)
|
|
}
|
|
|
|
func TestSignMTLS_failsAccessDenied(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
clusterName := env.server.ClusterName()
|
|
username := "[email protected]"
|
|
|
|
roleUserUpdate, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindUser, []string{types.VerbUpdate}),
|
|
types.NewRule(types.KindToken, []string{types.VerbCreate}),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, username, []types.Role{roleUserUpdate})
|
|
|
|
endpoint := pack.clt.Endpoint("webapi", "token")
|
|
re, err := pack.clt.PostJSON(context.Background(), endpoint, types.ProvisionTokenSpecV2{
|
|
Roles: types.SystemRoles{types.RoleProxy},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var responseToken nodeJoinToken
|
|
err = json.Unmarshal(re.Bytes(), &responseToken)
|
|
require.NoError(t, err)
|
|
|
|
// download mTLS files from /webapi/sites/:site/sign/db
|
|
endpointSign := pack.clt.Endpoint("webapi", "sites", clusterName, "sign", "db")
|
|
|
|
bs, err := json.Marshal(struct {
|
|
Hostname string `json:"hostname"`
|
|
TTL string `json:"ttl"`
|
|
Format string `json:"format"`
|
|
}{
|
|
Hostname: "mypg.example.com",
|
|
TTL: "2h",
|
|
Format: "db",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, endpointSign, bytes.NewReader(bs))
|
|
require.NoError(t, err)
|
|
req.Header.Add("Content-Type", "application/json")
|
|
req.Header.Add("Authorization", "Bearer "+responseToken.ID)
|
|
|
|
anonHTTPClient := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := anonHTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// It fails because we passed a Provision Token with the wrong Role: Proxy
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
|
|
// using a user token also returns Forbidden
|
|
endpointResetToken := pack.clt.Endpoint("webapi", "users", "password", "token")
|
|
_, err = pack.clt.PostJSON(context.Background(), endpointResetToken, authclient.CreateUserTokenRequest{
|
|
Name: username,
|
|
TTL: time.Minute,
|
|
Type: authclient.UserTokenTypeResetPassword,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req, err = http.NewRequest(http.MethodPost, endpointSign, bytes.NewReader(bs))
|
|
require.NoError(t, err)
|
|
|
|
resp, err = anonHTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
}
|
|
|
|
func TestClusterDatabasesGet_NoRole(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
query := url.Values{"sort": []string{"name"}}
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "databases")
|
|
|
|
type testResponse struct {
|
|
Items []webui.Database `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
// add db
|
|
db, err := types.NewDatabaseV3(types.Metadata{
|
|
Name: "dbdb",
|
|
Labels: map[string]string{
|
|
"env": "prod",
|
|
},
|
|
}, types.DatabaseSpecV3{
|
|
Protocol: "test-protocol",
|
|
URI: "test-uri:1234",
|
|
})
|
|
require.NoError(t, err)
|
|
dbServer, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: "dddb1",
|
|
}, types.DatabaseServerSpecV3{
|
|
Hostname: "dddb1",
|
|
HostID: uuid.NewString(),
|
|
Database: db,
|
|
})
|
|
require.NoError(t, err)
|
|
dbServer.SetTargetHealth(types.TargetHealth{Status: "healthy"})
|
|
|
|
_, err = env.server.Auth().UpsertDatabaseServer(context.Background(), dbServer)
|
|
require.NoError(t, err)
|
|
|
|
// Test without defined database names or users in role.
|
|
re, err := pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
|
|
resp := testResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Items, 1)
|
|
require.ElementsMatch(t, resp.Items, []webui.Database{{
|
|
Kind: types.KindDatabase,
|
|
Name: "dbdb",
|
|
Type: types.DatabaseTypeSelfHosted,
|
|
Labels: []ui.Label{{Name: "env", Value: "prod"}},
|
|
Protocol: "test-protocol",
|
|
Hostname: "test-uri",
|
|
URI: "test-uri:1234",
|
|
TargetHealth: types.TargetHealth{Status: "healthy"},
|
|
}})
|
|
}
|
|
|
|
func TestClusterDatabasesGet_WithRole(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
|
|
type testResponse struct {
|
|
Items []webui.Database `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
// Register databases.
|
|
db, err := types.NewDatabaseServerV3(types.Metadata{
|
|
Name: "dbServer1",
|
|
}, types.DatabaseServerSpecV3{
|
|
Hostname: "test-hostname",
|
|
HostID: "test-hostID",
|
|
Database: &types.DatabaseV3{
|
|
Metadata: types.Metadata{
|
|
Name: "db1",
|
|
Description: "test-description",
|
|
},
|
|
Spec: types.DatabaseSpecV3{
|
|
Protocol: "test-protocol",
|
|
URI: "test-uri:1234",
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, err)
|
|
|
|
_, err = env.server.Auth().UpsertDatabaseServer(context.Background(), db)
|
|
require.NoError(t, err)
|
|
|
|
// Test with a role that defines database names and users.
|
|
extraRole := &types.RoleV6{
|
|
Metadata: types.Metadata{Name: "extra-role"},
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
DatabaseNames: []string{"name1"},
|
|
DatabaseUsers: []string{"user1"},
|
|
DatabaseLabels: types.Labels{
|
|
"*": []string{"*"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
query := url.Values{"sort": []string{"name"}}
|
|
pack := proxy.authPack(t, "[email protected]", services.NewRoleSet(extraRole))
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "databases")
|
|
re, err := pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
|
|
resp := testResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Items, 1)
|
|
}
|
|
|
|
func TestClusterKubesGet(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
|
|
extraRole := &types.RoleV6{
|
|
Metadata: types.Metadata{Name: "extra-role"},
|
|
Spec: types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
KubeUsers: []string{"user1"},
|
|
KubeGroups: []string{"group1"},
|
|
KubernetesLabels: types.Labels{
|
|
"*": []string{"*"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cluster1, err := types.NewKubernetesClusterV3(
|
|
types.Metadata{
|
|
Name: "test-kube1",
|
|
Labels: map[string]string{"test-field": "test-value"},
|
|
},
|
|
types.KubernetesClusterSpecV3{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// duplicate same server
|
|
for i := range 3 {
|
|
server, err := types.NewKubernetesServerV3FromCluster(
|
|
cluster1,
|
|
fmt.Sprintf("hostname-%d", i),
|
|
fmt.Sprintf("uid-%d", i),
|
|
)
|
|
require.NoError(t, err)
|
|
// Register a kube service.
|
|
_, err = env.server.Auth().UpsertKubernetesServer(context.Background(), server)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
cluster2, err := types.NewKubernetesClusterV3(
|
|
types.Metadata{
|
|
Name: "test-kube2",
|
|
},
|
|
types.KubernetesClusterSpecV3{},
|
|
)
|
|
require.NoError(t, err)
|
|
server2, err := types.NewKubernetesServerV3FromCluster(
|
|
cluster2,
|
|
"test-kube2-hostname",
|
|
"test-kube2-hostid",
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertKubernetesServer(context.Background(), server2)
|
|
require.NoError(t, err)
|
|
|
|
type testResponse struct {
|
|
Items []webui.KubeCluster `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
tt := []struct {
|
|
name string
|
|
user string
|
|
extraRoles services.RoleSet
|
|
expectedResponse []webui.KubeCluster
|
|
}{
|
|
{
|
|
name: "user with no extra roles",
|
|
user: "[email protected]",
|
|
expectedResponse: []webui.KubeCluster{
|
|
{
|
|
Name: "test-kube1",
|
|
Labels: []ui.Label{{Name: "test-field", Value: "test-value"}},
|
|
KubeUsers: nil,
|
|
KubeGroups: nil,
|
|
},
|
|
{
|
|
Name: "test-kube2",
|
|
Labels: []ui.Label{},
|
|
KubeUsers: nil,
|
|
KubeGroups: nil,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user with extra roles",
|
|
user: "[email protected]",
|
|
extraRoles: services.NewRoleSet(extraRole),
|
|
expectedResponse: []webui.KubeCluster{
|
|
{
|
|
Name: "test-kube1",
|
|
Labels: []ui.Label{{Name: "test-field", Value: "test-value"}},
|
|
KubeUsers: []string{"user1"},
|
|
KubeGroups: []string{"group1"},
|
|
},
|
|
{
|
|
Name: "test-kube2",
|
|
Labels: []ui.Label{},
|
|
KubeUsers: []string{"user1"},
|
|
KubeGroups: []string{"group1"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tt {
|
|
pack := proxy.authPack(t, tc.user, tc.extraRoles)
|
|
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "kubernetes")
|
|
|
|
re, err := pack.clt.Get(context.Background(), endpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
resp := testResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Items, 2)
|
|
require.Equal(t, 2, resp.TotalCount)
|
|
require.ElementsMatch(t, tc.expectedResponse, resp.Items)
|
|
}
|
|
}
|
|
|
|
func TestClusterKubeResourcesGet(t *testing.T) {
|
|
t.Parallel()
|
|
kubeClusterName := "kube_cluster"
|
|
|
|
roleWithFullAccess := func(username string) []types.Role {
|
|
ret, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Namespaces: []string{apidefaults.Namespace},
|
|
KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindConnectionDiagnostic, services.RW()),
|
|
},
|
|
KubeGroups: []string{"groups"},
|
|
KubernetesResources: []types.KubernetesResource{
|
|
// NOTE: Here we use the k8s resource kinds, i.e. plurals.
|
|
{
|
|
Kind: "pods",
|
|
Namespace: types.Wildcard,
|
|
Name: types.Wildcard,
|
|
APIGroup: types.Wildcard,
|
|
},
|
|
{
|
|
Kind: "namespaces",
|
|
Name: types.Wildcard,
|
|
APIGroup: types.Wildcard,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
return []types.Role{ret}
|
|
}
|
|
require.NotNil(t, roleWithFullAccess)
|
|
|
|
env := newWebPack(t, 1)
|
|
|
|
type testResponse struct {
|
|
Items []webui.KubeResource `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
tt := []struct {
|
|
name string
|
|
user string
|
|
kind string
|
|
kubeCluster string
|
|
expectedResponse []webui.KubeResource
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "get pods from gRPC server",
|
|
kind: types.KindKubePod, // NOTE: Here we use the teleport kind.
|
|
kubeCluster: kubeClusterName,
|
|
expectedResponse: []webui.KubeResource{
|
|
{
|
|
Kind: "pods",
|
|
Name: "test-pod",
|
|
Namespace: "default",
|
|
Labels: []ui.Label{{Name: "app", Value: "test"}},
|
|
KubeCluster: kubeClusterName,
|
|
},
|
|
{
|
|
Kind: "pods",
|
|
Name: "test-pod2",
|
|
Namespace: "default",
|
|
Labels: []ui.Label{{Name: "app", Value: "test2"}},
|
|
KubeCluster: kubeClusterName,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "get namespaces",
|
|
kind: types.KindKubeNamespace,
|
|
kubeCluster: kubeClusterName,
|
|
expectedResponse: []webui.KubeResource{
|
|
{
|
|
Kind: "namespaces",
|
|
Name: "default",
|
|
Namespace: "",
|
|
Labels: []ui.Label{{Name: "app", Value: "test"}},
|
|
KubeCluster: kubeClusterName,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "missing kind",
|
|
kind: "",
|
|
kubeCluster: kubeClusterName,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid kind",
|
|
kind: "invalid-kind",
|
|
kubeCluster: kubeClusterName,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "missing kube cluster",
|
|
kind: types.KindKubeNamespace,
|
|
kubeCluster: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
proxy := env.proxies[0]
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
// Init fake gRPC Kube service.
|
|
initGRPCServer(t, env, listener)
|
|
addr := utils.MustParseAddr(listener.Addr().String())
|
|
proxy.handler.handler.cfg.ProxyWebAddr = *addr
|
|
|
|
user := "[email protected]"
|
|
pack := proxy.authPack(t, user, roleWithFullAccess(user))
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "kubernetes", "resources")
|
|
params := url.Values{}
|
|
params.Add("kubeCluster", tc.kubeCluster)
|
|
params.Add("kind", tc.kind)
|
|
re, err := pack.clt.Get(context.Background(), endpoint, params)
|
|
|
|
if tc.wantErr {
|
|
require.True(t, trace.IsBadParameter(err))
|
|
} else {
|
|
require.NoError(t, err)
|
|
resp := testResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.ElementsMatch(t, tc.expectedResponse, resp.Items)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestApplicationAccessDisabled makes sure application access can be disabled
|
|
// via modules.
|
|
func TestApplicationAccessDisabled(t *testing.T) {
|
|
env := newWebPack(t, 1,
|
|
withModules(&modulestest.Modules{
|
|
TestBuildType: modules.BuildOSS,
|
|
TestFeatures: modules.Features{
|
|
Entitlements: map[entitlements.EntitlementKind]modules.EntitlementInfo{
|
|
entitlements.App: {Enabled: false},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
// Register an application.
|
|
app, err := types.NewAppV3(types.Metadata{
|
|
Name: "panel",
|
|
}, types.AppSpecV3{
|
|
URI: "localhost",
|
|
PublicAddr: "panel.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
server, err := types.NewAppServerV3FromApp(app, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertApplicationServer(context.Background(), server)
|
|
require.NoError(t, err)
|
|
|
|
endpoint := pack.clt.Endpoint("webapi", "sessions", "app")
|
|
_, err = pack.clt.PostJSON(context.Background(), endpoint, &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "panel.example.com",
|
|
PublicAddr: "panel.example.com",
|
|
ClusterName: "localhost",
|
|
},
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "this Teleport cluster is not licensed for application access")
|
|
}
|
|
|
|
// TestApplicationWebSessionsDeletedAfterLogout makes sure user's application
|
|
// sessions are deleted after user logout.
|
|
func TestApplicationWebSessionsDeletedAfterLogout(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
// Register multiple applications.
|
|
applications := []struct {
|
|
name string
|
|
publicAddr string
|
|
}{
|
|
{name: "panel", publicAddr: "panel.example.com"},
|
|
{name: "admin", publicAddr: "admin.example.com"},
|
|
{name: "metrics", publicAddr: "metrics.example.com"},
|
|
}
|
|
|
|
// Register and create a session for each application.
|
|
for _, application := range applications {
|
|
// Register an application.
|
|
app, err := types.NewAppV3(types.Metadata{
|
|
Name: application.name,
|
|
}, types.AppSpecV3{
|
|
URI: "localhost",
|
|
PublicAddr: application.publicAddr,
|
|
})
|
|
require.NoError(t, err)
|
|
server, err := types.NewAppServerV3FromApp(app, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertApplicationServer(context.Background(), server)
|
|
require.NoError(t, err)
|
|
|
|
// Create application session
|
|
endpoint := pack.clt.Endpoint("webapi", "sessions", "app")
|
|
_, err = pack.clt.PostJSON(context.Background(), endpoint, &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: application.publicAddr,
|
|
PublicAddr: application.publicAddr,
|
|
ClusterName: "localhost",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
collectAppSessions := func(ctx context.Context) []types.WebSession {
|
|
var (
|
|
nextToken string
|
|
sessions []types.WebSession
|
|
)
|
|
for {
|
|
webSessions, token, err := proxy.client.ListAppSessions(ctx, apidefaults.DefaultChunkSize, nextToken, "")
|
|
require.NoError(t, err)
|
|
sessions = append(sessions, webSessions...)
|
|
if token == "" {
|
|
break
|
|
}
|
|
|
|
nextToken = token
|
|
}
|
|
|
|
return sessions
|
|
}
|
|
|
|
// List sessions, should have one for each application.
|
|
require.Len(t, collectAppSessions(context.Background()), len(applications))
|
|
|
|
// Logout from Telport.
|
|
_, err := pack.clt.Delete(context.Background(), pack.clt.Endpoint("webapi", "sessions", "web"))
|
|
require.NoError(t, err)
|
|
|
|
// Check sessions after logout, should be empty.
|
|
require.Empty(t, collectAppSessions(context.Background()))
|
|
}
|
|
|
|
func TestGetAppDetails(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newWebSuite(t)
|
|
pack := s.authPack(t, "[email protected]")
|
|
|
|
// Register an application called "api".
|
|
apiApp, err := types.NewAppV3(types.Metadata{
|
|
Name: "api",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
PublicAddr: "api.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
server, err := types.NewAppServerV3FromApp(apiApp, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertApplicationServer(s.ctx, server)
|
|
require.NoError(t, err)
|
|
|
|
// Register an application called "client" and have "api" required.
|
|
clientApp, err := types.NewAppV3(types.Metadata{
|
|
Name: "client",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
PublicAddr: "client.example.com",
|
|
RequiredAppNames: []string{"api"},
|
|
})
|
|
require.NoError(t, err)
|
|
server2, err := types.NewAppServerV3FromApp(clientApp, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertApplicationServer(s.ctx, server2)
|
|
require.NoError(t, err)
|
|
|
|
// Register an application called "client.web"
|
|
clientWebApp, err := types.NewAppV3(types.Metadata{
|
|
Name: "client.web",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
UseAnyProxyPublicAddr: true,
|
|
})
|
|
require.NoError(t, err)
|
|
server3, err := types.NewAppServerV3FromApp(clientWebApp, "hostweb", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertApplicationServer(s.ctx, server3)
|
|
require.NoError(t, err)
|
|
|
|
clientFQDN := "client.example.com"
|
|
|
|
tests := []struct {
|
|
name string
|
|
endpoint string
|
|
fqdn string
|
|
expectedResponse GetAppDetailsResponse
|
|
}{
|
|
{
|
|
name: "request app details with clientName and publicAddr",
|
|
endpoint: pack.clt.Endpoint("webapi", "apps", clientFQDN, s.server.ClusterName(), clientApp.GetPublicAddr()),
|
|
expectedResponse: GetAppDetailsResponse{
|
|
FQDN: "client.example.com",
|
|
RequiredAppFQDNs: []string{"api.example.com", "client.example.com"},
|
|
},
|
|
},
|
|
{
|
|
name: "request app details with fqdn only",
|
|
endpoint: pack.clt.Endpoint("webapi", "apps", clientFQDN),
|
|
expectedResponse: GetAppDetailsResponse{
|
|
FQDN: "client.example.com",
|
|
RequiredAppFQDNs: []string{"api.example.com", "client.example.com"},
|
|
},
|
|
},
|
|
{
|
|
name: "request app details with fqdn only that doesnt match public addr",
|
|
endpoint: pack.clt.Endpoint("webapi", "apps", "client.localhost"),
|
|
expectedResponse: GetAppDetailsResponse{
|
|
FQDN: "client.example.com", // no use_any_proxy_public_addr so we want the default proxy name back
|
|
RequiredAppFQDNs: []string{"api.example.com", "client.example.com"},
|
|
},
|
|
},
|
|
{
|
|
name: "app details found by app name with subdomain",
|
|
endpoint: pack.clt.Endpoint("webapi", "apps", "client.web.localhost"),
|
|
expectedResponse: GetAppDetailsResponse{
|
|
FQDN: "client.web.localhost",
|
|
RequiredAppFQDNs: []string{"client.web.localhost"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
re, err := pack.clt.Get(ctx, tc.endpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
resp := GetAppDetailsResponse{}
|
|
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Equal(t, tc.expectedResponse, resp)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetWebConfig_WithEntitlements(t *testing.T) {
|
|
ctx := context.Background()
|
|
testModules := modulestest.OSSModules()
|
|
env := newWebPack(t, 1, withModules(testModules))
|
|
handler := env.proxies[0].handler.handler
|
|
|
|
// Set auth preference with passwordless.
|
|
const MOTD = "Welcome to cluster, your activity will be recorded."
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
ConnectorName: constants.PasswordlessConnector,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
MessageOfTheDay: MOTD,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Add a test connector.
|
|
github, err := types.NewGithubConnector("test-github", types.GithubConnectorSpecV3{
|
|
TeamsToLogins: []types.TeamMapping{
|
|
{
|
|
Organization: "octocats",
|
|
Team: "dummy",
|
|
Logins: []string{"dummy"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertGithubConnector(ctx, github)
|
|
require.NoError(t, err)
|
|
|
|
// start the feature watcher so the web config gets new features
|
|
env.clock.Advance(DefaultFeatureWatchInterval * 2)
|
|
|
|
expectedCfg := webclient.WebConfig{
|
|
Auth: webclient.WebConfigAuthSettings{
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Providers: []webclient.WebConfigAuthProvider{{
|
|
Name: "test-github",
|
|
Type: constants.Github,
|
|
WebAPIURL: webclient.WebConfigAuthProviderGitHubURL,
|
|
}},
|
|
LocalAuthEnabled: true,
|
|
AllowPasswordless: true,
|
|
AuthType: constants.Local,
|
|
PreferredLocalMFA: constants.SecondFactorWebauthn,
|
|
LocalConnectorName: constants.PasswordlessConnector,
|
|
PrivateKeyPolicy: keys.PrivateKeyPolicyNone,
|
|
MOTD: MOTD,
|
|
},
|
|
CanJoinSessions: true,
|
|
ProxyClusterName: env.server.ClusterName(),
|
|
IsCloud: false,
|
|
AutomaticUpgrades: false,
|
|
Edition: testModules.BuildType(),
|
|
Entitlements: map[string]webclient.EntitlementInfo{
|
|
string(entitlements.AccessGraph): {Enabled: false},
|
|
string(entitlements.AccessGraphDemoMode): {Enabled: false},
|
|
string(entitlements.AccessLists): {Enabled: false},
|
|
string(entitlements.AccessMonitoring): {Enabled: false},
|
|
string(entitlements.AccessRequests): {Enabled: false},
|
|
string(entitlements.App): {Enabled: true},
|
|
string(entitlements.ClientIPRestrictions): {Enabled: false},
|
|
string(entitlements.CloudAuditLogRetention): {Enabled: false},
|
|
string(entitlements.DB): {Enabled: true},
|
|
string(entitlements.Desktop): {Enabled: true},
|
|
string(entitlements.DeviceTrust): {Enabled: false},
|
|
string(entitlements.ExternalAuditStorage): {Enabled: false},
|
|
string(entitlements.FeatureHiding): {Enabled: false},
|
|
string(entitlements.HSM): {Enabled: false},
|
|
string(entitlements.Identity): {Enabled: false},
|
|
string(entitlements.JoinActiveSessions): {Enabled: true},
|
|
string(entitlements.K8s): {Enabled: true},
|
|
string(entitlements.LicenseAutoUpdate): {Enabled: false},
|
|
string(entitlements.MobileDeviceManagement): {Enabled: false},
|
|
string(entitlements.OIDC): {Enabled: false},
|
|
string(entitlements.OktaSCIM): {Enabled: false},
|
|
string(entitlements.OktaUserSync): {Enabled: false},
|
|
string(entitlements.Policy): {Enabled: false},
|
|
string(entitlements.SAML): {Enabled: false},
|
|
string(entitlements.SessionLocks): {Enabled: false},
|
|
string(entitlements.UpsellAlert): {Enabled: false},
|
|
string(entitlements.UsageReporting): {Enabled: false},
|
|
string(entitlements.UnrestrictedManagedUpdates): {Enabled: false},
|
|
string(entitlements.WorkloadClusters): {Enabled: false},
|
|
},
|
|
TunnelPublicAddress: "",
|
|
RecoveryCodesEnabled: false,
|
|
UI: webclient.UIConfig{},
|
|
IsPolicyRoleVisualizerEnabled: true,
|
|
IsDashboard: false,
|
|
IsUsageBasedBilling: false,
|
|
AutomaticUpgradesTargetVersion: "",
|
|
CustomTheme: "",
|
|
Questionnaire: false,
|
|
IsStripeManaged: false,
|
|
PremiumSupport: false,
|
|
PlayableDatabaseProtocols: player.SupportedDatabaseProtocols,
|
|
}
|
|
|
|
// Make a request.
|
|
clt := env.proxies[0].newClient(t)
|
|
endpoint := clt.Endpoint("web", "config.js")
|
|
re, err := clt.Get(ctx, endpoint, nil)
|
|
require.NoError(t, err)
|
|
require.True(t, strings.HasPrefix(string(re.Bytes()), "var GRV_CONFIG"))
|
|
|
|
// Response is type application/javascript, we need to strip off the variable name
|
|
// and the semicolon at the end, then we are left with json like object.
|
|
var cfg webclient.WebConfig
|
|
str := strings.ReplaceAll(string(re.Bytes()), "var GRV_CONFIG = ", "")
|
|
err = json.Unmarshal([]byte(str[:len(str)-1]), &cfg)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expectedCfg, cfg)
|
|
|
|
// update features and assert that it is properly updated on the config object
|
|
// TODO(tross): remove when auth modules can be injected
|
|
modulestest.SetTestModules(t, modulestest.Modules{
|
|
TestFeatures: modules.Features{
|
|
Cloud: true,
|
|
IsUsageBasedBilling: true,
|
|
AutomaticUpgrades: true,
|
|
Entitlements: map[entitlements.EntitlementKind]modules.EntitlementInfo{
|
|
entitlements.DB: {Enabled: true, Limit: 22},
|
|
entitlements.DeviceTrust: {Enabled: true, Limit: 33},
|
|
entitlements.Desktop: {Enabled: true, Limit: 44},
|
|
},
|
|
},
|
|
})
|
|
env.clock.Advance(DefaultFeatureWatchInterval * 2)
|
|
|
|
require.NoError(t, err)
|
|
// This version is too high and MUST NOT be used
|
|
testVersion := "v99.0.1"
|
|
channels := automaticupgrades.Channels{
|
|
automaticupgrades.DefaultCloudChannelName: {
|
|
StaticVersion: testVersion,
|
|
},
|
|
}
|
|
require.NoError(t, channels.CheckAndSetDefaults())
|
|
handler.cfg.AutomaticUpgradesChannels = channels
|
|
|
|
expectedCfg.IsCloud = true
|
|
expectedCfg.IsUsageBasedBilling = true
|
|
expectedCfg.AutomaticUpgrades = true
|
|
expectedCfg.AutomaticUpgradesTargetVersion = "v" + teleport.Version
|
|
expectedCfg.Edition = testModules.BuildType()
|
|
expectedCfg.Entitlements[string(entitlements.App)] = webclient.EntitlementInfo{Enabled: false}
|
|
expectedCfg.Entitlements[string(entitlements.DB)] = webclient.EntitlementInfo{Enabled: true, Limit: 22}
|
|
expectedCfg.Entitlements[string(entitlements.DeviceTrust)] = webclient.EntitlementInfo{Enabled: true, Limit: 33}
|
|
expectedCfg.Entitlements[string(entitlements.Desktop)] = webclient.EntitlementInfo{Enabled: true, Limit: 44}
|
|
expectedCfg.Entitlements[string(entitlements.JoinActiveSessions)] = webclient.EntitlementInfo{Enabled: false}
|
|
expectedCfg.Entitlements[string(entitlements.K8s)] = webclient.EntitlementInfo{Enabled: false}
|
|
|
|
// request and verify enabled features are eventually enabled.
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
re, err := clt.Get(ctx, endpoint, nil)
|
|
require.NoError(t, err)
|
|
require.True(t, bytes.HasPrefix(re.Bytes(), []byte("var GRV_CONFIG")))
|
|
res := bytes.ReplaceAll(re.Bytes(), []byte("var GRV_CONFIG = "), []byte{})
|
|
err = json.Unmarshal(res[:len(res)-1], &cfg)
|
|
require.NoError(t, err)
|
|
diff := cmp.Diff(expectedCfg, cfg)
|
|
require.Empty(t, diff)
|
|
}, time.Second*5, time.Millisecond*50)
|
|
|
|
// use mock client to assert that if ping returns an error, we'll default to
|
|
// cluster config
|
|
mockClient := mockedPingTestProxy{
|
|
mockedPing: func(ctx context.Context) (authproto.PingResponse, error) {
|
|
return authproto.PingResponse{}, errors.New("err")
|
|
},
|
|
}
|
|
env.proxies[0].client = mockClient
|
|
expectedCfg.AutomaticUpgrades = false
|
|
expectedCfg.Entitlements[string(entitlements.DB)] = webclient.EntitlementInfo{Enabled: false}
|
|
expectedCfg.Entitlements[string(entitlements.Desktop)] = webclient.EntitlementInfo{Enabled: false}
|
|
expectedCfg.Entitlements[string(entitlements.DeviceTrust)] = webclient.EntitlementInfo{Enabled: false}
|
|
|
|
// update modules but NOT the expected config
|
|
// TODO(tross): remove when auth modules can be injected
|
|
modulestest.SetTestModules(t, modulestest.Modules{
|
|
TestFeatures: modules.Features{
|
|
Cloud: false,
|
|
IsUsageBasedBilling: false,
|
|
},
|
|
})
|
|
env.clock.Advance(DefaultFeatureWatchInterval * 2)
|
|
|
|
// request and verify again
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
re, err := clt.Get(ctx, endpoint, nil)
|
|
require.NoError(t, err)
|
|
|
|
require.True(t, bytes.HasPrefix(re.Bytes(), []byte("var GRV_CONFIG")))
|
|
res := bytes.ReplaceAll(re.Bytes(), []byte("var GRV_CONFIG = "), []byte{})
|
|
err = json.Unmarshal(res[:len(res)-1], &cfg)
|
|
require.NoError(t, err)
|
|
diff := cmp.Diff(expectedCfg, cfg)
|
|
require.Empty(t, diff)
|
|
}, time.Second*5, time.Millisecond*50)
|
|
}
|
|
|
|
func TestCreatePrivilegeToken(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
|
|
// Create a user with second factor totp.
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
// Get a totp code.
|
|
totpCode, err := totp.GenerateCode(pack.otpSecret, env.clock.Now().Add(30*time.Second))
|
|
require.NoError(t, err)
|
|
|
|
endpoint := pack.clt.Endpoint("webapi", "users", "privilege", "token")
|
|
re, err := pack.clt.PostJSON(context.Background(), endpoint, &privilegeTokenRequest{
|
|
SecondFactorToken: totpCode,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var privilegeToken string
|
|
err = json.Unmarshal(re.Bytes(), &privilegeToken)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, privilegeToken)
|
|
}
|
|
|
|
func TestAddMFADevice(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
// Enable second factor.
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOptional,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Get a totp code to re-auth.
|
|
totpCode, err := totp.GenerateCode(pack.otpSecret, env.clock.Now().Add(30*time.Second))
|
|
require.NoError(t, err)
|
|
|
|
// Obtain a privilege token.
|
|
endpoint := pack.clt.Endpoint("webapi", "users", "privilege", "token")
|
|
re, err := pack.clt.PostJSON(ctx, endpoint, &privilegeTokenRequest{
|
|
SecondFactorToken: totpCode,
|
|
})
|
|
require.NoError(t, err)
|
|
var privilegeToken string
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &privilegeToken))
|
|
|
|
tests := []struct {
|
|
name string
|
|
deviceName string
|
|
getTOTPCode func() string
|
|
getWebauthnResp func() *wantypes.CredentialCreationResponse
|
|
}{
|
|
{
|
|
name: "new TOTP device",
|
|
deviceName: "new-totp",
|
|
getTOTPCode: func() string {
|
|
// Create totp secrets.
|
|
res, err := env.server.Auth().CreateRegisterChallenge(ctx, &authproto.CreateRegisterChallengeRequest{
|
|
TokenID: privilegeToken,
|
|
DeviceType: authproto.DeviceType_DEVICE_TYPE_TOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, regRes, err := authtest.NewTestDeviceFromChallenge(res, authtest.WithTestDeviceClock(env.clock))
|
|
require.NoError(t, err)
|
|
|
|
return regRes.GetTOTP().Code
|
|
},
|
|
},
|
|
{
|
|
name: "new Webauthn device",
|
|
deviceName: "new-webauthn",
|
|
getWebauthnResp: func() *wantypes.CredentialCreationResponse {
|
|
// Get webauthn register challenge.
|
|
res, err := env.server.Auth().CreateRegisterChallenge(ctx, &authproto.CreateRegisterChallengeRequest{
|
|
TokenID: privilegeToken,
|
|
DeviceType: authproto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, regRes, err := authtest.NewTestDeviceFromChallenge(res)
|
|
require.NoError(t, err)
|
|
|
|
return wantypes.CredentialCreationResponseFromProto(regRes.GetWebauthn())
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
var totpCode string
|
|
var webauthnRegResp *wantypes.CredentialCreationResponse
|
|
|
|
if tc.getWebauthnResp != nil {
|
|
webauthnRegResp = tc.getWebauthnResp()
|
|
} else {
|
|
totpCode = tc.getTOTPCode()
|
|
}
|
|
|
|
// Add device.
|
|
endpoint := pack.clt.Endpoint("webapi", "mfa", "devices")
|
|
_, err := pack.clt.PostJSON(ctx, endpoint, addMFADeviceRequest{
|
|
PrivilegeTokenID: privilegeToken,
|
|
DeviceName: tc.deviceName,
|
|
SecondFactorToken: totpCode,
|
|
WebauthnRegisterResponse: webauthnRegResp,
|
|
})
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteMFA(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
// setting up client manually because we need sanitizer off
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
opts := []roundtrip.ClientParam{roundtrip.BearerAuth(pack.session.Token), roundtrip.CookieJar(jar), roundtrip.HTTPClient(client.NewInsecureWebClient())}
|
|
rclt, err := roundtrip.NewClient(proxy.webURL.String(), "", opts...)
|
|
require.NoError(t, err)
|
|
clt := client.WebClient{Client: rclt}
|
|
jar.SetCookies(&proxy.webURL, pack.cookies)
|
|
|
|
totpCode, err := totp.GenerateCode(pack.otpSecret, env.clock.Now().Add(30*time.Second))
|
|
require.NoError(t, err)
|
|
|
|
// Obtain a privilege token.
|
|
endpoint := pack.clt.Endpoint("webapi", "users", "privilege", "token")
|
|
re, err := pack.clt.PostJSON(ctx, endpoint, &privilegeTokenRequest{
|
|
SecondFactorToken: totpCode,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var privilegeToken string
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &privilegeToken))
|
|
|
|
names := []string{"x", "??", "%123/", "///", "my/device", "?/%&*1"}
|
|
for _, devName := range names {
|
|
t.Run(devName, func(t *testing.T) {
|
|
t.Parallel()
|
|
otpSecret := newOTPSharedSecret()
|
|
dev, err := services.NewTOTPDevice(devName, otpSecret, env.clock.Now())
|
|
require.NoError(t, err)
|
|
err = env.server.Auth().UpsertMFADevice(ctx, pack.user, dev)
|
|
require.NoError(t, err)
|
|
|
|
enc := url.PathEscape(devName)
|
|
_, err = clt.Delete(ctx, pack.clt.Endpoint("webapi", "mfa", "token", privilegeToken, "devices", enc))
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetMFADevicesWithAuth(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
endpoint := pack.clt.Endpoint("webapi", "mfa", "devices")
|
|
re, err := pack.clt.Get(context.Background(), endpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
var devices []webui.MFADevice
|
|
err = json.Unmarshal(re.Bytes(), &devices)
|
|
require.NoError(t, err)
|
|
require.Len(t, devices, 1)
|
|
}
|
|
|
|
func TestGetAndDeleteMFADevices_WithRecoveryApprovedToken(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
|
|
// Create a user with a TOTP device.
|
|
username := "llama"
|
|
proxy.createUser(ctx, t, username, "root", "password1234", "some-otp-secret", nil /* roles */)
|
|
|
|
// Enable second factor.
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOptional,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: env.server.ClusterName(),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Acquire an approved token.
|
|
approvedToken, err := types.NewUserToken("some-token-id")
|
|
require.NoError(t, err)
|
|
approvedToken.SetUser(username)
|
|
approvedToken.SetSubKind(authclient.UserTokenTypeRecoveryApproved)
|
|
approvedToken.SetExpiry(env.clock.Now().Add(5 * time.Minute))
|
|
_, err = env.server.Auth().CreateUserToken(ctx, approvedToken)
|
|
require.NoError(t, err)
|
|
|
|
// Call the getter endpoint.
|
|
clt := proxy.newClient(t)
|
|
getDevicesEndpoint := clt.Endpoint("webapi", "mfa", "token", approvedToken.GetName(), "devices")
|
|
res, err := clt.Get(ctx, getDevicesEndpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
var devices []webui.MFADevice
|
|
err = json.Unmarshal(res.Bytes(), &devices)
|
|
require.NoError(t, err)
|
|
require.Len(t, devices, 1)
|
|
|
|
// Call the delete endpoint.
|
|
_, err = clt.Delete(ctx, clt.Endpoint("webapi", "mfa", "token", approvedToken.GetName(), "devices", devices[0].Name))
|
|
require.NoError(t, err)
|
|
|
|
// Check device has been deleted.
|
|
res, err = clt.Get(ctx, getDevicesEndpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
err = json.Unmarshal(res.Bytes(), &devices)
|
|
require.NoError(t, err)
|
|
require.Empty(t, devices)
|
|
}
|
|
|
|
func TestCreateAuthenticateChallenge(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
|
|
// Create a user with a TOTP device, with second factor preference to OTP only.
|
|
authPack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
// Authenticated client for private endpoints.
|
|
authnClt := authPack.clt
|
|
|
|
// Unauthenticated client for public endpoints.
|
|
publicClt := proxy.newClient(t)
|
|
|
|
// Acquire a start token, for the request the requires it.
|
|
startToken, err := types.NewUserToken("some-token-id")
|
|
require.NoError(t, err)
|
|
startToken.SetUser(authPack.user)
|
|
startToken.SetSubKind(authclient.UserTokenTypeRecoveryStart)
|
|
startToken.SetExpiry(env.clock.Now().Add(5 * time.Minute))
|
|
_, err = env.server.Auth().CreateUserToken(ctx, startToken)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
clt *TestWebClient
|
|
ep []string
|
|
reqBody any
|
|
}{
|
|
{
|
|
name: "/webapi/mfa/login/begin",
|
|
clt: publicClt,
|
|
ep: []string{"webapi", "mfa", "login", "begin"},
|
|
reqBody: client.MFAChallengeRequest{
|
|
User: authPack.user,
|
|
Pass: authPack.password,
|
|
},
|
|
},
|
|
{
|
|
name: "/webapi/mfa/authenticatechallenge",
|
|
clt: authnClt,
|
|
ep: []string{"webapi", "mfa", "authenticatechallenge"},
|
|
reqBody: CreateAuthenticateChallengeRequest{
|
|
ChallengeScope: int(mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN),
|
|
},
|
|
},
|
|
{
|
|
name: "/webapi/mfa/token/:token/authenticatechallenge",
|
|
clt: publicClt,
|
|
ep: []string{"webapi", "mfa", "token", startToken.GetName(), "authenticatechallenge"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
endpoint := tc.clt.Endpoint(tc.ep...)
|
|
res, err := tc.clt.PostJSON(ctx, endpoint, tc.reqBody)
|
|
require.NoError(t, err)
|
|
|
|
var chal client.MFAAuthenticateChallenge
|
|
err = json.Unmarshal(res.Bytes(), &chal)
|
|
require.NoError(t, err)
|
|
require.True(t, chal.TOTPChallenge)
|
|
require.Empty(t, chal.WebauthnChallenge)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateRegisterChallenge(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
clt := proxy.newClient(t)
|
|
|
|
// Enable second factor.
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: env.server.ClusterName(),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Acquire an accepted token.
|
|
token, err := types.NewUserToken("some-token-id")
|
|
require.NoError(t, err)
|
|
token.SetUser("llama")
|
|
token.SetSubKind(authclient.UserTokenTypePrivilege)
|
|
token.SetExpiry(env.clock.Now().Add(5 * time.Minute))
|
|
_, err = env.server.Auth().CreateUserToken(ctx, token)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
req *createRegisterChallengeWithTokenRequest
|
|
assertChallenge func(t *testing.T, c *client.MFARegisterChallenge)
|
|
}{
|
|
{
|
|
name: "totp",
|
|
req: &createRegisterChallengeWithTokenRequest{
|
|
DeviceType: "totp",
|
|
},
|
|
},
|
|
{
|
|
name: "webauthn",
|
|
req: &createRegisterChallengeWithTokenRequest{
|
|
DeviceType: "webauthn",
|
|
},
|
|
},
|
|
{
|
|
name: "passwordless",
|
|
req: &createRegisterChallengeWithTokenRequest{
|
|
DeviceType: "webauthn",
|
|
DeviceUsage: "passwordless",
|
|
},
|
|
assertChallenge: func(t *testing.T, c *client.MFARegisterChallenge) {
|
|
// rrk=true is a good proxy for passwordless.
|
|
require.NotNil(t, c.Webauthn.Response.AuthenticatorSelection.RequireResidentKey, "rrk cannot be nil")
|
|
require.True(t, *c.Webauthn.Response.AuthenticatorSelection.RequireResidentKey, "rrk cannot be false")
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
endpoint := clt.Endpoint("webapi", "mfa", "token", token.GetName(), "registerchallenge")
|
|
res, err := clt.PostJSON(ctx, endpoint, tc.req)
|
|
require.NoError(t, err)
|
|
|
|
var chal client.MFARegisterChallenge
|
|
require.NoError(t, json.Unmarshal(res.Bytes(), &chal))
|
|
|
|
switch tc.req.DeviceType {
|
|
case "totp":
|
|
require.NotNil(t, chal.TOTP.QRCode, "TOTP QR code cannot be nil")
|
|
case "webauthn":
|
|
require.NotNil(t, chal.Webauthn, "WebAuthn challenge cannot be nil")
|
|
}
|
|
|
|
if tc.assertChallenge != nil {
|
|
tc.assertChallenge(t, &chal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateAppSession verifies that an existing session to the Web UI can
|
|
// be exchanged for an application specific session.
|
|
func TestCreateAppSession(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
pack := s.authPack(t, "[email protected]")
|
|
|
|
// Register an application called "panel".
|
|
app, err := types.NewAppV3(types.Metadata{
|
|
Name: "panel",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
PublicAddr: "panel.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
server, err := types.NewAppServerV3FromApp(app, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertApplicationServer(s.ctx, server)
|
|
require.NoError(t, err)
|
|
|
|
// Extract the session ID and bearer token for the current session.
|
|
rawCookie := *pack.cookies[0]
|
|
cookieBytes, err := hex.DecodeString(rawCookie.Value)
|
|
require.NoError(t, err)
|
|
var sessionCookie websession.Cookie
|
|
err = json.Unmarshal(cookieBytes, &sessionCookie)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
inCreateRequest *CreateAppSessionRequest
|
|
outError require.ErrorAssertionFunc
|
|
outFQDN string
|
|
outUsername string
|
|
}{
|
|
{
|
|
name: "Valid request: all fields",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "panel.example.com",
|
|
PublicAddr: "panel.example.com",
|
|
ClusterName: "localhost",
|
|
},
|
|
},
|
|
outError: require.NoError,
|
|
outFQDN: "panel.example.com",
|
|
outUsername: "[email protected]",
|
|
},
|
|
{
|
|
name: "Valid request: without FQDN",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
PublicAddr: "panel.example.com",
|
|
ClusterName: "localhost",
|
|
},
|
|
},
|
|
outError: require.NoError,
|
|
outFQDN: "panel.example.com",
|
|
outUsername: "[email protected]",
|
|
},
|
|
{
|
|
name: "Valid request: only FQDN",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "panel.example.com",
|
|
},
|
|
},
|
|
outError: require.NoError,
|
|
outFQDN: "panel.example.com",
|
|
outUsername: "[email protected]",
|
|
},
|
|
{
|
|
name: "Invalid request: only public address",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
PublicAddr: "panel.example.com",
|
|
},
|
|
},
|
|
outError: require.Error,
|
|
},
|
|
{
|
|
name: "Invalid request: only cluster name",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
ClusterName: "localhost",
|
|
},
|
|
},
|
|
outError: require.Error,
|
|
},
|
|
{
|
|
name: "Invalid application",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "panel.example.com",
|
|
PublicAddr: "invalid.example.com",
|
|
ClusterName: "localhost",
|
|
},
|
|
},
|
|
outError: require.Error,
|
|
},
|
|
{
|
|
name: "Invalid cluster name",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "panel.example.com",
|
|
PublicAddr: "panel.example.com",
|
|
ClusterName: "example.com",
|
|
},
|
|
},
|
|
outError: require.Error,
|
|
},
|
|
{
|
|
name: "Malicious request: all fields",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "[email protected]",
|
|
PublicAddr: "panel.example.com",
|
|
ClusterName: "localhost",
|
|
},
|
|
},
|
|
outError: require.NoError,
|
|
outFQDN: "panel.example.com",
|
|
outUsername: "[email protected]",
|
|
},
|
|
{
|
|
name: "Malicious request: only FQDN",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "[email protected]",
|
|
},
|
|
},
|
|
outError: require.Error,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
// Make a request to create an application session for "panel".
|
|
endpoint := pack.clt.Endpoint("webapi", "sessions", "app")
|
|
resp, err := pack.clt.PostJSON(s.ctx, endpoint, tt.inCreateRequest)
|
|
tt.outError(t, err)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Unmarshal the response.
|
|
var response *CreateAppSessionResponse
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &response))
|
|
require.Equal(t, tt.outFQDN, response.FQDN)
|
|
|
|
// Verify that the application session was created.
|
|
sess, err := s.server.Auth().GetAppSession(s.ctx, types.GetAppSessionRequest{
|
|
SessionID: response.CookieValue,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.outUsername, sess.GetUser())
|
|
require.NotEmpty(t, response.CookieValue)
|
|
require.Equal(t, response.CookieValue, sess.GetName())
|
|
require.NotEmpty(t, response.SubjectCookieValue, "every session should create a secret token")
|
|
require.Equal(t, response.SubjectCookieValue, sess.GetBearerToken())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateAppSessionHealthCheckAppServer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
validApp, err := types.NewAppV3(types.Metadata{
|
|
Name: "valid",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
PublicAddr: "valid.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
invalidApp, err := types.NewAppV3(types.Metadata{
|
|
Name: "invalid",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
PublicAddr: "invalid.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
HealthCheckAppServer: func(_ context.Context, publicAddr string, _ string) error {
|
|
// Can only serve "validApp".
|
|
if publicAddr == validApp.GetPublicAddr() {
|
|
return nil
|
|
}
|
|
|
|
return trace.ConnectionProblem(nil, "offline AppServer")
|
|
},
|
|
})
|
|
|
|
for _, app := range []*types.AppV3{validApp, invalidApp} {
|
|
server, err := types.NewAppServerV3FromApp(app, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertApplicationServer(s.ctx, server)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
pack := s.authPack(t, "[email protected]")
|
|
rawCookie := *pack.cookies[0]
|
|
cookieBytes, err := hex.DecodeString(rawCookie.Value)
|
|
require.NoError(t, err)
|
|
var sessionCookie websession.Cookie
|
|
err = json.Unmarshal(cookieBytes, &sessionCookie)
|
|
require.NoError(t, err)
|
|
|
|
for _, tc := range []struct {
|
|
desc string
|
|
publicAddr string
|
|
expectErr require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
desc: "request to application that can be served",
|
|
publicAddr: validApp.GetPublicAddr(),
|
|
expectErr: require.NoError,
|
|
},
|
|
{
|
|
desc: "request to application that cannot be served",
|
|
publicAddr: invalidApp.GetPublicAddr(),
|
|
expectErr: require.Error,
|
|
},
|
|
} {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
endpoint := pack.clt.Endpoint("webapi", "sessions", "app")
|
|
_, err := pack.clt.PostJSON(s.ctx, endpoint, &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: tc.publicAddr,
|
|
},
|
|
})
|
|
tc.expectErr(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateAppSession_RequireSessionMFA(t *testing.T) {
|
|
ctx := context.Background()
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
pack := s.authPack(t, "[email protected]")
|
|
|
|
// Register an application called "panel".
|
|
app, err := types.NewAppV3(types.Metadata{
|
|
Name: "panel",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
PublicAddr: "panel.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
server, err := types.NewAppServerV3FromApp(app, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertApplicationServer(s.ctx, server)
|
|
require.NoError(t, err)
|
|
|
|
// Enable per session MFA.
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorWebauthn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: "localhost",
|
|
},
|
|
RequireMFAType: types.RequireMFAType_SESSION,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = s.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// register an mfa device for the user.
|
|
userClient, err := s.server.NewClient(authtest.TestUser(pack.user))
|
|
require.NoError(t, err)
|
|
webauthnDev, err := authtest.RegisterTestDevice(
|
|
ctx,
|
|
userClient,
|
|
"webauthn", authproto.DeviceType_DEVICE_TYPE_WEBAUTHN, pack.device /* authenticator */)
|
|
require.NoError(t, err)
|
|
|
|
// Prepare a valid mfa response for the user.
|
|
chal, err := userClient.CreateAuthenticateChallenge(ctx, &authproto.CreateAuthenticateChallengeRequest{
|
|
Request: &authproto.CreateAuthenticateChallengeRequest_ContextUser{},
|
|
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
|
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
mfaResp, err := webauthnDev.SolveAuthn(chal)
|
|
require.NoError(t, err)
|
|
|
|
// Extract the session ID and bearer token for the current session.
|
|
rawCookie := *pack.cookies[0]
|
|
cookieBytes, err := hex.DecodeString(rawCookie.Value)
|
|
require.NoError(t, err)
|
|
var sessionCookie websession.Cookie
|
|
err = json.Unmarshal(cookieBytes, &sessionCookie)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
inCreateRequest *CreateAppSessionRequest
|
|
expectMFAVerified bool
|
|
}{
|
|
{
|
|
name: "NOK MFA not provided",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "panel.example.com",
|
|
PublicAddr: "panel.example.com",
|
|
ClusterName: "localhost",
|
|
},
|
|
},
|
|
expectMFAVerified: false,
|
|
},
|
|
{
|
|
name: "OK MFA provided",
|
|
inCreateRequest: &CreateAppSessionRequest{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: "panel.example.com",
|
|
PublicAddr: "panel.example.com",
|
|
ClusterName: "localhost",
|
|
},
|
|
MFAResponse: client.MFAChallengeResponse{
|
|
WebauthnAssertionResponse: wantypes.CredentialAssertionResponseFromProto(mfaResp.GetWebauthn()),
|
|
},
|
|
},
|
|
expectMFAVerified: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
// Make a request to create an application session for "panel".
|
|
endpoint := pack.clt.Endpoint("webapi", "sessions", "app")
|
|
resp, err := pack.clt.PostJSON(s.ctx, endpoint, tt.inCreateRequest)
|
|
require.NoError(t, err)
|
|
|
|
// Unmarshal the response.
|
|
var response *CreateAppSessionResponse
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &response))
|
|
|
|
// Verify that the application session was created.
|
|
sess, err := s.server.Auth().GetAppSession(s.ctx, types.GetAppSessionRequest{
|
|
SessionID: response.CookieValue,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify that the session is MFA verified
|
|
certificate, err := tlsca.ParseCertificatePEM(sess.GetTLSCert())
|
|
require.NoError(t, err)
|
|
identity, err := tlsca.FromSubject(certificate.Subject, certificate.NotAfter)
|
|
require.NoError(t, err)
|
|
|
|
if tt.expectMFAVerified {
|
|
require.NotEmpty(t, identity.MFAVerified, "expected app session to be MFA verified")
|
|
} else {
|
|
require.Empty(t, identity.MFAVerified, "expected app session to not be MFA verified")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewSessionResponseWithRenewSession(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
|
|
// Set a web idle timeout.
|
|
duration := time.Duration(5) * time.Minute
|
|
cfg := types.DefaultClusterNetworkingConfig()
|
|
cfg.SetWebIdleTimeout(duration)
|
|
_, err := env.server.Auth().UpsertClusterNetworkingConfig(context.Background(), cfg)
|
|
require.NoError(t, err)
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "foo", nil /* roles */)
|
|
|
|
assertSession := func(t *testing.T, session *CreateSessionResponse) {
|
|
t.Helper()
|
|
|
|
assert.Equal(t, roundtrip.AuthBearer, session.TokenType, "token type mismatch")
|
|
assert.NotEmpty(t, session.Token, "token")
|
|
assert.NotZero(t, session.TokenExpiresIn, "expires_in")
|
|
assert.GreaterOrEqual(t, session.SessionExpiresIn, session.TokenExpiresIn, "sessionExpiresIn must be >= expires_in")
|
|
assert.NotZero(t, session.SessionExpires, "sessionExpires")
|
|
assert.Equal(t, int(duration.Milliseconds()), session.SessionInactiveTimeoutMS, "sessionInactiveTimeout")
|
|
}
|
|
|
|
t.Run("pack.session", func(t *testing.T) {
|
|
assertSession(t, pack.session)
|
|
})
|
|
|
|
t.Run("renew", func(t *testing.T) {
|
|
resp := pack.renewSession(context.Background(), t)
|
|
|
|
session := &CreateSessionResponse{}
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &session), "unmarshal renewed session")
|
|
assertSession(t, session)
|
|
})
|
|
}
|
|
|
|
// TestWebSessionsRenewDoesNotBreakExistingTerminalSession validates that the
|
|
// session renewed via one proxy does not force the terminals created by another
|
|
// proxy to disconnect
|
|
//
|
|
// See https://github.com/gravitational/teleport/issues/5265
|
|
func TestWebSessionsRenewDoesNotBreakExistingTerminalSession(t *testing.T) {
|
|
env := newWebPack(t, 2)
|
|
|
|
proxy1, proxy2 := env.proxies[0], env.proxies[1]
|
|
// Connect to both proxies
|
|
pack1 := proxy1.authPack(t, "foo", nil /* roles */)
|
|
pack2 := proxy2.authPackFromPack(t, pack1)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
term, err := connectToHost(ctx, connectConfig{
|
|
pack: pack2,
|
|
host: proxy2.node.ID(),
|
|
proxy: proxy2.webURL.Host,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, term.Close()) })
|
|
|
|
// Advance the time before renewing the session.
|
|
// This will allow the new session to have a more plausible
|
|
// expiration
|
|
const delta = 30 * time.Second
|
|
env.clock.Advance(defaults.BearerTokenTTL - delta)
|
|
|
|
// Renew the session using the 1st proxy
|
|
resp := pack1.renewSession(context.Background(), t)
|
|
|
|
// Expire the old session and make sure it has been removed.
|
|
// The bearer token is also removed after this point, so we have to
|
|
// use the new session data for future connects
|
|
env.clock.Advance(delta + 1*time.Second)
|
|
pack2 = proxy2.authPackFromResponse(t, resp)
|
|
|
|
// Verify that access via the 2nd proxy also works for the same session
|
|
pack2.validateAPI(context.Background(), t)
|
|
|
|
// Check whether the terminal session is still active
|
|
validateTerminal(t, term)
|
|
}
|
|
|
|
// TestWebSessionsRenewAllowsOldBearerTokenToLinger validates that the
|
|
// bearer token bound to the previous session is still active after the
|
|
// session renewal, if the renewal happens with a time margin.
|
|
//
|
|
// See https://github.com/gravitational/teleport/issues/5265
|
|
func TestWebSessionsRenewAllowsOldBearerTokenToLinger(t *testing.T) {
|
|
// Login to implicitly create a new web session
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "foo", nil /* roles */)
|
|
|
|
delta := 30 * time.Second
|
|
// Advance the time before renewing the session.
|
|
// This will allow the new session to have a more plausible
|
|
// expiration
|
|
env.clock.Advance(defaults.BearerTokenTTL - delta)
|
|
|
|
// make sure we can use client to make authenticated requests
|
|
// before we issue this request, we will recover session id and bearer token
|
|
//
|
|
prevSessionCookie := *pack.cookies[0]
|
|
prevBearerToken := pack.session.Token
|
|
resp := pack.renewSession(context.Background(), t)
|
|
|
|
newPack := proxy.authPackFromResponse(t, resp)
|
|
|
|
// new session is functioning
|
|
newPack.validateAPI(context.Background(), t)
|
|
|
|
sessionCookie := *newPack.cookies[0]
|
|
bearerToken := newPack.session.Token
|
|
require.NotEmpty(t, bearerToken)
|
|
require.NotEmpty(t, cmp.Diff(bearerToken, prevBearerToken))
|
|
|
|
prevSessionID := decodeSessionCookie(t, prevSessionCookie.Value)
|
|
activeSessionID := decodeSessionCookie(t, sessionCookie.Value)
|
|
require.NotEmpty(t, cmp.Diff(prevSessionID, activeSessionID))
|
|
|
|
// old session is still valid
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
oldClt := proxy.newClient(t, roundtrip.BearerAuth(prevBearerToken), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(&proxy.webURL, []*http.Cookie{&prevSessionCookie})
|
|
_, err = oldClt.Get(context.Background(), pack.clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
// now expire the old session and make sure it has been removed
|
|
env.clock.Advance(delta)
|
|
|
|
_, err = proxy.client.GetWebSession(context.Background(), types.GetWebSessionRequest{
|
|
User: "foo",
|
|
SessionID: prevSessionID,
|
|
})
|
|
require.Regexp(t, "^key.*not found$", err.Error())
|
|
|
|
// now delete session
|
|
_, err = newPack.clt.Delete(
|
|
context.Background(),
|
|
pack.clt.Endpoint("webapi", "sessions", "web"))
|
|
require.NoError(t, err)
|
|
|
|
// subsequent requests to use this session will fail
|
|
_, err = newPack.clt.Get(context.Background(), pack.clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
}
|
|
|
|
// TestChangeUserAuthentication_recoveryCodesReturnedForCloud tests for following:
|
|
// - Recovery codes are not returned for usernames that are not emails
|
|
// - Recovery codes are returned for usernames that are valid emails
|
|
func TestChangeUserAuthentication_recoveryCodesReturnedForCloud(t *testing.T) {
|
|
env := newWebPack(t, 1, withModules(&modulestest.Modules{
|
|
TestBuildType: modules.BuildEnterprise,
|
|
TestFeatures: modules.Features{
|
|
RecoveryCodes: true,
|
|
},
|
|
}))
|
|
ctx := context.Background()
|
|
|
|
// Enable second factor.
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Creaet a username that is not a valid email format for recovery.
|
|
teleUser, err := types.NewUser("invalid-name-for-recovery")
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().CreateUser(ctx, teleUser)
|
|
require.NoError(t, err)
|
|
|
|
// Create a reset password token and secrets.
|
|
resetToken, err := env.server.Auth().CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
|
|
Name: "invalid-name-for-recovery",
|
|
})
|
|
require.NoError(t, err)
|
|
res, err := env.server.Auth().CreateRegisterChallenge(ctx, &authproto.CreateRegisterChallengeRequest{
|
|
TokenID: resetToken.GetName(),
|
|
DeviceType: authproto.DeviceType_DEVICE_TYPE_TOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
totpCode, err := totp.GenerateCode(res.GetTOTP().GetSecret(), env.clock.Now())
|
|
require.NoError(t, err)
|
|
|
|
// Test invalid username does not receive codes.
|
|
clt := env.proxies[0].client
|
|
re, err := clt.ChangeUserAuthentication(ctx, &authproto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetToken.GetName(),
|
|
NewPassword: []byte("abcdef123456"),
|
|
NewMFARegisterResponse: &authproto.MFARegisterResponse{Response: &authproto.MFARegisterResponse_TOTP{
|
|
TOTP: &authproto.TOTPRegisterResponse{Code: totpCode},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, re.Recovery)
|
|
require.False(t, re.PrivateKeyPolicyEnabled)
|
|
|
|
// Create a user that is valid for recovery.
|
|
teleUser, err = types.NewUser("[email protected]")
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().CreateUser(ctx, teleUser)
|
|
require.NoError(t, err)
|
|
|
|
// Create a reset password token and secrets.
|
|
resetToken, err = env.server.Auth().CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
|
|
Name: "[email protected]",
|
|
})
|
|
require.NoError(t, err)
|
|
res, err = env.server.Auth().CreateRegisterChallenge(ctx, &authproto.CreateRegisterChallengeRequest{
|
|
TokenID: resetToken.GetName(),
|
|
DeviceType: authproto.DeviceType_DEVICE_TYPE_TOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
totpCode, err = totp.GenerateCode(res.GetTOTP().GetSecret(), env.clock.Now())
|
|
require.NoError(t, err)
|
|
|
|
// Test valid username (email) returns codes.
|
|
re, err = clt.ChangeUserAuthentication(ctx, &authproto.ChangeUserAuthenticationRequest{
|
|
TokenID: resetToken.GetName(),
|
|
NewPassword: []byte("abcdef123456"),
|
|
NewMFARegisterResponse: &authproto.MFARegisterResponse{Response: &authproto.MFARegisterResponse_TOTP{
|
|
TOTP: &authproto.TOTPRegisterResponse{Code: totpCode},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, re.Recovery.Codes, 3)
|
|
require.NotEmpty(t, re.Recovery.Created)
|
|
require.False(t, re.PrivateKeyPolicyEnabled)
|
|
}
|
|
|
|
// TestChangeUserAuthentication_WithPrivacyPolicyEnabledError tests
|
|
// that when there is a privacy policy enabled error, we still get
|
|
// a non error response with recovery codes and a privacy policy
|
|
// flag set to true.
|
|
func TestChangeUserAuthentication_WithPrivacyPolicyEnabledError(t *testing.T) {
|
|
env := newWebPack(t, 1, withModules(&modulestest.Modules{
|
|
TestFeatures: modules.Features{
|
|
RecoveryCodes: true,
|
|
},
|
|
MockAttestationData: &keys.AttestationData{
|
|
PrivateKeyPolicy: keys.PrivateKeyPolicyNone,
|
|
},
|
|
}),
|
|
)
|
|
ctx := context.Background()
|
|
|
|
// Enable second factor required by cloud and a privacy policy.
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Enable cloud feature.
|
|
|
|
// Create a user that is valid for recovery.
|
|
teleUser, err := types.NewUser("[email protected]")
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().CreateUser(ctx, teleUser)
|
|
require.NoError(t, err)
|
|
|
|
// Create a reset password token and secrets.
|
|
resetToken, err := env.server.Auth().CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
|
|
Name: "[email protected]",
|
|
})
|
|
require.NoError(t, err)
|
|
res, err := env.server.Auth().CreateRegisterChallenge(ctx, &authproto.CreateRegisterChallengeRequest{
|
|
TokenID: resetToken.GetName(),
|
|
DeviceType: authproto.DeviceType_DEVICE_TYPE_TOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
totpCode, err := totp.GenerateCode(res.GetTOTP().GetSecret(), env.clock.Now())
|
|
require.NoError(t, err)
|
|
|
|
// Craft http request data.
|
|
clt := env.proxies[0].newClient(t)
|
|
req := changeUserAuthenticationRequest{
|
|
SecondFactorToken: totpCode,
|
|
Password: []byte("abcdef123456"),
|
|
TokenID: resetToken.GetName(),
|
|
}
|
|
httpReqData, err := json.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
httpReq, err := http.NewRequest("PUT", clt.Endpoint("webapi", "users", "password", "token"), bytes.NewBuffer(httpReqData))
|
|
require.NoError(t, err)
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpRes, err := httplib.ConvertResponse(clt.RoundTrip(func() (*http.Response, error) {
|
|
return clt.HTTPClient().Do(httpReq)
|
|
}))
|
|
require.NoError(t, err)
|
|
|
|
var apiRes webui.ChangedUserAuthn
|
|
require.NoError(t, json.Unmarshal(httpRes.Bytes(), &apiRes))
|
|
require.Len(t, apiRes.Recovery.Codes, 3)
|
|
require.NotEmpty(t, apiRes.Recovery.Created)
|
|
require.True(t, apiRes.PrivateKeyPolicyEnabled)
|
|
}
|
|
|
|
func TestChangeUserAuthentication_settingDefaultClusterAuthPreference(t *testing.T) {
|
|
tt := []struct {
|
|
name string
|
|
cloud bool
|
|
numberOfUsers int
|
|
password []byte
|
|
authPreferenceType string
|
|
initialConnectorName string
|
|
resultConnectorName string
|
|
}{{
|
|
name: "first cloud sign-in changes connector to passwordless",
|
|
cloud: true,
|
|
numberOfUsers: 1,
|
|
authPreferenceType: constants.Local,
|
|
initialConnectorName: "",
|
|
resultConnectorName: constants.PasswordlessConnector,
|
|
}, {
|
|
name: "first non-cloud sign-in doesn't change the connector",
|
|
cloud: false,
|
|
numberOfUsers: 1,
|
|
authPreferenceType: constants.Local,
|
|
initialConnectorName: "",
|
|
resultConnectorName: "",
|
|
}, {
|
|
name: "second cloud sign-in doesn't change the connector",
|
|
cloud: true,
|
|
numberOfUsers: 2,
|
|
authPreferenceType: constants.Local,
|
|
initialConnectorName: "",
|
|
resultConnectorName: "",
|
|
}, {
|
|
name: "first cloud sign-in does not change custom connector",
|
|
cloud: true,
|
|
numberOfUsers: 1,
|
|
authPreferenceType: constants.OIDC,
|
|
initialConnectorName: "custom",
|
|
resultConnectorName: "custom",
|
|
}, {
|
|
name: "first cloud sign-in with password does not change connector",
|
|
cloud: true,
|
|
numberOfUsers: 1,
|
|
password: []byte("abcdef123456"),
|
|
authPreferenceType: constants.Local,
|
|
initialConnectorName: "",
|
|
resultConnectorName: "",
|
|
}}
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
const RPID = "localhost"
|
|
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
authPreferenceSpec: &types.AuthPreferenceSpecV2{
|
|
Type: tc.authPreferenceType,
|
|
ConnectorName: tc.initialConnectorName,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: RPID,
|
|
},
|
|
},
|
|
modules: &modulestest.Modules{
|
|
TestBuildType: modules.BuildEnterprise,
|
|
TestFeatures: modules.Features{
|
|
Cloud: tc.cloud,
|
|
},
|
|
},
|
|
})
|
|
|
|
// user and role
|
|
users := make([]types.User, tc.numberOfUsers)
|
|
|
|
for i := range tc.numberOfUsers {
|
|
user, err := types.NewUser(fmt.Sprintf("test_user_%v", i))
|
|
require.NoError(t, err)
|
|
|
|
user.SetCreatedBy(types.CreatedBy{
|
|
User: types.UserRef{Name: "other_user"},
|
|
})
|
|
|
|
role := services.RoleForUser(user)
|
|
|
|
role, err = s.server.Auth().UpsertRole(s.ctx, role)
|
|
require.NoError(t, err)
|
|
|
|
user.AddRole(role.GetName())
|
|
|
|
user, err = s.server.Auth().CreateUser(s.ctx, user)
|
|
require.NoError(t, err)
|
|
|
|
users[i] = user
|
|
}
|
|
|
|
initialUser := users[0]
|
|
|
|
clt := s.client(t)
|
|
|
|
// create register challenge
|
|
token, err := s.server.Auth().CreateResetPasswordToken(s.ctx, authclient.CreateUserTokenRequest{
|
|
Name: initialUser.GetName(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
res, err := s.server.Auth().CreateRegisterChallenge(s.ctx, &authproto.CreateRegisterChallengeRequest{
|
|
TokenID: token.GetName(),
|
|
DeviceType: authproto.DeviceType_DEVICE_TYPE_WEBAUTHN,
|
|
DeviceUsage: authproto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cc := wantypes.CredentialCreationFromProto(res.GetWebauthn())
|
|
|
|
// use passwordless as auth method
|
|
device, err := mocku2f.Create()
|
|
require.NoError(t, err)
|
|
|
|
device.SetPasswordless()
|
|
|
|
ccr, err := device.SignCredentialCreation("https://"+RPID, cc)
|
|
require.NoError(t, err)
|
|
|
|
// send sign-in response to server
|
|
body, err := json.Marshal(changeUserAuthenticationRequest{
|
|
WebauthnCreationResponse: ccr,
|
|
TokenID: token.GetName(),
|
|
DeviceName: "passwordless-device",
|
|
Password: tc.password,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest("PUT", clt.Endpoint("webapi", "users", "password", "token"), bytes.NewBuffer(body))
|
|
require.NoError(t, err)
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
re, err := clt.Client.RoundTrip(func() (*http.Response, error) {
|
|
return clt.Client.HTTPClient().Do(req)
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, re.Code())
|
|
|
|
// check if auth preference connectorName is set
|
|
authPreference, err := s.server.Auth().GetAuthPreference(s.ctx)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, tc.resultConnectorName, authPreference.GetConnectorName(), "Found unexpected auth connector name")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseSSORequestParams(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
token := "someMeaninglessTokenString"
|
|
|
|
tests := []struct {
|
|
name, url string
|
|
wantErr bool
|
|
expected *SSORequestParams
|
|
}{
|
|
{
|
|
name: "preserve redirect's query params (escaped)",
|
|
url: "https://localhost/login?connector_id=oidc&redirect_url=https:%2F%2Flocalhost:8080%2Fweb%2Fcluster%2Fim-a-cluster-name%2Fnodes%3Fsearch=tunnel&sort=hostname:asc",
|
|
expected: &SSORequestParams{
|
|
ClientRedirectURL: "https://localhost:8080/web/cluster/im-a-cluster-name/nodes?search=tunnel&sort=hostname:asc",
|
|
ConnectorID: "oidc",
|
|
CSRFToken: token,
|
|
},
|
|
},
|
|
{
|
|
name: "preserve redirect's query params (unescaped)",
|
|
url: "https://localhost/login?connector_id=github&redirect_url=https://localhost:8080/web/cluster/im-a-cluster-name/nodes?search=tunnel&sort=hostname:asc",
|
|
expected: &SSORequestParams{
|
|
ClientRedirectURL: "https://localhost:8080/web/cluster/im-a-cluster-name/nodes?search=tunnel&sort=hostname:asc",
|
|
ConnectorID: "github",
|
|
CSRFToken: token,
|
|
},
|
|
},
|
|
{
|
|
name: "preserve various encoded chars",
|
|
url: "https://localhost/login?connector_id=saml&redirect_url=https:%2F%2Flocalhost:8080%2Fweb%2Fcluster%2Fim-a-cluster-name%2Fapps%3Fquery=search(%2522watermelon%2522%252C%2520%2522this%2522)%2520%2526%2526%2520labels%255B%2522unique-id%2522%255D%2520%253D%253D%2520%2522hi%2522&sort=name:asc",
|
|
expected: &SSORequestParams{
|
|
ClientRedirectURL: "https://localhost:8080/web/cluster/im-a-cluster-name/apps?query=search(%22watermelon%22%2C%20%22this%22)%20%26%26%20labels%5B%22unique-id%22%5D%20%3D%3D%20%22hi%22&sort=name:asc",
|
|
ConnectorID: "saml",
|
|
CSRFToken: token,
|
|
},
|
|
},
|
|
{
|
|
name: "invalid redirect_url query param",
|
|
url: "https://localhost/login?redirect=https://localhost/nodes&connector_id=oidc",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid connector_id query param",
|
|
url: "https://localhost/login?redirect_url=https://localhost/nodes&connector=oidc",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req, err := http.NewRequest("", tc.url, nil)
|
|
require.NoError(t, err)
|
|
|
|
req.AddCookie(&http.Cookie{
|
|
Name: csrf.CookieName,
|
|
Value: token,
|
|
})
|
|
|
|
params, err := ParseSSORequestParams(req)
|
|
|
|
switch {
|
|
case tc.wantErr:
|
|
require.Error(t, err)
|
|
default:
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.expected, params)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetUserMatchedAuthConnectors tests that auth connectors are matched correctly to users.
|
|
func TestGetUserMatchedAuthConnectors(t *testing.T) {
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil)
|
|
|
|
// Create GitHub connectors with various matchers
|
|
githubConnectors := []struct {
|
|
name string
|
|
matchers []string
|
|
}{
|
|
{
|
|
name: "github-specific",
|
|
matchers: []string{"[email protected]", "[email protected]"},
|
|
},
|
|
{
|
|
name: "github-domain",
|
|
matchers: []string{"*@company.com"},
|
|
},
|
|
{
|
|
name: "github-fallback",
|
|
matchers: []string{"*"},
|
|
},
|
|
}
|
|
|
|
for _, connSpec := range githubConnectors {
|
|
connector, err := types.NewGithubConnector(connSpec.name, types.GithubConnectorSpecV3{
|
|
UserMatchers: connSpec.matchers,
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "octocats",
|
|
Team: "devs",
|
|
Roles: []string{"access"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertGithubConnector(ctx, connector)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Create SAML connectors with various matchers
|
|
samlConnectors := []struct {
|
|
name string
|
|
matchers []string
|
|
}{
|
|
{
|
|
name: "saml-specific",
|
|
matchers: []string{"[email protected]"},
|
|
},
|
|
{
|
|
name: "saml-pattern",
|
|
matchers: []string{"*@saml-company.com"},
|
|
},
|
|
{
|
|
name: "saml-fallback",
|
|
matchers: []string{"*"},
|
|
},
|
|
{
|
|
name: "saml-fallback-and-explicit",
|
|
matchers: []string{"*", "*@example.com"},
|
|
},
|
|
}
|
|
|
|
for _, connSpec := range samlConnectors {
|
|
connector, err := types.NewSAMLConnector(connSpec.name, types.SAMLConnectorSpecV2{
|
|
UserMatchers: connSpec.matchers,
|
|
AssertionConsumerService: "test",
|
|
SSO: "test",
|
|
EntityDescriptor: `<?xml version="1.0" encoding="UTF-8"?>
|
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="test">
|
|
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<md:KeyDescriptor use="signing">
|
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
|
<ds:X509Data>
|
|
<ds:X509Certificate></ds:X509Certificate>
|
|
</ds:X509Data>
|
|
</ds:KeyInfo>
|
|
</md:KeyDescriptor>
|
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
|
|
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://example.com" />
|
|
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.com" />
|
|
</md:IDPSSODescriptor>
|
|
</md:EntityDescriptor>`,
|
|
AttributesToRoles: []types.AttributeMapping{
|
|
{
|
|
Name: "test",
|
|
Value: "test",
|
|
Roles: []string{"default-implicit-role"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertSAMLConnector(ctx, connector)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Create OIDC connectors with various matchers
|
|
oidcConnectors := []struct {
|
|
name string
|
|
matchers []string
|
|
}{
|
|
{
|
|
name: "oidc-specific",
|
|
matchers: []string{"[email protected]"},
|
|
},
|
|
{
|
|
name: "oidc-fallback",
|
|
matchers: []string{"*"},
|
|
},
|
|
}
|
|
|
|
for _, connSpec := range oidcConnectors {
|
|
connector, err := types.NewOIDCConnector(connSpec.name, types.OIDCConnectorSpecV3{
|
|
UserMatchers: connSpec.matchers,
|
|
ClientID: "test",
|
|
ClientSecret: "test",
|
|
ClaimsToRoles: []types.ClaimMapping{
|
|
{
|
|
Claim: "team",
|
|
Value: "dev",
|
|
Roles: []string{"dev-team-access"},
|
|
},
|
|
},
|
|
RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertOIDCConnector(ctx, connector)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
username string
|
|
expectedConnectors []string
|
|
}{
|
|
{
|
|
name: "explicit matches",
|
|
username: "[email protected]",
|
|
expectedConnectors: []string{"github-specific", "saml-fallback-and-explicit"},
|
|
},
|
|
{
|
|
name: "domain matches",
|
|
username: "[email protected]",
|
|
expectedConnectors: []string{"github-domain"},
|
|
},
|
|
{
|
|
name: "multiple specific matches excludes fallbacks",
|
|
username: "[email protected]",
|
|
expectedConnectors: []string{"saml-specific", "saml-fallback-and-explicit"},
|
|
},
|
|
{
|
|
name: "mixed specific matches across connector types",
|
|
username: "[email protected]",
|
|
expectedConnectors: []string{"saml-pattern"},
|
|
},
|
|
{
|
|
name: "fallbacks used when no specific matches",
|
|
username: "[email protected]",
|
|
expectedConnectors: []string{"github-fallback", "saml-fallback", "saml-fallback-and-explicit", "oidc-fallback"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reqPayload := struct {
|
|
Username string `json:"username"`
|
|
}{
|
|
Username: tc.username,
|
|
}
|
|
|
|
resp, err := pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "authconnectors"), reqPayload)
|
|
require.NoError(t, err, "failed to call getUserMatchedAuthConnectors endpoint")
|
|
require.Equal(t, http.StatusOK, resp.Code(), "unexpected status code")
|
|
|
|
var response struct {
|
|
Connectors []struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
} `json:"connectors"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &response), "failed to unmarshal response")
|
|
|
|
var actualConnectors []string
|
|
for _, conn := range response.Connectors {
|
|
actualConnectors = append(actualConnectors, conn.Name)
|
|
}
|
|
|
|
sort.Strings(actualConnectors)
|
|
sort.Strings(tc.expectedConnectors)
|
|
require.Equal(t, tc.expectedConnectors, actualConnectors)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClusterDesktopsGet(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
|
|
type testResponse struct {
|
|
Items []webui.Desktop `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
}
|
|
|
|
// Add a few desktops.
|
|
resource, err := types.NewWindowsDesktopV3("desktop1", map[string]string{"test-field": "test-value"}, types.WindowsDesktopSpecV3{
|
|
Addr: "addr:3389", // test stripping off rdp port
|
|
HostID: "host",
|
|
})
|
|
require.NoError(t, err)
|
|
resource2, err := types.NewWindowsDesktopV3("desktop2", map[string]string{"test-field": "test-value2"}, types.WindowsDesktopSpecV3{
|
|
Addr: "addr",
|
|
HostID: "host",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = env.server.Auth().UpsertWindowsDesktop(context.Background(), resource)
|
|
require.NoError(t, err)
|
|
err = env.server.Auth().UpsertWindowsDesktop(context.Background(), resource2)
|
|
require.NoError(t, err)
|
|
|
|
// Make the call.
|
|
query := url.Values{"sort": []string{"name"}}
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "desktops")
|
|
re, err := pack.clt.Get(context.Background(), endpoint, query)
|
|
require.NoError(t, err)
|
|
|
|
// Test correct response.
|
|
resp := testResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Items, 2)
|
|
require.Equal(t, 2, resp.TotalCount)
|
|
require.ElementsMatch(t, resp.Items, []webui.Desktop{{
|
|
Kind: types.KindWindowsDesktop,
|
|
OS: constants.WindowsOS,
|
|
Name: "desktop1",
|
|
Addr: "addr",
|
|
Labels: []ui.Label{{Name: "test-field", Value: "test-value"}},
|
|
HostID: "host",
|
|
}, {
|
|
Kind: types.KindWindowsDesktop,
|
|
OS: constants.WindowsOS,
|
|
Name: "desktop2",
|
|
Addr: "addr",
|
|
Labels: []ui.Label{{Name: "test-field", Value: "test-value2"}},
|
|
HostID: "host",
|
|
}})
|
|
}
|
|
|
|
func TestDesktopActive(t *testing.T) {
|
|
desktopName := "rickey-rock"
|
|
env := newWebPack(t, 1)
|
|
ctx := context.Background()
|
|
|
|
role, err := types.NewRole("admin", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
WindowsDesktopLabels: types.Labels{"environment": []string{"dev"}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
pack := env.proxies[0].authPack(t, "foo", []types.Role{role})
|
|
|
|
check := func(match string) {
|
|
resp, err := pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "desktops", desktopName, "active"), url.Values{})
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(resp.Bytes()), match)
|
|
}
|
|
|
|
check("\"active\":false")
|
|
desktop, err := types.NewWindowsDesktopV3(desktopName, map[string]string{"environment": "dev"}, types.WindowsDesktopSpecV3{
|
|
Domain: "ad",
|
|
Addr: "foo",
|
|
HostID: "bar",
|
|
})
|
|
require.NoError(t, err)
|
|
err = env.server.Auth().CreateWindowsDesktop(ctx, desktop)
|
|
require.NoError(t, err)
|
|
tracker, err := types.NewSessionTracker(types.SessionTrackerSpecV1{
|
|
SessionID: "foo",
|
|
Kind: string(types.WindowsDesktopSessionKind),
|
|
State: types.SessionState_SessionStateRunning,
|
|
DesktopName: desktopName,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().CreateSessionTracker(ctx, tracker)
|
|
require.NoError(t, err)
|
|
check("\"active\":true")
|
|
}
|
|
|
|
func TestDecodeURLPathParamField(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
assertError require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "string with special characters encoded",
|
|
input: "foo-bar.baz_qux%2B10%40testing.com",
|
|
expected: "[email protected]",
|
|
assertError: require.NoError,
|
|
},
|
|
{
|
|
name: "string with special characters NOT encoded",
|
|
input: "[email protected]",
|
|
expected: "[email protected]",
|
|
assertError: require.NoError,
|
|
},
|
|
{
|
|
name: "string with special characters double encoded",
|
|
input: "foo-bar.baz_qux%252B10%2540testing.com",
|
|
expected: "foo-bar.baz_qux%2B10%40testing.com",
|
|
assertError: require.NoError,
|
|
},
|
|
{
|
|
name: "plain string",
|
|
input: "llama",
|
|
expected: "llama",
|
|
assertError: require.NoError,
|
|
},
|
|
{
|
|
name: "invalid percent encoding",
|
|
input: "bad%2Zvalue",
|
|
expected: "",
|
|
assertError: require.Error,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := decodeURLPathParamField(tt.input)
|
|
tt.assertError(t, err)
|
|
require.Equal(t, tt.expected, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetUserOrResetToken(t *testing.T) {
|
|
env := newWebPack(t, 1)
|
|
ctx := context.Background()
|
|
username := "[email protected]"
|
|
|
|
// Create a username.
|
|
teleUser, err := types.NewUser(username)
|
|
require.NoError(t, err)
|
|
teleUser.SetLogins([]string{"login1"})
|
|
_, err = env.server.Auth().CreateUser(ctx, teleUser)
|
|
require.NoError(t, err)
|
|
|
|
// Create a reset password token and secrets.
|
|
resetToken, err := env.server.Auth().CreateResetPasswordToken(ctx, authclient.CreateUserTokenRequest{
|
|
Name: username,
|
|
Type: authclient.UserTokenTypeResetPasswordInvite,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
pack := env.proxies[0].authPack(t, "foo", nil /* roles */)
|
|
|
|
// the default roles of foo don't have users read but we need it on our tests
|
|
fooRole, err := env.server.Auth().GetRole(ctx, "user:foo")
|
|
require.NoError(t, err)
|
|
fooAllowRules := fooRole.GetRules(types.Allow)
|
|
fooAllowRules = append(fooAllowRules, types.NewRule(types.KindUser, services.RO()))
|
|
fooRole.SetRules(types.Allow, fooAllowRules)
|
|
_, err = env.server.Auth().UpsertRole(ctx, fooRole)
|
|
require.NoError(t, err)
|
|
|
|
encodedUsername := "foo-bar.baz_qux%2B10%40testing.com"
|
|
resp, err := pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "users", encodedUsername), url.Values{})
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(resp.Bytes()), "login1")
|
|
|
|
resp, err = pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "users", "password", "token", resetToken.GetName()), url.Values{})
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code())
|
|
|
|
_, err = pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "users", "password", "notToken", resetToken.GetName()), url.Values{})
|
|
require.True(t, trace.IsNotFound(err))
|
|
}
|
|
|
|
func TestListConnectionsDiagnostic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
username := "someuser"
|
|
diagName := "diag1"
|
|
roleROConnectionDiagnostics, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindConnectionDiagnostic,
|
|
[]string{types.VerbRead}),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1)
|
|
clusterName := env.server.ClusterName()
|
|
pack := env.proxies[0].authPack(t, username, []types.Role{roleROConnectionDiagnostics})
|
|
|
|
connectionsEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "diagnostics", "connections", diagName)
|
|
|
|
// No connection diagnostics so far, should return not found
|
|
_, err = pack.clt.Get(ctx, connectionsEndpoint, url.Values{})
|
|
require.True(t, trace.IsNotFound(err))
|
|
|
|
connectionDiagnostic, err := types.NewConnectionDiagnosticV1(diagName, map[string]string{}, types.ConnectionDiagnosticSpecV1{
|
|
Success: true,
|
|
Message: "success for cd0",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, env.server.Auth().CreateConnectionDiagnostic(ctx, connectionDiagnostic))
|
|
|
|
resp, err := pack.clt.Get(ctx, connectionsEndpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code())
|
|
|
|
var receivedConnectionDiagnostic webui.ConnectionDiagnostic
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &receivedConnectionDiagnostic))
|
|
|
|
require.True(t, receivedConnectionDiagnostic.Success)
|
|
require.Equal(t, diagName, receivedConnectionDiagnostic.ID)
|
|
require.Equal(t, "success for cd0", receivedConnectionDiagnostic.Message)
|
|
|
|
diag, err := env.server.Auth().GetConnectionDiagnostic(ctx, diagName)
|
|
require.NoError(t, err)
|
|
|
|
// Adding traces
|
|
diag.AppendTrace(&types.ConnectionDiagnosticTrace{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_NODE,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "some details",
|
|
})
|
|
diag.SetMessage("after update")
|
|
require.NoError(t, env.server.Auth().UpdateConnectionDiagnostic(ctx, diag))
|
|
|
|
resp, err = pack.clt.Get(ctx, connectionsEndpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code())
|
|
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &receivedConnectionDiagnostic))
|
|
|
|
require.True(t, receivedConnectionDiagnostic.Success)
|
|
require.Equal(t, diagName, receivedConnectionDiagnostic.ID)
|
|
require.Equal(t, "after update", receivedConnectionDiagnostic.Message)
|
|
require.Len(t, receivedConnectionDiagnostic.Traces, 1)
|
|
require.NotNil(t, receivedConnectionDiagnostic.Traces[0])
|
|
require.Equal(t, "some details", receivedConnectionDiagnostic.Traces[0].Details)
|
|
}
|
|
|
|
func TestDiagnoseSSHConnection(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
osUser, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
osUsername := osUser.Username
|
|
require.NotEmpty(t, osUsername)
|
|
|
|
roleWithFullAccess := func(username string, login string) []types.Role {
|
|
ret, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Namespaces: []string{apidefaults.Namespace},
|
|
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindConnectionDiagnostic, services.RW()),
|
|
},
|
|
Logins: []string{login},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
return []types.Role{ret}
|
|
}
|
|
require.NotNil(t, roleWithFullAccess)
|
|
|
|
rolesWithoutAccessToNode := func(username string, login string) []types.Role {
|
|
ret, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Namespaces: []string{apidefaults.Namespace},
|
|
NodeLabels: types.Labels{"forbidden": []string{"yes"}},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindConnectionDiagnostic, services.RW()),
|
|
},
|
|
Logins: []string{login},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
return []types.Role{ret}
|
|
}
|
|
require.NotNil(t, rolesWithoutAccessToNode)
|
|
|
|
roleWithPrincipal := func(username string, principal string) []types.Role {
|
|
ret, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Namespaces: []string{apidefaults.Namespace},
|
|
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindConnectionDiagnostic, services.RW()),
|
|
},
|
|
Logins: []string{principal},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
return []types.Role{ret}
|
|
}
|
|
require.NotNil(t, roleWithPrincipal)
|
|
|
|
env := newWebPack(t, 1)
|
|
nodeName := env.node.GetInfo().GetHostname()
|
|
|
|
// Wait for node to show up
|
|
require.Eventually(t, func() bool {
|
|
_, err := env.server.Auth().GetNode(ctx, apidefaults.Namespace, nodeName)
|
|
if trace.IsNotFound(err) {
|
|
return false
|
|
}
|
|
assert.NoError(t, err, "GetNode returned an unexpected error")
|
|
return true
|
|
}, 5*time.Second, 250*time.Millisecond)
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
teleportUser string
|
|
roles []types.Role
|
|
resourceName string
|
|
nodeUser string
|
|
nodeOS string
|
|
setupMethod string
|
|
stopNode bool
|
|
expectedSuccess bool
|
|
expectedMessage string
|
|
expectedTraces []types.ConnectionDiagnosticTrace
|
|
}{
|
|
{
|
|
name: "success",
|
|
roles: roleWithFullAccess("success", osUsername),
|
|
teleportUser: "success",
|
|
resourceName: nodeName,
|
|
nodeUser: osUsername,
|
|
expectedSuccess: true,
|
|
expectedMessage: "success",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_NODE,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "You have access to the Node.",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Node is alive and reachable.",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "The requested principal is allowed.",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_NODE_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: fmt.Sprintf("%q user exists in target node", osUsername),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "Linux node not found",
|
|
roles: roleWithFullAccess("nodenotfound-linux", osUsername),
|
|
teleportUser: "nodenotfound-linux",
|
|
resourceName: "notanode",
|
|
nodeUser: osUsername,
|
|
nodeOS: constants.LinuxOS,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Failed to connect to the Node. Ensure teleport service is running using "systemctl status teleport".`,
|
|
Error: "target host notanode is offline or does not exist",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "Darwin node not found",
|
|
roles: roleWithFullAccess("nodenotfound-darwin", osUsername),
|
|
teleportUser: "nodenotfound-darwin",
|
|
resourceName: "notanode",
|
|
nodeUser: osUsername,
|
|
nodeOS: constants.DarwinOS,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Failed to connect to the Node. Ensure teleport service is running using "launchctl print 'system/Teleport Service'".`,
|
|
Error: "target host notanode is offline or does not exist",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "Connect My Computer node not found",
|
|
roles: roleWithFullAccess("nodenotfound-connect-my-computer", osUsername),
|
|
teleportUser: "nodenotfound-connect-my-computer",
|
|
resourceName: "notanode",
|
|
nodeUser: osUsername,
|
|
nodeOS: constants.DarwinOS,
|
|
setupMethod: conntest.SSHNodeSetupMethodConnectMyComputer,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Open the Connect My Computer tab in Teleport Connect and make sure that the agent is running.`,
|
|
Error: "target host notanode is offline or does not exist",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "node not reachable",
|
|
teleportUser: "nodenotreachable",
|
|
roles: roleWithFullAccess("nodenotreachable", osUsername),
|
|
resourceName: nodeName,
|
|
nodeUser: osUsername,
|
|
stopNode: true,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Failed to connect to the Node. Ensure teleport service is running using "systemctl status teleport".`,
|
|
Error: "Teleport proxy failed to connect to",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "no access to node",
|
|
teleportUser: "userwithoutaccess",
|
|
roles: rolesWithoutAccessToNode("userwithoutaccess", osUsername),
|
|
resourceName: nodeName,
|
|
nodeUser: osUsername,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_NODE,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: "You are not authorized to access this node. Ensure your role grants access by adding it to the 'node_labels' property.",
|
|
Error: fmt.Sprintf("user userwithoutaccess@localhost is not authorized to login as %s@localhost: access to node denied", osUsername),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "selected principal is not part of the allowed principals",
|
|
teleportUser: "deniedprincipal",
|
|
roles: roleWithFullAccess("deniedprincipal", "otherprincipal"),
|
|
resourceName: nodeName,
|
|
nodeUser: osUsername,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Principal "` + osUsername + `" is not allowed by this certificate. Ensure your roles grants access by adding it to the 'login' property.`,
|
|
Error: `ssh: principal "` + osUsername + `" not in the set of valid principals for given certificate: ["otherprincipal" "-teleport-internal-join"]`,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "principal does not exist in target host",
|
|
teleportUser: "principaldoesnotexist",
|
|
roles: roleWithPrincipal("principaldoesnotexist", "nonvalidlinuxuser"),
|
|
resourceName: nodeName,
|
|
nodeUser: "nonvalidlinuxuser",
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_NODE_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Invalid user. Please ensure the principal "nonvalidlinuxuser" is a valid login in the target node. Output from Node: Failed to launch: user:`,
|
|
Error: "Process exited with status 255",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "principal does not exist in target Connect My Computer host",
|
|
teleportUser: "principaldoesnotexist-connect-my-computer",
|
|
roles: roleWithPrincipal("principaldoesnotexist-connect-my-computer", "nonvaliduser"),
|
|
resourceName: nodeName,
|
|
nodeUser: "nonvaliduser",
|
|
setupMethod: conntest.SSHNodeSetupMethodConnectMyComputer,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_NODE_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Invalid user`,
|
|
Error: `The role "connect-my-computer-principaldoesnotexist-connect-my-computer" includes only the login "nonvaliduser" and "nonvaliduser" is not a valid principal for this node`,
|
|
},
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
localEnv := env
|
|
|
|
if tt.stopNode {
|
|
localEnv = newWebPack(t, 1)
|
|
require.NoError(t, localEnv.node.Close())
|
|
}
|
|
|
|
clusterName := localEnv.server.ClusterName()
|
|
pack := localEnv.proxies[0].authPack(t, tt.teleportUser, tt.roles)
|
|
|
|
createConnectionEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "diagnostics", "connections")
|
|
|
|
resp, err := pack.clt.PostJSON(ctx, createConnectionEndpoint, conntest.TestConnectionRequest{
|
|
ResourceKind: types.KindNode,
|
|
ResourceName: tt.resourceName,
|
|
SSHPrincipal: tt.nodeUser,
|
|
SSHNodeOS: tt.nodeOS,
|
|
SSHNodeSetupMethod: tt.setupMethod,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code())
|
|
|
|
var connectionDiagnostic webui.ConnectionDiagnostic
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &connectionDiagnostic))
|
|
|
|
gotFailedTraces := 0
|
|
expectedFailedTraces := 0
|
|
|
|
t.Log(tt.name)
|
|
t.Log(connectionDiagnostic.Message, connectionDiagnostic.Success)
|
|
for i, trace := range connectionDiagnostic.Traces {
|
|
if trace.Status == types.ConnectionDiagnosticTrace_FAILED.String() {
|
|
gotFailedTraces++
|
|
}
|
|
|
|
t.Logf("%d status='%s' type='%s' details='%s' error='%s'\n", i, trace.Status, trace.TraceType, trace.Details, trace.Error)
|
|
}
|
|
|
|
require.Equal(t, tt.expectedSuccess, connectionDiagnostic.Success)
|
|
require.Equal(t, tt.expectedMessage, connectionDiagnostic.Message)
|
|
|
|
for _, expectedTrace := range tt.expectedTraces {
|
|
if expectedTrace.Status == types.ConnectionDiagnosticTrace_FAILED {
|
|
expectedFailedTraces++
|
|
}
|
|
|
|
foundTrace := false
|
|
for _, returnedTrace := range connectionDiagnostic.Traces {
|
|
if expectedTrace.Type.String() != returnedTrace.TraceType {
|
|
continue
|
|
}
|
|
|
|
foundTrace = true
|
|
require.Equal(t, returnedTrace.Status, expectedTrace.Status.String())
|
|
require.Contains(t, returnedTrace.Details, expectedTrace.Details)
|
|
require.Contains(t, returnedTrace.Error, expectedTrace.Error)
|
|
}
|
|
|
|
require.True(t, foundTrace, "expected trace '%v' was not found, got '%v'", expectedTrace, connectionDiagnostic.Traces)
|
|
}
|
|
require.Equal(t, expectedFailedTraces, gotFailedTraces)
|
|
})
|
|
}
|
|
|
|
// Test success with per-session MFA.
|
|
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
RequireMFAType: types.RequireMFAType_SESSION,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Get a totp code to re-auth.
|
|
pack := env.proxies[0].authPack(t, "llama", roleWithFullAccess("success", osUsername))
|
|
totpCode, err := totp.GenerateCode(pack.otpSecret, env.clock.Now().Add(30*time.Second))
|
|
require.NoError(t, err)
|
|
|
|
clusterName := env.server.ClusterName()
|
|
createConnectionEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "diagnostics", "connections")
|
|
|
|
resp, err := pack.clt.PostJSON(ctx, createConnectionEndpoint, conntest.TestConnectionRequest{
|
|
ResourceKind: types.KindNode,
|
|
ResourceName: nodeName,
|
|
SSHPrincipal: osUsername,
|
|
MFAResponse: client.MFAChallengeResponse{TOTPCode: totpCode},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code())
|
|
|
|
var connectionDiagnostic webui.ConnectionDiagnostic
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &connectionDiagnostic))
|
|
require.True(t, connectionDiagnostic.Success)
|
|
}
|
|
|
|
func TestDiagnoseKubeConnection(t *testing.T) {
|
|
var (
|
|
validKubeUsers = []string{}
|
|
multiKubeUsers = []string{"user1", "user2"}
|
|
validKubeGroups = []string{"validKubeGroup"}
|
|
invalidKubeGroups = []string{"invalidKubeGroups"}
|
|
kubeClusterName = "kube_cluster"
|
|
disconnectedKubeClustername = "dis_kube_cluster"
|
|
ctx = context.Background()
|
|
)
|
|
|
|
roleWithFullAccess := func(username string, kubeUsers, kubeGroups []string) []types.Role {
|
|
ret, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Namespaces: []string{apidefaults.Namespace},
|
|
KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindConnectionDiagnostic, services.RW()),
|
|
},
|
|
KubeGroups: kubeGroups,
|
|
KubeUsers: kubeUsers,
|
|
KubernetesResources: []types.KubernetesResource{
|
|
{
|
|
Kind: "pods",
|
|
Namespace: types.Wildcard,
|
|
Name: types.Wildcard,
|
|
Verbs: []string{types.Wildcard},
|
|
APIGroup: types.Wildcard,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
return []types.Role{ret}
|
|
}
|
|
require.NotNil(t, roleWithFullAccess)
|
|
|
|
rolesWithoutAccessToKubeCluster := func(username string, kubeUsers, kubeGroups []string) []types.Role {
|
|
ret, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Namespaces: []string{apidefaults.Namespace},
|
|
KubernetesLabels: types.Labels{"forbidden": []string{"yes"}},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindConnectionDiagnostic, services.RW()),
|
|
},
|
|
KubeGroups: kubeGroups,
|
|
KubeUsers: kubeUsers,
|
|
KubernetesResources: []types.KubernetesResource{
|
|
{
|
|
Kind: "pods",
|
|
Namespace: types.Wildcard,
|
|
Name: types.Wildcard,
|
|
Verbs: []string{types.Wildcard},
|
|
APIGroup: types.Wildcard,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
return []types.Role{ret}
|
|
}
|
|
require.NotNil(t, rolesWithoutAccessToKubeCluster)
|
|
|
|
env := newWebPack(t, 1,
|
|
withWebPackProxyOptions(
|
|
withKubeProxy(),
|
|
),
|
|
)
|
|
|
|
rt := http.NewServeMux()
|
|
rt.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if slices.Contains(r.Header.Values("Impersonate-Group"), invalidKubeGroups[0]) {
|
|
marshalRBACError(t, w)
|
|
return
|
|
}
|
|
marshalValidPodList(t, w)
|
|
})
|
|
testKube := httptest.NewTLSServer(rt)
|
|
|
|
t.Cleanup(func() {
|
|
testKube.Close()
|
|
})
|
|
|
|
startKube(
|
|
ctx,
|
|
t,
|
|
startKubeOptions{
|
|
serviceType: kubeproxy.KubeService,
|
|
authServer: env.server.TLS,
|
|
clusters: []kubeClusterConfig{
|
|
{
|
|
name: kubeClusterName,
|
|
apiEndpoint: testKube.URL,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
teleportUser string
|
|
roleFunc func(string, []string, []string) []types.Role
|
|
kubeUsers []string
|
|
kubeGroups []string
|
|
resourceName string
|
|
selectedKubeUser string
|
|
selectedKubeGroups []string
|
|
expectedSuccess bool
|
|
disconnectedKube bool
|
|
expectedMessage string
|
|
expectedTraces []types.ConnectionDiagnosticTrace
|
|
}{
|
|
{
|
|
name: "kube cluster not found",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: validKubeUsers,
|
|
teleportUser: "notfound",
|
|
resourceName: "notregistered",
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Failed to connect to Kubernetes cluster. Ensure the cluster is registered and online.`,
|
|
Error: "kubernetes cluster \"notregistered\" is not registered or is offline",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "kube cluster disconnected",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: validKubeUsers,
|
|
teleportUser: "disconnected",
|
|
resourceName: disconnectedKubeClustername,
|
|
disconnectedKube: true,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `Failed to connect to Kubernetes cluster. Ensure the cluster is registered and online.`,
|
|
Error: fmt.Sprintf("kubernetes cluster %q is not registered or is offline", disconnectedKubeClustername),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "no access to kube cluster",
|
|
teleportUser: "userwithoutaccess",
|
|
roleFunc: rolesWithoutAccessToKubeCluster,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: validKubeUsers,
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "User-associated roles define valid Kubernetes principals.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_KUBE,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: "You are not authorized to access this Kubernetes Cluster. Ensure your role grants access by adding it to the 'kubernetes_labels' property.",
|
|
Error: "[00] access denied",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "no kube principals",
|
|
teleportUser: "userwithoutprincipals",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: nil,
|
|
kubeUsers: nil,
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: "User-associated roles do not configure \"kubernetes_groups\" or \"kubernetes_users\". Make sure that at least one is configured for the user.",
|
|
Error: "Your user's Teleport role does not allow Kubernetes access." +
|
|
" Please ask cluster administrator to ensure your role has appropriate kubernetes_groups and kubernetes_users set.",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "teleport access but Kube RBAC fails",
|
|
teleportUser: "userbadrbac",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: invalidKubeGroups,
|
|
kubeUsers: validKubeUsers,
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "User-associated roles define valid Kubernetes principals.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_KUBE_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: "You are not allowed to list pods in the \"default\" namespace. Make sure your \"kubernetes_groups\" or \"kubernetes_users\" exist in the cluster and grant you access to list pods.",
|
|
Error: "pods is forbidden: User \"USER\" cannot list resource \"pods\" in API group \"\" in the namespace \"default\"",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user with multiple defined kube_users",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: multiKubeUsers,
|
|
teleportUser: "multiuser",
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `User-associated roles define multiple "kubernetes_users". Make sure that only one value is defined or that you select the target user.`,
|
|
Error: "please select a user to impersonate, refusing to select a user due to several kubernetes_users set up for this user",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user chose to impersonate invalid kube_users",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: multiKubeUsers,
|
|
teleportUser: "userwithWrongImpUser",
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
selectedKubeUser: "missingUser",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `User-associated roles do now allow the desired "kubernetes_user" impersonation. Please define a "kubernetes_user" that your roles allow to impersonate.`,
|
|
Error: `impersonation request has been denied, user header "missingUser" is not allowed in roles`,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user chose to impersonate invalid kube_group",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: multiKubeUsers,
|
|
teleportUser: "userwithWrongImpGroup",
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: false,
|
|
expectedMessage: "failed",
|
|
selectedKubeUser: "user1",
|
|
selectedKubeGroups: []string{"missingGroup"},
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_FAILED,
|
|
Details: `User-associated roles do now allow the desired "kubernetes_group" impersonation. Please define a "kubernetes_group" that your roles allow to impersonate.`,
|
|
Error: `impersonation request has been denied, group header "missingGroup" value is not allowed in roles`,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user with multiple defined kube_users",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: validKubeUsers,
|
|
teleportUser: "successwithmultiusers",
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: true,
|
|
expectedMessage: "success",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "User-associated roles define valid Kubernetes principals.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_KUBE,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "You are authorized to access this Kubernetes Cluster.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_KUBE_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Access to the Kubernetes Cluster granted.",
|
|
Error: "",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "success",
|
|
roleFunc: roleWithFullAccess,
|
|
kubeGroups: validKubeGroups,
|
|
kubeUsers: validKubeUsers,
|
|
teleportUser: "success",
|
|
resourceName: kubeClusterName,
|
|
expectedSuccess: true,
|
|
expectedMessage: "success",
|
|
expectedTraces: []types.ConnectionDiagnosticTrace{
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_CONNECTIVITY,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Kubernetes Cluster is registered in Teleport.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "User-associated roles define valid Kubernetes principals.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_RBAC_KUBE,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "You are authorized to access this Kubernetes Cluster.",
|
|
Error: "",
|
|
},
|
|
{
|
|
Type: types.ConnectionDiagnosticTrace_KUBE_PRINCIPAL,
|
|
Status: types.ConnectionDiagnosticTrace_SUCCESS,
|
|
Details: "Access to the Kubernetes Cluster granted.",
|
|
Error: "",
|
|
},
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
localEnv := env
|
|
|
|
if tt.disconnectedKube {
|
|
kubeServer, cleanup, _ := startKubeWithoutCleanup(ctx, t, startKubeOptions{
|
|
serviceType: kubeproxy.KubeService,
|
|
authServer: env.server.TLS,
|
|
clusters: []kubeClusterConfig{
|
|
{
|
|
name: tt.resourceName,
|
|
apiEndpoint: testKube.URL,
|
|
},
|
|
},
|
|
})
|
|
err := kubeServer.Close()
|
|
require.NoError(t, err)
|
|
require.NoError(t, cleanup())
|
|
}
|
|
|
|
clusterName := localEnv.server.ClusterName()
|
|
roles := tt.roleFunc(tt.teleportUser, tt.kubeUsers, tt.kubeGroups)
|
|
pack := localEnv.proxies[0].authPack(t, tt.teleportUser, roles)
|
|
|
|
createConnectionEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "diagnostics", "connections")
|
|
|
|
resp, err := pack.clt.PostJSON(ctx, createConnectionEndpoint, conntest.TestConnectionRequest{
|
|
ResourceKind: types.KindKubernetesCluster,
|
|
ResourceName: tt.resourceName,
|
|
// Default is 30 seconds but since tests run locally, we can reduce this value to also improve test responsiveness
|
|
DialTimeout: time.Second,
|
|
KubernetesImpersonation: conntest.KubernetesImpersonation{
|
|
KubernetesUser: tt.selectedKubeUser,
|
|
KubernetesGroups: tt.selectedKubeGroups,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code())
|
|
|
|
var connectionDiagnostic webui.ConnectionDiagnostic
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &connectionDiagnostic))
|
|
gotFailedTraces := 0
|
|
expectedFailedTraces := 0
|
|
|
|
t.Log(tt.name)
|
|
t.Log(connectionDiagnostic.Message, connectionDiagnostic.Success)
|
|
for i, trace := range connectionDiagnostic.Traces {
|
|
if trace.Status == types.ConnectionDiagnosticTrace_FAILED.String() {
|
|
gotFailedTraces++
|
|
}
|
|
|
|
t.Logf("%d status='%s' type='%s' details='%s' error='%s'\n", i, trace.Status, trace.TraceType, trace.Details, trace.Error)
|
|
}
|
|
|
|
require.Equal(t, tt.expectedSuccess, connectionDiagnostic.Success)
|
|
require.Equal(t, tt.expectedMessage, connectionDiagnostic.Message)
|
|
|
|
for _, expectedTrace := range tt.expectedTraces {
|
|
if expectedTrace.Status == types.ConnectionDiagnosticTrace_FAILED {
|
|
expectedFailedTraces++
|
|
}
|
|
|
|
foundTrace := false
|
|
for _, returnedTrace := range connectionDiagnostic.Traces {
|
|
if expectedTrace.Type.String() != returnedTrace.TraceType {
|
|
continue
|
|
}
|
|
|
|
foundTrace = true
|
|
require.Equal(t, expectedTrace.Status.String(), returnedTrace.Status)
|
|
require.Equal(t, expectedTrace.Details, returnedTrace.Details)
|
|
require.Contains(t, expectedTrace.Error, returnedTrace.Error)
|
|
}
|
|
|
|
require.True(t, foundTrace, expectedTrace)
|
|
}
|
|
|
|
require.Equal(t, expectedFailedTraces, gotFailedTraces)
|
|
})
|
|
}
|
|
|
|
// Test success with per-session MFA.
|
|
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
RequireMFAType: types.RequireMFAType_SESSION,
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
// Get a totp code to re-auth.
|
|
pack := env.proxies[0].authPack(t, "llama", roleWithFullAccess("llama", validKubeUsers, validKubeGroups))
|
|
totpCode, err := totp.GenerateCode(pack.otpSecret, env.clock.Now().Add(30*time.Second))
|
|
require.NoError(t, err)
|
|
|
|
clusterName := env.server.ClusterName()
|
|
createConnectionEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "diagnostics", "connections")
|
|
|
|
resp, err := pack.clt.PostJSON(ctx, createConnectionEndpoint, conntest.TestConnectionRequest{
|
|
ResourceKind: types.KindKubernetesCluster,
|
|
ResourceName: kubeClusterName,
|
|
// Default is 30 seconds but since tests run locally, we can reduce this value to also improve test responsiveness
|
|
DialTimeout: time.Second,
|
|
KubernetesImpersonation: conntest.KubernetesImpersonation{},
|
|
MFAResponse: client.MFAChallengeResponse{TOTPCode: totpCode},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.Code())
|
|
|
|
var connectionDiagnostic webui.ConnectionDiagnostic
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &connectionDiagnostic))
|
|
require.True(t, connectionDiagnostic.Success)
|
|
}
|
|
|
|
func TestCreateDatabase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
username := "someuser"
|
|
roleCreateDatabase, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
DatabaseNames: []string{"name1"},
|
|
DatabaseUsers: []string{"user1"},
|
|
DatabaseRoles: []string{"readonly"},
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindDatabase,
|
|
[]string{types.VerbCreate}),
|
|
},
|
|
DatabaseLabels: types.Labels{
|
|
types.Wildcard: {types.Wildcard},
|
|
},
|
|
},
|
|
Options: types.RoleOptions{
|
|
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1)
|
|
clusterName := env.server.ClusterName()
|
|
pack := env.proxies[0].authPack(t, username, []types.Role{roleCreateDatabase})
|
|
|
|
createDatabaseEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "databases")
|
|
|
|
// Create an initial database to table test a duplicate creation
|
|
_, err = pack.clt.PostJSON(ctx, createDatabaseEndpoint, createOrOverwriteDatabaseRequest{
|
|
Name: "duplicatedb",
|
|
Protocol: "mysql",
|
|
URI: "someuri:3306",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
req createOrOverwriteDatabaseRequest
|
|
expectedStatus int
|
|
errAssert require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "valid",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "mydatabase",
|
|
Protocol: "mysql",
|
|
URI: "someuri:3306",
|
|
Labels: []ui.Label{
|
|
{
|
|
Name: "teleport.dev/origin",
|
|
Value: "dynamic",
|
|
},
|
|
},
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
errAssert: require.NoError,
|
|
},
|
|
{
|
|
name: "valid with labels",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "dbwithlabels",
|
|
Protocol: "mysql",
|
|
URI: "someuri:3306",
|
|
Labels: []ui.Label{
|
|
{
|
|
Name: "env",
|
|
Value: "prod",
|
|
},
|
|
{
|
|
Name: "teleport.dev/origin",
|
|
Value: "dynamic",
|
|
},
|
|
},
|
|
},
|
|
expectedStatus: http.StatusOK,
|
|
errAssert: require.NoError,
|
|
},
|
|
{
|
|
name: "empty name",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "",
|
|
Protocol: "mysql",
|
|
URI: "someuri:3306",
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing database name")
|
|
},
|
|
},
|
|
{
|
|
name: "empty protocol",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "emptyprotocol",
|
|
Protocol: "",
|
|
URI: "someuri:3306",
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing protocol")
|
|
},
|
|
},
|
|
{
|
|
name: "empty uri",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "emptyuri",
|
|
Protocol: "mysql",
|
|
URI: "",
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing uri")
|
|
},
|
|
},
|
|
{
|
|
name: "missing port",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "missingport",
|
|
Protocol: "mysql",
|
|
URI: "someuri",
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing port in address")
|
|
},
|
|
},
|
|
{
|
|
name: "duplicatedb",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "duplicatedb",
|
|
Protocol: "mysql",
|
|
URI: "someuri:3306",
|
|
},
|
|
expectedStatus: http.StatusConflict,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsAlreadyExists(err), "expected already exists error, got %v", err)
|
|
require.Contains(t, err.Error(), `failed to create database ("duplicatedb" already exists), please use another name`)
|
|
},
|
|
},
|
|
} {
|
|
// Create database
|
|
resp, err := pack.clt.PostJSON(ctx, createDatabaseEndpoint, tt.req)
|
|
tt.errAssert(t, err)
|
|
|
|
require.Equal(t, tt.expectedStatus, resp.Code(), "invalid status code received")
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Ensure database exists
|
|
database, err := env.proxies[0].client.GetDatabase(ctx, tt.req.Name)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, tt.req.Name, database.GetName())
|
|
require.Equal(t, tt.req.Protocol, database.GetProtocol())
|
|
require.Equal(t, tt.req.URI, database.GetURI())
|
|
|
|
// At least the provided labels exist in the database resource
|
|
databaseLabels := database.GetAllLabels()
|
|
for _, label := range tt.req.Labels {
|
|
require.Contains(t, databaseLabels, label.Name, "label not found")
|
|
require.Equal(t, label.Value, databaseLabels[label.Name], "label exists but has unexpected value")
|
|
}
|
|
|
|
// Check response value:
|
|
if tt.expectedStatus == http.StatusOK {
|
|
result := webui.Database{}
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &result))
|
|
expected := webui.Database{
|
|
Kind: types.KindDatabase,
|
|
Name: tt.req.Name,
|
|
Protocol: tt.req.Protocol,
|
|
Type: types.DatabaseTypeSelfHosted,
|
|
Labels: tt.req.Labels,
|
|
Hostname: "someuri",
|
|
DatabaseUsers: []string{"user1"},
|
|
DatabaseNames: []string{"name1"},
|
|
DatabaseRoles: []string{"readonly"},
|
|
URI: "someuri:3306",
|
|
}
|
|
require.Equal(t, expected, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOverwriteDatabase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "user", nil /* roles */)
|
|
accessChecker := services.NewAccessCheckerWithRoleSet(&services.AccessInfo{}, env.server.ClusterName(), nil)
|
|
|
|
initDb, err := types.NewDatabaseV3(types.Metadata{
|
|
Name: "postgres",
|
|
}, types.DatabaseSpecV3{
|
|
Protocol: "postgres",
|
|
URI: "localhost:5432",
|
|
AWS: types.AWS{
|
|
AccountID: "123456789012",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = env.server.Auth().CreateDatabase(context.Background(), initDb)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
req createOrOverwriteDatabaseRequest
|
|
verifyResponse func(*testing.T, *roundtrip.Response, createOrOverwriteDatabaseRequest, error)
|
|
}{
|
|
{
|
|
name: "overwrite",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: initDb.GetName(),
|
|
Overwrite: true,
|
|
URI: "some-other-uri:3306",
|
|
Protocol: "postgres",
|
|
},
|
|
verifyResponse: func(t *testing.T, resp *roundtrip.Response, req createOrOverwriteDatabaseRequest, err error) {
|
|
require.NoError(t, err)
|
|
|
|
var gotDb webui.Database
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &gotDb))
|
|
require.Equal(t, req.URI, gotDb.URI)
|
|
require.Equal(t, req.Protocol, gotDb.Protocol)
|
|
require.Empty(t, req.AWSRDS)
|
|
require.Equal(t, initDb.GetName(), gotDb.Name)
|
|
|
|
backendDb, err := env.server.Auth().GetDatabase(context.Background(), req.Name)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, webui.MakeDatabase(backendDb, accessChecker, proxy.handler.handler.cfg.DatabaseREPLRegistry, false), gotDb)
|
|
},
|
|
},
|
|
{
|
|
name: "overwrite error: database does not exist",
|
|
req: createOrOverwriteDatabaseRequest{
|
|
Name: "this-db-does-not-exist",
|
|
URI: "some-uri",
|
|
Protocol: "mysql",
|
|
Overwrite: true,
|
|
},
|
|
verifyResponse: func(t *testing.T, resp *roundtrip.Response, req createOrOverwriteDatabaseRequest, err error) {
|
|
require.True(t, trace.IsNotFound(err))
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "databases")
|
|
resp, err := pack.clt.PostJSON(context.Background(), endpoint, test.req)
|
|
|
|
test.verifyResponse(t, resp, test.req, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateDatabase_Errors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
databaseName := "somedb"
|
|
username := "someuser"
|
|
roleCreateUpdateDatabase, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindDatabase,
|
|
[]string{types.VerbCreate, types.VerbUpdate, types.VerbRead}),
|
|
},
|
|
DatabaseLabels: types.Labels{
|
|
types.Wildcard: {types.Wildcard},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1)
|
|
clusterName := env.server.ClusterName()
|
|
pack := env.proxies[0].authPack(t, username, []types.Role{roleCreateUpdateDatabase})
|
|
|
|
// Create database
|
|
createDatabaseEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "databases")
|
|
_, err = pack.clt.PostJSON(ctx, createDatabaseEndpoint, createOrOverwriteDatabaseRequest{
|
|
Name: databaseName,
|
|
Protocol: "mysql",
|
|
URI: "someuri:3306",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
req updateDatabaseRequest
|
|
expectedStatus int
|
|
errAssert require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "empty ca_cert",
|
|
req: updateDatabaseRequest{
|
|
CACert: strPtr(""),
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing CA certificate data")
|
|
},
|
|
},
|
|
{
|
|
name: "invalid certificate",
|
|
req: updateDatabaseRequest{
|
|
CACert: strPtr("Not a certificate"),
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "could not parse provided CA as X.509 PEM certificate")
|
|
},
|
|
},
|
|
|
|
{
|
|
name: "invalid awsRDS missing resourceID field",
|
|
req: updateDatabaseRequest{
|
|
AWSRDS: &awsRDS{
|
|
AccountID: "123123123123",
|
|
},
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing aws rds field resource id")
|
|
},
|
|
},
|
|
{
|
|
name: "invalid awsRDS missing accountID field",
|
|
req: updateDatabaseRequest{
|
|
AWSRDS: &awsRDS{
|
|
ResourceID: "123123123123",
|
|
},
|
|
},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing aws rds field account id")
|
|
},
|
|
},
|
|
{
|
|
name: "no fields defined",
|
|
req: updateDatabaseRequest{},
|
|
expectedStatus: http.StatusBadRequest,
|
|
errAssert: func(tt require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(tt, err, "missing fields to update the database")
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Update database's CA Cert
|
|
updateDatabaseEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "databases", databaseName)
|
|
resp, err := pack.clt.PutJSON(ctx, updateDatabaseEndpoint, tt.req)
|
|
tt.errAssert(t, err)
|
|
|
|
require.Equal(t, tt.expectedStatus, resp.Code(), "invalid status code received")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpdateDatabase_NonErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
databaseName := "somedb"
|
|
username := "someuser"
|
|
roleCreateUpdateDatabase, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Rules: []types.Rule{
|
|
types.NewRule(types.KindDatabase,
|
|
[]string{types.VerbCreate, types.VerbUpdate, types.VerbRead}),
|
|
},
|
|
DatabaseLabels: types.Labels{
|
|
types.Wildcard: {types.Wildcard},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
env := newWebPack(t, 1)
|
|
clusterName := env.server.ClusterName()
|
|
pack := env.proxies[0].authPack(t, username, []types.Role{roleCreateUpdateDatabase})
|
|
|
|
// Create a database.
|
|
dbProtocol := "mysql"
|
|
database, err := getNewDatabaseResource(createOrOverwriteDatabaseRequest{
|
|
Name: databaseName,
|
|
Protocol: dbProtocol,
|
|
URI: "someuri:3306",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, env.server.Auth().CreateDatabase(ctx, database))
|
|
|
|
requiredOriginLabel := ui.Label{Name: types.OriginLabel, Value: types.OriginDynamic}
|
|
|
|
// Each test case builds on top of each other.
|
|
for _, tt := range []struct {
|
|
name string
|
|
req updateDatabaseRequest
|
|
expectedFields webui.Database
|
|
expectedAWSRDS awsRDS
|
|
}{
|
|
{
|
|
name: "update caCert",
|
|
req: updateDatabaseRequest{
|
|
CACert: &fakeValidTLSCert,
|
|
},
|
|
expectedFields: webui.Database{
|
|
Kind: types.KindDatabase,
|
|
Name: databaseName,
|
|
Protocol: dbProtocol,
|
|
Type: "self-hosted",
|
|
Hostname: "someuri",
|
|
Labels: []ui.Label{requiredOriginLabel},
|
|
URI: "someuri:3306",
|
|
},
|
|
},
|
|
{
|
|
name: "update URI",
|
|
req: updateDatabaseRequest{
|
|
URI: "something-else:3306",
|
|
},
|
|
expectedFields: webui.Database{
|
|
Kind: types.KindDatabase,
|
|
Name: databaseName,
|
|
Protocol: dbProtocol,
|
|
Type: "self-hosted",
|
|
Hostname: "something-else",
|
|
Labels: []ui.Label{requiredOriginLabel},
|
|
URI: "something-else:3306",
|
|
},
|
|
},
|
|
{
|
|
name: "update aws rds fields",
|
|
req: updateDatabaseRequest{
|
|
URI: "llama.cgi8.us-west-2.rds.amazonaws.com:3306",
|
|
AWSRDS: &awsRDS{
|
|
AccountID: "123123123123",
|
|
ResourceID: "db-1234",
|
|
},
|
|
},
|
|
expectedAWSRDS: awsRDS{
|
|
AccountID: "123123123123",
|
|
ResourceID: "db-1234",
|
|
},
|
|
expectedFields: webui.Database{
|
|
Kind: types.KindDatabase,
|
|
Name: databaseName,
|
|
Protocol: dbProtocol,
|
|
Type: "rds",
|
|
Hostname: "llama.cgi8.us-west-2.rds.amazonaws.com",
|
|
Labels: []ui.Label{requiredOriginLabel},
|
|
URI: "llama.cgi8.us-west-2.rds.amazonaws.com:3306",
|
|
AWS: &webui.AWS{
|
|
AWS: types.AWS{
|
|
Region: "us-west-2",
|
|
AccountID: "123123123123",
|
|
RDS: types.RDS{
|
|
ResourceID: "db-1234",
|
|
InstanceID: "llama",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "update labels",
|
|
req: updateDatabaseRequest{
|
|
Labels: []ui.Label{{Name: "env", Value: "prod"}},
|
|
},
|
|
expectedAWSRDS: awsRDS{
|
|
AccountID: "123123123123",
|
|
ResourceID: "db-1234",
|
|
},
|
|
expectedFields: webui.Database{
|
|
Kind: types.KindDatabase,
|
|
Name: databaseName,
|
|
Protocol: dbProtocol,
|
|
Type: "rds",
|
|
Hostname: "llama.cgi8.us-west-2.rds.amazonaws.com",
|
|
Labels: []ui.Label{{Name: "env", Value: "prod"}, requiredOriginLabel},
|
|
URI: "llama.cgi8.us-west-2.rds.amazonaws.com:3306",
|
|
AWS: &webui.AWS{
|
|
AWS: types.AWS{
|
|
Region: "us-west-2",
|
|
AccountID: "123123123123",
|
|
RDS: types.RDS{
|
|
ResourceID: "db-1234",
|
|
InstanceID: "llama",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "update multiple fields",
|
|
req: updateDatabaseRequest{
|
|
URI: "alpaca.cgi8.us-east-1.rds.amazonaws.com:3306",
|
|
AWSRDS: &awsRDS{
|
|
AccountID: "000000000000",
|
|
ResourceID: "db-0000",
|
|
},
|
|
},
|
|
expectedAWSRDS: awsRDS{
|
|
AccountID: "000000000000",
|
|
ResourceID: "db-0000",
|
|
},
|
|
expectedFields: webui.Database{
|
|
Kind: types.KindDatabase,
|
|
Name: databaseName,
|
|
Protocol: dbProtocol,
|
|
Type: "rds",
|
|
Hostname: "alpaca.cgi8.us-east-1.rds.amazonaws.com",
|
|
Labels: []ui.Label{{Name: "env", Value: "prod"}, requiredOriginLabel},
|
|
URI: "alpaca.cgi8.us-east-1.rds.amazonaws.com:3306",
|
|
AWS: &webui.AWS{
|
|
AWS: types.AWS{
|
|
Region: "us-east-1",
|
|
AccountID: "000000000000",
|
|
RDS: types.RDS{
|
|
ResourceID: "db-0000",
|
|
InstanceID: "alpaca",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
updateDatabaseEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "databases", databaseName)
|
|
resp, err := pack.clt.PutJSON(ctx, updateDatabaseEndpoint, tt.req)
|
|
require.NoError(t, err)
|
|
var dbResp webui.Database
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &dbResp))
|
|
require.Equal(t, tt.expectedFields, dbResp)
|
|
|
|
// Ensure database was updated
|
|
database, err := env.proxies[0].client.GetDatabase(ctx, databaseName)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, database.GetCA(), fakeValidTLSCert) // should not have changed
|
|
require.Equal(t, database.GetType(), tt.expectedFields.Type)
|
|
require.Equal(t, database.GetProtocol(), tt.expectedFields.Protocol)
|
|
require.Equal(t, database.GetURI(), fmt.Sprintf("%s:3306", tt.expectedFields.Hostname))
|
|
|
|
require.Equal(t, database.GetAWS().AccountID, tt.expectedAWSRDS.AccountID)
|
|
require.Equal(t, database.GetAWS().RDS.ResourceID, tt.expectedAWSRDS.ResourceID)
|
|
})
|
|
}
|
|
}
|
|
|
|
type authProviderMock struct {
|
|
server types.ServerV2
|
|
}
|
|
|
|
func (mock authProviderMock) ListUnifiedResources(ctx context.Context, req *authproto.ListUnifiedResourcesRequest) (*authproto.ListUnifiedResourcesResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mock authProviderMock) GetNode(ctx context.Context, namespace, name string) (types.Server, error) {
|
|
return &mock.server, nil
|
|
}
|
|
|
|
func (mock authProviderMock) GetSessionTracker(ctx context.Context, sessionID string) (types.SessionTracker, error) {
|
|
return nil, trace.NotFound("foo")
|
|
}
|
|
|
|
func (mock authProviderMock) IsMFARequired(ctx context.Context, req *authproto.IsMFARequiredRequest) (*authproto.IsMFARequiredResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mock authProviderMock) CreateAuthenticateChallenge(ctx context.Context, req *authproto.CreateAuthenticateChallengeRequest) (*authproto.MFAAuthenticateChallenge, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mock authProviderMock) GenerateUserCerts(ctx context.Context, req authproto.UserCertsRequest) (*authproto.Certs, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mock authProviderMock) GenerateOpenSSHCert(ctx context.Context, req *authproto.OpenSSHCertRequest) (*authproto.OpenSSHCert, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mock authProviderMock) MaintainSessionPresence(ctx context.Context) (authproto.AuthService_MaintainSessionPresenceClient, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mock authProviderMock) GetUser(_ context.Context, _ string, _ bool) (types.User, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (mock authProviderMock) GetRole(_ context.Context, _ string) (types.Role, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func waitForOutputWithDuration(r ReaderWithDeadline, substr string, timeout time.Duration) error {
|
|
timeoutCh := time.After(timeout)
|
|
|
|
var prev string
|
|
out := make([]byte, int64(len(substr)*3))
|
|
for {
|
|
select {
|
|
case <-timeoutCh:
|
|
return trace.BadParameter("timeout waiting on terminal for output: %v", substr)
|
|
default:
|
|
}
|
|
|
|
if err := r.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
n, err := r.Read(out)
|
|
outStr := removeSpace(string(out[:n]))
|
|
|
|
// Check for [substr] before checking the error,
|
|
// as it's valid for n > 0 even when there is an error.
|
|
// The [substr] is checked against the current and previous
|
|
// output to account for scenarios where the [substr] is split
|
|
// across two reads. While we try to prevent this by reading
|
|
// twice the length of [substr] there are no guarantees the
|
|
// whole thing will arrive in a single read.
|
|
if n > 0 && strings.Contains(prev+outStr, substr) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
prev = outStr
|
|
}
|
|
}
|
|
|
|
type ReaderWithDeadline interface {
|
|
io.Reader
|
|
SetReadDeadline(time.Time) error
|
|
}
|
|
|
|
type ReadWriterWithDeadline interface {
|
|
io.Reader
|
|
io.Writer
|
|
SetReadDeadline(time.Time) error
|
|
SetWriteDeadline(time.Time) error
|
|
}
|
|
|
|
func waitForOutput(r ReaderWithDeadline, substr string) error {
|
|
return waitForOutputWithDuration(r, substr, 10*time.Second)
|
|
}
|
|
|
|
func (s *WebSuite) client(t *testing.T, opts ...roundtrip.ClientParam) *TestWebClient {
|
|
opts = append(opts, roundtrip.HTTPClient(client.NewInsecureWebClient()))
|
|
wc, err := client.NewWebClient(s.url().String(), opts...)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return &TestWebClient{wc, t}
|
|
}
|
|
|
|
type TestWebClient struct {
|
|
*client.WebClient
|
|
t *testing.T
|
|
}
|
|
|
|
// It is understood that implementing RoundTrip here will NOT result in calls from `Get`, or `PostJSON` from
|
|
// client.WebClient getting this verification. Those functions would additionally need to be specified here.
|
|
// Despite that, currently our use of RoundTrip directly is providing us enough broad coverage to verify these headers.
|
|
func (c *TestWebClient) RoundTrip(fn roundtrip.RoundTripFn) (*roundtrip.Response, error) {
|
|
c.t.Helper()
|
|
resp, err := c.WebClient.RoundTrip(fn)
|
|
|
|
verifySecurityResponseHeaders(c.t, resp.Headers())
|
|
|
|
return resp, err
|
|
}
|
|
|
|
func (s *WebSuite) url() *url.URL {
|
|
u, err := url.Parse("https://" + s.webServer.Listener.Addr().String())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func removeSpace(in string) string {
|
|
for _, c := range []string{"\n", "\r", "\t"} {
|
|
in = strings.ReplaceAll(in, c, " ")
|
|
}
|
|
return strings.TrimSpace(in)
|
|
}
|
|
|
|
func decodeSessionCookie(t *testing.T, value string) (sessionID string) {
|
|
sessionBytes, err := hex.DecodeString(value)
|
|
require.NoError(t, err)
|
|
var cookie struct {
|
|
User string `json:"user"`
|
|
SessionID string `json:"sid"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(sessionBytes, &cookie))
|
|
return cookie.SessionID
|
|
}
|
|
|
|
type WebPackOptions struct {
|
|
proxyOptions []proxyOption
|
|
enableAuthCache bool
|
|
modules *modulestest.Modules
|
|
insecureMode bool
|
|
}
|
|
|
|
type webPackOptions func(*WebPackOptions)
|
|
|
|
func withWebPackProxyOptions(opts ...proxyOption) webPackOptions {
|
|
return func(cfg *WebPackOptions) {
|
|
cfg.proxyOptions = opts
|
|
}
|
|
}
|
|
|
|
func withWebPackAuthCacheEnabled(enable bool) webPackOptions {
|
|
return func(cfg *WebPackOptions) {
|
|
cfg.enableAuthCache = enable
|
|
}
|
|
}
|
|
|
|
func withModules(m *modulestest.Modules) webPackOptions {
|
|
return func(cfg *WebPackOptions) {
|
|
cfg.modules = m
|
|
}
|
|
}
|
|
|
|
func withInsecureMode() webPackOptions {
|
|
return func(cfg *WebPackOptions) {
|
|
cfg.insecureMode = true
|
|
}
|
|
}
|
|
|
|
func newWebPack(t *testing.T, numProxies int, opts ...webPackOptions) *webPack {
|
|
options := &WebPackOptions{}
|
|
|
|
for _, opt := range opts {
|
|
opt(options)
|
|
}
|
|
|
|
if options.modules == nil {
|
|
options.modules = modulestest.OSSModules()
|
|
} else {
|
|
// TODO(tross) remove this when modules are injected into the auth server
|
|
modulestest.SetTestModules(t, *options.modules)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
clock := clockwork.NewFakeClockAt(time.Now())
|
|
|
|
server, err := authtest.NewTestServer(authtest.ServerConfig{
|
|
Auth: authtest.AuthServerConfig{
|
|
ClusterName: "localhost",
|
|
Dir: t.TempDir(),
|
|
Clock: clock,
|
|
AuditLog: events.NewDiscardAuditLog(),
|
|
CacheEnabled: options.enableAuthCache,
|
|
InsecureMode: options.insecureMode,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, server.Shutdown(ctx)) })
|
|
|
|
// use a sync recording mode because the disk-based uploader
|
|
// that runs in the background introduces races with test cleanup
|
|
recConfig := types.DefaultSessionRecordingConfig()
|
|
recConfig.SetMode(types.RecordAtNodeSync)
|
|
_, err = server.AuthServer.AuthServer.UpsertSessionRecordingConfig(ctx, recConfig)
|
|
require.NoError(t, err)
|
|
|
|
// Register the auth server, since test auth server doesn't start its own
|
|
// heartbeat.
|
|
err = server.Auth().UpsertAuthServer(ctx, &types.ServerV2{
|
|
Kind: types.KindAuthServer,
|
|
Version: types.V2,
|
|
Metadata: types.Metadata{
|
|
Namespace: apidefaults.Namespace,
|
|
Name: "auth",
|
|
},
|
|
Spec: types.ServerSpecV2{
|
|
Addr: server.TLS.Addr().String(),
|
|
Hostname: "localhost",
|
|
Version: teleport.Version,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
priv, pub, err := testauthority.GenerateKeyPair()
|
|
require.NoError(t, err)
|
|
|
|
tlsPub, err := authtest.PrivateKeyToPublicKeyTLS(priv)
|
|
require.NoError(t, err)
|
|
|
|
const nodeID = "node"
|
|
// start auth server
|
|
certs, err := server.Auth().GenerateHostCerts(ctx,
|
|
auth.HostCertsParams{
|
|
Req: &authproto.HostCertsRequest{
|
|
HostID: hostID,
|
|
NodeName: nodeID,
|
|
Role: types.RoleNode,
|
|
PublicSSHKey: pub,
|
|
PublicTLSKey: tlsPub,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
signer, err := sshutils.NewSigner(priv, certs.SSH)
|
|
require.NoError(t, err)
|
|
hostSigners := []ssh.Signer{signer}
|
|
|
|
nodeClient, err := server.TLS.NewClient(authtest.TestIdentity{
|
|
I: authz.BuiltinRole{
|
|
Role: types.RoleNode,
|
|
Username: nodeID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, nodeClient.Close()) })
|
|
|
|
nodeLockWatcher, err := services.NewLockWatcher(ctx, services.LockWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentNode,
|
|
Client: nodeClient,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(nodeLockWatcher.Close)
|
|
|
|
nodeSessionController, err := srv.NewSessionController(srv.SessionControllerConfig{
|
|
Semaphores: nodeClient,
|
|
AccessPoint: nodeClient,
|
|
LockEnforcer: nodeLockWatcher,
|
|
Emitter: nodeClient,
|
|
Component: teleport.ComponentNode,
|
|
ServerID: nodeID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// create SSH service:
|
|
nodeDataDir := t.TempDir()
|
|
node, err := regular.New(
|
|
ctx,
|
|
utils.NetAddr{AddrNetwork: "tcp", Addr: "127.0.0.1:0"},
|
|
nodeID,
|
|
sshutils.StaticHostSigners(hostSigners...),
|
|
nodeClient,
|
|
nodeDataDir,
|
|
"",
|
|
utils.NetAddr{},
|
|
nodeClient,
|
|
regular.SetUUID(nodeID),
|
|
regular.SetNamespace(apidefaults.Namespace),
|
|
regular.SetEmitter(nodeClient),
|
|
regular.SetPAMConfig(&pamcfg.PAMConfig{Enabled: false}),
|
|
regular.SetBPF(&bpf.NOP{}),
|
|
regular.SetClock(clock),
|
|
regular.SetLockWatcher(nodeLockWatcher),
|
|
regular.SetSessionController(nodeSessionController),
|
|
regular.SetConnectedProxyGetter(reversetunnel.NewConnectedProxyGetter()),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, node.Start())
|
|
t.Cleanup(func() {
|
|
require.NoError(t, node.Close())
|
|
node.Wait()
|
|
})
|
|
|
|
var proxies []*testProxy
|
|
for p := range numProxies {
|
|
proxyID := fmt.Sprintf("proxy%v", p)
|
|
proxies = append(proxies, createProxy(ctx, t, proxyID, node, server.TLS, hostSigners, clock, options.modules, options.insecureMode, options.proxyOptions...))
|
|
}
|
|
|
|
// Wait for proxies to fully register before starting the test.
|
|
for start := time.Now(); ; {
|
|
//nolint:staticcheck // TODO(kiosion) DELETE IN 21.0.0
|
|
proxies, err := proxies[0].client.GetProxies()
|
|
require.NoError(t, err)
|
|
if len(proxies) == numProxies {
|
|
break
|
|
}
|
|
if time.Since(start) > 5*time.Second {
|
|
t.Fatalf("Proxies didn't register within 5s after startup; registered: %d, want: %d", len(proxies), numProxies)
|
|
}
|
|
}
|
|
|
|
return &webPack{
|
|
proxies: proxies,
|
|
server: server,
|
|
node: node,
|
|
clock: clock,
|
|
}
|
|
}
|
|
|
|
// wrappedAuthClient is used when tests need to mock or replace parts of the
|
|
// underlying auth.Client used by the Proxy.
|
|
type wrappedAuthClient struct {
|
|
*authclient.Client
|
|
devicesClient devicepb.DeviceTrustServiceClient
|
|
}
|
|
|
|
func (w *wrappedAuthClient) DevicesClient() devicepb.DeviceTrustServiceClient {
|
|
return w.devicesClient
|
|
}
|
|
|
|
type proxyConfig struct {
|
|
minimalHandler bool
|
|
devicesClientOverride devicepb.DeviceTrustServiceClient
|
|
kubeProxy bool
|
|
}
|
|
|
|
type proxyOption func(cfg *proxyConfig)
|
|
|
|
func withDevicesClientOverride(c devicepb.DeviceTrustServiceClient) proxyOption {
|
|
return func(cfg *proxyConfig) {
|
|
cfg.devicesClientOverride = c
|
|
}
|
|
}
|
|
|
|
func withKubeProxy() proxyOption {
|
|
return func(cfg *proxyConfig) {
|
|
cfg.kubeProxy = true
|
|
}
|
|
}
|
|
|
|
func createProxy(ctx context.Context, t *testing.T, proxyID string, node *regular.Server, authServer *authtest.TLSServer,
|
|
hostSigners []ssh.Signer, clock *clockwork.FakeClock, m *modulestest.Modules, insecureMode bool, opts ...proxyOption,
|
|
) *testProxy {
|
|
t.Helper()
|
|
cfg := proxyConfig{}
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
// create reverse tunnel service:
|
|
authClient, err := authServer.NewClient(authtest.TestIdentity{
|
|
I: authz.BuiltinRole{
|
|
Role: types.RoleProxy,
|
|
Username: proxyID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, authClient.Close()) })
|
|
|
|
// Replace underlying devicesClient, if the option was supplied.
|
|
var client authclient.ClientI
|
|
if cfg.devicesClientOverride != nil {
|
|
client = &wrappedAuthClient{
|
|
Client: authClient,
|
|
devicesClient: cfg.devicesClientOverride,
|
|
}
|
|
} else {
|
|
client = authClient
|
|
}
|
|
// Favor client instead of authClient from here on.
|
|
|
|
revTunListener, err := net.Listen("tcp", fmt.Sprintf("%v:0", authServer.ClusterName()))
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, revTunListener.Close()) })
|
|
|
|
proxyLockWatcher, err := services.NewLockWatcher(ctx, services.LockWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: client,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(proxyLockWatcher.Close)
|
|
|
|
//nolint:staticcheck // SA1019 This should be updated to use [services.NewCertAuthorityWatcher]
|
|
proxyCAWatcher, err := services.DeprecatedNewCertAuthorityWatcher(ctx, services.CertAuthorityWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: client,
|
|
},
|
|
Types: []types.CertAuthType{types.HostCA, types.UserCA},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(proxyLockWatcher.Close)
|
|
|
|
proxyNodeWatcher, err := services.NewNodeWatcher(ctx, services.NodeWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: client,
|
|
},
|
|
NodesGetter: client,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(proxyNodeWatcher.Close)
|
|
|
|
proxyGitServerWatcher, err := services.NewGitServerWatcher(ctx, services.GitServerWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: client,
|
|
},
|
|
GitServerGetter: client.GitServerReadOnlyClient(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(proxyGitServerWatcher.Close)
|
|
|
|
appServerWatcher, err := services.NewAppServersWatcher(ctx, services.AppServersWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: client,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(appServerWatcher.Close)
|
|
|
|
databaseServerWatcher, err := services.NewDatabaseServerWatcher(ctx, services.DatabaseServerWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: client,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(databaseServerWatcher.Close)
|
|
|
|
revTunServer, err := reversetunnel.NewServer(reversetunnel.Config{
|
|
ID: node.ID(),
|
|
Listener: revTunListener,
|
|
GetClientTLSCertificate: func() (*tls.Certificate, error) {
|
|
return &authClient.TLSConfig().Certificates[0], nil
|
|
},
|
|
ClusterName: authServer.ClusterName(),
|
|
GetHostSigners: sshutils.StaticHostSigners(hostSigners...),
|
|
LocalAuthClient: client,
|
|
LocalAccessPoint: client,
|
|
Emitter: client,
|
|
NewCachingAccessPoint: noCache,
|
|
DataDir: t.TempDir(),
|
|
LockWatcher: proxyLockWatcher,
|
|
NodeWatcher: proxyNodeWatcher,
|
|
GitServerWatcher: proxyGitServerWatcher,
|
|
CertAuthorityWatcher: proxyCAWatcher,
|
|
AppServerWatcher: appServerWatcher,
|
|
DatabaseServerWatcher: databaseServerWatcher,
|
|
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
|
|
LocalAuthAddresses: []string{authServer.Addr().String()},
|
|
EICESigner: func(ctx context.Context, target types.Server, integration types.Integration, login, token string, ap cryptosuites.AuthPreferenceGetter) (ssh.Signer, error) {
|
|
return nil, errors.New("eice disabled in tests")
|
|
},
|
|
EICEDialer: func(ctx context.Context, target types.Server, integration types.Integration, token string) (net.Conn, error) {
|
|
return nil, errors.New("eice disabled in tests")
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, revTunServer.Close()) })
|
|
|
|
clustername := authServer.ClusterName()
|
|
router, err := proxy.NewRouter(proxy.RouterConfig{
|
|
ClusterName: clustername,
|
|
LocalAccessPoint: client,
|
|
ClusterGetter: revTunServer,
|
|
TracerProvider: tracing.NoopProvider(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sessionController, err := srv.NewSessionController(srv.SessionControllerConfig{
|
|
Semaphores: client,
|
|
AccessPoint: client,
|
|
LockEnforcer: proxyLockWatcher,
|
|
Emitter: client,
|
|
Component: teleport.ComponentProxy,
|
|
ServerID: proxyID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
proxyListener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
|
|
mux, err := multiplexer.New(multiplexer.Config{
|
|
Listener: proxyListener,
|
|
PROXYProtocolMode: multiplexer.PROXYProtocolUnspecified,
|
|
ID: teleport.Component(teleport.ComponentProxy, "ssh"),
|
|
CertAuthorityGetter: func(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) {
|
|
return client.GetCertAuthority(ctx, id, loadKeys)
|
|
},
|
|
LocalClusterName: clustername,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, mux.Close()) })
|
|
|
|
go func() {
|
|
if err := mux.Serve(); err != nil && !utils.IsOKNetworkError(err) {
|
|
slog.ErrorContext(context.Background(), "Mux encountered error serving", "error", err)
|
|
}
|
|
}()
|
|
|
|
authorizerOpts := authz.AuthorizerOpts{
|
|
ClusterName: clustername,
|
|
AccessPoint: authServer.Auth(),
|
|
ScopedRoleReader: authServer.Auth().ScopedRoleReader(),
|
|
LockWatcher: proxyLockWatcher,
|
|
}
|
|
|
|
authorizer, err := authz.NewAuthorizer(authorizerOpts)
|
|
require.NoError(t, err)
|
|
|
|
scopedAuthorizer, err := authz.NewScopedAuthorizer(authorizerOpts)
|
|
require.NoError(t, err)
|
|
|
|
tlscfg, err := authServer.Identity.TLSConfig(utils.DefaultCipherSuites())
|
|
require.NoError(t, err)
|
|
tlscfg.ClientAuth = tls.RequireAndVerifyClientCert
|
|
if insecureMode {
|
|
tlscfg.InsecureSkipVerify = true
|
|
tlscfg.ClientAuth = tls.RequireAnyClientCert
|
|
}
|
|
tlscfg.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
|
tlsClone := tlscfg.Clone()
|
|
|
|
// Build the client CA pool containing the cluster's user CA in
|
|
// order to be able to validate certificates provided by users.
|
|
var err error
|
|
tlsClone.ClientCAs, _, _, err = authclient.DefaultClientCertPool(info.Context(), authServer.Auth(), clustername)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return tlsClone, nil
|
|
}
|
|
|
|
creds, err := auth.NewTransportCredentials(auth.TransportCredentialsConfig{
|
|
TransportCredentials: credentials.NewTLS(tlscfg),
|
|
UserGetter: &authz.Middleware{
|
|
ClusterName: authServer.ClusterName(),
|
|
},
|
|
Authorizer: authorizer,
|
|
ScopedAuthorizer: scopedAuthorizer,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
sshGRPCServer := grpc.NewServer(
|
|
grpc.ChainUnaryInterceptor(interceptors.GRPCServerUnaryErrorInterceptor),
|
|
grpc.ChainStreamInterceptor(interceptors.GRPCServerStreamErrorInterceptor),
|
|
grpc.Creds(creds),
|
|
)
|
|
t.Cleanup(sshGRPCServer.Stop)
|
|
|
|
connMonitor, err := srv.NewConnectionMonitor(srv.ConnectionMonitorConfig{
|
|
AccessPoint: client,
|
|
LockWatcher: proxyLockWatcher,
|
|
Clock: clock,
|
|
ServerID: node.ID(),
|
|
Emitter: client,
|
|
EmitterContext: ctx,
|
|
Logger: logtest.NewLogger(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
transportService, err := transportv1.NewService(transportv1.ServerConfig{
|
|
FIPS: false,
|
|
Logger: logtest.NewLogger(),
|
|
Dialer: router,
|
|
SignerFn: func(authzCtx *authz.ScopedContext, clusterName string) agentless.SignerCreator {
|
|
if unscopedCtx, ok := authzCtx.UnscopedContext(); ok {
|
|
return agentless.SignerFromAuthzContext(unscopedCtx, client, clusterName)
|
|
}
|
|
|
|
return func(ctx context.Context, localAccessPoint agentless.LocalAccessPoint, certGen agentless.CertGenerator) (ssh.Signer, error) {
|
|
// TODO(fspamarshall/scopes): implement agentless transport signer for scoped identities
|
|
return nil, trace.NotImplemented("agentless transport signer is not implemented for scoped identities")
|
|
}
|
|
},
|
|
ConnectionMonitor: connMonitor,
|
|
LocalAddr: proxyListener.Addr(),
|
|
})
|
|
require.NoError(t, err)
|
|
transportpb.RegisterTransportServiceServer(sshGRPCServer, transportService)
|
|
|
|
go func() {
|
|
if err := sshGRPCServer.Serve(mux.TLS()); err != nil && !utils.IsOKNetworkError(err) {
|
|
slog.ErrorContext(context.Background(), "gRPC proxy server terminated unexpectedly", "error", err)
|
|
}
|
|
}()
|
|
|
|
proxyServer, err := regular.New(
|
|
ctx,
|
|
utils.NetAddr{AddrNetwork: proxyListener.Addr().Network(), Addr: mux.SSH().Addr().String()},
|
|
authServer.ClusterName(),
|
|
sshutils.StaticHostSigners(hostSigners...),
|
|
client,
|
|
t.TempDir(),
|
|
"",
|
|
utils.NetAddr{AddrNetwork: "tcp", Addr: "proxy-1.example.com:443"},
|
|
client,
|
|
regular.SetUUID(proxyID),
|
|
regular.SetProxyMode("", revTunServer, client, router),
|
|
regular.SetEmitter(client),
|
|
regular.SetNamespace(apidefaults.Namespace),
|
|
regular.SetBPF(&bpf.NOP{}),
|
|
regular.SetClock(clock),
|
|
regular.SetLockWatcher(proxyLockWatcher),
|
|
regular.SetSessionController(sessionController),
|
|
regular.SetPublicAddrs([]utils.NetAddr{{AddrNetwork: "tcp", Addr: "127.0.0.1:0"}}),
|
|
regular.SetConnectedProxyGetter(reversetunnel.NewConnectedProxyGetter()),
|
|
)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, proxyServer.Close()) })
|
|
|
|
authID := state.IdentityID{
|
|
Role: types.RoleProxy,
|
|
HostUUID: proxyID,
|
|
}
|
|
dns := []string{"localhost", "127.0.0.1"}
|
|
proxyIdentity, err := auth.LocalRegister(authID, authServer.Auth(), nil, dns, "", nil)
|
|
require.NoError(t, err)
|
|
proxyClientCert, err := keys.X509KeyPair(proxyIdentity.TLSCertBytes, proxyIdentity.KeyBytes)
|
|
require.NoError(t, err)
|
|
handler, err := NewHandler(Config{
|
|
Proxy: revTunServer,
|
|
AuthServers: utils.FromAddr(authServer.Addr()),
|
|
ProxyClient: client,
|
|
ProxyPublicAddrs: utils.MustParseAddrList("proxy-1.example.com", "proxy-2.example.com"),
|
|
CipherSuites: utils.DefaultCipherSuites(),
|
|
AccessPoint: client,
|
|
Context: ctx,
|
|
HostUUID: proxyID,
|
|
Emitter: client,
|
|
ProxySettings: &ProxySettings{
|
|
ServiceConfig: servicecfg.MakeDefaultConfig(),
|
|
ProxySSHAddr: "127.0.0.1",
|
|
AccessPoint: client,
|
|
},
|
|
SessionControl: SessionControllerFunc(func(ctx context.Context, sctx *SessionContext, login, localAddr, remoteAddr string) (context.Context, error) {
|
|
controller := srv.WebSessionController(sessionController)
|
|
ctx, err := controller(ctx, sctx, login, localAddr, remoteAddr)
|
|
return ctx, trace.Wrap(err)
|
|
}),
|
|
Router: router,
|
|
HealthCheckAppServer: func(context.Context, string, string) error { return nil },
|
|
MinimalReverseTunnelRoutesOnly: cfg.minimalHandler,
|
|
GetProxyClientCertificate: func() (*tls.Certificate, error) {
|
|
return &proxyClientCert, nil
|
|
},
|
|
IntegrationAppHandler: &mockIntegrationAppHandler{},
|
|
DatabaseREPLRegistry: &mockDatabaseREPLRegistry{repl: map[string]dbrepl.REPLNewFunc{}},
|
|
Modules: m,
|
|
ClusterFeatures: *m.TestFeatures.ToProto(),
|
|
InsecureMode: insecureMode,
|
|
}, SetClock(clock))
|
|
require.NoError(t, err)
|
|
|
|
webServer := httptest.NewTLSServer(handler)
|
|
t.Cleanup(webServer.Close)
|
|
go func() {
|
|
if err := proxyServer.Serve(mux.SSH()); err != nil && !utils.IsOKNetworkError(err) {
|
|
slog.ErrorContext(context.Background(), "SSH proxy server terminated unexpectedly", "error", err)
|
|
}
|
|
}()
|
|
|
|
proxyAddr := mux.Listener.Addr()
|
|
addr := utils.MustParseAddr(webServer.Listener.Addr().String())
|
|
handler.handler.cfg.ProxyWebAddr = *addr
|
|
handler.handler.cfg.ProxySSHAddr = utils.NetAddr{AddrNetwork: proxyAddr.Network(), Addr: proxyAddr.String()}
|
|
|
|
_, sshPort, err := net.SplitHostPort(proxyAddr.String())
|
|
require.NoError(t, err)
|
|
handler.handler.sshPort = sshPort
|
|
|
|
if cfg.kubeProxy {
|
|
kubeProxyAddr := startKube(
|
|
ctx,
|
|
t,
|
|
startKubeOptions{
|
|
serviceType: kubeproxy.ProxyService,
|
|
authServer: authServer,
|
|
revTunnel: revTunServer,
|
|
},
|
|
)
|
|
handler.handler.cfg.ProxyKubeAddr = utils.FromAddr(kubeProxyAddr)
|
|
}
|
|
handler.handler.cfg.PublicProxyAddr = webServer.Listener.Addr().String()
|
|
url, err := url.Parse("https://" + webServer.Listener.Addr().String())
|
|
require.NoError(t, err)
|
|
|
|
return &testProxy{
|
|
clock: clock,
|
|
auth: authServer,
|
|
client: client,
|
|
revTun: revTunServer,
|
|
node: node,
|
|
proxy: proxyServer,
|
|
web: webServer,
|
|
handler: handler,
|
|
webURL: *url,
|
|
}
|
|
}
|
|
|
|
type mockIntegrationAppHandler struct{}
|
|
|
|
func (m *mockIntegrationAppHandler) HandleConnection(_ net.Conn) {}
|
|
|
|
// webPack represents the state of a single web test.
|
|
// It replicates most of the WebSuite and serves to gradually
|
|
// transition the test suite to use the testing package
|
|
// directly.
|
|
type webPack struct {
|
|
proxies []*testProxy
|
|
server *authtest.Server
|
|
node *regular.Server
|
|
clock *clockwork.FakeClock
|
|
}
|
|
|
|
type testProxy struct {
|
|
clock *clockwork.FakeClock
|
|
client authclient.ClientI
|
|
auth *authtest.TLSServer
|
|
revTun reversetunnelclient.Server
|
|
node *regular.Server
|
|
proxy *regular.Server
|
|
handler *APIHandler
|
|
web *httptest.Server
|
|
webURL url.URL
|
|
}
|
|
|
|
// authPack returns new authenticated package consisting of created valid
|
|
// user, otp token, created web session and authenticated client.
|
|
func (r *testProxy) authPack(t *testing.T, teleportUser string, roles []types.Role) *authPack {
|
|
ctx := context.Background()
|
|
const (
|
|
pass = "abcdef123456"
|
|
rawSecret = "def456"
|
|
)
|
|
|
|
u, err := user.Current()
|
|
require.NoError(t, err)
|
|
loginUser := u.Username
|
|
|
|
otpSecret := newOTPSharedSecret()
|
|
|
|
ap, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorOTP,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = r.auth.Auth().UpsertAuthPreference(ctx, ap)
|
|
require.NoError(t, err)
|
|
|
|
r.createUser(context.Background(), t, teleportUser, loginUser, pass, otpSecret, roles)
|
|
|
|
sessionResp, httpResp := loginWebOTP(t, ctx, loginWebOTPParams{
|
|
webClient: r.newClient(t),
|
|
clock: r.clock,
|
|
user: teleportUser,
|
|
password: pass,
|
|
otpSecret: otpSecret,
|
|
})
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
clt := r.newClient(t, roundtrip.BearerAuth(sessionResp.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(&r.webURL, httpResp.Cookies())
|
|
|
|
return &authPack{
|
|
otpSecret: otpSecret,
|
|
user: teleportUser,
|
|
login: loginUser,
|
|
session: sessionResp,
|
|
clt: clt,
|
|
cookies: httpResp.Cookies(),
|
|
password: pass,
|
|
device: &authtest.Device{
|
|
TOTPSecret: otpSecret,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *testProxy) authPackFromPack(t *testing.T, pack *authPack) *authPack {
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
clt := r.newClient(t, roundtrip.BearerAuth(pack.session.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(&r.webURL, pack.cookies)
|
|
|
|
result := *pack
|
|
result.clt = clt
|
|
return &result
|
|
}
|
|
|
|
func (r *testProxy) authPackFromResponse(t *testing.T, httpResp *roundtrip.Response) *authPack {
|
|
var resp *CreateSessionResponse
|
|
require.NoError(t, json.Unmarshal(httpResp.Bytes(), &resp))
|
|
if resp.TokenExpiresIn < 0 {
|
|
t.Errorf("Expected expiry time to be in the future but got %v", resp.TokenExpiresIn)
|
|
}
|
|
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
clt := r.newClient(t, roundtrip.BearerAuth(resp.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(&r.webURL, httpResp.Cookies())
|
|
|
|
return &authPack{
|
|
session: resp,
|
|
clt: clt,
|
|
cookies: httpResp.Cookies(),
|
|
}
|
|
}
|
|
|
|
func defaultRoleForNewUser(teleUser types.User, login string) types.Role {
|
|
role := services.RoleForUser(teleUser)
|
|
role.SetLogins(types.Allow, []string{login})
|
|
role.SetWindowsDesktopLabels(types.Allow, types.Labels{types.Wildcard: {types.Wildcard}})
|
|
options := role.GetOptions()
|
|
options.ForwardAgent = types.NewBool(true)
|
|
role.SetOptions(options)
|
|
return role
|
|
}
|
|
|
|
func (r *testProxy) createUser(ctx context.Context, t *testing.T, user, login, pass, otpSecret string, roles []types.Role) {
|
|
teleUser, err := types.NewUser(user)
|
|
require.NoError(t, err)
|
|
|
|
if len(roles) == 0 {
|
|
roles = []types.Role{defaultRoleForNewUser(teleUser, login)}
|
|
}
|
|
|
|
for _, role := range roles {
|
|
role, err = r.auth.Auth().UpsertRole(ctx, role)
|
|
require.NoError(t, err)
|
|
|
|
teleUser.AddRole(role.GetName())
|
|
}
|
|
|
|
teleUser.SetCreatedBy(types.CreatedBy{
|
|
User: types.UserRef{Name: "some-auth-user"},
|
|
})
|
|
|
|
_, err = r.auth.Auth().CreateUser(ctx, teleUser)
|
|
require.NoError(t, err)
|
|
|
|
err = r.auth.Auth().UpsertPassword(user, []byte(pass))
|
|
require.NoError(t, err)
|
|
|
|
if otpSecret != "" {
|
|
dev, err := services.NewTOTPDevice("otp", otpSecret, r.clock.Now())
|
|
require.NoError(t, err)
|
|
err = r.auth.Auth().UpsertMFADevice(ctx, user, dev)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
func (r *testProxy) newClient(t *testing.T, opts ...roundtrip.ClientParam) *TestWebClient {
|
|
opts = append(opts, roundtrip.HTTPClient(client.NewInsecureWebClient()))
|
|
clt, err := client.NewWebClient(r.webURL.String(), opts...)
|
|
require.NoError(t, err)
|
|
return &TestWebClient{clt, t}
|
|
}
|
|
|
|
func makeAuthReqOverWS(ws *websocket.Conn, token string) error {
|
|
authReq, err := json.Marshal(struct {
|
|
Token string `json:"token"`
|
|
}{Token: token})
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
if err := ws.WriteMessage(websocket.TextMessage, authReq); err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
_, authRes, err := ws.ReadMessage()
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
if !strings.Contains(string(authRes), `"status":"ok"`) {
|
|
return trace.AccessDenied("unexpected response")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *testProxy) makeDesktopSession(t *testing.T, pack *authPack) *websocket.Conn {
|
|
u := url.URL{
|
|
Host: r.webURL.Host,
|
|
Scheme: client.WSS,
|
|
Path: fmt.Sprintf("/webapi/sites/%s/desktops/%s/connect/ws", currentSiteShortcut, "desktop1"),
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Set("username", "marek")
|
|
q.Set("width", "100")
|
|
q.Set("height", "100")
|
|
u.RawQuery = q.Encode()
|
|
|
|
dialer := websocket.Dialer{}
|
|
dialer.TLSClientConfig = &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
}
|
|
|
|
header := http.Header{}
|
|
for _, cookie := range pack.cookies {
|
|
header.Add("Cookie", cookie.String())
|
|
}
|
|
|
|
ws, resp, err := dialer.Dial(u.String(), header)
|
|
require.NoError(t, err)
|
|
|
|
err = makeAuthReqOverWS(ws, pack.session.Token)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
require.NoError(t, ws.Close())
|
|
require.NoError(t, resp.Body.Close())
|
|
})
|
|
return ws
|
|
}
|
|
|
|
func validateTerminal(t *testing.T, term ReadWriterWithDeadline) {
|
|
t.Helper()
|
|
|
|
// here we intentionally run a command where the output we're looking
|
|
// for is not present in the command itself
|
|
_, err := io.WriteString(term, "echo txlxport | sed 's/x/e/g'\r\n")
|
|
require.NoError(t, err)
|
|
require.NoError(t, waitForOutput(term, "teleport"))
|
|
}
|
|
|
|
// TestUserContextWithAccessRequest checks that the userContext includes the ID of the
|
|
// access request after it has been consumed and the web session has been renewed.
|
|
func TestUserContextWithAccessRequest(t *testing.T) {
|
|
t.Parallel()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
ctx := context.Background()
|
|
|
|
// Set user and role names.
|
|
username := "user"
|
|
baseRoleName := "role"
|
|
requestableRolename := "requestable-role"
|
|
|
|
// Create user's base role with the ability to request the requestable role.
|
|
baseRole, err := types.NewRole(baseRoleName, types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
Request: &types.AccessRequestConditions{
|
|
Roles: []string{requestableRolename},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create user with the base role.
|
|
pack := proxy.authPack(t, username, []types.Role{baseRole})
|
|
|
|
// Create the requestable role.
|
|
requestableRole, err := types.NewRole(requestableRolename, types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertRole(ctx, requestableRole)
|
|
require.NoError(t, err)
|
|
|
|
identity := tlsca.Identity{
|
|
Expires: env.clock.Now().Add(1 * time.Hour),
|
|
}
|
|
|
|
// Create and approve an access request for the requestable role.
|
|
accessReq, err := services.NewAccessRequest(username, requestableRolename)
|
|
require.NoError(t, err)
|
|
accessReq.SetState(types.RequestState_APPROVED)
|
|
accessReq, err = env.server.Auth().CreateAccessRequestV2(ctx, accessReq, identity)
|
|
require.NoError(t, err)
|
|
|
|
// Get the ID of the created and approved access request.
|
|
accessRequestID := accessReq.GetMetadata().Name
|
|
|
|
// Make a request to renew the session with the ID of the access request.
|
|
_, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "sessions", "web", "renew"), renewSessionRequest{
|
|
AccessRequestID: accessRequestID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Make a request to fetch the userContext.
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "context")
|
|
response, err := pack.clt.Get(context.Background(), endpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
// Process the JSON response of the request.
|
|
var userContext webui.UserContext
|
|
err = json.Unmarshal(response.Bytes(), &userContext)
|
|
require.NoError(t, err)
|
|
|
|
// Verify that the userContext returned contains the correct Access Request ID.
|
|
require.Equal(t, accessRequestID, userContext.ConsumedAccessRequestID)
|
|
}
|
|
|
|
// TestIsMFARequired_AcceptedRequests mostly tests that requests
|
|
// are formatted correctly.
|
|
func TestIsMFARequired_AcceptedRequests(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "llama", nil /* roles */)
|
|
|
|
cfg, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
SecondFactor: constants.SecondFactorWebauthn,
|
|
RequireMFAType: types.RequireMFAType_SESSION,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: env.server.ClusterName(),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertAuthPreference(ctx, cfg)
|
|
require.NoError(t, err)
|
|
|
|
// Register an application called "panel".
|
|
app, err := types.NewAppV3(types.Metadata{
|
|
Name: "panel",
|
|
}, types.AppSpecV3{
|
|
URI: "http://127.0.0.1:8080",
|
|
PublicAddr: "panel.example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
server, err := types.NewAppServerV3FromApp(app, "host", uuid.New().String())
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertApplicationServer(ctx, server)
|
|
require.NoError(t, err)
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
errMsg string
|
|
getRequest func() IsMFARequiredRequest
|
|
}{
|
|
{
|
|
name: "valid db req",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
Database: &isMFARequiredDatabase{
|
|
ServiceName: "name",
|
|
Protocol: "protocol",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid db req",
|
|
errMsg: "missing service_name",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{Database: &isMFARequiredDatabase{}}
|
|
},
|
|
},
|
|
{
|
|
name: "valid node req",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
Node: &isMFARequiredNode{
|
|
NodeName: "name",
|
|
Login: "login",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid node req",
|
|
errMsg: "missing login",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{Node: &isMFARequiredNode{}}
|
|
},
|
|
},
|
|
{
|
|
name: "valid kube req",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
Kube: &isMFARequiredKube{
|
|
ClusterName: "name",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid kube req",
|
|
errMsg: "missing cluster_name",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{Kube: &isMFARequiredKube{}}
|
|
},
|
|
},
|
|
{
|
|
name: "valid windows desktop req",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
WindowsDesktop: &isMFARequiredWindowsDesktop{
|
|
DesktopName: "name",
|
|
Login: "login",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid windows desktop req",
|
|
errMsg: "missing desktop_name",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{WindowsDesktop: &isMFARequiredWindowsDesktop{}}
|
|
},
|
|
},
|
|
{
|
|
name: "valid app req - resolve addr",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
App: &IsMFARequiredApp{
|
|
ResolveAppParams: ResolveAppParams{
|
|
PublicAddr: app.GetPublicAddr(),
|
|
ClusterName: env.server.ClusterName(),
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "valid app req - resolve fqdn",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
App: &IsMFARequiredApp{
|
|
ResolveAppParams: ResolveAppParams{
|
|
FQDNHint: fmt.Sprintf("%v.%v", app.GetName(), "proxy-1.example.com"),
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid app req",
|
|
errMsg: "no inputs to resolve application",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{App: &IsMFARequiredApp{}}
|
|
},
|
|
},
|
|
{
|
|
name: "valid admin action req",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
AdminAction: &isMFARequiredAdminAction{},
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid empty req",
|
|
errMsg: "missing target",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid multi field",
|
|
errMsg: "only one target is allowed",
|
|
getRequest: func() IsMFARequiredRequest {
|
|
return IsMFARequiredRequest{
|
|
Kube: &isMFARequiredKube{
|
|
ClusterName: "name",
|
|
},
|
|
Node: &isMFARequiredNode{
|
|
NodeName: "name",
|
|
Login: "login",
|
|
},
|
|
}
|
|
},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "mfa", "required")
|
|
re, err := pack.clt.PostJSON(ctx, endpoint, test.getRequest())
|
|
|
|
if test.errMsg != "" {
|
|
require.True(t, trace.IsBadParameter(err), "isMFARequired returned err = %v (%T), wanted trace.BadParameter", err, err)
|
|
require.ErrorContains(t, err, test.errMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
resp := isMfaRequiredResponse{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.True(t, resp.Required, "isMFARequired returned response with unexpected value for Required field")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWithLimiterHandlerFunc(t *testing.T) {
|
|
const burst = 20
|
|
limiter, err := limiter.NewRateLimiter(limiter.Config{
|
|
Rates: []limiter.Rate{
|
|
{
|
|
Period: time.Minute,
|
|
Average: 10,
|
|
Burst: burst,
|
|
},
|
|
},
|
|
Clock: clockwork.NewFakeClock(),
|
|
})
|
|
require.NoError(t, err)
|
|
h := &Handler{limiter: limiter}
|
|
hf := h.WithLimiterHandlerFunc(func(http.ResponseWriter, *http.Request, httprouter.Params) (any, error) {
|
|
return nil, nil
|
|
})
|
|
|
|
// Verify that a valid burst is allowed.
|
|
r := &http.Request{}
|
|
for i := range burst {
|
|
r.RemoteAddr = fmt.Sprintf("127.0.0.1:%v", i)
|
|
_, err = hf(nil, r, nil)
|
|
require.NoError(t, err, "WithLimiterHandlerFunc failed unexpectedly")
|
|
}
|
|
|
|
// Verify that exceeding the limit causes errors.
|
|
r.RemoteAddr = fmt.Sprintf("127.0.0.1:%v", burst)
|
|
_, err = hf(nil, r, nil)
|
|
require.True(t, trace.IsLimitExceeded(err), "WithLimiterHandlerFunc returned err = %T, want trace.LimitExceededError", err)
|
|
}
|
|
|
|
func TestWitWithAccessDeniedLimiter(t *testing.T) {
|
|
rateLimiter, err := limiter.NewRateLimiter(limiter.Config{
|
|
Rates: []limiter.Rate{
|
|
{Period: time.Minute, Average: 2, Burst: 3},
|
|
},
|
|
Clock: clockwork.NewFakeClock(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
h := &Handler{limiter: rateLimiter}
|
|
|
|
callCount := 0
|
|
shouldFail := false
|
|
handle := h.WithAccessDeniedLimiter(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) {
|
|
callCount++
|
|
if shouldFail {
|
|
return nil, trace.AccessDenied("invalid credentials")
|
|
}
|
|
return "success", nil
|
|
})
|
|
|
|
r := &http.Request{RemoteAddr: "192.168.1.1:1234"}
|
|
|
|
// Make some successful requests - these should not count toward rate limit
|
|
for i := 0; i < 5; i++ {
|
|
w := httptest.NewRecorder()
|
|
handle(w, r, nil)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
// Now make access denied requests - only these should count
|
|
shouldFail = true
|
|
for i := 0; i < 3; i++ {
|
|
w := httptest.NewRecorder()
|
|
handle(w, r, nil)
|
|
require.Equal(t, http.StatusForbidden, w.Code)
|
|
}
|
|
require.Equal(t, 8, callCount)
|
|
|
|
// Next request should be rate limited (handler not called)
|
|
w := httptest.NewRecorder()
|
|
handle(w, r, nil)
|
|
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
|
|
require.Equal(t, 8, callCount)
|
|
}
|
|
|
|
func TestWithUnauthenticatedLimiter(t *testing.T) {
|
|
rateLimiter, err := limiter.NewRateLimiter(limiter.Config{
|
|
Rates: []limiter.Rate{
|
|
{Period: time.Minute, Average: 2, Burst: 3},
|
|
},
|
|
Clock: clockwork.NewFakeClock(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
h := &Handler{limiter: rateLimiter}
|
|
|
|
callCount := 0
|
|
handle := h.WithUnauthenticatedLimiter(func(w http.ResponseWriter, r *http.Request, p httprouter.Params) (any, error) {
|
|
callCount++
|
|
return "success", nil
|
|
})
|
|
|
|
r := &http.Request{RemoteAddr: "192.168.1.1:1234"}
|
|
|
|
// Request should return the error from handler
|
|
w := httptest.NewRecorder()
|
|
handle(w, r, nil)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
require.Equal(t, 1, callCount)
|
|
|
|
// Should be able to make more requests (they still count toward rate limit)
|
|
for range 2 {
|
|
w := httptest.NewRecorder()
|
|
handle(w, r, nil)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
require.Equal(t, 3, callCount)
|
|
|
|
// Next request should be rate limited
|
|
w = httptest.NewRecorder()
|
|
handle(w, r, nil)
|
|
require.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
require.Equal(t, 3, callCount)
|
|
}
|
|
|
|
// kubeClusterConfig defines the cluster to be created
|
|
type kubeClusterConfig struct {
|
|
name string
|
|
apiEndpoint string
|
|
}
|
|
|
|
func newKubeConfigFile(ctx context.Context, t *testing.T, clusters ...kubeClusterConfig) string {
|
|
tmpDir := t.TempDir()
|
|
|
|
kubeConf := clientcmdapi.NewConfig()
|
|
for _, cluster := range clusters {
|
|
kubeConf.Clusters[cluster.name] = &clientcmdapi.Cluster{
|
|
Server: cluster.apiEndpoint,
|
|
InsecureSkipTLSVerify: true,
|
|
}
|
|
kubeConf.AuthInfos[cluster.name] = &clientcmdapi.AuthInfo{}
|
|
|
|
kubeConf.Contexts[cluster.name] = &clientcmdapi.Context{
|
|
Cluster: cluster.name,
|
|
AuthInfo: cluster.name,
|
|
}
|
|
}
|
|
kubeConfigLocation := filepath.Join(tmpDir, "kubeconfig")
|
|
err := clientcmd.WriteToFile(*kubeConf, kubeConfigLocation)
|
|
require.NoError(t, err)
|
|
return kubeConfigLocation
|
|
}
|
|
|
|
type startKubeOptions struct {
|
|
clusters []kubeClusterConfig
|
|
authServer *authtest.TLSServer
|
|
revTunnel reversetunnelclient.Server
|
|
serviceType kubeproxy.KubeServiceType
|
|
}
|
|
|
|
func startKube(ctx context.Context, t *testing.T, cfg startKubeOptions) net.Addr {
|
|
server, cleanup, addr := startKubeWithoutCleanup(ctx, t, cfg)
|
|
t.Cleanup(func() {
|
|
err := server.Close()
|
|
require.NoError(t, err)
|
|
require.NoError(t, cleanup())
|
|
})
|
|
return addr
|
|
}
|
|
|
|
type cleanupFunc func() error
|
|
|
|
func startKubeWithoutCleanup(ctx context.Context, t *testing.T, cfg startKubeOptions) (*kubeproxy.TLSServer, cleanupFunc, net.Addr) {
|
|
role := types.RoleProxy
|
|
if cfg.serviceType == kubeproxy.KubeService {
|
|
role = types.RoleKube
|
|
}
|
|
var kubeConfigLocation string
|
|
if len(cfg.clusters) > 0 {
|
|
kubeConfigLocation = newKubeConfigFile(ctx, t, cfg.clusters...)
|
|
}
|
|
|
|
keyGen, err := tlsutils.New(tlsutils.Config{BuildType: modules.BuildOSS})
|
|
require.NoError(t, err)
|
|
|
|
hostID := uuid.New().String()
|
|
// heartbeatsWaitChannel waits for clusters heartbeats to start.
|
|
heartbeatsWaitChannel := make(chan struct{}, len(cfg.clusters))
|
|
client, err := cfg.authServer.NewClient(authtest.TestServerID(role, hostID))
|
|
require.NoError(t, err)
|
|
|
|
// Auth client, lock watcher and authorizer for Kube proxy.
|
|
proxyAuthClient, err := cfg.authServer.NewClient(authtest.TestBuiltin(types.RoleProxy))
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, proxyAuthClient.Close()) })
|
|
|
|
proxyLockWatcher, err := services.NewLockWatcher(ctx, services.LockWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: teleport.ComponentProxy,
|
|
Client: proxyAuthClient,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
proxyAuthorizer, err := authz.NewAuthorizer(authz.AuthorizerOpts{
|
|
ClusterName: cfg.authServer.ClusterName(),
|
|
AccessPoint: proxyAuthClient,
|
|
LockWatcher: proxyLockWatcher,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// TLS config for kube proxy and Kube service.
|
|
authID := state.IdentityID{
|
|
Role: role,
|
|
HostUUID: hostID,
|
|
NodeName: "kube_server",
|
|
}
|
|
dns := []string{"localhost", "127.0.0.1", constants.KubeTeleportProxyALPNPrefix + constants.APIDomain, "*" + constants.APIDomain}
|
|
identity, err := auth.LocalRegister(authID, cfg.authServer.Auth(), nil, dns, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
tlsConfig, err := identity.TLSConfig(nil)
|
|
require.NoError(t, err)
|
|
|
|
component := teleport.Component(teleport.ComponentProxy, teleport.ComponentProxyKube)
|
|
if cfg.serviceType == kubeproxy.KubeService {
|
|
component = teleport.ComponentKube
|
|
}
|
|
|
|
proxySigner := &mockPROXYSigner{}
|
|
if cfg.serviceType == kubeproxy.KubeService {
|
|
proxySigner = nil
|
|
}
|
|
clock := clockwork.NewRealClock()
|
|
watcher, err := services.NewKubeServerWatcher(ctx, services.KubeServerWatcherConfig{
|
|
ResourceWatcherConfig: services.ResourceWatcherConfig{
|
|
Component: component,
|
|
Client: client,
|
|
Clock: clock,
|
|
},
|
|
KubernetesServerGetter: client,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(watcher.Close)
|
|
|
|
// wait for the watcher to init before continuing
|
|
require.NoError(t, watcher.WaitInitialization())
|
|
|
|
healthCheckManager, err := healthcheck.NewManager(
|
|
ctx,
|
|
healthcheck.ManagerConfig{
|
|
Component: teleport.ComponentKube,
|
|
Events: client,
|
|
HealthCheckConfigReader: client,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
err = healthCheckManager.Start(ctx)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, healthCheckManager.Close()) })
|
|
|
|
inventoryHandle, err := inventory.NewDownstreamHandle(client.InventoryControlStream,
|
|
func(ctx context.Context) (*authproto.UpstreamInventoryHello, error) {
|
|
return &authproto.UpstreamInventoryHello{
|
|
ServerID: hostID,
|
|
Version: teleport.Version,
|
|
Services: types.SystemRoles{role}.StringSlice(),
|
|
Hostname: "test",
|
|
}, nil
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, inventoryHandle.Close()) })
|
|
|
|
kubeServer, err := kubeproxy.NewTLSServer(kubeproxy.TLSServerConfig{
|
|
ForwarderConfig: kubeproxy.ForwarderConfig{
|
|
Namespace: apidefaults.Namespace,
|
|
Keygen: keyGen,
|
|
ClusterName: cfg.authServer.ClusterName(),
|
|
Authz: proxyAuthorizer,
|
|
AuthClient: client,
|
|
Emitter: client,
|
|
DataDir: t.TempDir(),
|
|
CachingAuthClient: client,
|
|
HostID: hostID,
|
|
Context: ctx,
|
|
KubeconfigPath: kubeConfigLocation,
|
|
KubeServiceType: cfg.serviceType,
|
|
Component: component,
|
|
LockWatcher: proxyLockWatcher,
|
|
ReverseTunnelSrv: cfg.revTunnel,
|
|
PROXYSigner: proxySigner,
|
|
// skip Impersonation validation
|
|
CheckImpersonationPermissions: func(ctx context.Context, clusterName string, sarClient authztypes.SelfSubjectAccessReviewInterface) error {
|
|
return nil
|
|
},
|
|
|
|
GetConnTLSCertificate: func() (*tls.Certificate, error) {
|
|
return &tlsConfig.Certificates[0], nil
|
|
},
|
|
GetConnTLSRoots: func() (*x509.CertPool, error) {
|
|
return tlsConfig.RootCAs, nil
|
|
},
|
|
|
|
Clock: clockwork.NewRealClock(),
|
|
ClusterFeatures: func() authproto.Features {
|
|
return authproto.Features{
|
|
Entitlements: map[string]*authproto.EntitlementInfo{
|
|
string(entitlements.K8s): {Enabled: true},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
TLS: tlsConfig.Clone(),
|
|
AccessPoint: client,
|
|
DynamicLabels: nil,
|
|
LimiterConfig: limiter.Config{
|
|
MaxConnections: 1000,
|
|
},
|
|
// each time heartbeat is called we insert data into the channel.
|
|
// this is used to make sure that heartbeat started and the clusters
|
|
// are registered in the auth server
|
|
OnHeartbeat: func(err error) {
|
|
select {
|
|
case heartbeatsWaitChannel <- struct{}{}:
|
|
default:
|
|
}
|
|
},
|
|
GetRotation: func(role types.SystemRole) (*types.Rotation, error) { return &types.Rotation{}, nil },
|
|
ResourceMatchers: nil,
|
|
OnReconcile: func(kc types.KubeClusters) {},
|
|
KubernetesServersWatcher: watcher,
|
|
InventoryHandle: inventoryHandle,
|
|
ConnectedProxyGetter: reversetunnel.NewConnectedProxyGetter(),
|
|
HealthCheckManager: healthCheckManager,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
errChan := make(chan error, 1)
|
|
go func() {
|
|
defer close(errChan)
|
|
err := kubeServer.Serve(listener)
|
|
// ignore server closed error returned when .Close is called.
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return
|
|
}
|
|
errChan <- err
|
|
}()
|
|
|
|
// Waits for len(clusters) heartbeats to start
|
|
heartbeatsToExpect := len(cfg.clusters)
|
|
for range heartbeatsToExpect {
|
|
<-heartbeatsWaitChannel
|
|
}
|
|
|
|
return kubeServer, func() error {
|
|
return <-errChan
|
|
}, listener.Addr()
|
|
}
|
|
|
|
func marshalRBACError(t *testing.T, w http.ResponseWriter) {
|
|
status := &metav1.Status{
|
|
Message: "pods is forbidden: User \"USER\" cannot list resource \"pods\" in API group \"\" in the namespace \"default\"",
|
|
Code: http.StatusForbidden,
|
|
Reason: metav1.StatusReasonForbidden,
|
|
Status: metav1.StatusFailure,
|
|
}
|
|
|
|
data, err := runtime.Encode(statusCodecs.LegacyCodec(), status)
|
|
require.NoError(t, err)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, err = w.Write(data)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func marshalValidPodList(t *testing.T, w http.ResponseWriter) {
|
|
result := &corev1.PodList{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "PodList",
|
|
APIVersion: "v1",
|
|
},
|
|
ListMeta: metav1.ListMeta{
|
|
SelfLink: "",
|
|
ResourceVersion: "1231415",
|
|
Continue: "",
|
|
RemainingItemCount: nil,
|
|
},
|
|
Items: []corev1.Pod{},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
err := json.NewEncoder(w).Encode(result)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// statusScheme is private scheme for the decoding here until someone fixes the TODO in NewConnection
|
|
var statusScheme = runtime.NewScheme()
|
|
|
|
// ParameterCodec knows about query parameters used with the meta v1 API spec.
|
|
var statusCodecs = serializer.NewCodecFactory(statusScheme)
|
|
|
|
func init() {
|
|
statusScheme.AddUnversionedTypes(metav1.SchemeGroupVersion,
|
|
&metav1.Status{},
|
|
)
|
|
}
|
|
|
|
// TestForwardingTraces checks that the userContext includes the ID of the
|
|
// access request after it has been consumed and the web session has been renewed.
|
|
func TestForwardingTraces(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newWebPack(t, 1)
|
|
p := env.proxies[0]
|
|
|
|
newRequest := func(t *testing.T) *http.Request {
|
|
req, err := http.NewRequest(http.MethodGet, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
return req
|
|
}
|
|
|
|
// Span captured from the UI which was marshaled by opentelemetry-js.
|
|
const rawSpan = `{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"web-ui"}},{"key":"telemetry.sdk.language","value":{"stringValue":"webjs"}},{"key":"telemetry.sdk.name","value":{"stringValue":"opentelemetry"}},{"key":"telemetry.sdk.version","value":{"stringValue":"1.7.0"}},{"key":"service.version","value":{"stringValue":"0.1.0"}}],"droppedAttributesCount":0},"scopeSpans":[{"scope":{"name":"@opentelemetry/instrumentation-fetch","version":"0.33.0"},"spans":[{"traceId":"255c8d876e7dbf3707ee8451ad518652","spanId":"d9edec516e598d8c","name":"HTTP GET","kind":3,"startTimeUnixNano":1668606426497000000,"endTimeUnixNano":1668502943215499800,"attributes":[{"key":"component","value":{"stringValue":"fetch"}},{"key":"http.method","value":{"stringValue":"GET"}},{"key":"http.url","value":{"stringValue":"https://proxy.example.com/v1/webapi/user/status"}},{"key":"http.status_code","value":{"intValue":0}},{"key":"http.status_text","value":{"stringValue":"Failed to fetch"}},{"key":"http.host","value":{"stringValue":"proxy.example.com"}},{"key":"http.scheme","value":{"stringValue":"https"}},{"key":"http.user_agent","value":{"stringValue":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 "}},{"key":"http.response_content_length","value":{"intValue":0}}],"droppedAttributesCount":0,"events":[{"attributes":[],"name":"fetchStart","timeUnixNano":1668502943210900000,"droppedAttributesCount":0},{"attributes":[],"name":"domainLookupStart","timeUnixNano":1668502687491499800,"droppedAttributesCount":0},{"attributes":[],"name":"domainLookupEnd","timeUnixNano":1668502687491499800,"droppedAttributesCount":0},{"attributes":[],"name":"connectStart","timeUnixNano":1668502687491499800,"droppedAttributesCount":0},{"attributes":[],"name":"secureConnectionStart","timeUnixNano":1668502687491499800,"droppedAttributesCount":0},{"attributes":[],"name":"connectEnd","timeUnixNano":1668502687491499800,"droppedAttributesCount":0},{"attributes":[],"name":"requestStart","timeUnixNano":1668502687491499800,"droppedAttributesCount":0},{"attributes":[],"name":"responseStart","timeUnixNano":1668502687491499800,"droppedAttributesCount":0},{"attributes":[],"name":"responseEnd","timeUnixNano":1668502943215100000,"droppedAttributesCount":0}],"droppedEventsCount":0,"status":{"code":0},"links":[],"droppedLinksCount":0}]}]}]}`
|
|
|
|
// dummy span with arbitrary data, needed to be able to protojson.Marshal in tests
|
|
span := &tracepb.TracesData{
|
|
ResourceSpans: []*tracepb.ResourceSpans{
|
|
{
|
|
Resource: &resourcev1.Resource{
|
|
Attributes: []*commonv1.KeyValue{
|
|
{
|
|
Key: "test",
|
|
Value: &commonv1.AnyValue{
|
|
Value: &commonv1.AnyValue_IntValue{
|
|
IntValue: 0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ScopeSpans: []*tracepb.ScopeSpans{
|
|
{
|
|
Spans: []*tracepb.Span{
|
|
{
|
|
TraceId: []byte{1, 2, 3, 4},
|
|
SpanId: []byte{5, 6, 7, 8},
|
|
TraceState: "",
|
|
ParentSpanId: []byte{9, 10, 11, 12},
|
|
Name: "test",
|
|
Kind: tracepb.Span_SPAN_KIND_CLIENT,
|
|
StartTimeUnixNano: uint64(time.Now().Add(-1 * time.Minute).Unix()),
|
|
EndTimeUnixNano: uint64(time.Now().Unix()),
|
|
Attributes: []*commonv1.KeyValue{
|
|
{
|
|
Key: "test",
|
|
Value: &commonv1.AnyValue{
|
|
Value: &commonv1.AnyValue_IntValue{
|
|
IntValue: 11,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Status: &tracepb.Status{
|
|
Message: "success!",
|
|
Code: tracepb.Status_STATUS_CODE_OK,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
req func(t *testing.T) *http.Request
|
|
assertion func(t *testing.T, spans []*tracepb.ResourceSpans, err error, code int)
|
|
}{
|
|
{
|
|
name: "no data",
|
|
req: func(t *testing.T) *http.Request {
|
|
r := newRequest(t)
|
|
r.Body = io.NopCloser(&bytes.Buffer{})
|
|
return r
|
|
},
|
|
assertion: func(t *testing.T, spans []*tracepb.ResourceSpans, err error, code int) {
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, code)
|
|
require.Empty(t, spans)
|
|
},
|
|
},
|
|
{
|
|
name: "invalid data",
|
|
req: func(t *testing.T) *http.Request {
|
|
r := newRequest(t)
|
|
r.Body = io.NopCloser(strings.NewReader(`{"test": "abc"}`))
|
|
return r
|
|
},
|
|
assertion: func(t *testing.T, spans []*tracepb.ResourceSpans, err error, code int) {
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, code)
|
|
require.Empty(t, spans)
|
|
},
|
|
},
|
|
{
|
|
name: "no traces",
|
|
req: func(t *testing.T) *http.Request {
|
|
r := newRequest(t)
|
|
|
|
raw, err := protojson.Marshal(&tracepb.ResourceSpans{})
|
|
require.NoError(t, err)
|
|
r.Body = io.NopCloser(bytes.NewBuffer(raw))
|
|
|
|
return r
|
|
},
|
|
assertion: func(t *testing.T, spans []*tracepb.ResourceSpans, err error, code int) {
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, code)
|
|
require.Empty(t, spans)
|
|
},
|
|
},
|
|
{
|
|
name: "traces with base64 encoded ids",
|
|
req: func(t *testing.T) *http.Request {
|
|
r := newRequest(t)
|
|
|
|
// Since the id fields of the span are all []byte,
|
|
// protojson will marshal them into base64
|
|
raw, err := protojson.Marshal(span)
|
|
require.NoError(t, err)
|
|
r.Body = io.NopCloser(bytes.NewBuffer(raw))
|
|
|
|
return r
|
|
},
|
|
assertion: func(t *testing.T, spans []*tracepb.ResourceSpans, err error, code int) {
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, code)
|
|
require.Len(t, spans, 1)
|
|
require.Empty(t, cmp.Diff(span.ResourceSpans[0], spans[0], protocmp.Transform()))
|
|
},
|
|
},
|
|
{
|
|
name: "traces with hex encoded ids",
|
|
req: func(t *testing.T) *http.Request {
|
|
r := newRequest(t)
|
|
|
|
// The id fields are hex encoded instead of base64 encoded
|
|
// by opentelemetry-js for the rawSpan
|
|
r.Body = io.NopCloser(strings.NewReader(rawSpan))
|
|
|
|
return r
|
|
},
|
|
assertion: func(t *testing.T, spans []*tracepb.ResourceSpans, err error, code int) {
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, code)
|
|
require.Len(t, spans, 1)
|
|
|
|
var data tracepb.TracesData
|
|
require.NoError(t, protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal([]byte(rawSpan), &data))
|
|
|
|
// compare the spans, but ignore the ids since we know that the rawSpan
|
|
// has hex encoded ids and protojson.Unmarshal will give us an invalid value
|
|
require.Empty(t, cmp.Diff(data.ResourceSpans[0], spans[0], protocmp.Transform(), protocmp.IgnoreFields(&tracepb.Span{}, "span_id", "trace_id")))
|
|
|
|
// compare the ids separately
|
|
sid1 := spans[0].ScopeSpans[0].Spans[0].SpanId
|
|
tid1 := spans[0].ScopeSpans[0].Spans[0].TraceId
|
|
|
|
sid2 := data.ResourceSpans[0].ScopeSpans[0].Spans[0].SpanId
|
|
tid2 := data.ResourceSpans[0].ScopeSpans[0].Spans[0].TraceId
|
|
|
|
require.Equal(t, hex.EncodeToString(sid1), base64.StdEncoding.EncodeToString(sid2))
|
|
require.Equal(t, hex.EncodeToString(tid1), base64.StdEncoding.EncodeToString(tid2))
|
|
},
|
|
},
|
|
}
|
|
|
|
// NOTE: resetting the tracing client prevents
|
|
// the test cases from running in parallel
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
clt := &mockTraceClient{
|
|
uploadReceived: make(chan struct{}),
|
|
}
|
|
p.handler.handler.cfg.TraceClient = clt
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
// use the handler directly because there is no easy way to pipe in our tracing
|
|
// data using the pack client in a format that would match the webui.
|
|
_, err := p.handler.handler.traces(recorder, tt.req(t), nil, nil)
|
|
|
|
// if traces weren't uploaded perform the assertion
|
|
// without waiting for traces to be forwarded
|
|
if err != nil || recorder.Code != http.StatusOK {
|
|
tt.assertion(t, clt.spans, err, recorder.Code)
|
|
return
|
|
}
|
|
|
|
// traces are forwarded in a goroutine, wait for them
|
|
// to be received by the trace client before doing the
|
|
// assertion
|
|
select {
|
|
case <-clt.uploadReceived:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("Timed out waiting for traces to be uploaded")
|
|
}
|
|
|
|
tt.assertion(t, clt.spans, err, recorder.Code)
|
|
})
|
|
}
|
|
}
|
|
|
|
type mockPROXYSigner struct{}
|
|
|
|
func (m *mockPROXYSigner) SignPROXYHeader(source, destination net.Addr) ([]byte, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type mockTraceClient struct {
|
|
uploadError error
|
|
uploadReceived chan struct{}
|
|
spans []*tracepb.ResourceSpans
|
|
}
|
|
|
|
func (m *mockTraceClient) Start(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTraceClient) Stop(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockTraceClient) UploadTraces(ctx context.Context, protoSpans []*tracepb.ResourceSpans) error {
|
|
m.spans = append(m.spans, protoSpans...)
|
|
m.uploadReceived <- struct{}{}
|
|
return m.uploadError
|
|
}
|
|
|
|
func TestLogout(t *testing.T) {
|
|
ctx := context.Background()
|
|
t.Parallel()
|
|
env := newWebPack(t, 2)
|
|
|
|
// create a logged in user for proxy 1
|
|
pack := env.proxies[0].authPack(t, "llama", nil /* roles */)
|
|
|
|
// ensure the client is authenticated
|
|
re, err := pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.NoError(t, err)
|
|
var clusters []webui.Cluster
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &clusters))
|
|
require.Len(t, clusters, 1)
|
|
|
|
// create a client for proxy 2 with the token and cookies from proxy 1
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
jar.SetCookies(&env.proxies[1].webURL, pack.cookies)
|
|
clt2 := env.proxies[1].newClient(t, roundtrip.BearerAuth(pack.session.Token), roundtrip.CookieJar(jar))
|
|
|
|
// ensure the second client is authenticated
|
|
re, err = clt2.Get(ctx, clt2.Endpoint("webapi", "sites"), url.Values{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &clusters))
|
|
require.Len(t, clusters, 1)
|
|
|
|
// logout from proxy 1
|
|
_, err = pack.clt.Delete(ctx, pack.clt.Endpoint("webapi", "sessions", "web"))
|
|
require.NoError(t, err)
|
|
|
|
// ensure proxy 1 invalidated the session
|
|
_, err = pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "sites"), url.Values{})
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, trace.AccessDenied("missing session cookie"))
|
|
|
|
// should still be authenticated to proxy 2 until the expiration loop kicks in
|
|
re, err = clt2.Get(ctx, clt2.Endpoint("webapi", "sites"), url.Values{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &clusters))
|
|
require.Len(t, clusters, 1)
|
|
|
|
// advance the clock to fire the expiration ticker
|
|
env.clock.Advance(time.Second)
|
|
|
|
// wait for the expiration loop to purge the session
|
|
require.Eventually(t, func() bool {
|
|
return env.proxies[1].handler.handler.auth.ActiveSessions() == 0
|
|
}, 5*time.Second, 100*time.Millisecond)
|
|
|
|
// ensure proxy 2 invalidated the session
|
|
_, err = clt2.Get(ctx, clt2.Endpoint("webapi", "sites"), url.Values{})
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
require.ErrorIs(t, err, trace.AccessDenied("need auth"))
|
|
}
|
|
|
|
// initGRPCServer creates a gRPC server serving on the provided listener.
|
|
func initGRPCServer(t *testing.T, env *webPack, listener net.Listener) {
|
|
clusterName := env.server.ClusterName()
|
|
// Auth client, lock watcher and authorizer for Kube proxy.
|
|
proxyAuthClient, err := env.server.TLS.NewClient(authtest.TestBuiltin(types.RoleProxy))
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, proxyAuthClient.Close()) })
|
|
|
|
serverIdentity, err := authtest.NewServerIdentity(env.server.Auth(), uuid.NewString(), types.RoleProxy)
|
|
require.NoError(t, err)
|
|
tlsConfig, err := serverIdentity.TLSConfig(nil)
|
|
require.NoError(t, err)
|
|
limiter, err := limiter.NewLimiter(limiter.Config{MaxConnections: 100})
|
|
require.NoError(t, err)
|
|
// authMiddleware authenticates request assuming TLS client authentication
|
|
// adds authentication information to the context
|
|
// and passes it to the API server
|
|
authMiddleware := &auth.Middleware{
|
|
Middleware: authz.Middleware{
|
|
ClusterName: clusterName,
|
|
AcceptedUsage: []string{teleport.UsageKubeOnly},
|
|
},
|
|
Limiter: limiter,
|
|
}
|
|
|
|
tlsConf := copyAndConfigureTLS(tlsConfig, proxyAuthClient, clusterName)
|
|
creds, err := auth.NewTransportCredentials(auth.TransportCredentialsConfig{
|
|
TransportCredentials: credentials.NewTLS(tlsConf),
|
|
UserGetter: authMiddleware,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
grpcServer := grpc.NewServer(
|
|
grpc.ChainUnaryInterceptor(authMiddleware.UnaryInterceptors()...),
|
|
grpc.ChainStreamInterceptor(authMiddleware.StreamInterceptors()...),
|
|
grpc.Creds(creds),
|
|
)
|
|
|
|
kubeproto.RegisterKubeServiceServer(grpcServer, &fakeKubeService{})
|
|
errC := make(chan error, 1)
|
|
t.Cleanup(func() {
|
|
grpcServer.GracefulStop()
|
|
require.NoError(t, <-errC)
|
|
})
|
|
go func() {
|
|
err := grpcServer.Serve(listener)
|
|
errC <- trace.Wrap(err)
|
|
}()
|
|
}
|
|
|
|
// copyAndConfigureTLS can be used to copy and modify an existing *tls.Config
|
|
// for Teleport application proxy servers.
|
|
func copyAndConfigureTLS(config *tls.Config, accessPoint authclient.AccessCache, clusterName string) *tls.Config {
|
|
tlsConfig := config.Clone()
|
|
|
|
// Require clients to present a certificate
|
|
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
|
|
|
// Configure function that will be used to fetch the CA that signed the
|
|
// client's certificate to verify the chain presented. If the client does not
|
|
// pass in the cluster name, this functions pulls back all CA to try and
|
|
// match the certificate presented against any CA.
|
|
tlsConfig.GetConfigForClient = authclient.WithClusterCAs(tlsConfig.Clone(), accessPoint, clusterName, slog.Default())
|
|
|
|
return tlsConfig
|
|
}
|
|
|
|
type fakeKubeService struct {
|
|
kubeproto.UnimplementedKubeServiceServer
|
|
}
|
|
|
|
func (s *fakeKubeService) ListKubernetesResources(ctx context.Context, req *kubeproto.ListKubernetesResourcesRequest) (*kubeproto.ListKubernetesResourcesResponse, error) {
|
|
switch req.GetResourceType() {
|
|
// NOTE: Here we are using the Teleport kinds.
|
|
case types.KindKubePod:
|
|
{
|
|
return &kubeproto.ListKubernetesResourcesResponse{
|
|
Resources: []*types.KubernetesResourceV1{
|
|
{
|
|
Kind: "pods",
|
|
Metadata: types.Metadata{
|
|
Name: "test-pod",
|
|
Labels: map[string]string{
|
|
"app": "test",
|
|
},
|
|
},
|
|
Spec: types.KubernetesResourceSpecV1{
|
|
Namespace: "default",
|
|
},
|
|
},
|
|
{
|
|
Kind: "pods",
|
|
Metadata: types.Metadata{
|
|
Name: "test-pod2",
|
|
Labels: map[string]string{
|
|
"app": "test2",
|
|
},
|
|
},
|
|
Spec: types.KubernetesResourceSpecV1{
|
|
Namespace: "default",
|
|
},
|
|
},
|
|
},
|
|
TotalCount: 2,
|
|
}, nil
|
|
}
|
|
case types.KindKubeNamespace:
|
|
{
|
|
return &kubeproto.ListKubernetesResourcesResponse{
|
|
Resources: []*types.KubernetesResourceV1{
|
|
{
|
|
Kind: "namespaces",
|
|
Metadata: types.Metadata{
|
|
Name: "default",
|
|
Labels: map[string]string{
|
|
"app": "test",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
TotalCount: 1,
|
|
}, nil
|
|
}
|
|
default:
|
|
return nil, trace.BadParameter("kubernetes resource kind %q is not mocked", req.GetResourceType())
|
|
}
|
|
}
|
|
|
|
func TestWebSocketAuthenticateRequest(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil)
|
|
for _, tc := range []struct {
|
|
name string
|
|
serverExpectError string
|
|
expectResponse wsStatus
|
|
token string
|
|
writeTimeout func()
|
|
readTimeout func()
|
|
}{
|
|
{
|
|
name: "valid token",
|
|
expectResponse: wsStatus{
|
|
Type: "create_session_response",
|
|
Status: "ok",
|
|
},
|
|
token: pack.session.Token,
|
|
},
|
|
{
|
|
name: "invalid token",
|
|
serverExpectError: "not found",
|
|
expectResponse: wsStatus{
|
|
Type: "create_session_response",
|
|
Status: "error",
|
|
Message: "invalid token",
|
|
},
|
|
token: "honk",
|
|
},
|
|
{
|
|
name: "server read timeout",
|
|
serverExpectError: "i/o timeout",
|
|
token: pack.session.Token,
|
|
readTimeout: func() {
|
|
<-time.After(wsIODeadline * 3)
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sctx, ws, err := proxy.handler.handler.AuthenticateRequestWS(w, r)
|
|
if err != nil {
|
|
if tc.serverExpectError == "" {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.serverExpectError) {
|
|
t.Errorf("unexpected error: %v", err)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
t.Cleanup(func() { ws.Close() })
|
|
if tc.serverExpectError != "" {
|
|
t.Errorf("expected error, got nil")
|
|
return
|
|
}
|
|
|
|
clt, err := sctx.GetClient()
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
return
|
|
}
|
|
_, err = clt.GetDomainName(ctx)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
return
|
|
}
|
|
}))
|
|
|
|
header := http.Header{}
|
|
for _, cookie := range pack.cookies {
|
|
header.Add("Cookie", cookie.String())
|
|
}
|
|
|
|
u := strings.Replace(server.URL, "http:", "ws:", 1)
|
|
conn, resp, err := websocket.DefaultDialer.Dial(u, header)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { conn.Close() })
|
|
t.Cleanup(func() { resp.Body.Close() })
|
|
|
|
if tc.readTimeout != nil {
|
|
tc.readTimeout()
|
|
}
|
|
err = conn.WriteJSON(wsBearerToken{
|
|
Token: tc.token,
|
|
})
|
|
require.NoError(t, err)
|
|
if tc.readTimeout != nil {
|
|
return // Reading will fail as the server will have closed the connection
|
|
}
|
|
|
|
var status wsStatus
|
|
err = conn.ReadJSON(&status)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.expectResponse, status)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetKubeExecClusterData(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
listenerMode types.ProxyListenerMode
|
|
proxyKubeAddr string
|
|
proxyWebAddr string
|
|
|
|
expectedServerAddr string
|
|
expectedTLSName string
|
|
|
|
errCheck require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "separate, regular addr",
|
|
listenerMode: types.ProxyListenerMode_Separate,
|
|
proxyKubeAddr: "kube.example.com:555",
|
|
expectedServerAddr: "https://kube.example.com:555",
|
|
},
|
|
{
|
|
name: "separate, specified ip addr",
|
|
listenerMode: types.ProxyListenerMode_Separate,
|
|
proxyKubeAddr: "1.2.3.4:444",
|
|
expectedServerAddr: "https://1.2.3.4:444",
|
|
},
|
|
{
|
|
name: "separate, unspecified ip addr",
|
|
listenerMode: types.ProxyListenerMode_Separate,
|
|
proxyKubeAddr: "0.0.0.0:444",
|
|
expectedServerAddr: "https://localhost:444",
|
|
},
|
|
{
|
|
name: "multiplex, regular proxy web addr",
|
|
listenerMode: types.ProxyListenerMode_Multiplex,
|
|
proxyWebAddr: "web.example.com:777",
|
|
expectedServerAddr: "https://web.example.com:777",
|
|
expectedTLSName: "kube-teleport-proxy-alpn.web.example.com",
|
|
},
|
|
{
|
|
name: "multiplex, proxy web addr unspecified ip",
|
|
listenerMode: types.ProxyListenerMode_Multiplex,
|
|
proxyWebAddr: "0.0.0.0:888",
|
|
expectedServerAddr: "https://localhost:888",
|
|
expectedTLSName: "kube-teleport-proxy-alpn.teleport.cluster.local",
|
|
},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := Handler{}
|
|
if tt.proxyWebAddr != "" {
|
|
h.cfg.ProxyWebAddr = *utils.MustParseAddr(tt.proxyWebAddr)
|
|
}
|
|
if tt.proxyKubeAddr != "" {
|
|
h.cfg.ProxyKubeAddr = *utils.MustParseAddr(tt.proxyKubeAddr)
|
|
}
|
|
|
|
netConfig := types.ClusterNetworkingConfigV2{Spec: types.ClusterNetworkingConfigSpecV2{
|
|
ProxyListenerMode: tt.listenerMode,
|
|
}}
|
|
|
|
serverAddr, tlsServerName, err := h.getKubeExecClusterData(&netConfig)
|
|
if tt.errCheck != nil {
|
|
tt.errCheck(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.expectedServerAddr, serverAddr)
|
|
require.Equal(t, tt.expectedTLSName, tlsServerName)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSimultaneousAuthenticateRequest ensures that multiple authenticated
|
|
// requests do not race to create a SessionContext. This would happen when
|
|
// Proxies were deployed behind a round-robin load balancer. Only the Proxy
|
|
// that handled the login will have initially created a SessionContext for
|
|
// the particular user+session. All subsequent requests to the other Proxies
|
|
// in the load balancer pool attempt to create a SessionContext in
|
|
// [Handler.AuthenticateRequest] if one didn't already exist. If the web UI
|
|
// makes enough requests fast enough it can result in the Proxy trying to
|
|
// create multiple SessionContext for a user+session. Since only one SessionContext
|
|
// is stored in the sessionCache all previous SessionContext and their underlying
|
|
// auth client get closed, which results in an ugly and unfriendly
|
|
// `grpc: the client connection is closing` error banner on the web webui.
|
|
func TestSimultaneousAuthenticateRequest(t *testing.T) {
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
|
|
proxy := env.proxies[0]
|
|
|
|
// Authenticate to get a session token and cookies.
|
|
pack := proxy.authPack(t, "[email protected]", nil)
|
|
|
|
// Reset the sessions so that all future requests will race to create
|
|
// a new SessionContext for the user + session pair to simulate multiple
|
|
// proxies behind a load balancer.
|
|
proxy.handler.handler.auth.sessions = map[string]*SessionContext{}
|
|
|
|
// Create a request with the auth header and cookies for the session.
|
|
endpoint := pack.clt.Endpoint("webapi", "sites")
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
require.NoError(t, err)
|
|
|
|
req.Header.Set("Authorization", "Bearer "+pack.session.Token)
|
|
for _, cookie := range pack.cookies {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
// Spawn several requests in parallel and attempt to use the auth client.
|
|
type res struct {
|
|
domain string
|
|
err error
|
|
}
|
|
const requests = 10
|
|
respC := make(chan res, requests)
|
|
for range requests {
|
|
go func() {
|
|
sctx, err := proxy.handler.handler.AuthenticateRequest(httptest.NewRecorder(), req.Clone(ctx), false)
|
|
if err != nil {
|
|
respC <- res{err: err}
|
|
return
|
|
}
|
|
|
|
clt, err := sctx.GetClient()
|
|
if err != nil {
|
|
respC <- res{err: err}
|
|
return
|
|
}
|
|
|
|
domain, err := clt.GetDomainName(ctx)
|
|
respC <- res{domain: domain, err: err}
|
|
}()
|
|
}
|
|
|
|
// Assert that all requests were successful and each one was able to
|
|
// get the domain name without its auth client being closed.
|
|
for range requests {
|
|
select {
|
|
case res := <-respC:
|
|
require.NoError(t, res.err)
|
|
require.Equal(t, "localhost", res.domain)
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for responses")
|
|
}
|
|
}
|
|
}
|
|
|
|
// mockedPingTestProxy is a test proxy with a mocked Ping method
|
|
type mockedPingTestProxy struct {
|
|
authclient.ClientI
|
|
mockedPing func(ctx context.Context) (authproto.PingResponse, error)
|
|
}
|
|
|
|
func (m mockedPingTestProxy) Ping(ctx context.Context) (authproto.PingResponse, error) {
|
|
return m.mockedPing(ctx)
|
|
}
|
|
|
|
// TestModeratedSession validates that peers are able to start Moderated
|
|
// Sessions and remain in the waiting room until the required number of
|
|
// moderators are present. Only when the moderator is present the peer
|
|
// is allowed to access the host and start entering input and receiving
|
|
// output until the moderator terminates the session.
|
|
func TestModeratedSession(t *testing.T) {
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
disableDiskBasedRecording: true,
|
|
modules: modulestest.EnterpriseModules(),
|
|
})
|
|
|
|
peerRole, err := types.NewRole("moderated", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
RequireSessionJoin: []*types.SessionRequirePolicy{
|
|
{
|
|
Name: "moderated",
|
|
Filter: "contains(user.roles, \"moderator\")",
|
|
Kinds: []string{string(types.SSHSessionKind)},
|
|
Count: 1,
|
|
Modes: []string{string(types.SessionModeratorMode)},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
peerRole, err = s.server.Auth().UpsertRole(s.ctx, peerRole)
|
|
require.NoError(t, err)
|
|
|
|
moderatorRole, err := types.NewRole("moderator", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
JoinSessions: []*types.SessionJoinPolicy{
|
|
{
|
|
Name: "moderated",
|
|
Roles: []string{peerRole.GetName()},
|
|
Kinds: []string{string(types.SSHSessionKind)},
|
|
Modes: []string{string(types.SessionModeratorMode), string(types.SessionObserverMode)},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
moderatorRole, err = s.server.Auth().UpsertRole(s.ctx, moderatorRole)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
peerTerm, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "foo", peerRole.GetName()),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, peerTerm.Close()) })
|
|
|
|
require.NoError(t, waitForOutput(peerTerm, "Teleport > Waiting for required participants..."), "waiting for peer to enter session")
|
|
|
|
moderatorTerm, err := connectToHost(ctx, connectConfig{
|
|
pack: s.authPack(t, "bar", moderatorRole.GetName()),
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
sessionID: peerTerm.GetSession().ID,
|
|
participantMode: types.SessionModeratorMode,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, moderatorTerm.Close()) })
|
|
|
|
require.NoError(t, waitForOutput(peerTerm, "Teleport > Connecting to node over SSH"), "waiting for peer connection to node after moderator joins")
|
|
|
|
// here we intentionally run a command where the output we're looking
|
|
// for is not present in the command itself
|
|
_, err = io.WriteString(peerTerm, "echo llxmx | sed 's/x/a/g'\r\n")
|
|
require.NoError(t, err)
|
|
require.NoError(t, waitForOutput(peerTerm, "llama"), "waiting for output on peer terminal")
|
|
require.NoError(t, waitForOutput(moderatorTerm, "llama"), "waiting for output on moderator terminal")
|
|
|
|
// the moderator terminates the session
|
|
_, err = io.WriteString(moderatorTerm, "t")
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, waitForOutput(moderatorTerm, "Stopping session..."), "waiting for moderator to terminate session")
|
|
require.NoError(t, waitForOutput(peerTerm, "Process exited with status 255"), "waiting for peer session to be terminated")
|
|
}
|
|
|
|
// TestModeratedSessionWithMFA validates the same behavior as TestModeratedSession while
|
|
// also ensuring that MFA is performed prior to accessing the host and that periodic
|
|
// presence checks are performed by the moderator. When presence checks are not performed
|
|
// the session is aborted.
|
|
func TestModeratedSessionWithMFA(t *testing.T) {
|
|
const RPID = "localhost"
|
|
|
|
presenceClock := clockwork.NewFakeClock()
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
clock: clockwork.NewFakeClockAt(presenceClock.Now()),
|
|
disableDiskBasedRecording: true,
|
|
authPreferenceSpec: &types.AuthPreferenceSpecV2{
|
|
Type: constants.Local,
|
|
ConnectorName: constants.PasswordlessConnector,
|
|
SecondFactor: constants.SecondFactorOn,
|
|
RequireMFAType: types.RequireMFAType_SESSION,
|
|
Webauthn: &types.Webauthn{
|
|
RPID: RPID,
|
|
},
|
|
},
|
|
presenceChecker: func(ctx context.Context, term io.Writer, maintainer client.PresenceMaintainer, sessionID string, mfaCeremony *mfa.Ceremony, opts ...client.PresenceOption) error {
|
|
return trace.Wrap(client.RunPresenceTask(ctx, term, maintainer, sessionID, mfaCeremony, client.WithPresenceClock(presenceClock)))
|
|
},
|
|
modules: modulestest.EnterpriseModules(),
|
|
})
|
|
|
|
peerRole, err := types.NewRole("moderated", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
RequireSessionJoin: []*types.SessionRequirePolicy{
|
|
{
|
|
Name: "moderated",
|
|
Filter: "contains(user.roles, \"moderator\")",
|
|
Kinds: []string{string(types.SSHSessionKind)},
|
|
Count: 1,
|
|
Modes: []string{string(types.SessionModeratorMode)},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
moderatorRole, err := types.NewRole("moderator", types.RoleSpecV6{
|
|
Allow: types.RoleConditions{
|
|
JoinSessions: []*types.SessionJoinPolicy{
|
|
{
|
|
Name: "moderated",
|
|
Roles: []string{peerRole.GetName()},
|
|
Kinds: []string{string(types.SSHSessionKind)},
|
|
Modes: []string{string(types.SessionModeratorMode), string(types.SessionObserverMode)},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
peer := s.authPackWithMFA(t, "foo", peerRole)
|
|
moderator := s.authPackWithMFA(t, "bar", moderatorRole)
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
peerTerm, err := connectToHost(ctx, connectConfig{
|
|
pack: peer,
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
mfaCeremony: func(challenge client.MFAAuthenticateChallenge) []byte {
|
|
res, err := peer.device.SolveAuthn(&authproto.MFAAuthenticateChallenge{
|
|
WebauthnChallenge: wantypes.CredentialAssertionToProto(challenge.WebauthnChallenge),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
webauthnResBytes, err := json.Marshal(wantypes.CredentialAssertionResponseFromProto(res.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
|
|
envelope := &terminal.Envelope{
|
|
Version: defaults.WebsocketVersion,
|
|
Type: defaults.WebsocketMFAChallenge,
|
|
Payload: string(webauthnResBytes),
|
|
}
|
|
envelopeBytes, err := proto.Marshal(envelope)
|
|
require.NoError(t, err)
|
|
|
|
return envelopeBytes
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, peerTerm.Close()) })
|
|
|
|
require.NoError(t, waitForOutput(peerTerm, "Teleport > Waiting for required participants..."), "waiting for peer to start session")
|
|
|
|
moderatorTerm, err := connectToHost(ctx, connectConfig{
|
|
pack: moderator,
|
|
host: s.node.ID(),
|
|
proxy: s.webServer.Listener.Addr().String(),
|
|
sessionID: peerTerm.GetSession().ID,
|
|
participantMode: types.SessionModeratorMode,
|
|
mfaCeremony: func(challenge client.MFAAuthenticateChallenge) []byte {
|
|
res, err := moderator.device.SolveAuthn(&authproto.MFAAuthenticateChallenge{
|
|
WebauthnChallenge: wantypes.CredentialAssertionToProto(challenge.WebauthnChallenge),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
webauthnResBytes, err := json.Marshal(wantypes.CredentialAssertionResponseFromProto(res.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
|
|
envelope := &terminal.Envelope{
|
|
Version: defaults.WebsocketVersion,
|
|
Type: defaults.WebsocketMFAChallenge,
|
|
Payload: string(webauthnResBytes),
|
|
}
|
|
envelopeBytes, err := proto.Marshal(envelope)
|
|
require.NoError(t, err)
|
|
|
|
return envelopeBytes
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, moderatorTerm.Close()) })
|
|
|
|
require.NoError(t, waitForOutput(peerTerm, "Teleport > Connecting to node over SSH"), "waiting for peer to connect after moderator joins")
|
|
|
|
// here we intentionally run a command where the output we're looking
|
|
// for is not present in the command itself
|
|
_, err = io.WriteString(peerTerm, "echo llxmx | sed 's/x/a/g'\r\n")
|
|
require.NoError(t, err)
|
|
require.NoError(t, waitForOutput(peerTerm, "llama"), "waiting for output in peer terminal")
|
|
require.NoError(t, waitForOutput(moderatorTerm, "llama"), "waiting for output in moderator terminal")
|
|
|
|
// run the presence check a few times
|
|
for range 3 {
|
|
presenceClock.BlockUntil(1)
|
|
presenceClock.Advance(30 * time.Second)
|
|
require.NoError(t, waitForOutput(moderatorTerm, "Teleport > Please tap your MFA key"), "waiting for moderator mfa prompt")
|
|
|
|
challenge, err := moderatorTerm.stream.ReadChallenge(protobufMFACodec{})
|
|
require.NoError(t, err)
|
|
|
|
res, err := moderator.device.SolveAuthn(challenge)
|
|
require.NoError(t, err)
|
|
|
|
webauthnResBytes, err := json.Marshal(wantypes.CredentialAssertionResponseFromProto(res.GetWebauthn()))
|
|
require.NoError(t, err)
|
|
|
|
envelope := &terminal.Envelope{
|
|
Version: defaults.WebsocketVersion,
|
|
Type: defaults.WebsocketMFAChallenge,
|
|
Payload: string(webauthnResBytes),
|
|
}
|
|
envelopeBytes, err := proto.Marshal(envelope)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, moderatorTerm.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes))
|
|
}
|
|
|
|
// Advance the clock far enough in the future to make the moderator stale
|
|
// which will terminate the session - because the clock is used by ALL server
|
|
// components, it's not practical to use BlockUntil here, so we use EventuallyWithT instead.
|
|
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
|
s.clock.Advance(3 * time.Minute)
|
|
require.NoError(t, waitForOutputWithDuration(moderatorTerm, "wait: remote command exited without exit status or exit signal", 3*time.Second))
|
|
require.NoError(t, waitForOutputWithDuration(peerTerm, "Process exited with status 255", 3*time.Second))
|
|
}, 15*time.Second, 500*time.Millisecond)
|
|
}
|
|
|
|
type proxyClientMock struct {
|
|
authclient.ClientI
|
|
tokens map[string]types.ProvisionToken
|
|
}
|
|
|
|
// GetToken returns provisioning token
|
|
func (pc *proxyClientMock) GetToken(_ context.Context, token string) (types.ProvisionToken, error) {
|
|
tok, ok := pc.tokens[token]
|
|
if ok {
|
|
return tok, nil
|
|
}
|
|
|
|
return nil, trace.NotFound("%s", token)
|
|
}
|
|
|
|
func (pc *proxyClientMock) DeleteToken(_ context.Context, token string) error {
|
|
_, ok := pc.tokens[token]
|
|
if ok {
|
|
delete(pc.tokens, token)
|
|
return nil
|
|
}
|
|
return trace.NotFound("%s", token)
|
|
}
|
|
|
|
func Test_consumeTokenForAPICall(t *testing.T) {
|
|
pc := &proxyClientMock{tokens: map[string]types.ProvisionToken{}}
|
|
|
|
tests := []struct {
|
|
name string
|
|
getToken func() (string, types.ProvisionToken)
|
|
wantErr require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
name: "missing token is rejected",
|
|
getToken: func() (string, types.ProvisionToken) {
|
|
return "fake", nil
|
|
},
|
|
wantErr: func(t require.TestingT, err error, i ...any) {
|
|
require.True(t, trace.IsNotFound(err))
|
|
},
|
|
},
|
|
{
|
|
name: "valid token is accepted",
|
|
getToken: func() (string, types.ProvisionToken) {
|
|
tok, err := types.NewProvisionToken(uuid.New().String(), []types.SystemRole{types.RoleDatabase}, time.Now().Add(time.Hour))
|
|
require.NoError(t, err)
|
|
pc.tokens[tok.GetName()] = tok
|
|
return tok.GetName(), tok
|
|
},
|
|
},
|
|
{
|
|
name: "token with no expiry is accepted",
|
|
getToken: func() (string, types.ProvisionToken) {
|
|
tok, err := types.NewProvisionToken(uuid.New().String(), []types.SystemRole{types.RoleDatabase}, time.Time{})
|
|
require.NoError(t, err)
|
|
pc.tokens[tok.GetName()] = tok
|
|
return tok.GetName(), tok
|
|
},
|
|
},
|
|
{
|
|
name: "expired token is rejected",
|
|
getToken: func() (string, types.ProvisionToken) {
|
|
tok, err := types.NewProvisionToken(uuid.New().String(), []types.SystemRole{types.RoleDatabase}, time.Now().Add(-time.Hour))
|
|
require.NoError(t, err)
|
|
pc.tokens[tok.GetName()] = tok
|
|
return tok.GetName(), tok
|
|
},
|
|
wantErr: func(t require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(t, err, "expired token")
|
|
},
|
|
},
|
|
{
|
|
name: "token with invalid join type is rejected",
|
|
getToken: func() (string, types.ProvisionToken) {
|
|
tok, err := types.NewProvisionTokenFromSpec("ec2-token", time.Now().Add(time.Hour), types.ProvisionTokenSpecV2{
|
|
Roles: []types.SystemRole{types.RoleDatabase},
|
|
Allow: []*types.TokenRule{{AWSAccount: "1234"}},
|
|
JoinMethod: types.JoinMethodEC2,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
pc.tokens[tok.GetName()] = tok
|
|
return tok.GetName(), tok
|
|
},
|
|
wantErr: func(t require.TestingT, err error, i ...any) {
|
|
require.ErrorContains(t, err, "unexpected join method \"ec2\" for token \"ec2-token\"")
|
|
},
|
|
},
|
|
}
|
|
|
|
tokenExists := func(tokenName string) bool {
|
|
tok, _ := pc.GetToken(context.Background(), tokenName)
|
|
return tok != nil
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tokName, tok := tt.getToken()
|
|
tokenInitiallyPresent := tokenExists(tokName)
|
|
result, err := consumeTokenForAPICall(context.Background(), pc, tokName)
|
|
if tt.wantErr != nil {
|
|
tt.wantErr(t, err)
|
|
// verify that if token was present, then it has not been deleted.
|
|
require.Equal(t, tokenInitiallyPresent, tokenExists(tokName))
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, tok, result)
|
|
// verify that token does not exist now, even if it did.
|
|
require.False(t, tokenExists(tokName))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGithubAuthCompat asserts that the proxy can handle github login flows
|
|
// from tsh clients that send split keys in the new format, or only a single key
|
|
// in the old format.
|
|
func TestGithubAuthCompat(t *testing.T) {
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
|
|
env.server.Auth().GithubUserAndTeamsOverride = func() (*auth.GithubUserResponse, []auth.GithubTeamResponse, error) {
|
|
return &auth.GithubUserResponse{
|
|
Login: "alice",
|
|
}, []auth.GithubTeamResponse{{
|
|
Name: "devs",
|
|
Slug: "devs",
|
|
Org: auth.GithubOrgResponse{Login: "octocats"},
|
|
}}, nil
|
|
}
|
|
|
|
connector, err := types.NewGithubConnector("github", types.GithubConnectorSpecV3{
|
|
ClientID: "12345",
|
|
ClientSecret: "678910",
|
|
RedirectURL: "https://proxy.example.com/v1/webapi/github/callback",
|
|
Display: "Github",
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "octocats",
|
|
Team: "devs",
|
|
Roles: []string{"access"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().UpsertGithubConnector(ctx, connector)
|
|
require.NoError(t, err)
|
|
|
|
access, err := types.NewRole("access", types.RoleSpecV6{})
|
|
require.NoError(t, err)
|
|
_, err = env.server.Auth().CreateRole(ctx, access)
|
|
require.NoError(t, err)
|
|
|
|
clt := env.proxies[0].newClient(t)
|
|
|
|
// The response in the callback will be encrypted with this key.
|
|
secretKey, err := secret.NewKey()
|
|
require.NoError(t, err)
|
|
|
|
sshKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519)
|
|
require.NoError(t, err)
|
|
sshPub, err := ssh.NewPublicKey(sshKey.Public())
|
|
require.NoError(t, err)
|
|
sshPubBytes := ssh.MarshalAuthorizedKey(sshPub)
|
|
|
|
tlsKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
|
|
require.NoError(t, err)
|
|
tlsPubBytes, err := keys.MarshalPublicKey(tlsKey.Public())
|
|
require.NoError(t, err)
|
|
|
|
for _, tc := range []struct {
|
|
desc string
|
|
sshPubKey, tlsPubKey []byte
|
|
expectLoginError string
|
|
expectSSHSubjectKey ssh.PublicKey
|
|
expectTLSSubjectKey crypto.PublicKey
|
|
}{
|
|
{
|
|
desc: "no keys",
|
|
expectLoginError: "Failed to login",
|
|
},
|
|
{
|
|
desc: "both keys",
|
|
sshPubKey: sshPubBytes,
|
|
tlsPubKey: tlsPubBytes,
|
|
expectSSHSubjectKey: sshPub,
|
|
expectTLSSubjectKey: tlsKey.Public(),
|
|
},
|
|
{
|
|
desc: "only ssh",
|
|
sshPubKey: sshPubBytes,
|
|
expectSSHSubjectKey: sshPub,
|
|
},
|
|
{
|
|
desc: "only tls",
|
|
tlsPubKey: tlsPubBytes,
|
|
expectTLSSubjectKey: tlsKey.Public(),
|
|
},
|
|
} {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
// Initiate the github SSO login.
|
|
loginResponse, err := clt.PostJSON(ctx, clt.Endpoint("webapi", "github", "login", "console"), client.SSOLoginConsoleReq{
|
|
RedirectURL: (&url.URL{
|
|
Scheme: "http",
|
|
Host: "localhost",
|
|
Path: "callback",
|
|
RawQuery: url.Values{"secret_key": []string{secretKey.String()}}.Encode(),
|
|
}).String(),
|
|
ConnectorID: "github",
|
|
UserPublicKeys: client.UserPublicKeys{
|
|
SSHPubKey: tc.sshPubKey,
|
|
TLSPubKey: tc.tlsPubKey,
|
|
},
|
|
CertTTL: time.Hour,
|
|
})
|
|
if tc.expectLoginError != "" {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tc.expectLoginError)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
// Retrieve the state token from the redirect URL in the response.
|
|
stateToken := stateTokenFromConsoleLoginResponse(t, loginResponse.Bytes())
|
|
|
|
// Send the callback to the proxy to complete the login.
|
|
callbackResponse, err := clt.Get(ctx, clt.Endpoint("webapi", "github", "callback"), url.Values{
|
|
"state": []string{stateToken},
|
|
"code": []string{"success"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Retrieve the login response from the callback response HTML.
|
|
sshLoginResponse := sshLoginResponseFromCallbackResponse(t, callbackResponse.Reader(), secretKey)
|
|
|
|
// Make sure the subject key in the issued SSH cert matches the
|
|
// expected key and didn't get accidentally switched.
|
|
if tc.expectSSHSubjectKey != nil {
|
|
sshCert, err := apisshutils.ParseCertificate(sshLoginResponse.Cert)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.expectSSHSubjectKey, sshCert.Key)
|
|
} else {
|
|
// No SSH cert should be issued if we didn't ask for one.
|
|
require.Empty(t, sshLoginResponse.Cert)
|
|
}
|
|
|
|
// Make sure the subject key in the issued TLS cert matches the
|
|
// expected key and didn't get accidentally switched.
|
|
if tc.expectTLSSubjectKey != nil {
|
|
tlsCert, err := tlsca.ParseCertificatePEM(sshLoginResponse.TLSCert)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.expectTLSSubjectKey, tlsCert.PublicKey)
|
|
} else {
|
|
// No TLS cert should be issued if we didn't ask for one.
|
|
require.Empty(t, sshLoginResponse.TLSCert)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func stateTokenFromConsoleLoginResponse(t *testing.T, responseBody []byte) string {
|
|
var loginResp client.SSOLoginConsoleResponse
|
|
require.NoError(t, json.Unmarshal(responseBody, &loginResp))
|
|
u, err := url.Parse(loginResp.RedirectURL)
|
|
require.NoError(t, err, "parsing login redirect URL")
|
|
q, err := url.ParseQuery(u.RawQuery)
|
|
require.NoError(t, err, "parsing login redirect URL query params")
|
|
require.Contains(t, q, "state", "redirect query did not contain state token")
|
|
return q["state"][0]
|
|
}
|
|
|
|
// The login response we're after with the certs in it is:
|
|
// - JSON
|
|
// - encrypted with [secretKey]
|
|
// - in a query param
|
|
// - in a redirect URL
|
|
// - in an HTML document
|
|
func sshLoginResponseFromCallbackResponse(t *testing.T, responseBody io.Reader, secretKey secret.Key) *authclient.CLILoginResponse {
|
|
// First pull the URL from the HTML meta redirect.
|
|
redirectURL, err := app.GetURLFromMetaRedirect(responseBody)
|
|
require.NoError(t, err)
|
|
|
|
// Then get the encrypted JSON out of the query param.
|
|
u, err := url.Parse(redirectURL)
|
|
require.NoError(t, err)
|
|
require.Contains(t, u.Query(), "response", "redirect query did not contain response")
|
|
ciphertext := u.Query().Get("response")
|
|
|
|
// Then unencrypt.
|
|
callbackPlaintext, err := secretKey.Open([]byte(ciphertext))
|
|
require.NoError(t, err, "unencrypting github callback response")
|
|
|
|
// Then unmarshal the JSON.
|
|
var sshLoginResponse authclient.CLILoginResponse
|
|
require.NoError(t, json.Unmarshal(callbackPlaintext, &sshLoginResponse))
|
|
return &sshLoginResponse
|
|
}
|
|
|
|
func TestGithubConnector(t *testing.T) {
|
|
// We run this test as a Teleport Enterprise server to bypass the check for whether
|
|
// the GitHub org uses SSO.
|
|
ctx := t.Context()
|
|
env := newWebPack(t, 1, withModules(modulestest.EnterpriseModules()))
|
|
proxy := env.proxies[0]
|
|
|
|
// Authenticate to get a session token and cookies.
|
|
pack := proxy.authPack(t, "[email protected]", nil)
|
|
|
|
expected, err := types.NewGithubConnector("github", types.GithubConnectorSpecV3{
|
|
ClientID: "12345",
|
|
ClientSecret: "678910",
|
|
RedirectURL: "https://proxy.example.com/v1/webapi/github/callback",
|
|
Display: "Github",
|
|
TeamsToRoles: []types.TeamRolesMapping{
|
|
{
|
|
Organization: "acme",
|
|
Team: "users",
|
|
Roles: []string{"access", "editor", "auditor"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err, "creating initial connector resource")
|
|
|
|
createPayload := func(connector types.GithubConnector) webui.ResourceItem {
|
|
raw, err := services.MarshalGithubConnector(connector, services.PreserveRevision())
|
|
require.NoError(t, err, "marshaling connector")
|
|
|
|
return webui.ResourceItem{
|
|
Kind: types.KindGithubConnector,
|
|
Name: connector.GetName(),
|
|
Content: string(raw),
|
|
}
|
|
}
|
|
|
|
unmarshalResponse := func(resp []byte) types.GithubConnector {
|
|
var item webui.ResourceItem
|
|
require.NoError(t, json.Unmarshal(resp, &item), "response from server contained an invalid resource item")
|
|
|
|
var conn types.GithubConnectorV3
|
|
require.NoError(t, yaml.Unmarshal([]byte(item.Content), &conn), "resource item content was not a github connector")
|
|
return &conn
|
|
}
|
|
|
|
// Create the initial connector.
|
|
resp, err := pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "github"), createPayload(expected))
|
|
require.NoError(t, err, "expected creating the initial connector to succeed")
|
|
require.Equal(t, http.StatusOK, resp.Code(), "unexpected status code creating connector")
|
|
|
|
created := unmarshalResponse(resp.Bytes())
|
|
|
|
// Validate that creating the connector again fails.
|
|
resp, err = pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "github"), createPayload(expected))
|
|
assert.Error(t, err, "expected an error creating a duplicate connector")
|
|
assert.True(t, trace.IsAlreadyExists(err), "expected an already exists error got %T", err)
|
|
assert.Equal(t, http.StatusConflict, resp.Code(), "unexpected status code creating duplicate connector")
|
|
|
|
// Update the connector.
|
|
created.SetDisplay("test")
|
|
resp, err = pack.clt.PutJSON(ctx, pack.clt.Endpoint("webapi", "github", expected.GetName()), createPayload(created))
|
|
require.NoError(t, err, "unexpected error updating the connector")
|
|
require.Equal(t, http.StatusOK, resp.Code(), "unexpected status code updating the connector")
|
|
|
|
updated := unmarshalResponse(resp.Bytes())
|
|
|
|
require.Empty(t, cmp.Diff(created, updated, cmpopts.IgnoreFields(types.Metadata{}, "Revision", "Namespace"),
|
|
cmpopts.IgnoreFields(types.GithubConnectorSpecV3{}, "Display", "ClientSecret"),
|
|
))
|
|
require.NotEqual(t, expected.GetDisplay(), updated.GetDisplay(), "expected update to modify the display name")
|
|
require.Equal(t, "test", updated.GetDisplay(), "display name should have been updated to test. got %s", updated.GetDisplay())
|
|
|
|
// Validate that a stale revision prevents updates.
|
|
resp, err = pack.clt.PutJSON(ctx, pack.clt.Endpoint("webapi", "github", expected.GetName()), createPayload(expected))
|
|
assert.Error(t, err, "expected an error updating a connector with a stale revision")
|
|
assert.True(t, trace.IsCompareFailed(err), "expected a compare failed error got %T", err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, resp.Code(), "unexpected status code updating the connector")
|
|
|
|
// Validate that renaming the connector prevents updates.
|
|
updated.SetName(uuid.NewString())
|
|
resp, err = pack.clt.PutJSON(ctx, pack.clt.Endpoint("webapi", "github", expected.GetName()), createPayload(updated))
|
|
assert.Error(t, err, "expected and error when renaming a connector")
|
|
assert.True(t, trace.IsBadParameter(err), "expected a bad parameter error got %T", err)
|
|
assert.Equal(t, http.StatusBadRequest, resp.Code(), "unexpected status code updating the connector")
|
|
|
|
// Validate that updating a nonexistent connector fails.
|
|
updated.SetName(uuid.NewString())
|
|
resp, err = pack.clt.PutJSON(ctx, pack.clt.Endpoint("webapi", "github", updated.GetName()), createPayload(updated))
|
|
assert.Error(t, err, "expected updating a nonexistent connector to fail")
|
|
assert.True(t, trace.IsCompareFailed(err), "expected a compare failed error got %T", err)
|
|
assert.Equal(t, http.StatusPreconditionFailed, resp.Code(), "unexpected status code updating the connector")
|
|
|
|
// Validate that the connector can be deleted
|
|
_, err = pack.clt.Delete(ctx, pack.clt.Endpoint("webapi", "github", expected.GetName()))
|
|
require.NoError(t, err, "unexpected error deleting connector")
|
|
|
|
resp, err = pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "github"), nil)
|
|
assert.NoError(t, err, "unexpected error listing github connectors")
|
|
|
|
authConnectorsResp := webui.ListAuthConnectorsResponse{}
|
|
require.NoError(t, json.Unmarshal(resp.Bytes(), &authConnectorsResp), "invalid response received")
|
|
|
|
assert.Empty(t, authConnectorsResp.Connectors)
|
|
assert.Equal(t, http.StatusOK, resp.Code(), "unexpected status code getting connectors")
|
|
}
|
|
|
|
func TestWebSocketClosedBeforeSSHSessionCreated(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{disableDiskBasedRecording: true})
|
|
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
t.Cleanup(cancel)
|
|
|
|
pack := s.authPack(t, "foo")
|
|
|
|
req := TerminalRequest{
|
|
Server: s.node.ID(),
|
|
Login: pack.login,
|
|
Term: session.TerminalParams{
|
|
W: 100,
|
|
H: 100,
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(req)
|
|
require.NoError(t, err)
|
|
|
|
u := url.URL{
|
|
Host: s.webServer.Listener.Addr().String(),
|
|
Scheme: client.WSS,
|
|
Path: "/v1/webapi/sites/-current-/connect/ws",
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Set("params", string(data))
|
|
u.RawQuery = q.Encode()
|
|
|
|
header := http.Header{}
|
|
header.Add("Origin", "http://localhost")
|
|
for _, cookie := range pack.cookies {
|
|
header.Add("Cookie", cookie.String())
|
|
}
|
|
|
|
dialer := websocket.Dialer{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
}
|
|
|
|
ws, resp, err := dialer.Dial(u.String(), header)
|
|
if err != nil {
|
|
var sb strings.Builder
|
|
sb.WriteString("websocket dial")
|
|
if resp != nil {
|
|
fmt.Fprintf(&sb, "; status code %v;", resp.StatusCode)
|
|
fmt.Fprintf(&sb, "headers: %v; body: ", resp.Header)
|
|
io.Copy(&sb, resp.Body)
|
|
}
|
|
require.NoError(t, err, sb.String())
|
|
}
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
require.NoError(t, makeAuthReqOverWS(ws, pack.session.Token))
|
|
|
|
wsClosedChan := make(chan struct{})
|
|
|
|
// Create a stream that closes the web socket when the server writes the session metadata
|
|
// to the client. At this point, the SSH connection to the target should be in flight but
|
|
// not yet established.
|
|
stream := terminal.NewStream(ctx, terminal.StreamConfig{
|
|
WS: ws,
|
|
Logger: logtest.NewLogger(),
|
|
Handlers: map[string]terminal.WSHandlerFunc{
|
|
defaults.WebsocketSessionMetadata: func(ctx context.Context, envelope terminal.Envelope) {
|
|
if envelope.Type != defaults.WebsocketSessionMetadata {
|
|
return
|
|
}
|
|
|
|
var sessResp siteSessionGenerateResponse
|
|
if err := json.Unmarshal([]byte(envelope.Payload), &sessResp); err != nil {
|
|
return
|
|
}
|
|
|
|
assert.NoError(t, ws.WriteControl(websocket.CloseMessage, nil, time.Now().Add(time.Second)))
|
|
close(wsClosedChan)
|
|
},
|
|
},
|
|
})
|
|
t.Cleanup(func() { require.NoError(t, stream.Close()) })
|
|
|
|
// Set a read deadline to unblock ReadAll below in the event of a bug
|
|
// preventing the ws from closing above.
|
|
require.NoError(t, stream.SetReadDeadline(time.Now().Add(30*time.Second)))
|
|
|
|
// Wait for the web socket to be closed above.
|
|
select {
|
|
case <-wsClosedChan:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("timed out waiting for session metadata")
|
|
}
|
|
|
|
// Validate that the SSH connection is terminated in response to the WS closing.
|
|
require.Eventually(t, func() bool {
|
|
return s.node.ActiveConnections() == 0
|
|
}, 10*time.Second, 100*time.Millisecond)
|
|
|
|
// Validate that reading nothing was permitted.
|
|
out, err := io.ReadAll(stream)
|
|
require.NoError(t, err)
|
|
require.Empty(t, out)
|
|
}
|
|
|
|
func TestUnstartedServerShutdown(t *testing.T) {
|
|
t.Parallel()
|
|
s := newWebSuite(t)
|
|
srv, err := NewServer(ServerConfig{
|
|
Server: &http.Server{},
|
|
Handler: s.webHandler,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Shutdown the server before starting it shouldn't panic.
|
|
require.NoError(t, srv.Shutdown(context.Background()))
|
|
}
|
|
|
|
// TestPingWithSAMLURL asserts that /webapi/ping and other endpoints necessary
|
|
// for local user login return successfully even when a SAML connector is
|
|
// configured with an entity descriptor URL that hangs when requested.
|
|
func TestPingWithSAMLURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const entityDescriptor = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
|
|
entityID="https://test-idp.example.com/metadata">
|
|
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
<KeyDescriptor use="signing">
|
|
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
|
|
<KeyName>test-signing-key</KeyName>
|
|
<X509Data>
|
|
<X509Certificate></X509Certificate>
|
|
</X509Data>
|
|
</KeyInfo>
|
|
</KeyDescriptor>
|
|
<SingleSignOnService
|
|
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
|
Location="https://test-idp.example.com/sso"/>
|
|
</IDPSSODescriptor>
|
|
</EntityDescriptor>`
|
|
|
|
ctx := t.Context()
|
|
|
|
// Start an HTTP server to serve the SAML entity descriptor. At first it
|
|
// needs to not hang and return a valid response so that the test can upsert
|
|
// the SAML connector.
|
|
l, err := net.Listen("tcp", "localhost:0")
|
|
require.NoError(t, err)
|
|
requestShouldHang := false
|
|
httpServer := &http.Server{
|
|
Addr: l.Addr().String(),
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if requestShouldHang {
|
|
<-r.Context().Done()
|
|
}
|
|
w.Write([]byte(entityDescriptor))
|
|
}),
|
|
}
|
|
testutils.RunTestBackgroundTask(ctx, t, &testutils.TestBackgroundTask{
|
|
Name: "HTTP server",
|
|
Task: func(ctx context.Context) error {
|
|
if err := httpServer.Serve(l); !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
},
|
|
Terminate: httpServer.Close,
|
|
})
|
|
|
|
// Upsert the test SAML connector.
|
|
connector, err := types.NewSAMLConnector("test-idp", types.SAMLConnectorSpecV2{
|
|
AssertionConsumerService: "test-idp.example.com/acs",
|
|
EntityDescriptorURL: "http://" + l.Addr().String() + "/metadata",
|
|
AttributesToRoles: []types.AttributeMapping{
|
|
{Name: "roles", Value: "^(.*)$", Roles: []string{"$1"}},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
pack := newWebPack(t, 1)
|
|
_, err = pack.server.Auth().CreateSAMLConnector(ctx, connector)
|
|
require.NoError(t, err)
|
|
|
|
// Set the HTTP server to start hanging requests for the entity descriptor.
|
|
requestShouldHang = true
|
|
clt := pack.proxies[0].newClient(t)
|
|
for _, endpoint := range []string{
|
|
clt.Endpoint("webapi", "ping"),
|
|
clt.Endpoint("webapi", "ping", connector.GetName()),
|
|
clt.Endpoint("web", "config.js"),
|
|
} {
|
|
t.Run(endpoint, func(t *testing.T) {
|
|
// Requests for the URL should succeed quickly.
|
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
_, err := clt.Get(ctx, endpoint, nil)
|
|
assert.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetLocksV2(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
clusterName := env.server.ClusterName()
|
|
pack := proxy.authPack(t, "[email protected]", nil /* roles */)
|
|
endpoint := pack.clt.Endpoint("v2", "webapi", "sites", clusterName, "locks")
|
|
|
|
locks := []types.Lock{
|
|
newLock(t, "test-lock-1", false, types.LockTarget{User: "test-user-1"}),
|
|
newLock(t, "test-lock-2", false, types.LockTarget{Role: "test-role-1"}),
|
|
newLock(t, "test-lock-3", false, types.LockTarget{Login: "test-login-1"}),
|
|
newLock(t, "test-lock-4", false, types.LockTarget{MFADevice: "test-mfa-1"}),
|
|
newLock(t, "test-lock-5", false, types.LockTarget{WindowsDesktop: "test-win-1"}),
|
|
newLock(t, "test-lock-6", false, types.LockTarget{Device: "test-device-1"}),
|
|
newLock(t, "test-lock-7", false, types.LockTarget{ServerID: "test-server-1"}),
|
|
newLock(t, "test-lock-8", false, types.LockTarget{BotInstanceID: "test-instance-1"}),
|
|
newLock(t, "test-lock-9", false, types.LockTarget{JoinToken: "test-token-1"}),
|
|
|
|
newLock(t, "test-lock-10", true, types.LockTarget{
|
|
User: "test-user-1",
|
|
Role: "test-role-1",
|
|
}),
|
|
}
|
|
|
|
for _, l := range locks {
|
|
err := env.server.Auth().UpsertLock(ctx, l)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Without filters - returns all items
|
|
re, err := pack.clt.Get(ctx, endpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
resp := GetClusterLocksV2Response{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Locks, len(locks))
|
|
|
|
// With target filter - returns partial matching items also
|
|
re, err = pack.clt.Get(ctx, endpoint, url.Values{
|
|
"target": []string{"user|test-user-1", "login|test-login-1"},
|
|
})
|
|
require.NoError(t, err)
|
|
resp = GetClusterLocksV2Response{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Locks, 3)
|
|
|
|
// With target filter and in_force_only - returns only non-expired items
|
|
re, err = pack.clt.Get(ctx, endpoint, url.Values{
|
|
"target": []string{"user|test-user-1"},
|
|
"in_force_only": []string{"true"},
|
|
})
|
|
require.NoError(t, err)
|
|
resp = GetClusterLocksV2Response{}
|
|
require.NoError(t, json.Unmarshal(re.Bytes(), &resp))
|
|
require.Len(t, resp.Locks, 1)
|
|
}
|
|
|
|
func newLock(t *testing.T, name string, expired bool, target types.LockTarget) types.Lock {
|
|
t.Helper()
|
|
|
|
expires := time.Now()
|
|
if expired {
|
|
expires = expires.Add(-time.Hour)
|
|
} else {
|
|
expires = expires.Add(time.Hour)
|
|
}
|
|
|
|
lock, err := types.NewLock(name, types.LockSpecV2{
|
|
Target: target,
|
|
Expires: &expires,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return lock
|
|
}
|
|
|
|
func TestAuthenticateReqForAccessGraphAPI(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
caKeyPEM, caCertPEM, err := tlsca.GenerateSelfSignedCA(pkix.Name{}, nil, time.Hour)
|
|
require.NoError(t, err)
|
|
ca, err := tlsca.FromKeys(caCertPEM, caKeyPEM)
|
|
require.NoError(t, err)
|
|
|
|
clock := clockwork.NewFakeClock()
|
|
|
|
makeCert := func(t *testing.T, identity tlsca.Identity) *x509.Certificate {
|
|
t.Helper()
|
|
key, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
|
|
require.NoError(t, err)
|
|
subj, err := identity.Subject()
|
|
require.NoError(t, err)
|
|
certPEM, err := ca.GenerateCertificate(tlsca.CertificateRequest{
|
|
Clock: clock,
|
|
PublicKey: key.Public(),
|
|
Subject: subj,
|
|
NotAfter: clock.Now().Add(time.Hour),
|
|
})
|
|
require.NoError(t, err)
|
|
cert, err := tlsca.ParseCertificatePEM(certPEM)
|
|
require.NoError(t, err)
|
|
return cert
|
|
}
|
|
|
|
newRequestWithCert := func(cert *x509.Certificate) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}}
|
|
return req
|
|
}
|
|
|
|
h := &Handler{}
|
|
|
|
t.Run("no TLS returns access denied", func(t *testing.T) {
|
|
t.Parallel()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
_, err := h.AuthenticateReqForAccessGraphAPI(req)
|
|
require.True(t, trace.IsAccessDenied(err), "expected access denied, got %v", err)
|
|
require.ErrorContains(t, err, "client certificate required")
|
|
})
|
|
|
|
t.Run("TLS without peer certificate returns access denied", func(t *testing.T) {
|
|
t.Parallel()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.TLS = &tls.ConnectionState{}
|
|
_, err := h.AuthenticateReqForAccessGraphAPI(req)
|
|
require.True(t, trace.IsAccessDenied(err), "expected access denied, got %v", err)
|
|
require.ErrorContains(t, err, "client certificate required")
|
|
})
|
|
|
|
t.Run("cert without access graph usage returns access denied", func(t *testing.T) {
|
|
t.Parallel()
|
|
cert := makeCert(t, tlsca.Identity{
|
|
Username: "user",
|
|
Groups: []string{"access"},
|
|
})
|
|
_, err := h.AuthenticateReqForAccessGraphAPI(newRequestWithCert(cert))
|
|
require.True(t, trace.IsAccessDenied(err), "expected access denied, got %v", err)
|
|
require.ErrorContains(t, err, "client certificate is not valid for Access Graph API")
|
|
})
|
|
|
|
env := newWebPack(t, 1)
|
|
proxy := env.proxies[0]
|
|
pack := proxy.authPack(t, "[email protected]", nil)
|
|
|
|
// Create a web session with Access Graph API usage for testing.
|
|
userInfo, err := proxy.auth.Auth().GetUser(context.Background(), pack.user, false)
|
|
require.NoError(t, err)
|
|
agSession, err := proxy.auth.Auth().CreateWebSessionFromReq(context.Background(), auth.NewWebSessionRequest{
|
|
User: pack.user,
|
|
Roles: userInfo.GetRoles(),
|
|
Traits: userInfo.GetTraits(),
|
|
Usage: types.WebSessionUsage_WEB_SESSION_USAGE_ACCESS_GRAPH_API,
|
|
})
|
|
require.NoError(t, err)
|
|
agSessionID := agSession.GetName()
|
|
|
|
t.Run("cert with access graph usage but unknown session returns access denied", func(t *testing.T) {
|
|
t.Parallel()
|
|
cert := makeCert(t, tlsca.Identity{
|
|
Username: pack.user,
|
|
Groups: []string{"access"},
|
|
Usage: []string{teleport.UsageAccessGraphAPIOnly},
|
|
WebSessionID: "non-existent-session-id",
|
|
})
|
|
_, err := proxy.handler.handler.AuthenticateReqForAccessGraphAPI(newRequestWithCert(cert))
|
|
require.True(t, trace.IsAccessDenied(err), "expected access denied, got %v", err)
|
|
require.ErrorContains(t, err, "need auth")
|
|
})
|
|
|
|
t.Run("cert with access graph usage and valid session returns session context", func(t *testing.T) {
|
|
t.Parallel()
|
|
cert := makeCert(t, tlsca.Identity{
|
|
Username: pack.user,
|
|
Groups: []string{"access"},
|
|
Usage: []string{teleport.UsageAccessGraphAPIOnly},
|
|
WebSessionID: agSessionID,
|
|
})
|
|
sctx, err := proxy.handler.handler.AuthenticateReqForAccessGraphAPI(newRequestWithCert(cert))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sctx)
|
|
require.Equal(t, pack.user, sctx.GetUser())
|
|
})
|
|
}
|
|
|
|
func TestIPPinning(t *testing.T) {
|
|
modulestest.SetTestModules(t, modulestest.Modules{TestBuildType: modules.BuildEnterprise})
|
|
|
|
clientAddr := utils.MustParseAddr("1.2.3.4:1234")
|
|
hostAddr := utils.MustParseAddr("127.0.0.1:3080")
|
|
clientAddrMiddleware := func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := authz.ContextWithClientAddrs(r.Context(), clientAddr, hostAddr)
|
|
r.RemoteAddr = clientAddr.String()
|
|
r = r.WithContext(ctx)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
s := newWebSuiteWithConfig(t, webSuiteConfig{
|
|
middleware: clientAddrMiddleware,
|
|
})
|
|
|
|
// Create a role that enforces IP Pinning.
|
|
ipPinningRole, err := types.NewRole("pinned-ip", types.RoleSpecV6{
|
|
Options: types.RoleOptions{
|
|
PinSourceIP: true,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
ipPinningRole, err = s.server.Auth().UpsertRole(s.ctx, ipPinningRole)
|
|
require.NoError(t, err)
|
|
|
|
// create a user with the ip pinning role and login.
|
|
pack := s.authPack(t, "foo", ipPinningRole.GetName())
|
|
|
|
// IP is pinned on login. Making an Auth server request with the web session
|
|
// with the same IP should succeed.
|
|
clusterName := s.server.ClusterName()
|
|
endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "nodes")
|
|
_, err = pack.clt.Get(t.Context(), endpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
|
|
// Requests with a different IP should fail. Use a different client to simulate a cookie hijacking.
|
|
jar, err := cookiejar.New(nil)
|
|
require.NoError(t, err)
|
|
|
|
clt := s.client(t, roundtrip.BearerAuth(pack.session.Token), roundtrip.CookieJar(jar))
|
|
jar.SetCookies(s.url(), pack.cookies)
|
|
|
|
*clientAddr = *utils.MustParseAddr("5.6.7.8:5678")
|
|
_, err = clt.Get(t.Context(), endpoint, url.Values{})
|
|
require.True(t, trace.IsAccessDenied(err), "expected access denied error but got %q", err)
|
|
require.Len(t, jar.Cookies(s.url()), 1)
|
|
require.Empty(t, jar.Cookies(s.url())[0].Value, "expected hijacked client cookie to be cleared")
|
|
|
|
// Requests from the correct IP should succeed.
|
|
*clientAddr = *utils.MustParseAddr("1.2.3.4:1234")
|
|
_, err = pack.clt.Get(t.Context(), endpoint, url.Values{})
|
|
require.NoError(t, err)
|
|
}
|