Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions lib/auth/grpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2587,20 +2587,30 @@ func (g *GRPCServer) GenerateUserSingleUseCerts(stream proto.AuthService_Generat
}

mfaRequired := proto.MFARequired_MFA_REQUIRED_UNSPECIFIED
if required, err := isMFARequiredForSingleUseCertRequest(ctx, actx, initReq); err == nil {
// If MFA is not required to gain access to the resource then let the client
// know and abort the ceremony.
if !required {
return trace.Wrap(stream.Send(&proto.UserSingleUseCertsResponse{
Response: &proto.UserSingleUseCertsResponse_MFAChallenge{
MFAChallenge: &proto.MFAAuthenticateChallenge{
MFARequired: proto.MFARequired_MFA_REQUIRED_NO,
clusterName, err := actx.GetClusterName()
if err != nil {
return trace.Wrap(err)
}

// Only check if MFA is required for resources within the current cluster. Determining if
// MFA is required for a resource in a leaf cluster will result in a not found error and
// prevent users from accessing resources in leaf clusters.
if initReq.RouteToCluster == "" || clusterName.GetClusterName() == initReq.RouteToCluster {
if required, err := isMFARequiredForSingleUseCertRequest(ctx, actx, initReq); err == nil {
// If MFA is not required to gain access to the resource then let the client
// know and abort the ceremony.
if !required {
return trace.Wrap(stream.Send(&proto.UserSingleUseCertsResponse{
Response: &proto.UserSingleUseCertsResponse_MFAChallenge{
MFAChallenge: &proto.MFAAuthenticateChallenge{
MFARequired: proto.MFARequired_MFA_REQUIRED_NO,
},
},
},
}))
}
}))
}

mfaRequired = proto.MFARequired_MFA_REQUIRED_YES
mfaRequired = proto.MFARequired_MFA_REQUIRED_YES
}
}

// 2. send MFAChallenge
Expand Down
139 changes: 139 additions & 0 deletions lib/auth/grpcserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,20 @@ func TestGenerateUserSingleUseCert(t *testing.T) {
_, err = srv.Auth().UpsertDatabaseServer(ctx, db)
require.NoError(t, err)

desktop, err := types.NewWindowsDesktopV3("desktop", nil, types.WindowsDesktopSpecV3{
Addr: "localhost",
HostID: "test",
})
require.NoError(t, err)

require.NoError(t, srv.Auth().CreateWindowsDesktop(ctx, desktop))

leaf, err := types.NewRemoteCluster("leaf")
require.NoError(t, err)

// create remote cluster
require.NoError(t, srv.Auth().CreateRemoteCluster(leaf))

// Create a fake user.
user, role, err := CreateUserAndRole(srv.Auth(), "mfa-user", []string{"role"})
require.NoError(t, err)
Expand All @@ -1232,6 +1246,8 @@ func TestGenerateUserSingleUseCert(t *testing.T) {
role.SetDatabaseUsers(types.Allow, []string{types.Wildcard})
role.SetDatabaseLabels(types.Allow, types.Labels{types.Wildcard: {types.Wildcard}})
role.SetDatabaseNames(types.Allow, []string{types.Wildcard})
role.SetWindowsLogins(types.Allow, []string{"role"})
role.SetWindowsDesktopLabels(types.Allow, types.Labels{types.Wildcard: {types.Wildcard}})
role.SetOptions(roleOpt)
err = srv.Auth().UpsertRole(ctx, role)
require.NoError(t, err)
Expand Down Expand Up @@ -1626,6 +1642,129 @@ func TestGenerateUserSingleUseCert(t *testing.T) {
checkAuthErr: require.Error,
},
},
{
desc: "k8s in leaf cluster",
opts: generateUserSingleUseCertTestOpts{
initReq: &proto.UserCertsRequest{
PublicKey: pub,
Username: user.GetName(),
// This expiry is longer than allowed, should be
// automatically adjusted.
Expires: clock.Now().Add(2 * teleport.UserSingleUseCertTTL),
Usage: proto.UserCertsRequest_Kubernetes,
KubernetesCluster: "kube-b",
RouteToCluster: "leaf",
},
checkInitErr: require.NoError,
mfaRequiredHandler: func(t *testing.T, required proto.MFARequired) {
require.Equal(t, proto.MFARequired_MFA_REQUIRED_UNSPECIFIED, required)
},
authHandler: registered.webAuthHandler,
checkAuthErr: require.NoError,
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
crt := c.GetTLS()
require.NotEmpty(t, crt)

cert, err := tlsca.ParseCertificatePEM(crt)
require.NoError(t, err)
require.Equal(t, cert.NotAfter, clock.Now().Add(teleport.UserSingleUseCertTTL))

identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
require.NoError(t, err)
require.Equal(t, webDevID, identity.MFAVerified)
require.Equal(t, userCertExpires, identity.PreviousIdentityExpires)
require.True(t, net.ParseIP(identity.LoginIP).IsLoopback())
require.Equal(t, []string{teleport.UsageKubeOnly}, identity.Usage)
require.Equal(t, "kube-b", identity.KubernetesCluster)
},
},
},
{
desc: "db in leaf cluster",
opts: generateUserSingleUseCertTestOpts{
initReq: &proto.UserCertsRequest{
PublicKey: pub,
Username: user.GetName(),
// This expiry is longer than allowed, should be
// automatically adjusted.
Expires: clock.Now().Add(2 * teleport.UserSingleUseCertTTL),
Usage: proto.UserCertsRequest_Database,
RouteToDatabase: proto.RouteToDatabase{
ServiceName: "db-b",
Database: "db-b",
},
RouteToCluster: "leaf",
},
checkInitErr: require.NoError,
mfaRequiredHandler: func(t *testing.T, required proto.MFARequired) {
require.Equal(t, proto.MFARequired_MFA_REQUIRED_UNSPECIFIED, required)
},
authHandler: registered.webAuthHandler,
checkAuthErr: require.NoError,
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
crt := c.GetTLS()
require.NotEmpty(t, crt)

cert, err := tlsca.ParseCertificatePEM(crt)
require.NoError(t, err)
require.Equal(t, clock.Now().Add(teleport.UserSingleUseCertTTL), cert.NotAfter)

identity, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
require.NoError(t, err)
require.Equal(t, webDevID, identity.MFAVerified)
require.Equal(t, userCertExpires, identity.PreviousIdentityExpires)
require.True(t, net.ParseIP(identity.LoginIP).IsLoopback())
require.Equal(t, []string{teleport.UsageDatabaseOnly}, identity.Usage)
require.Equal(t, identity.RouteToDatabase.ServiceName, "db-b")
},
},
},
{
desc: "ssh in leaf node",
opts: generateUserSingleUseCertTestOpts{
initReq: &proto.UserCertsRequest{
PublicKey: pub,
Username: user.GetName(),
// This expiry is longer than allowed, should be
// automatically adjusted.
Expires: clock.Now().Add(2 * teleport.UserSingleUseCertTTL),
Usage: proto.UserCertsRequest_SSH,
NodeName: "node-b",
SSHLogin: "role",
RouteToCluster: "leaf",
},
checkInitErr: require.NoError,
mfaRequiredHandler: func(t *testing.T, required proto.MFARequired) {
require.Equal(t, proto.MFARequired_MFA_REQUIRED_UNSPECIFIED, required)
},
authHandler: registered.webAuthHandler,
checkAuthErr: require.NoError,
validateCert: func(t *testing.T, c *proto.SingleUseUserCert) {
sshCertBytes := c.GetSSH()
require.NotEmpty(t, sshCertBytes)

cert, err := sshutils.ParseCertificate(sshCertBytes)
require.NoError(t, err)

require.Equal(t, webDevID, cert.Extensions[teleport.CertExtensionMFAVerified])
require.Equal(t, userCertExpires.Format(time.RFC3339), cert.Extensions[teleport.CertExtensionPreviousIdentityExpires])
require.True(t, net.ParseIP(cert.Extensions[teleport.CertExtensionLoginIP]).IsLoopback())
require.Equal(t, uint64(clock.Now().Add(teleport.UserSingleUseCertTTL).Unix()), cert.ValidBefore)
},
},
},
{
desc: "fail - app access not supported",
opts: generateUserSingleUseCertTestOpts{
initReq: &proto.UserCertsRequest{
PublicKey: pub,
Username: user.GetName(),
Expires: clock.Now().Add(teleport.UserSingleUseCertTTL),
Usage: proto.UserCertsRequest_App,
},
checkInitErr: require.Error,
},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
Expand Down
20 changes: 14 additions & 6 deletions lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1546,14 +1546,22 @@ func (tc *TeleportClient) ConnectToNode(ctx context.Context, proxyClient *ProxyC
mfaCancel()
directCancel()

// Only return the error from connecting with mfa if the error
// originates from the mfa ceremony. If mfa is not required then
// the error from the direct connection to the node must be returned.
if mfaErr != nil && !errors.Is(mfaErr, io.EOF) && !errors.Is(mfaErr, MFARequiredUnknownErr{}) && !errors.Is(mfaErr, services.ErrSessionMFANotRequired) {
switch {
// No MFA errors, return any errors from the direct connection
case mfaErr == nil:
return nil, trace.Wrap(directErr)
// Any direct connection errors other than access denied, which should be returned
// if MFA is required, take precedent over MFA errors due to users not having any
// enrolled devices.
case !trace.IsAccessDenied(directErr) && errors.Is(mfaErr, auth.ErrNoMFADevices):
return nil, trace.Wrap(directErr)
case !errors.Is(mfaErr, io.EOF) && // Ignore any errors from MFA due to locks being enforced, the direct error will be friendlier
!errors.Is(mfaErr, MFARequiredUnknownErr{}) && // Ignore any failures that occurred before determining if MFA was required
!errors.Is(mfaErr, services.ErrSessionMFANotRequired): // Ignore any errors caused by attempting the MFA ceremony when MFA will not grant access
return nil, trace.Wrap(mfaErr)
default:
return nil, trace.Wrap(directErr)
}

return nil, trace.Wrap(directErr)
}

// MFARequiredUnknownErr indicates that connections to an instance failed
Expand Down
33 changes: 19 additions & 14 deletions lib/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ import (
"time"

"github.com/gravitational/trace"
"github.com/gravitational/trace/trail"
"github.com/moby/term"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
oteltrace "go.opentelemetry.io/otel/trace"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"google.golang.org/grpc"

"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/breaker"
Expand Down Expand Up @@ -443,19 +443,17 @@ func (proxy *ProxyClient) IssueUserCertsWithMFA(ctx context.Context, params Reis
}
}

var clt auth.ClientI
clt, mfaClt := params.AuthClient, params.AuthClient
// Connect to the target cluster (root or leaf) to check whether MFA is
// required or if we know from param that it's required, connect because
// it will be needed to do MFA check.
if params.AuthClient != nil {
clt = params.AuthClient
} else {
if clt == nil {
authClt, err := proxy.ConnectToCluster(ctx, params.RouteToCluster)
if err != nil {
return nil, trace.Wrap(MFARequiredUnknown(err))
}
clt = authClt
defer clt.Close()
clt, mfaClt = authClt, authClt
defer authClt.Close()
}

// Always connect to root for getting new credentials, but attempt to reuse
Expand All @@ -465,12 +463,11 @@ func (proxy *ProxyClient) IssueUserCertsWithMFA(ctx context.Context, params Reis
return nil, trace.Wrap(err)
}
if params.RouteToCluster != rootClusterName {
clt.Close()
rootClusterProxy := proxy
if jumpHost := proxy.teleportClient.JumpHosts; jumpHost != nil {
// In case of MFA connect to root teleport proxy instead of JumpHost to request
// MFA certificates.
proxy.teleportClient.WithoutJumpHosts(func(tcNoJump *TeleportClient) error {
err := proxy.teleportClient.WithoutJumpHosts(func(tcNoJump *TeleportClient) error {
rootClusterProxy, err = tcNoJump.ConnectToProxy(ctx)
return trace.Wrap(err)
})
Expand All @@ -479,17 +476,17 @@ func (proxy *ProxyClient) IssueUserCertsWithMFA(ctx context.Context, params Reis
}
defer rootClusterProxy.Close()
}
clt, err = rootClusterProxy.ConnectToCluster(ctx, rootClusterName)
mfaClt, err = rootClusterProxy.ConnectToCluster(ctx, rootClusterName)
if err != nil {
return nil, trace.Wrap(err)
}
defer clt.Close()
defer mfaClt.Close()
}

params.AuthClient = clt
params.AuthClient = mfaClt

log.Debug("Attempting to issue a single-use user certificate with an MFA check.")
stream, err := clt.GenerateUserSingleUseCerts(ctx)
stream, err := mfaClt.GenerateUserSingleUseCerts(ctx)
if err != nil {
if trace.IsNotImplemented(err) {
// Probably talking to an older server, use the old non-MFA endpoint.
Expand Down Expand Up @@ -527,7 +524,7 @@ func (proxy *ProxyClient) IssueUserCertsWithMFA(ctx context.Context, params Reis
// challenge and will terminate the stream with an auth.ErrNoMFADevices error.
// In this case for all protocols other than SSH fall back to reissuing
// certs without MFA.
if errors.Is(trail.FromGRPC(err), auth.ErrNoMFADevices) {
if errors.Is(err, auth.ErrNoMFADevices) {
if params.usage() != proto.UserCertsRequest_SSH {
return proxy.reissueUserCerts(ctx, CertCacheKeep, params)
}
Expand Down Expand Up @@ -1163,6 +1160,10 @@ func (proxy *ProxyClient) ConnectToAuthServiceThroughALPNSNIProxy(ctx context.Co
},
ALPNSNIAuthDialClusterName: clusterName,
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
DialOpts: []grpc.DialOption{
grpc.WithStreamInterceptor(utils.GRPCClientStreamErrorInterceptor),
grpc.WithUnaryInterceptor(utils.GRPCClientUnaryErrorInterceptor),
},
})
if err != nil {
return nil, trace.Wrap(err)
Expand Down Expand Up @@ -1241,6 +1242,10 @@ func (proxy *ProxyClient) ConnectToCluster(ctx context.Context, clusterName stri
client.LoadTLS(tlsConfig),
},
CircuitBreakerConfig: breaker.NoopBreakerConfig(),
DialOpts: []grpc.DialOption{
grpc.WithStreamInterceptor(utils.GRPCClientStreamErrorInterceptor),
grpc.WithUnaryInterceptor(utils.GRPCClientUnaryErrorInterceptor),
},
})
if err != nil {
return nil, trace.Wrap(err)
Expand Down
21 changes: 15 additions & 6 deletions lib/web/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/auth"
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/defaults"
Expand Down Expand Up @@ -709,14 +710,22 @@ func (t *TerminalHandler) connectToHost(ctx context.Context, ws *websocket.Conn,
mfaCancel()
directCancel()

// Only return the error from connecting with mfa if the error
// originates from the mfa ceremony. If mfa is not required then
// the error from the direct connection to the node must be returned.
if mfaErr != nil && !errors.Is(mfaErr, io.EOF) && !errors.Is(mfaErr, client.MFARequiredUnknownErr{}) && !errors.Is(mfaErr, services.ErrSessionMFANotRequired) {
switch {
// No MFA errors, return any errors from the direct connection
case mfaErr == nil:
return nil, trace.Wrap(directErr)
// Any direct connection errors other than access denied, which should be returned
// if MFA is required, take precedent over MFA errors due to users not having any
// enrolled devices.
case !trace.IsAccessDenied(directErr) && errors.Is(mfaErr, auth.ErrNoMFADevices):
return nil, trace.Wrap(directErr)
case !errors.Is(mfaErr, io.EOF) && // Ignore any errors from MFA due to locks being enforced, the direct error will be friendlier
!errors.Is(mfaErr, client.MFARequiredUnknownErr{}) && // Ignore any failures that occurred before determining if MFA was required
!errors.Is(mfaErr, services.ErrSessionMFANotRequired): // Ignore any errors caused by attempting the MFA ceremony when MFA will not grant access
return nil, trace.Wrap(mfaErr)
default:
return nil, trace.Wrap(directErr)
}

return nil, trace.Wrap(directErr)
}

// streamTerminal opens a SSH connection to the remote host and streams
Expand Down
Loading