mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-02 23:31:08 +08:00
* Fix an issue expired app session won't redirect to login on DynamoDB backend * fix ut * convert all usages from access point
893 lines
28 KiB
Go
893 lines
28 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 app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/google/uuid"
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"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/keys"
|
|
"github.com/gravitational/teleport/lib/auth/authclient"
|
|
"github.com/gravitational/teleport/lib/cryptosuites"
|
|
"github.com/gravitational/teleport/lib/defaults"
|
|
"github.com/gravitational/teleport/lib/events"
|
|
"github.com/gravitational/teleport/lib/reversetunnelclient"
|
|
"github.com/gravitational/teleport/lib/tlsca"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
)
|
|
|
|
type eventCheckFn func(t *testing.T, events []apievents.AuditEvent)
|
|
|
|
func hasAuditEvent(idx int, want apievents.AuditEvent) eventCheckFn {
|
|
return func(t *testing.T, events []apievents.AuditEvent) {
|
|
t.Helper()
|
|
require.Greater(t, len(events), idx)
|
|
require.Empty(t, cmp.Diff(want, events[idx],
|
|
cmpopts.IgnoreFields(apievents.AuthAttempt{}, "ConnectionMetadata")))
|
|
}
|
|
}
|
|
|
|
func hasAuditEventCount(want int) eventCheckFn {
|
|
return func(t *testing.T, events []apievents.AuditEvent) {
|
|
t.Helper()
|
|
require.Len(t, events, want)
|
|
}
|
|
}
|
|
|
|
// TestAuthPOST tests the handler of POST /x-teleport-auth.
|
|
func TestAuthPOST(t *testing.T) {
|
|
secretToken := "012ac605867e5a7d693cd6f49c7ff0fb"
|
|
cookieID := "cookie-name"
|
|
stateValue := fmt.Sprintf("%s_%s", secretToken, cookieID)
|
|
appCookieValue := "5588e2be54a2834b4f152c56bafcd789f53b15477129d2ab4044e9a3c1bf0f3b"
|
|
|
|
fakeClock := clockwork.NewFakeClock()
|
|
clusterName := "test-cluster"
|
|
publicAddr := "app.example.com"
|
|
// Generate CA TLS key and cert with the cluster and application DNS.
|
|
key, cert, err := tlsca.GenerateSelfSignedCA(
|
|
pkix.Name{CommonName: clusterName},
|
|
[]string{publicAddr, apiutils.EncodeClusterName(clusterName)},
|
|
defaults.CATTL,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
desc string
|
|
sessionError error
|
|
outStatusCode int
|
|
makeRequestBody func(types.WebSession) fragmentRequest
|
|
getEventChecks func(types.WebSession) []eventCheckFn
|
|
}{
|
|
{
|
|
desc: "success",
|
|
makeRequestBody: func(appSession types.WebSession) fragmentRequest {
|
|
return fragmentRequest{
|
|
StateValue: stateValue,
|
|
CookieValue: appCookieValue,
|
|
SubjectCookieValue: appSession.GetBearerToken(),
|
|
}
|
|
},
|
|
outStatusCode: http.StatusOK,
|
|
getEventChecks: func(types.WebSession) []eventCheckFn {
|
|
return []eventCheckFn{hasAuditEventCount(0)}
|
|
},
|
|
},
|
|
{
|
|
desc: "missing state token in request",
|
|
makeRequestBody: func(appSession types.WebSession) fragmentRequest {
|
|
return fragmentRequest{
|
|
StateValue: "",
|
|
CookieValue: appCookieValue,
|
|
SubjectCookieValue: appSession.GetBearerToken(),
|
|
}
|
|
},
|
|
outStatusCode: http.StatusForbidden,
|
|
getEventChecks: func(types.WebSession) []eventCheckFn {
|
|
return []eventCheckFn{
|
|
hasAuditEventCount(1),
|
|
hasAuditEvent(0, &apievents.AuthAttempt{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.AuthAttemptEvent,
|
|
Code: events.AuthAttemptFailureCode,
|
|
},
|
|
UserMetadata: apievents.UserMetadata{
|
|
User: "unknown",
|
|
},
|
|
Status: apievents.Status{
|
|
Success: false,
|
|
Error: "Failed app access authentication: state token was not in the expected format",
|
|
},
|
|
}),
|
|
}
|
|
},
|
|
},
|
|
{
|
|
desc: "missing subject session token in request",
|
|
makeRequestBody: func(ws types.WebSession) fragmentRequest {
|
|
return fragmentRequest{
|
|
StateValue: stateValue,
|
|
CookieValue: appCookieValue,
|
|
SubjectCookieValue: "",
|
|
}
|
|
},
|
|
outStatusCode: http.StatusForbidden,
|
|
getEventChecks: func(appSession types.WebSession) []eventCheckFn {
|
|
return []eventCheckFn{
|
|
hasAuditEventCount(1),
|
|
hasAuditEvent(0, &apievents.AuthAttempt{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.AuthAttemptEvent,
|
|
Code: events.AuthAttemptFailureCode,
|
|
},
|
|
UserMetadata: apievents.UserMetadata{
|
|
User: "unknown",
|
|
Login: "testuser",
|
|
},
|
|
Status: apievents.Status{
|
|
Success: false,
|
|
Error: "Failed app access authentication: subject session token is not set",
|
|
},
|
|
}),
|
|
}
|
|
},
|
|
},
|
|
{
|
|
desc: "subject session token in request does not match",
|
|
makeRequestBody: func(ws types.WebSession) fragmentRequest {
|
|
return fragmentRequest{
|
|
StateValue: stateValue,
|
|
CookieValue: appCookieValue,
|
|
SubjectCookieValue: "foobar",
|
|
}
|
|
},
|
|
outStatusCode: http.StatusForbidden,
|
|
getEventChecks: func(appSession types.WebSession) []eventCheckFn {
|
|
return []eventCheckFn{
|
|
hasAuditEventCount(1),
|
|
hasAuditEvent(0, &apievents.AuthAttempt{
|
|
Metadata: apievents.Metadata{
|
|
Type: events.AuthAttemptEvent,
|
|
Code: events.AuthAttemptFailureCode,
|
|
},
|
|
UserMetadata: apievents.UserMetadata{
|
|
Login: appSession.GetUser(),
|
|
User: "unknown",
|
|
},
|
|
Status: apievents.Status{
|
|
Success: false,
|
|
Error: "Failed app access authentication: subject session token does not match",
|
|
},
|
|
}),
|
|
}
|
|
},
|
|
},
|
|
{
|
|
desc: "invalid session",
|
|
makeRequestBody: func(appSession types.WebSession) fragmentRequest {
|
|
return fragmentRequest{
|
|
StateValue: stateValue,
|
|
CookieValue: appCookieValue,
|
|
SubjectCookieValue: appSession.GetBearerToken(),
|
|
}
|
|
},
|
|
sessionError: trace.NotFound("invalid session"),
|
|
outStatusCode: http.StatusForbidden,
|
|
getEventChecks: func(types.WebSession) []eventCheckFn {
|
|
return []eventCheckFn{hasAuditEventCount(0)}
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.desc, func(t *testing.T) {
|
|
t.Parallel()
|
|
appSession := createAppSession(t, fakeClock, key, cert, clusterName, publicAddr)
|
|
authClient := &mockAuthClient{
|
|
sessionError: test.sessionError,
|
|
appSession: appSession,
|
|
}
|
|
p := setup(t, fakeClock, authClient, nil)
|
|
|
|
reqBody := test.makeRequestBody(appSession)
|
|
req, err := json.Marshal(reqBody)
|
|
require.NoError(t, err)
|
|
|
|
status, _ := p.makeRequest(t, "POST", "/x-teleport-auth", req, []http.Cookie{{
|
|
Name: fmt.Sprintf("%s_%s", AuthStateCookieName, cookieID),
|
|
Value: secretToken,
|
|
}})
|
|
require.Equal(t, status, test.outStatusCode)
|
|
for _, check := range test.getEventChecks(appSession) {
|
|
check(t, authClient.emittedEvents)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasName(t *testing.T) {
|
|
for _, test := range []struct {
|
|
desc string
|
|
addrs []string
|
|
reqHost string
|
|
reqURL string
|
|
expectedURL string
|
|
hasName bool
|
|
}{
|
|
{
|
|
desc: "NOK - invalid host",
|
|
addrs: []string{"proxy.com"},
|
|
reqURL: "badurl.com",
|
|
expectedURL: "",
|
|
hasName: false,
|
|
},
|
|
{
|
|
desc: "OK - adds path",
|
|
addrs: []string{"proxy.com"},
|
|
reqURL: "https://app1.proxy.com/foo",
|
|
expectedURL: "https://proxy.com/web/launch/app1.proxy.com?path=%2Ffoo",
|
|
hasName: true,
|
|
},
|
|
{
|
|
desc: "OK - adds paths with ampersands",
|
|
addrs: []string{"proxy.com"},
|
|
reqURL: "https://app1.proxy.com/foo/this&/that",
|
|
expectedURL: "https://proxy.com/web/launch/app1.proxy.com?path=%2Ffoo%2Fthis%26%2Fthat",
|
|
hasName: true,
|
|
},
|
|
{
|
|
desc: "OK - adds root path",
|
|
addrs: []string{"proxy.com"},
|
|
reqURL: "https://app1.proxy.com/",
|
|
expectedURL: "https://proxy.com/web/launch/app1.proxy.com?path=%2F",
|
|
hasName: true,
|
|
},
|
|
} {
|
|
t.Run(test.desc, func(t *testing.T) {
|
|
req, err := http.NewRequest(http.MethodGet, test.reqURL, nil)
|
|
require.NoError(t, err)
|
|
|
|
addrs := utils.MustParseAddrList(test.addrs...)
|
|
u, ok := HasName(req, addrs)
|
|
require.Equal(t, test.expectedURL, u)
|
|
require.Equal(t, test.hasName, ok)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMatchApplicationServers(t *testing.T) {
|
|
clusterName := "test-cluster"
|
|
publicAddr := "app.example.com"
|
|
|
|
// Generate CA TLS key and cert with the cluster and application DNS.
|
|
key, cert, err := tlsca.GenerateSelfSignedCA(
|
|
pkix.Name{CommonName: clusterName},
|
|
[]string{publicAddr, apiutils.EncodeClusterName(clusterName)},
|
|
defaults.CATTL,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
fakeClock := clockwork.NewFakeClock()
|
|
authClient := &mockAuthClient{
|
|
clusterName: clusterName,
|
|
appSession: createAppSession(t, fakeClock, key, cert, clusterName, publicAddr),
|
|
// Three app servers with same public addr from our session, and three
|
|
// that won't match.
|
|
appServers: []types.AppServer{
|
|
createAppServer(t, publicAddr),
|
|
createAppServer(t, publicAddr),
|
|
createAppServer(t, publicAddr),
|
|
createAppServer(t, "random.example.com"),
|
|
createAppServer(t, "random2.example.com"),
|
|
createAppServer(t, "random3.example.com"),
|
|
},
|
|
caKey: key,
|
|
caCert: cert,
|
|
}
|
|
|
|
// Create a httptest server to serve the application requests. It must serve
|
|
// TLS content with the generated certificate.
|
|
expectedContent := "Hello application"
|
|
fakeRemoteSite := startFakeAppServerOnRemoteSite(t, clusterName, authClient, cert, key)
|
|
tunnel := &reversetunnelclient.FakeServer{
|
|
Sites: []reversetunnelclient.RemoteSite{
|
|
fakeRemoteSite,
|
|
},
|
|
}
|
|
|
|
p := setup(t, fakeClock, authClient, tunnel)
|
|
status, content := p.makeRequest(t, "GET", "/", []byte{}, []http.Cookie{
|
|
{
|
|
Name: CookieName,
|
|
Value: "abc",
|
|
},
|
|
{
|
|
Name: SubjectCookieName,
|
|
Value: authClient.appSession.GetBearerToken(),
|
|
},
|
|
})
|
|
|
|
require.Equal(t, http.StatusOK, status)
|
|
// Remote site should receive only 4 connection requests: 3 from the
|
|
// MatchHealthy and 1 from the transport.
|
|
require.Equal(t, int64(4), fakeRemoteSite.DialCount())
|
|
// Guarantee the request was returned by the httptest server.
|
|
require.Equal(t, expectedContent, content)
|
|
}
|
|
|
|
func TestHealthCheckAppServer(t *testing.T) {
|
|
ctx := context.Background()
|
|
clusterName := "test-cluster"
|
|
publicAddr := "valid.example.com"
|
|
|
|
key, cert, err := tlsca.GenerateSelfSignedCA(
|
|
pkix.Name{CommonName: clusterName},
|
|
[]string{publicAddr, apiutils.EncodeClusterName(clusterName)},
|
|
defaults.CATTL,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
for _, tc := range []struct {
|
|
desc string
|
|
publicAddr string
|
|
appServersFunc func(t *testing.T, remoteSite *reversetunnelclient.FakeRemoteSite) []types.AppServer
|
|
expectedTunnelCalls int
|
|
expectErr require.ErrorAssertionFunc
|
|
}{
|
|
{
|
|
desc: "match and online services",
|
|
publicAddr: "valid.example.com",
|
|
appServersFunc: func(t *testing.T, _ *reversetunnelclient.FakeRemoteSite) []types.AppServer {
|
|
return []types.AppServer{createAppServer(t, "valid.example.com")}
|
|
},
|
|
expectedTunnelCalls: 1,
|
|
expectErr: require.NoError,
|
|
},
|
|
{
|
|
desc: "match and but no online services",
|
|
publicAddr: "valid.example.com",
|
|
appServersFunc: func(t *testing.T, tunnel *reversetunnelclient.FakeRemoteSite) []types.AppServer {
|
|
appServer := createAppServer(t, "valid.example.com")
|
|
tunnel.OfflineTunnels = map[string]struct{}{
|
|
fmt.Sprintf("%s.%s", appServer.GetHostID(), clusterName): {},
|
|
}
|
|
return []types.AppServer{appServer}
|
|
},
|
|
expectedTunnelCalls: 1,
|
|
expectErr: require.Error,
|
|
},
|
|
{
|
|
desc: "no match",
|
|
publicAddr: "valid.example.com",
|
|
appServersFunc: func(t *testing.T, tunnel *reversetunnelclient.FakeRemoteSite) []types.AppServer {
|
|
return []types.AppServer{}
|
|
},
|
|
expectedTunnelCalls: 0,
|
|
expectErr: require.Error,
|
|
},
|
|
} {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
fakeClock := clockwork.NewFakeClock()
|
|
appSession := createAppSession(t, fakeClock, key, cert, clusterName, publicAddr)
|
|
authClient := &mockAuthClient{
|
|
clusterName: clusterName,
|
|
appSession: appSession,
|
|
caKey: key,
|
|
caCert: cert,
|
|
}
|
|
|
|
fakeRemoteSite := startFakeAppServerOnRemoteSite(t, clusterName, authClient, cert, key)
|
|
authClient.appServers = tc.appServersFunc(t, fakeRemoteSite)
|
|
|
|
tunnel := &reversetunnelclient.FakeServer{
|
|
Sites: []reversetunnelclient.RemoteSite{fakeRemoteSite},
|
|
}
|
|
|
|
appHandler, err := NewHandler(ctx, &HandlerConfig{
|
|
Clock: fakeClock,
|
|
AuthClient: authClient,
|
|
AccessPoint: authClient,
|
|
ProxyClient: tunnel,
|
|
CipherSuites: utils.DefaultCipherSuites(),
|
|
IntegrationAppHandler: &mockIntegrationAppHandler{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = appHandler.HealthCheckAppServer(ctx, tc.publicAddr, clusterName)
|
|
tc.expectErr(t, err)
|
|
require.Equal(t, int64(tc.expectedTunnelCalls), fakeRemoteSite.DialCount())
|
|
})
|
|
}
|
|
}
|
|
|
|
type testServer struct {
|
|
serverURL *url.URL
|
|
}
|
|
|
|
func setup(t *testing.T, clock *clockwork.FakeClock, authClient authclient.ClientI, proxyClient reversetunnelclient.Tunnel) *testServer {
|
|
appHandler, err := NewHandler(context.Background(), &HandlerConfig{
|
|
Clock: clock,
|
|
AuthClient: authClient,
|
|
AccessPoint: authClient,
|
|
ProxyClient: proxyClient,
|
|
CipherSuites: utils.DefaultCipherSuites(),
|
|
IntegrationAppHandler: &mockIntegrationAppHandler{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
server := httptest.NewUnstartedServer(appHandler)
|
|
server.StartTLS()
|
|
|
|
url, err := url.Parse(server.URL)
|
|
require.NoError(t, err)
|
|
|
|
return &testServer{
|
|
serverURL: url,
|
|
}
|
|
}
|
|
|
|
func (p *testServer) makeRequest(t *testing.T, method, endpoint string, reqBody []byte, cookies []http.Cookie) (int, string) {
|
|
u := url.URL{
|
|
Scheme: p.serverURL.Scheme,
|
|
Host: p.serverURL.Host,
|
|
Path: endpoint,
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(reqBody))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Attach the cookie.
|
|
for _, c := range cookies {
|
|
req.AddCookie(&c)
|
|
}
|
|
|
|
// Issue request.
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
content, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, resp.Body.Close())
|
|
return resp.StatusCode, string(content)
|
|
}
|
|
|
|
type mockAuthClient struct {
|
|
authclient.ClientI
|
|
clusterName string
|
|
appSession types.WebSession
|
|
sessionError error
|
|
appServers []types.AppServer
|
|
caKey []byte
|
|
caCert []byte
|
|
emittedEvents []apievents.AuditEvent
|
|
mtx sync.Mutex
|
|
}
|
|
|
|
type mockClusterName struct {
|
|
types.ClusterName
|
|
name string
|
|
}
|
|
|
|
func (c *mockAuthClient) EmitAuditEvent(ctx context.Context, event apievents.AuditEvent) error {
|
|
c.mtx.Lock()
|
|
defer c.mtx.Unlock()
|
|
c.emittedEvents = append(c.emittedEvents, event)
|
|
return nil
|
|
}
|
|
|
|
func (c *mockAuthClient) DeleteAppSession(ctx context.Context, r types.DeleteAppSessionRequest) error {
|
|
return nil
|
|
}
|
|
|
|
func (c *mockAuthClient) GetClusterName(_ context.Context) (types.ClusterName, error) {
|
|
return mockClusterName{name: c.clusterName}, nil
|
|
}
|
|
|
|
func (n mockClusterName) GetClusterName() string {
|
|
if n.name != "" {
|
|
return n.name
|
|
}
|
|
|
|
return "local-cluster"
|
|
}
|
|
|
|
func (c *mockAuthClient) GetAppSession(context.Context, types.GetAppSessionRequest) (types.WebSession, error) {
|
|
return c.appSession, c.sessionError
|
|
}
|
|
|
|
func (c *mockAuthClient) GetApplicationServers(_ context.Context, _ string) ([]types.AppServer, error) {
|
|
return c.appServers, nil
|
|
}
|
|
|
|
func (c *mockAuthClient) GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) {
|
|
ca, err := types.NewCertAuthority(types.CertAuthoritySpecV2{
|
|
Type: types.HostCA,
|
|
ClusterName: c.clusterName,
|
|
ActiveKeys: types.CAKeySet{
|
|
TLS: []*types.TLSKeyPair{{
|
|
Cert: c.caCert,
|
|
Key: c.caKey,
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ca, nil
|
|
}
|
|
|
|
func (c *mockAuthClient) NewWatcher(context.Context, types.Watch) (types.Watcher, error) {
|
|
return nil, trace.NotImplemented("")
|
|
}
|
|
|
|
func (c *mockAuthClient) GetProxies() ([]types.Server, error) {
|
|
return []types.Server{}, nil
|
|
}
|
|
|
|
// fakeRemoteListener Implements a `net.Listener` that return `net.Conn` from
|
|
// the `FakeRemoteSite`.
|
|
type fakeRemoteListener struct {
|
|
fakeRemote *reversetunnelclient.FakeRemoteSite
|
|
}
|
|
|
|
func (r *fakeRemoteListener) Accept() (net.Conn, error) {
|
|
conn, ok := <-r.fakeRemote.ProxyConn()
|
|
if !ok {
|
|
return nil, fmt.Errorf("remote closed")
|
|
}
|
|
|
|
return conn, nil
|
|
}
|
|
|
|
func (r *fakeRemoteListener) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeRemoteListener) Addr() net.Addr {
|
|
return &net.IPAddr{}
|
|
}
|
|
|
|
// createAppSession generates a WebSession for an application.
|
|
func createAppSession(t *testing.T, clock *clockwork.FakeClock, caKey, caCert []byte, clusterName, publicAddr string) types.WebSession {
|
|
key, cert := createAppKeyCertPair(t, clock, caKey, caCert, clusterName, publicAddr)
|
|
keyPEM, err := keys.MarshalPrivateKey(key)
|
|
require.NoError(t, err)
|
|
appSession, err := types.NewWebSession(uuid.New().String(), types.KindAppSession, types.WebSessionSpecV2{
|
|
User: "testuser",
|
|
TLSPriv: keyPEM,
|
|
TLSCert: cert,
|
|
Expires: clock.Now().Add(5 * time.Minute),
|
|
BearerToken: "abc123",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return appSession
|
|
}
|
|
|
|
// createAppKeyCertPair creates and a client key and signed app cert for the client key
|
|
func createAppKeyCertPair(t *testing.T, clock *clockwork.FakeClock, caKey, caCert []byte, clusterName, publicAddr string) (crypto.Signer, []byte) {
|
|
tlsCA, err := tlsca.FromKeys(caCert, caKey)
|
|
require.NoError(t, err)
|
|
|
|
privateKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
|
|
require.NoError(t, err)
|
|
|
|
// Generate the identity with a `RouteToApp` option.
|
|
subj, err := (&tlsca.Identity{
|
|
Username: "testuser",
|
|
Groups: []string{"access"},
|
|
RouteToApp: tlsca.RouteToApp{
|
|
PublicAddr: publicAddr,
|
|
ClusterName: clusterName,
|
|
Name: "testapp",
|
|
},
|
|
}).Subject()
|
|
require.NoError(t, err)
|
|
|
|
cert, err := tlsCA.GenerateCertificate(tlsca.CertificateRequest{
|
|
Clock: clock,
|
|
PublicKey: privateKey.Public(),
|
|
Subject: subj,
|
|
NotAfter: clock.Now().Add(5 * time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return privateKey, cert
|
|
}
|
|
|
|
func createAppServer(t *testing.T, publicAddr string) types.AppServer {
|
|
appName := uuid.New().String()
|
|
appServer, err := types.NewAppServerV3(
|
|
types.Metadata{Name: appName},
|
|
types.AppServerSpecV3{
|
|
HostID: uuid.New().String(),
|
|
App: &types.AppV3{
|
|
Metadata: types.Metadata{Name: appName},
|
|
Spec: types.AppSpecV3{
|
|
URI: "localhost",
|
|
PublicAddr: publicAddr,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
return appServer
|
|
}
|
|
|
|
func TestMakeAppRedirectURL(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
reqURL string
|
|
expectedURL string
|
|
launderURLParams launcherURLParams
|
|
}{
|
|
// with launcherURLParams empty (will be empty if user did not launch app from our web UI)
|
|
{
|
|
name: "OK - no path",
|
|
reqURL: "https://grafana.localhost",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=",
|
|
},
|
|
{
|
|
name: "OK - add root path",
|
|
reqURL: "https://grafana.localhost/",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=%2F",
|
|
},
|
|
{
|
|
name: "OK - add multi path",
|
|
reqURL: "https://grafana.localhost/foo/bar",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=%2Ffoo%2Fbar",
|
|
},
|
|
{
|
|
name: "OK - add paths with ampersands",
|
|
reqURL: "https://grafana.localhost/foo/this&/that",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=%2Ffoo%2Fthis%26%2Fthat",
|
|
},
|
|
{
|
|
name: "OK - add only query",
|
|
reqURL: "https://grafana.localhost?foo=bar",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=&query=foo%3Dbar",
|
|
},
|
|
{
|
|
name: "OK - add query with same keys used to store the original path and query",
|
|
reqURL: "https://grafana.localhost?foo=bar&query=test1&path=test",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=&query=foo%3Dbar%26query%3Dtest1%26path%3Dtest",
|
|
},
|
|
{
|
|
name: "OK - adds query with root path",
|
|
reqURL: "https://grafana.localhost/?foo=bar&baz=qux&fruit=apple",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=%2F&query=foo%3Dbar%26baz%3Dqux%26fruit%3Dapple",
|
|
},
|
|
{
|
|
name: "OK - real grafana query example (encoded spaces)",
|
|
reqURL: "https://grafana.localhost/alerting/list?search=state:inactive%20type:alerting%20health:nodata",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=%2Falerting%2Flist&query=search%3Dstate%3Ainactive%2520type%3Aalerting%2520health%3Anodata",
|
|
},
|
|
{
|
|
name: "OK - query with non-encoded spaces",
|
|
reqURL: "https://grafana.localhost/alerting /list?search=state:inactive type:alerting health:nodata",
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost?path=%2Falerting+%2Flist&query=search%3Dstate%3Ainactive+type%3Aalerting+health%3Anodata",
|
|
},
|
|
|
|
// with launcherURLParams (defined if user used the "launcher" button from our web UI)
|
|
{
|
|
name: "OK - with clusterId and publicAddr",
|
|
launderURLParams: launcherURLParams{
|
|
stateToken: "abc123",
|
|
clusterName: "im-a-cluster-name",
|
|
publicAddr: "grafana.localhost",
|
|
},
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost/im-a-cluster-name/grafana.localhost?path=&required-apps=&state=abc123",
|
|
},
|
|
{
|
|
name: "OK - with clusterId, publicAddr, and arn",
|
|
launderURLParams: launcherURLParams{
|
|
stateToken: "abc123",
|
|
clusterName: "im-a-cluster-name",
|
|
publicAddr: "grafana.localhost",
|
|
arn: "arn:aws:iam::123456789012:role%2Frole-name",
|
|
},
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost/im-a-cluster-name/grafana.localhost/arn:aws:iam::123456789012:role%252Frole-name?path=&required-apps=&state=abc123",
|
|
},
|
|
{
|
|
name: "OK - with clusterId, publicAddr, arn and path",
|
|
launderURLParams: launcherURLParams{
|
|
stateToken: "abc123",
|
|
clusterName: "im-a-cluster-name",
|
|
publicAddr: "grafana.localhost",
|
|
arn: "arn:aws:iam::123456789012:role%2Frole-name",
|
|
path: "/foo/bar?qux=qex",
|
|
},
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost/im-a-cluster-name/grafana.localhost/arn:aws:iam::123456789012:role%252Frole-name?path=%2Ffoo%2Fbar%3Fqux%3Dqex&required-apps=&state=abc123",
|
|
},
|
|
{
|
|
name: "OK - with clusterId, publicAddr, arn, path, and required-apps",
|
|
launderURLParams: launcherURLParams{
|
|
stateToken: "abc123",
|
|
clusterName: "im-a-cluster-name",
|
|
publicAddr: "grafana.localhost",
|
|
arn: "arn:aws:iam::123456789012:role%2Frole-name",
|
|
path: "/foo/bar?qux=qex",
|
|
requiredAppFQDNs: "api.example.com,grafana.localhost",
|
|
},
|
|
expectedURL: "https://proxy.com/web/launch/grafana.localhost/im-a-cluster-name/grafana.localhost/arn:aws:iam::123456789012:role%252Frole-name?path=%2Ffoo%2Fbar%3Fqux%3Dqex&required-apps=api.example.com%2Cgrafana.localhost&state=abc123",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
req, err := http.NewRequest(http.MethodGet, test.reqURL, nil)
|
|
require.NoError(t, err)
|
|
|
|
urlStr := makeAppRedirectURL(req, "proxy.com", "grafana.localhost", test.launderURLParams)
|
|
require.Equal(t, test.expectedURL, urlStr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func startFakeAppServerOnRemoteSite(t *testing.T, clusterName string, accessPoint authclient.RemoteProxyAccessPoint, cert, key []byte) *reversetunnelclient.FakeRemoteSite {
|
|
t.Helper()
|
|
|
|
tlsCert, err := tls.X509KeyPair(cert, key)
|
|
require.NoError(t, err)
|
|
|
|
fakeRemoteSite := reversetunnelclient.NewFakeRemoteSite(clusterName, accessPoint)
|
|
server := &httptest.Server{
|
|
TLS: &tls.Config{
|
|
Certificates: []tls.Certificate{tlsCert},
|
|
},
|
|
Listener: &fakeRemoteListener{
|
|
fakeRemote: fakeRemoteSite,
|
|
},
|
|
Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
fmt.Fprint(w, "Hello application")
|
|
})},
|
|
}
|
|
server.StartTLS()
|
|
t.Cleanup(func() {
|
|
// Close fake remote site first to make sure fake listener quits.
|
|
fakeRemoteSite.Close()
|
|
server.Close()
|
|
})
|
|
return fakeRemoteSite
|
|
}
|
|
|
|
func TestHandlerAuthenticate(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
|
|
clusterName := "test-cluster"
|
|
publicAddr := "app.example.com"
|
|
key, cert, err := tlsca.GenerateSelfSignedCA(
|
|
pkix.Name{CommonName: clusterName},
|
|
[]string{publicAddr, apiutils.EncodeClusterName(clusterName)},
|
|
defaults.CATTL,
|
|
)
|
|
require.NoError(t, err)
|
|
fakeClock := clockwork.NewFakeClock()
|
|
|
|
authClient := &mockAuthClient{
|
|
clusterName: clusterName,
|
|
appSession: createAppSession(t, fakeClock, key, cert, clusterName, publicAddr),
|
|
appServers: []types.AppServer{
|
|
createAppServer(t, publicAddr),
|
|
},
|
|
caKey: key,
|
|
caCert: cert,
|
|
}
|
|
|
|
fakeRemoteSite := startFakeAppServerOnRemoteSite(t, clusterName, authClient, cert, key)
|
|
|
|
appHandler, err := NewHandler(ctx, &HandlerConfig{
|
|
Clock: fakeClock,
|
|
AuthClient: authClient,
|
|
AccessPoint: authClient,
|
|
ProxyClient: &reversetunnelclient.FakeServer{
|
|
Sites: []reversetunnelclient.RemoteSite{fakeRemoteSite},
|
|
},
|
|
CipherSuites: utils.DefaultCipherSuites(),
|
|
IntegrationAppHandler: &mockIntegrationAppHandler{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
t.Run("with cookie", func(t *testing.T) {
|
|
request := httptest.NewRequest("GET", "https://"+publicAddr, nil)
|
|
addValidSessionCookiesToRequest(authClient.appSession, request)
|
|
|
|
_, err = appHandler.authenticate(ctx, request)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("with client cert", func(t *testing.T) {
|
|
clientCert, err := tls.X509KeyPair(authClient.appSession.GetTLSCert(), authClient.appSession.GetTLSPriv())
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, clientCert.Certificate)
|
|
x509Cert, err := x509.ParseCertificate(clientCert.Certificate[0])
|
|
require.NoError(t, err)
|
|
|
|
request := httptest.NewRequest("GET", "https://"+publicAddr, nil)
|
|
request.TLS.PeerCertificates = []*x509.Certificate{x509Cert}
|
|
|
|
_, err = appHandler.authenticate(ctx, request)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("without cookie or client cert", func(t *testing.T) {
|
|
request := httptest.NewRequest("GET", "https://"+publicAddr, nil)
|
|
_, err := appHandler.authenticate(ctx, request)
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
})
|
|
|
|
t.Run("session expired", func(t *testing.T) {
|
|
fakeClock.Advance(authClient.appSession.Expiry().Sub(fakeClock.Now()) + time.Minute)
|
|
request := httptest.NewRequest("GET", "https://"+publicAddr, nil)
|
|
addValidSessionCookiesToRequest(authClient.appSession, request)
|
|
|
|
_, err := appHandler.authenticate(ctx, request)
|
|
require.Error(t, err)
|
|
require.True(t, trace.IsAccessDenied(err))
|
|
})
|
|
}
|
|
|
|
func addValidSessionCookiesToRequest(appSession types.WebSession, r *http.Request) {
|
|
r.AddCookie(&http.Cookie{
|
|
Name: CookieName,
|
|
Value: appSession.GetName(),
|
|
})
|
|
r.AddCookie(&http.Cookie{
|
|
Name: SubjectCookieName,
|
|
Value: appSession.GetBearerToken(),
|
|
})
|
|
}
|