mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 01:58:44 +08:00
[Browser MFA] Add initial requests for browser MFA process to client tools (#64301)
This commit is contained in:
@@ -671,9 +671,9 @@ type Config struct {
|
||||
// MFAPromptConstructor is used to create MFA prompts when needed.
|
||||
// If nil, the client will not prompt for MFA.
|
||||
MFAPromptConstructor mfa.PromptConstructor
|
||||
// SSOMFACeremonyConstructor is used to handle SSO MFA when needed.
|
||||
// MFACeremonyConstructor is used to handle SSO or Browser MFA when needed.
|
||||
// If nil, the client will not prompt for MFA.
|
||||
SSOMFACeremonyConstructor mfa.SSOMFACeremonyConstructor
|
||||
MFACeremonyConstructor mfa.MFACeremonyConstructor
|
||||
}
|
||||
|
||||
// CheckAndSetDefaults checks and sets default config values.
|
||||
@@ -740,9 +740,9 @@ func (c *Client) SetMFAPromptConstructor(pc mfa.PromptConstructor) {
|
||||
c.c.MFAPromptConstructor = pc
|
||||
}
|
||||
|
||||
// SetSSOMFACeremonyConstructor sets the SSO MFA ceremony constructor for this client.
|
||||
func (c *Client) SetSSOMFACeremonyConstructor(scc mfa.SSOMFACeremonyConstructor) {
|
||||
c.c.SSOMFACeremonyConstructor = scc
|
||||
// SetMFACeremonyConstructor sets the MFA ceremony constructor for this client.
|
||||
func (c *Client) SetMFACeremonyConstructor(mcc mfa.MFACeremonyConstructor) {
|
||||
c.c.MFACeremonyConstructor = mcc
|
||||
}
|
||||
|
||||
// Close closes the Client connection to the auth server.
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ func (c *Client) PerformMFACeremony(ctx context.Context, challengeRequest *proto
|
||||
mfaCeremony := &mfa.Ceremony{
|
||||
CreateAuthenticateChallenge: c.CreateAuthenticateChallenge,
|
||||
PromptConstructor: c.c.MFAPromptConstructor,
|
||||
SSOMFACeremonyConstructor: c.c.SSOMFACeremonyConstructor,
|
||||
MFACeremonyConstructor: c.c.MFACeremonyConstructor,
|
||||
}
|
||||
return mfaCeremony.Run(ctx, challengeRequest, promptOpts...)
|
||||
}
|
||||
|
||||
+22
-18
@@ -33,21 +33,21 @@ type Ceremony struct {
|
||||
CreateAuthenticateChallenge CreateAuthenticateChallengeFunc
|
||||
// PromptConstructor creates a prompt to prompt the user to solve an authentication challenge.
|
||||
PromptConstructor PromptConstructor
|
||||
// SSOMFACeremonyConstructor is an optional SSO MFA ceremony constructor. If provided,
|
||||
// the MFA ceremony will also attempt to retrieve an SSO MFA challenge.
|
||||
SSOMFACeremonyConstructor SSOMFACeremonyConstructor
|
||||
// MFACeremonyConstructor is an optional MFA ceremony constructor. If provided,
|
||||
// the MFA ceremony will also attempt to retrieve an MFA challenge.
|
||||
MFACeremonyConstructor MFACeremonyConstructor
|
||||
}
|
||||
|
||||
// SSOMFACeremony is an SSO MFA ceremony.
|
||||
type SSOMFACeremony interface {
|
||||
// CallbackCeremony is an SSO/Browser callback ceremony.
|
||||
type CallbackCeremony interface {
|
||||
GetClientCallbackURL() string
|
||||
GetProxyAddress() string
|
||||
Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error)
|
||||
Close()
|
||||
}
|
||||
|
||||
// SSOMFACeremonyConstructor constructs a new SSO MFA ceremony.
|
||||
type SSOMFACeremonyConstructor func(ctx context.Context) (SSOMFACeremony, error)
|
||||
// MFACeremonyConstructor constructs a new SSO or Browser MFA ceremony.
|
||||
type MFACeremonyConstructor func(ctx context.Context) (CallbackCeremony, error)
|
||||
|
||||
// CreateAuthenticateChallengeFunc is a function that creates an authentication challenge.
|
||||
type CreateAuthenticateChallengeFunc func(ctx context.Context, req *proto.CreateAuthenticateChallengeRequest) (*proto.MFAAuthenticateChallenge, error)
|
||||
@@ -61,16 +61,16 @@ func (c *Ceremony) Run(ctx context.Context, req *proto.CreateAuthenticateChallen
|
||||
return nil, trace.BadParameter("mfa ceremony must have CreateAuthenticateChallenge set in order to begin")
|
||||
}
|
||||
|
||||
// If available, prepare an SSO MFA ceremony and set the client redirect URL in the challenge
|
||||
// request to request an SSO challenge in addition to other challenges.
|
||||
if c.SSOMFACeremonyConstructor != nil {
|
||||
ssoMFACeremony, err := c.SSOMFACeremonyConstructor(ctx)
|
||||
// If available, prepare an MFA ceremony and set the client redirect URL in the challenge
|
||||
// request to request an SSO or Browser MFA challenge in addition to other challenges.
|
||||
if c.MFACeremonyConstructor != nil {
|
||||
mfaCeremony, err := c.MFACeremonyConstructor(ctx)
|
||||
if err != nil {
|
||||
// We may fail to start the SSO MFA flow in cases where the Proxy is down or broken. Fall
|
||||
// back to skipping SSO MFA, especially since SSO MFA may not even be allowed on the server.
|
||||
slog.DebugContext(ctx, "Failed to attempt SSO MFA, continuing with other MFA methods", "error", err)
|
||||
// We may fail to start the MFA flow in cases where the Proxy is down or broken. Fall
|
||||
// back to skipping SSO/Browser MFA, especially since SSO/Browser MFA may not even be allowed on the server.
|
||||
slog.DebugContext(ctx, "Failed to attempt SSO/Browser MFA, continuing with other MFA methods", "error", err)
|
||||
} else {
|
||||
defer ssoMFACeremony.Close()
|
||||
defer mfaCeremony.Close()
|
||||
|
||||
// req may be nil in cases where the ceremony's CreateAuthenticateChallenge sources
|
||||
// its own req or uses a different e.g. login. We should still provide the sso client
|
||||
@@ -79,9 +79,13 @@ func (c *Ceremony) Run(ctx context.Context, req *proto.CreateAuthenticateChallen
|
||||
req = new(proto.CreateAuthenticateChallengeRequest)
|
||||
}
|
||||
|
||||
req.SSOClientRedirectURL = ssoMFACeremony.GetClientCallbackURL()
|
||||
req.ProxyAddress = ssoMFACeremony.GetProxyAddress()
|
||||
promptOpts = append(promptOpts, withSSOMFACeremony(ssoMFACeremony))
|
||||
req.SSOClientRedirectURL = mfaCeremony.GetClientCallbackURL()
|
||||
// Reuse the same callback server for Browser MFA because only one of
|
||||
// SSO MFA or Browser MFA can be used. Sending both redirect URLs
|
||||
// indicates to the server that both methods are available.
|
||||
req.BrowserMFATSHRedirectURL = mfaCeremony.GetClientCallbackURL()
|
||||
req.ProxyAddress = mfaCeremony.GetProxyAddress()
|
||||
promptOpts = append(promptOpts, withSSOMFACeremony(mfaCeremony))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-10
@@ -160,15 +160,15 @@ func TestMFACeremony_SSO(t *testing.T) {
|
||||
}
|
||||
|
||||
return mfa.PromptFunc(func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
if cfg.SSOMFACeremony == nil {
|
||||
return nil, trace.BadParameter("expected sso mfa ceremony")
|
||||
if cfg.MFACeremony == nil {
|
||||
return nil, trace.BadParameter("expected mfa ceremony")
|
||||
}
|
||||
|
||||
return cfg.SSOMFACeremony.Run(ctx, chal)
|
||||
return cfg.MFACeremony.Run(ctx, chal)
|
||||
})
|
||||
},
|
||||
SSOMFACeremonyConstructor: func(ctx context.Context) (mfa.SSOMFACeremony, error) {
|
||||
return &mockSSOMFACeremony{
|
||||
MFACeremonyConstructor: func(ctx context.Context) (mfa.CallbackCeremony, error) {
|
||||
return &mockMFACeremony{
|
||||
clientCallbackURL: "client-redirect",
|
||||
prompt: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
return testMFAResponse, nil
|
||||
@@ -187,23 +187,81 @@ func TestMFACeremony_SSO(t *testing.T) {
|
||||
require.Equal(t, testMFAResponse, resp)
|
||||
}
|
||||
|
||||
type mockSSOMFACeremony struct {
|
||||
func TestMFACeremony_BrowserMFA(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
expectedCallbackURL := "http://localhost:12345/?secret=X"
|
||||
|
||||
testMFAChallenge := &proto.MFAAuthenticateChallenge{
|
||||
BrowserMFAChallenge: &proto.BrowserMFAChallenge{
|
||||
RequestId: "request-id",
|
||||
},
|
||||
}
|
||||
testMFAResponse := &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_Browser{
|
||||
Browser: &proto.BrowserMFAResponse{
|
||||
RequestId: "request-id",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
browserMFACeremony := &mfa.Ceremony{
|
||||
CreateAuthenticateChallenge: func(ctx context.Context, req *proto.CreateAuthenticateChallengeRequest) (*proto.MFAAuthenticateChallenge, error) {
|
||||
require.NotNil(t, req)
|
||||
require.Equal(t, expectedCallbackURL, req.BrowserMFATSHRedirectURL)
|
||||
return testMFAChallenge, nil
|
||||
},
|
||||
PromptConstructor: func(opts ...mfa.PromptOpt) mfa.Prompt {
|
||||
cfg := new(mfa.PromptConfig)
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
return mfa.PromptFunc(func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
if cfg.MFACeremony == nil {
|
||||
return nil, trace.BadParameter("expected mfa ceremony")
|
||||
}
|
||||
|
||||
return cfg.MFACeremony.Run(ctx, chal)
|
||||
})
|
||||
},
|
||||
MFACeremonyConstructor: func(ctx context.Context) (mfa.CallbackCeremony, error) {
|
||||
return &mockMFACeremony{
|
||||
clientCallbackURL: expectedCallbackURL,
|
||||
prompt: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
return testMFAResponse, nil
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := browserMFACeremony.Run(ctx, &proto.CreateAuthenticateChallengeRequest{
|
||||
ChallengeExtensions: &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
},
|
||||
MFARequiredCheck: &proto.IsMFARequiredRequest{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testMFAResponse, resp)
|
||||
}
|
||||
|
||||
type mockMFACeremony struct {
|
||||
clientCallbackURL string
|
||||
prompt mfa.PromptFunc
|
||||
}
|
||||
|
||||
// GetClientCallbackURL returns the client callback URL.
|
||||
func (m *mockSSOMFACeremony) GetClientCallbackURL() string {
|
||||
func (m *mockMFACeremony) GetClientCallbackURL() string {
|
||||
return m.clientCallbackURL
|
||||
}
|
||||
|
||||
func (m *mockSSOMFACeremony) GetProxyAddress() string {
|
||||
func (m *mockMFACeremony) GetProxyAddress() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Run the SSO MFA ceremony.
|
||||
func (m *mockSSOMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
func (m *mockMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
return m.prompt(ctx, chal)
|
||||
}
|
||||
|
||||
func (m *mockSSOMFACeremony) Close() {}
|
||||
func (m *mockMFACeremony) Close() {}
|
||||
|
||||
+4
-4
@@ -54,8 +54,8 @@ type PromptConfig struct {
|
||||
// Extensions are the challenge extensions used to create the prompt's challenge.
|
||||
// Used to enrich certain prompts.
|
||||
Extensions *mfav1.ChallengeExtensions
|
||||
// SSOMFACeremony is an SSO MFA ceremony.
|
||||
SSOMFACeremony SSOMFACeremony
|
||||
// MFACeremony is an SSO or Browser MFA ceremony.
|
||||
MFACeremony CallbackCeremony
|
||||
}
|
||||
|
||||
// DeviceDescriptor is a descriptor for a device, such as "registered".
|
||||
@@ -121,8 +121,8 @@ func WithPromptChallengeExtensions(exts *mfav1.ChallengeExtensions) PromptOpt {
|
||||
}
|
||||
|
||||
// withSSOMFACeremony sets the SSO MFA ceremony for the MFA prompt.
|
||||
func withSSOMFACeremony(ssoMFACeremony SSOMFACeremony) PromptOpt {
|
||||
func withSSOMFACeremony(ssoMFACeremony CallbackCeremony) PromptOpt {
|
||||
return func(cfg *PromptConfig) {
|
||||
cfg.SSOMFACeremony = ssoMFACeremony
|
||||
cfg.MFACeremony = ssoMFACeremony
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,26 +238,14 @@ func TestCompleteBrowserMFAChallenge(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
testAuthServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{
|
||||
Dir: t.TempDir(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, testAuthServer.Close()) })
|
||||
|
||||
testServer, err := testAuthServer.NewTestTLSServer()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, testServer.Close()) })
|
||||
|
||||
a := testServer.Auth()
|
||||
|
||||
username := "test-user"
|
||||
_, _, err = authtest.CreateUserAndRole(a, username, []string{"role"}, nil)
|
||||
require.NoError(t, err)
|
||||
env := newBrowserMFATestEnv(t)
|
||||
a := env.auth
|
||||
username := env.webauthnUser.GetName()
|
||||
|
||||
secretKey, err := secret.NewKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
rawID := []byte("test-raw-id")
|
||||
rawID := env.webauthnDev.GetWebauthn().CredentialId
|
||||
webauthnResponse := &wantypes.CredentialAssertionResponse{
|
||||
PublicKeyCredential: wantypes.PublicKeyCredential{
|
||||
Credential: wantypes.Credential{
|
||||
@@ -403,23 +391,16 @@ func TestCreateAuthenticateChallenge_BrowserMFARequestID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
testServer, err := authtest.NewTestServer(authtest.ServerConfig{
|
||||
Auth: authtest.AuthServerConfig{
|
||||
Dir: t.TempDir(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { assert.NoError(t, testServer.Close()) })
|
||||
env := newBrowserMFATestEnv(t)
|
||||
a := env.auth
|
||||
|
||||
a := testServer.Auth()
|
||||
|
||||
userCreds, err := createUserWithSecondFactors(testServer.TLS)
|
||||
require.NoError(t, err)
|
||||
password := []byte("test-password")
|
||||
require.NoError(t, a.UpsertPassword(env.webauthnUser.GetName(), password))
|
||||
|
||||
userCredsRequest := &proto.CreateAuthenticateChallengeRequest_UserCredentials{
|
||||
UserCredentials: &proto.UserCredentials{
|
||||
Username: userCreds.username,
|
||||
Password: userCreds.password,
|
||||
Username: env.webauthnUser.GetName(),
|
||||
Password: password,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -456,13 +437,13 @@ func TestCreateAuthenticateChallenge_BrowserMFARequestID(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK browser MFA challenge extensions applied from SSO MFA session",
|
||||
name: "OK browser MFA challenge extensions applied from MFA session",
|
||||
setup: func(t *testing.T) {
|
||||
session := &services.SSOMFASessionData{
|
||||
session := &services.MFASessionData{
|
||||
RequestID: "test-request-1",
|
||||
Username: userCreds.username,
|
||||
ConnectorID: "Browser",
|
||||
ConnectorType: "Browser",
|
||||
Username: env.webauthnUser.GetName(),
|
||||
ConnectorID: constants.BrowserMFA,
|
||||
ConnectorType: constants.BrowserMFA,
|
||||
ChallengeExtensions: &mfatypes.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_NO,
|
||||
@@ -485,11 +466,11 @@ func TestCreateAuthenticateChallenge_BrowserMFARequestID(t *testing.T) {
|
||||
{
|
||||
name: "NOK nil challenge extensions",
|
||||
setup: func(t *testing.T) {
|
||||
session := &services.SSOMFASessionData{
|
||||
session := &services.MFASessionData{
|
||||
RequestID: "test-request-2",
|
||||
Username: userCreds.username,
|
||||
ConnectorID: "Browser",
|
||||
ConnectorType: "Browser",
|
||||
Username: env.webauthnUser.GetName(),
|
||||
ConnectorID: constants.BrowserMFA,
|
||||
ConnectorType: constants.BrowserMFA,
|
||||
ChallengeExtensions: nil,
|
||||
}
|
||||
err := a.UpsertMFASessionData(ctx, session)
|
||||
@@ -575,6 +556,16 @@ func TestBrowserMFAChallengeCreation(t *testing.T) {
|
||||
_, err = a.UpsertSAMLConnector(ctx, samlConnector)
|
||||
require.NoError(t, err)
|
||||
|
||||
samlUser.SetCreatedBy(types.CreatedBy{
|
||||
Time: env.clock.Now(),
|
||||
Connector: &types.ConnectorRef{
|
||||
ID: samlConnector.GetName(),
|
||||
Type: samlConnector.GetKind(),
|
||||
},
|
||||
})
|
||||
_, err = a.UpsertUser(ctx, samlUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
loginExt := &mfav1.ChallengeExtensions{
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN,
|
||||
}
|
||||
@@ -679,8 +670,7 @@ func TestBrowserMFAChallengeCreation(t *testing.T) {
|
||||
require.NotNil(t, chal.BrowserMFAChallenge, "expected Browser MFA challenge to be returned")
|
||||
assert.NotEmpty(t, chal.BrowserMFAChallenge.RequestId, "request ID should be generated")
|
||||
|
||||
// Find SSO MFA session data tied to the challenge.
|
||||
// Browser MFA reuses the SSO MFA session data storage.
|
||||
// Find MFA session data tied to the challenge.
|
||||
sd, err := a.GetSSOMFASessionData(ctx, chal.BrowserMFAChallenge.RequestId)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &services.MFASessionData{
|
||||
@@ -709,7 +699,7 @@ func TestBrowserMFAChallengeCreation(t *testing.T) {
|
||||
assertChallenge: func(t *testing.T, chal *proto.MFAAuthenticateChallenge) {
|
||||
require.NotNil(t, chal.BrowserMFAChallenge, "expected Browser MFA challenge to be returned")
|
||||
|
||||
// We should find SSO MFA session data tied to the challenge by request ID.
|
||||
// We should find MFA session data tied to the challenge by request ID.
|
||||
sd, err := a.GetSSOMFASessionData(ctx, chal.BrowserMFAChallenge.RequestId)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES, sd.ChallengeExtensions.AllowReuse)
|
||||
|
||||
+15
-11
@@ -370,6 +370,9 @@ type Config struct {
|
||||
// PreferSSO prefers SSO in favor of other MFA methods.
|
||||
PreferSSO bool
|
||||
|
||||
// PreferBrowser prefers browser-based WebAuthn MFA in favor of other MFA methods.
|
||||
PreferBrowser bool
|
||||
|
||||
// CheckVersions will check that client version is compatible
|
||||
// with auth server version when connecting.
|
||||
CheckVersions bool
|
||||
@@ -500,8 +503,8 @@ type Config struct {
|
||||
// MFAPromptConstructor is a custom MFA prompt constructor to use when prompting for MFA.
|
||||
MFAPromptConstructor func(cfg *libmfa.PromptConfig) mfa.Prompt
|
||||
|
||||
// SSOMFACeremonyConstructor is a custom SSO MFA ceremony constructor.
|
||||
SSOMFACeremonyConstructor func(rd *sso.Redirector) mfa.SSOMFACeremony
|
||||
// MFACeremonyConstructor is a custom SSO/Browser MFA ceremony constructor.
|
||||
MFACeremonyConstructor func(rd *sso.Redirector) mfa.CallbackCeremony
|
||||
|
||||
// DisableSSHResumption disables transparent SSH connection resumption.
|
||||
DisableSSHResumption bool
|
||||
@@ -3237,7 +3240,7 @@ func (tc *TeleportClient) ConnectToCluster(ctx context.Context) (_ *ClusterClien
|
||||
return nil, trace.NewAggregate(err, pclt.Close())
|
||||
}
|
||||
authClientCfg.MFAPromptConstructor = tc.NewMFAPrompt
|
||||
authClientCfg.SSOMFACeremonyConstructor = tc.NewSSOMFACeremony
|
||||
authClientCfg.MFACeremonyConstructor = tc.NewRedirectorMFACeremony
|
||||
|
||||
authClient, err := authclient.NewClient(authClientCfg)
|
||||
if err != nil {
|
||||
@@ -4220,10 +4223,11 @@ func (tc *TeleportClient) localLogin(ctx context.Context, keyRing *KeyRing, _ co
|
||||
}
|
||||
|
||||
response, err := SSHAgentMFALogin(ctx, SSHLoginMFA{
|
||||
SSHLogin: sshLogin,
|
||||
User: tc.Username,
|
||||
Password: password,
|
||||
MFAPromptConstructor: tc.NewMFAPrompt,
|
||||
SSHLogin: sshLogin,
|
||||
User: tc.Username,
|
||||
Password: password,
|
||||
MFAPromptConstructor: tc.NewMFAPrompt,
|
||||
MFACeremonyConstructor: tc.NewRedirectorMFACeremony,
|
||||
})
|
||||
return response, trace.Wrap(err)
|
||||
}
|
||||
@@ -5381,10 +5385,10 @@ func (tc *TeleportClient) NewKubernetesServiceClient(ctx context.Context, cluste
|
||||
Credentials: []client.Credentials{
|
||||
client.LoadTLS(tlsConfig),
|
||||
},
|
||||
ALPNConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired,
|
||||
InsecureAddressDiscovery: tc.InsecureSkipVerify,
|
||||
MFAPromptConstructor: tc.NewMFAPrompt,
|
||||
SSOMFACeremonyConstructor: tc.NewSSOMFACeremony,
|
||||
ALPNConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired,
|
||||
InsecureAddressDiscovery: tc.InsecureSkipVerify,
|
||||
MFAPromptConstructor: tc.NewMFAPrompt,
|
||||
MFACeremonyConstructor: tc.NewRedirectorMFACeremony,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
|
||||
@@ -151,6 +151,7 @@ func TestTeleportClient_Login_local(t *testing.T) {
|
||||
authConnector string
|
||||
allowStdinHijack bool
|
||||
preferOTP bool
|
||||
preferBrowser bool
|
||||
hasTouchIDCredentials bool
|
||||
authenticatorAttachment wancli.AuthenticatorAttachment
|
||||
scope string
|
||||
@@ -316,6 +317,7 @@ func TestTeleportClient_Login_local(t *testing.T) {
|
||||
tc.AllowStdinHijack = test.allowStdinHijack
|
||||
tc.AuthConnector = test.authConnector
|
||||
tc.PreferOTP = test.preferOTP
|
||||
tc.PreferBrowser = test.preferBrowser
|
||||
tc.AuthenticatorAttachment = test.authenticatorAttachment
|
||||
inputReader := test.makeInputReader(password, otpKey, clock)
|
||||
tc.StdinFunc = func() prompt.StdinReader { return inputReader }
|
||||
@@ -336,6 +338,16 @@ func TestTeleportClient_Login_local(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Only enable BrowserAuthentication for tests that explicitly request it
|
||||
if test.preferBrowser != false {
|
||||
authServer := sa.Auth.GetAuthServer()
|
||||
authPref, err := authServer.GetAuthPreference(ctx)
|
||||
require.NoError(t, err)
|
||||
authPref.SetAllowCLIAuthViaBrowser(true)
|
||||
_, err = authServer.UpsertAuthPreference(ctx, authPref)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Test.
|
||||
clock.Advance(30 * time.Second)
|
||||
keyRing, err := tc.Login(ctx)
|
||||
@@ -598,6 +610,8 @@ func newStandaloneTeleport(t *testing.T, clock clockwork.Clock) *standaloneBundl
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
// Disable by default and enable for tests that require it
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
SignatureAlgorithmSuite: types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
+6
-5
@@ -34,7 +34,7 @@ func (tc *TeleportClient) NewMFACeremony() *mfa.Ceremony {
|
||||
return &mfa.Ceremony{
|
||||
CreateAuthenticateChallenge: tc.createAuthenticateChallenge,
|
||||
PromptConstructor: tc.NewMFAPrompt,
|
||||
SSOMFACeremonyConstructor: tc.NewSSOMFACeremony,
|
||||
MFACeremonyConstructor: tc.NewRedirectorMFACeremony,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func (tc *TeleportClient) NewMFAPrompt(opts ...mfa.PromptOpt) mfa.Prompt {
|
||||
Writer: tc.Stderr,
|
||||
PreferOTP: tc.PreferOTP,
|
||||
PreferSSO: tc.PreferSSO,
|
||||
PreferBrowser: tc.PreferBrowser,
|
||||
AllowStdinHijack: tc.AllowStdinHijack,
|
||||
StdinFunc: tc.StdinFunc,
|
||||
})
|
||||
@@ -86,8 +87,8 @@ func (tc *TeleportClient) newPromptConfig(opts ...mfa.PromptOpt) *libmfa.PromptC
|
||||
return cfg
|
||||
}
|
||||
|
||||
// NewSSOMFACeremony creates a new SSO MFA ceremony.
|
||||
func (tc *TeleportClient) NewSSOMFACeremony(ctx context.Context) (mfa.SSOMFACeremony, error) {
|
||||
// NewRedirectorMFACeremony creates a new redirector for SSO or Browser MFA ceremony.
|
||||
func (tc *TeleportClient) NewRedirectorMFACeremony(ctx context.Context) (mfa.CallbackCeremony, error) {
|
||||
rdConfig, err := tc.ssoRedirectorConfig(ctx, "" /*connectorDisplayName*/)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
@@ -98,8 +99,8 @@ func (tc *TeleportClient) NewSSOMFACeremony(ctx context.Context) (mfa.SSOMFACere
|
||||
return nil, trace.Wrap(err, "failed to create a redirector for SSO MFA")
|
||||
}
|
||||
|
||||
if tc.SSOMFACeremonyConstructor != nil {
|
||||
return tc.SSOMFACeremonyConstructor(rd), nil
|
||||
if tc.MFACeremonyConstructor != nil {
|
||||
return tc.MFACeremonyConstructor(rd), nil
|
||||
}
|
||||
|
||||
return sso.NewCLIMFACeremony(rd), nil
|
||||
|
||||
+251
-89
@@ -19,6 +19,7 @@
|
||||
package mfa
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -46,6 +47,8 @@ const (
|
||||
cliMFATypeWebauthn = "WEBAUTHN"
|
||||
// cliMFATypeSSO is the CLI display name for SSO.
|
||||
cliMFATypeSSO = "SSO"
|
||||
// cliMFATypeBrowserMFA is the CLI display name for Browser MFA.
|
||||
cliMFATypeBrowserMFA = "BROWSER"
|
||||
)
|
||||
|
||||
// CLIPromptConfig contains CLI prompt config options.
|
||||
@@ -65,9 +68,14 @@ type CLIPromptConfig struct {
|
||||
// PreferSSO favors SSO challenges, if applicable.
|
||||
// Takes precedence over AuthenticatorAttachment settings.
|
||||
PreferSSO bool
|
||||
// PreferBrowser favors browser-based WebAuthn challenges, if applicable.
|
||||
// Takes precedence over AuthenticatorAttachment settings.
|
||||
PreferBrowser bool
|
||||
// StdinFunc allows tests to override prompt.Stdin().
|
||||
// If nil prompt.Stdin() is used.
|
||||
StdinFunc func() prompt.StdinReader
|
||||
// RuntimeOS overrides runtime.GOOS. Intended for tests only.
|
||||
RuntimeOS string
|
||||
}
|
||||
|
||||
// CLIPrompt is the default CLI mfa prompt implementation.
|
||||
@@ -100,90 +108,79 @@ func (c *CLIPrompt) writer() io.Writer {
|
||||
return c.cfg.Writer
|
||||
}
|
||||
|
||||
// Run prompts the user to complete an MFA authentication challenge.
|
||||
func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
if c.cfg.PromptReason != "" {
|
||||
fmt.Fprintln(c.writer(), c.cfg.PromptReason)
|
||||
}
|
||||
func (c *CLIPrompt) getOS() string {
|
||||
return cmp.Or(c.cfg.RuntimeOS, runtime.GOOS)
|
||||
}
|
||||
|
||||
promptOTP := chal.TOTP != nil
|
||||
promptWebauthn := chal.WebauthnChallenge != nil
|
||||
promptSSO := chal.SSOChallenge != nil
|
||||
// mfaPromptState represents which MFA methods are available to prompt.
|
||||
type mfaPromptState struct {
|
||||
promptWebauthn bool
|
||||
promptSSO bool
|
||||
promptOTP bool
|
||||
promptBrowser bool
|
||||
}
|
||||
|
||||
// No prompt to run, no-op.
|
||||
if !promptOTP && !promptWebauthn && !promptSSO {
|
||||
return &proto.MFAAuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
isPerSessionMFA := c.cfg.Extensions.GetScope() == mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION
|
||||
var availableMethods []string
|
||||
if promptWebauthn {
|
||||
availableMethods = append(availableMethods, cliMFATypeWebauthn)
|
||||
}
|
||||
if promptSSO {
|
||||
availableMethods = append(availableMethods, cliMFATypeSSO)
|
||||
}
|
||||
if promptOTP && !isPerSessionMFA {
|
||||
availableMethods = append(availableMethods, cliMFATypeOTP)
|
||||
}
|
||||
|
||||
// Check off unsupported methods.
|
||||
if promptWebauthn && !c.cfg.WebauthnSupported {
|
||||
promptWebauthn = false
|
||||
slog.DebugContext(ctx, "hardware device MFA not supported by your platform")
|
||||
}
|
||||
|
||||
if promptSSO && c.cfg.SSOMFACeremony == nil {
|
||||
promptSSO = false
|
||||
slog.DebugContext(ctx, "SSO MFA not supported by this client, this is likely a bug")
|
||||
}
|
||||
|
||||
// Short circuit if OTP was preferred by --mfa-mode during per-session MFA
|
||||
if c.cfg.PreferOTP && promptOTP && isPerSessionMFA {
|
||||
return nil, trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA, can not specify --mfa-mode=otp")
|
||||
}
|
||||
|
||||
// Prefer whatever method is requested by the client.
|
||||
// filterMFAMethods determines which MFA method(s) to prompt for based on available methods,
|
||||
// user preferences, and configuration. It prints a message to the user if multiple methods
|
||||
// are available and returns the filtered state and list of available methods.
|
||||
func (c *CLIPrompt) filterMFAMethods(state mfaPromptState, isPerSessionMFA bool, availableMethods []string) (mfaPromptState, bool) {
|
||||
var chosenMethods []string
|
||||
var userSpecifiedMethod bool
|
||||
|
||||
// Prefer whatever method is requested by the client.
|
||||
switch {
|
||||
case c.cfg.PreferSSO && promptSSO:
|
||||
chosenMethods = []string{cliMFATypeSSO}
|
||||
promptWebauthn, promptOTP = false, false
|
||||
case c.cfg.PreferBrowser && state.promptBrowser:
|
||||
chosenMethods = []string{cliMFATypeBrowserMFA}
|
||||
state.promptWebauthn, state.promptOTP, state.promptSSO = false, false, false
|
||||
userSpecifiedMethod = true
|
||||
case c.cfg.PreferOTP && promptOTP:
|
||||
case c.cfg.PreferSSO && state.promptSSO:
|
||||
chosenMethods = []string{cliMFATypeSSO}
|
||||
state.promptWebauthn, state.promptOTP, state.promptBrowser = false, false, false
|
||||
userSpecifiedMethod = true
|
||||
case c.cfg.PreferOTP && state.promptOTP:
|
||||
chosenMethods = []string{cliMFATypeOTP}
|
||||
promptWebauthn, promptSSO = false, false
|
||||
state.promptWebauthn, state.promptSSO, state.promptBrowser = false, false, false
|
||||
userSpecifiedMethod = true
|
||||
case c.cfg.AuthenticatorAttachment != wancli.AttachmentAuto:
|
||||
chosenMethods = []string{cliMFATypeWebauthn}
|
||||
promptSSO, promptOTP = false, false
|
||||
state.promptSSO, state.promptOTP, state.promptBrowser = false, false, false
|
||||
userSpecifiedMethod = true
|
||||
}
|
||||
|
||||
// Use stronger auth methods if hijack is not allowed.
|
||||
if !c.cfg.AllowStdinHijack && promptWebauthn {
|
||||
promptOTP = false
|
||||
if !c.cfg.AllowStdinHijack && state.promptWebauthn {
|
||||
state.promptOTP = false
|
||||
}
|
||||
|
||||
// If we have multiple viable options, prefer Webauthn > SSO > OTP.
|
||||
switch {
|
||||
case promptWebauthn:
|
||||
chosenMethods = []string{cliMFATypeWebauthn}
|
||||
promptSSO = false
|
||||
// Allow dual prompt with OTP.
|
||||
if promptOTP {
|
||||
chosenMethods = append(chosenMethods, cliMFATypeOTP)
|
||||
// If no user preference was specified, set initial MFA preference
|
||||
if !userSpecifiedMethod {
|
||||
// We should never prompt for OTP when per-session MFA is enabled. As long as other MFA methods are available,
|
||||
// we can completely ignore OTP. The promptOTP case in [Run] will return an error in the case that no other methods
|
||||
// are available.
|
||||
if isPerSessionMFA && (state.promptWebauthn || state.promptSSO || state.promptBrowser) {
|
||||
state.promptOTP = false
|
||||
}
|
||||
|
||||
// Determine initial method to show based on MFA hierarchy:
|
||||
// Webauthn > SSO > Browser > OTP.
|
||||
switch {
|
||||
case state.promptWebauthn:
|
||||
chosenMethods = []string{cliMFATypeWebauthn}
|
||||
// Allow dual prompt with OTP.
|
||||
if state.promptOTP {
|
||||
chosenMethods = append(chosenMethods, cliMFATypeOTP)
|
||||
}
|
||||
case state.promptSSO:
|
||||
chosenMethods = []string{cliMFATypeSSO}
|
||||
case state.promptBrowser:
|
||||
chosenMethods = []string{cliMFATypeBrowserMFA}
|
||||
case state.promptOTP:
|
||||
chosenMethods = []string{cliMFATypeOTP}
|
||||
}
|
||||
case promptSSO:
|
||||
chosenMethods = []string{cliMFATypeSSO}
|
||||
promptOTP = false
|
||||
case promptOTP:
|
||||
chosenMethods = []string{cliMFATypeOTP}
|
||||
}
|
||||
|
||||
// If there are multiple options and we chose one without it being specifically
|
||||
// requested by the user, notify the user about it and how to request a specific method.
|
||||
// If there are multiple options and we chose fewer without explicit user preference,
|
||||
// notify the user about the available methods and how to select a specific one.
|
||||
if len(availableMethods) > len(chosenMethods) && len(chosenMethods) > 0 && !userSpecifiedMethod {
|
||||
availableMethodsString := strings.ToLower(strings.Join(availableMethods, ","))
|
||||
const msg = "" +
|
||||
@@ -192,32 +189,184 @@ func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChalleng
|
||||
fmt.Fprintf(c.writer(), msg, strings.Join(availableMethods, ", "), strings.Join(chosenMethods, " and "), availableMethodsString, availableMethodsString)
|
||||
}
|
||||
|
||||
// We should never prompt for OTP when per-session MFA is enabled. As long as other MFA methods are available,
|
||||
// we can completely ignore OTP. The promptOTP case below will return an error in the case that no other methods
|
||||
// are available.
|
||||
if isPerSessionMFA && (promptWebauthn || promptSSO) {
|
||||
promptOTP = false
|
||||
return state, userSpecifiedMethod
|
||||
}
|
||||
|
||||
// Run prompts the user to complete an MFA authentication challenge.
|
||||
func (c *CLIPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
if c.cfg.PromptReason != "" {
|
||||
fmt.Fprintln(c.writer(), c.cfg.PromptReason)
|
||||
}
|
||||
|
||||
switch {
|
||||
case promptOTP && promptWebauthn:
|
||||
resp, err := c.promptWebauthnAndOTP(ctx, chal)
|
||||
return resp, trace.Wrap(err)
|
||||
case promptWebauthn:
|
||||
resp, err := c.promptWebauthn(ctx, chal, c.getWebauthnPrompt(ctx))
|
||||
return resp, trace.Wrap(err)
|
||||
case promptSSO:
|
||||
resp, err := c.promptSSO(ctx, chal)
|
||||
return resp, trace.Wrap(err)
|
||||
case promptOTP:
|
||||
if isPerSessionMFA {
|
||||
return nil, trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA")
|
||||
// Initialize prompt state from the challenge.
|
||||
state := mfaPromptState{
|
||||
promptOTP: chal.TOTP != nil,
|
||||
promptWebauthn: chal.WebauthnChallenge != nil,
|
||||
promptSSO: chal.SSOChallenge != nil,
|
||||
promptBrowser: chal.BrowserMFAChallenge != nil,
|
||||
}
|
||||
|
||||
// No prompt to run, no-op.
|
||||
if !state.promptOTP && !state.promptWebauthn && !state.promptSSO && !state.promptBrowser {
|
||||
return &proto.MFAAuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
isPerSessionMFA := c.cfg.Extensions.GetScope() == mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION
|
||||
|
||||
// Build list of available methods from the challenge before filtering
|
||||
// out unsupported methods. This list is used in user-facing messages.
|
||||
var availableMethods []string
|
||||
if state.promptWebauthn {
|
||||
availableMethods = append(availableMethods, cliMFATypeWebauthn)
|
||||
}
|
||||
if state.promptSSO {
|
||||
availableMethods = append(availableMethods, cliMFATypeSSO)
|
||||
}
|
||||
if state.promptOTP && !isPerSessionMFA {
|
||||
availableMethods = append(availableMethods, cliMFATypeOTP)
|
||||
}
|
||||
if state.promptBrowser {
|
||||
availableMethods = append(availableMethods, cliMFATypeBrowserMFA)
|
||||
}
|
||||
|
||||
// Check off unsupported methods.
|
||||
if state.promptWebauthn && !c.cfg.WebauthnSupported {
|
||||
state.promptWebauthn = false
|
||||
slog.DebugContext(ctx, "Disabling WebAuthn: hardware device MFA not supported by your platform")
|
||||
}
|
||||
|
||||
if state.promptSSO && c.cfg.MFACeremony == nil {
|
||||
state.promptSSO = false
|
||||
slog.DebugContext(ctx, "Disabling SSO MFA: SSO MFA ceremony not available (this is likely a bug)")
|
||||
}
|
||||
|
||||
if state.promptBrowser && (!c.cfg.WebauthnSupported || c.cfg.MFACeremony == nil) {
|
||||
state.promptBrowser = false
|
||||
slog.DebugContext(
|
||||
ctx,
|
||||
"Disabling Browser MFA: cluster needs to support Webauthn and client needs to support SSO MFA Ceremony",
|
||||
"webauthn_supported", c.cfg.WebauthnSupported,
|
||||
"mfa_ceremony_available (if false, this is a bug)", c.cfg.MFACeremony != nil,
|
||||
)
|
||||
}
|
||||
|
||||
// Short circuit if OTP was preferred by --mfa-mode during per-session MFA.
|
||||
if c.cfg.PreferOTP && state.promptOTP && isPerSessionMFA {
|
||||
return nil, trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA, cannot specify --mfa-mode=otp")
|
||||
}
|
||||
|
||||
// Determine which method(s) to use and print options if multiple are available.
|
||||
var userSpecifiedMethod bool
|
||||
state, userSpecifiedMethod = c.filterMFAMethods(state, isPerSessionMFA, availableMethods)
|
||||
|
||||
// Perform MFA with automatic fallback to other methods on failure.
|
||||
// In order: WebAuthn > SSO > Browser MFA > OTP
|
||||
return c.promptWithFallback(ctx, chal, state, availableMethods, isPerSessionMFA, userSpecifiedMethod)
|
||||
}
|
||||
|
||||
func (c *CLIPrompt) promptWithFallback(ctx context.Context, chal *proto.MFAAuthenticateChallenge, state mfaPromptState, availableMethods []string, isPerSessionMFA, userSpecifiedMethod bool) (*proto.MFAAuthenticateResponse, error) {
|
||||
var lastErr error
|
||||
|
||||
// If the user is running Windows and hasn't marked Browser MFA as preferred,
|
||||
// skip Browser MFA. They will have access to the same MFA methods using the
|
||||
// WebAuthn.dll prompt.
|
||||
skipBrowserMFAFallback := false
|
||||
if state.promptBrowser && !c.cfg.PreferBrowser && c.getOS() == constants.WindowsOS {
|
||||
skipBrowserMFAFallback = true
|
||||
slog.DebugContext(ctx, "Skipping Browser MFA fallback on Windows (WebAuthn.dll provides same functionality)")
|
||||
}
|
||||
|
||||
// Retry loop for fallback behavior.
|
||||
for {
|
||||
// Determine current method to try based on priority order.
|
||||
var currentMethod string
|
||||
switch {
|
||||
case state.promptWebauthn:
|
||||
currentMethod = cliMFATypeWebauthn
|
||||
case state.promptSSO:
|
||||
currentMethod = cliMFATypeSSO
|
||||
case state.promptBrowser && !skipBrowserMFAFallback:
|
||||
currentMethod = cliMFATypeBrowserMFA
|
||||
case state.promptOTP:
|
||||
currentMethod = cliMFATypeOTP
|
||||
default:
|
||||
// No more methods to try.
|
||||
slog.DebugContext(ctx, "No more MFA methods to try",
|
||||
"last_error", lastErr,
|
||||
"available_methods", strings.Join(availableMethods, ", "),
|
||||
)
|
||||
if lastErr != nil {
|
||||
return nil, trace.Wrap(lastErr)
|
||||
}
|
||||
return nil, trace.BadParameter("client does not support any available MFA methods [%v], see debug logs for details", strings.Join(availableMethods, ", "))
|
||||
}
|
||||
|
||||
resp, err := c.promptOTP(ctx, c.cfg.Quiet)
|
||||
return resp, trace.Wrap(err)
|
||||
default:
|
||||
return nil, trace.BadParameter("client does not support any available MFA methods [%v], see debug logs for details", strings.Join(availableMethods, ", "))
|
||||
// If we're retrying after a failure, inform the user.
|
||||
if lastErr != nil {
|
||||
fmt.Fprintf(c.writer(), "Attempting MFA authentication with %s\n", currentMethod)
|
||||
}
|
||||
|
||||
// Perform the chosen ceremony based on the filtered state.
|
||||
var resp *proto.MFAAuthenticateResponse
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case state.promptWebauthn:
|
||||
if state.promptOTP {
|
||||
resp, err = c.promptWebauthnAndOTP(ctx, chal)
|
||||
} else {
|
||||
resp, err = c.promptWebauthn(ctx, chal, c.getWebauthnPrompt(ctx))
|
||||
}
|
||||
case state.promptSSO:
|
||||
resp, err = c.promptSSO(ctx, chal)
|
||||
case state.promptBrowser && !skipBrowserMFAFallback:
|
||||
resp, err = c.promptBrowser(ctx, chal)
|
||||
case state.promptOTP:
|
||||
if isPerSessionMFA {
|
||||
return nil, trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA")
|
||||
}
|
||||
resp, err = c.promptOTP(ctx, c.cfg.Quiet)
|
||||
}
|
||||
|
||||
// MFA successful
|
||||
if err == nil {
|
||||
slog.DebugContext(ctx, "MFA authentication successful", "method", currentMethod)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
slog.ErrorContext(ctx, "MFA authentication failed",
|
||||
"method", currentMethod,
|
||||
"error", err,
|
||||
"user_specified", userSpecifiedMethod,
|
||||
)
|
||||
|
||||
// Don't fall back if the user explicitly chose this method.
|
||||
if userSpecifiedMethod {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// Print error message about the failure.
|
||||
fmt.Fprintf(c.writer(), "MFA authentication with %s failed, check logs for details\n", currentMethod)
|
||||
|
||||
// Don't fall back if the context is done (e.g. user canceled or request timed out).
|
||||
if ctx.Err() != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// Disable the failed method and loop to try the next one.
|
||||
// Fallback only moves forward in priority order: WebAuthn > SSO > Browser MFA > OTP
|
||||
switch currentMethod {
|
||||
case cliMFATypeWebauthn:
|
||||
state.promptWebauthn = false
|
||||
case cliMFATypeSSO:
|
||||
state.promptSSO = false
|
||||
case cliMFATypeBrowserMFA:
|
||||
state.promptBrowser = false
|
||||
case cliMFATypeOTP:
|
||||
state.promptOTP = false
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +421,7 @@ func (c *CLIPrompt) promptDevicePrefix() string {
|
||||
func (c *CLIPrompt) promptWebauthnAndOTP(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
spawnGoroutines := func(ctx context.Context, wg *sync.WaitGroup, respC chan<- MFAGoroutineResponse) {
|
||||
var message string
|
||||
if runtime.GOOS == constants.WindowsOS {
|
||||
if c.getOS() == constants.WindowsOS {
|
||||
message = "Follow the OS dialogs for platform authentication, or enter an OTP code here:"
|
||||
webauthnwin.SetPromptPlatformMessage("")
|
||||
} else {
|
||||
@@ -372,6 +521,19 @@ func (w *webauthnPromptWithOTP) PromptPIN() (string, error) {
|
||||
}
|
||||
|
||||
func (c *CLIPrompt) promptSSO(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
resp, err := c.cfg.SSOMFACeremony.Run(ctx, chal)
|
||||
// MFACeremony.Run can handle either SSO or Browser MFA. It defaults to SSO MFA,
|
||||
// but to be safe, copy and remove the Browser MFA challenge here.
|
||||
ssoChal := *chal
|
||||
ssoChal.BrowserMFAChallenge = nil
|
||||
resp, err := c.cfg.MFACeremony.Run(ctx, &ssoChal)
|
||||
return resp, trace.Wrap(err)
|
||||
}
|
||||
|
||||
func (c *CLIPrompt) promptBrowser(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
// MFACeremony.Run can handle either SSO or Browser MFA. It defaults to SSO MFA,
|
||||
// so remove copy and remove the SSO challenge so Browser MFA is used.
|
||||
browserChal := *chal
|
||||
browserChal.SSOChallenge = nil
|
||||
resp, err := c.cfg.MFACeremony.Run(ctx, &browserChal)
|
||||
return resp, trace.Wrap(err)
|
||||
}
|
||||
|
||||
+114
-8
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gravitational/teleport/api/client/proto"
|
||||
"github.com/gravitational/teleport/api/constants"
|
||||
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
|
||||
webauthnpb "github.com/gravitational/teleport/api/types/webauthn"
|
||||
"github.com/gravitational/teleport/api/utils/prompt"
|
||||
@@ -155,6 +156,33 @@ func TestCLIPrompt(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK prefer browser when specified",
|
||||
expectStdOut: "", // stdout is handled internally in the MFA ceremony, which is mocked in this test.
|
||||
challenge: &proto.MFAAuthenticateChallenge{
|
||||
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
|
||||
TOTP: &proto.TOTPChallenge{},
|
||||
SSOChallenge: &proto.SSOChallenge{},
|
||||
BrowserMFAChallenge: &proto.BrowserMFAChallenge{},
|
||||
},
|
||||
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
|
||||
cfg.PreferBrowser = true
|
||||
cfg.MFACeremony = &mockSSOMFACeremony{
|
||||
runFunc: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
return &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_Browser{
|
||||
Browser: &proto.BrowserMFAResponse{RequestId: "request-id"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
expectResp: &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_Browser{
|
||||
Browser: &proto.BrowserMFAResponse{RequestId: "request-id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK prefer webauthn over sso",
|
||||
expectStdOut: "" +
|
||||
@@ -270,7 +298,7 @@ func TestCLIPrompt(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "NOK no webauthn response",
|
||||
expectStdOut: "Tap any security key\n",
|
||||
expectStdOut: "Tap any security key\nMFA authentication with WEBAUTHN failed, check logs for details\n",
|
||||
challenge: &proto.MFAAuthenticateChallenge{
|
||||
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
|
||||
},
|
||||
@@ -278,7 +306,7 @@ func TestCLIPrompt(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "NOK no sso response",
|
||||
expectStdOut: "",
|
||||
expectStdOut: "MFA authentication with SSO failed, check logs for details\n",
|
||||
challenge: &proto.MFAAuthenticateChallenge{
|
||||
SSOChallenge: &proto.SSOChallenge{},
|
||||
},
|
||||
@@ -286,7 +314,7 @@ func TestCLIPrompt(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "NOK no otp response",
|
||||
expectStdOut: "Enter an OTP code from a device:\n",
|
||||
expectStdOut: "Enter an OTP code from a device:\nMFA authentication with OTP failed, check logs for details\n",
|
||||
challenge: &proto.MFAAuthenticateChallenge{
|
||||
TOTP: &proto.TOTPChallenge{},
|
||||
},
|
||||
@@ -294,7 +322,7 @@ func TestCLIPrompt(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "NOK no webauthn or otp response",
|
||||
expectStdOut: "Tap any security key or enter a code from a OTP device\n",
|
||||
expectStdOut: "Tap any security key or enter a code from a OTP device\nMFA authentication with WEBAUTHN failed, check logs for details\n",
|
||||
challenge: &proto.MFAAuthenticateChallenge{
|
||||
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
|
||||
TOTP: &proto.TOTPChallenge{},
|
||||
@@ -411,7 +439,7 @@ Enter your security key PIN:
|
||||
},
|
||||
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
|
||||
cfg.WebauthnSupported = false
|
||||
cfg.SSOMFACeremony = nil
|
||||
cfg.MFACeremony = nil
|
||||
},
|
||||
expectErr: trace.BadParameter("client does not support any available MFA methods [WEBAUTHN, SSO], see debug logs for details"),
|
||||
},
|
||||
@@ -425,7 +453,7 @@ Enter your security key PIN:
|
||||
Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_USER_SESSION,
|
||||
}
|
||||
},
|
||||
expectErr: trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA"),
|
||||
expectErr: trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA"),
|
||||
},
|
||||
{
|
||||
name: "NOK prefer otp with per-session MFA",
|
||||
@@ -438,7 +466,7 @@ Enter your security key PIN:
|
||||
}
|
||||
cfg.PreferOTP = true
|
||||
},
|
||||
expectErr: trace.AccessDenied("only WebAuthn and SSO MFA methods are supported with per-session MFA, can not specify --mfa-mode=otp"),
|
||||
expectErr: trace.AccessDenied("only WebAuthn, SSO MFA, and Browser MFA methods are supported with per-session MFA, cannot specify --mfa-mode=otp"),
|
||||
},
|
||||
{
|
||||
name: "OK webauthn or otp with stdin hijack and per-session MFA, no choice presented",
|
||||
@@ -500,6 +528,80 @@ Enter your security key PIN:
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NOK browser fallback skipped on windows when not preferred",
|
||||
expectStdOut: "" +
|
||||
"Available MFA methods [WEBAUTHN, BROWSER]. Continuing with WEBAUTHN.\n" +
|
||||
"If you wish to perform MFA with another method, specify with flag --mfa-mode=<webauthn,browser> or environment variable TELEPORT_MFA_MODE=<webauthn,browser>.\n\n" +
|
||||
"Tap any security key\n" +
|
||||
"MFA authentication with WEBAUTHN failed, check logs for details\n",
|
||||
challenge: &proto.MFAAuthenticateChallenge{
|
||||
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
|
||||
BrowserMFAChallenge: &proto.BrowserMFAChallenge{},
|
||||
},
|
||||
makeWebauthnLoginFunc: func(_ *prompt.FakeReader) mfa.WebauthnLoginFunc {
|
||||
return func(ctx context.Context, origin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, opts *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
|
||||
if _, err := prompt.PromptTouch(); err != nil {
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
return nil, "", context.DeadlineExceeded
|
||||
}
|
||||
},
|
||||
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
|
||||
cfg.RuntimeOS = constants.WindowsOS
|
||||
cfg.MFACeremony = &mockSSOMFACeremony{
|
||||
runFunc: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
return &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_Browser{
|
||||
Browser: &proto.BrowserMFAResponse{RequestId: "request-id"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
expectErr: context.DeadlineExceeded,
|
||||
},
|
||||
{
|
||||
name: "OK prompt fallback webauthn > SSO > browser MFA",
|
||||
expectStdOut: "" +
|
||||
"Available MFA methods [WEBAUTHN, BROWSER]. Continuing with WEBAUTHN.\n" +
|
||||
"If you wish to perform MFA with another method, specify with flag --mfa-mode=<webauthn,browser> or environment variable TELEPORT_MFA_MODE=<webauthn,browser>.\n\n" +
|
||||
"Tap any security key\n" +
|
||||
"MFA authentication with WEBAUTHN failed, check logs for details\n" +
|
||||
"Attempting MFA authentication with BROWSER\n",
|
||||
challenge: &proto.MFAAuthenticateChallenge{
|
||||
WebauthnChallenge: &webauthnpb.CredentialAssertion{},
|
||||
BrowserMFAChallenge: &proto.BrowserMFAChallenge{},
|
||||
},
|
||||
makeWebauthnLoginFunc: func(_ *prompt.FakeReader) mfa.WebauthnLoginFunc {
|
||||
return func(ctx context.Context, origin string, assertion *wantypes.CredentialAssertion, prompt wancli.LoginPrompt, opts *wancli.LoginOpts) (*proto.MFAAuthenticateResponse, string, error) {
|
||||
if _, err := prompt.PromptTouch(); err != nil {
|
||||
return nil, "", trace.Wrap(err)
|
||||
}
|
||||
return nil, "", errors.New("webauthn device not found")
|
||||
}
|
||||
},
|
||||
modifyPromptConfig: func(cfg *mfa.CLIPromptConfig) {
|
||||
cfg.MFACeremony = &mockSSOMFACeremony{
|
||||
runFunc: func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
return &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_Browser{
|
||||
Browser: &proto.BrowserMFAResponse{
|
||||
RequestId: "request-id",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
expectResp: &proto.MFAAuthenticateResponse{
|
||||
Response: &proto.MFAAuthenticateResponse_Browser{
|
||||
Browser: &proto.BrowserMFAResponse{
|
||||
RequestId: "request-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
@@ -529,7 +631,7 @@ Enter your security key PIN:
|
||||
}
|
||||
}
|
||||
|
||||
cfg.SSOMFACeremony = &mockSSOMFACeremony{
|
||||
cfg.MFACeremony = &mockSSOMFACeremony{
|
||||
mfaResp: tc.expectResp,
|
||||
}
|
||||
|
||||
@@ -563,6 +665,7 @@ Enter your security key PIN:
|
||||
|
||||
type mockSSOMFACeremony struct {
|
||||
mfaResp *proto.MFAAuthenticateResponse
|
||||
runFunc func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error)
|
||||
}
|
||||
|
||||
func (m *mockSSOMFACeremony) GetClientCallbackURL() string {
|
||||
@@ -575,6 +678,9 @@ func (m *mockSSOMFACeremony) GetProxyAddress() string {
|
||||
|
||||
// Run the SSO MFA ceremony.
|
||||
func (m *mockSSOMFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
if m.runFunc != nil {
|
||||
return m.runFunc(ctx, chal)
|
||||
}
|
||||
if m.mfaResp == nil {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
@@ -65,8 +65,9 @@ func TestPromptMFAChallenge_usingNonRegisteredDevice(t *testing.T) {
|
||||
}
|
||||
|
||||
challengeWebauthnOTP := &proto.MFAAuthenticateChallenge{
|
||||
TOTP: &proto.TOTPChallenge{}, // non-nil enables OTP prompt
|
||||
WebauthnChallenge: challengeWebauthnOnly.WebauthnChallenge,
|
||||
TOTP: &proto.TOTPChallenge{}, // non-nil enables OTP prompt
|
||||
WebauthnChallenge: challengeWebauthnOnly.WebauthnChallenge,
|
||||
BrowserMFAChallenge: nil,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -82,7 +83,8 @@ func TestPromptMFAChallenge_usingNonRegisteredDevice(t *testing.T) {
|
||||
name: "webauthn and OTP",
|
||||
challenge: challengeWebauthnOTP,
|
||||
customizePrompt: func(p *mfa.CLIPromptConfig) {
|
||||
p.AllowStdinHijack = true // required for OTP+WebAuthn prompt.
|
||||
// Specify cross-platform WebAuthn to prevent fallback to Browser MFA.
|
||||
p.AuthenticatorAttachment = wancli.AttachmentCrossPlatform
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func RunPresenceTask(ctx context.Context, term io.Writer, maintainer PresenceMai
|
||||
}
|
||||
|
||||
presenceCeremony := &mfa.Ceremony{
|
||||
SSOMFACeremonyConstructor: baseCeremony.SSOMFACeremonyConstructor,
|
||||
MFACeremonyConstructor: baseCeremony.MFACeremonyConstructor,
|
||||
PromptConstructor: func(opts ...mfa.PromptOpt) mfa.Prompt {
|
||||
return mfa.PromptFunc(func(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
// Replace normal output with terminal messages specific to moderated sessions.
|
||||
|
||||
@@ -121,6 +121,11 @@ func (m *MFACeremony) GetProxyAddress() string {
|
||||
|
||||
// Run the SSO MFA ceremony.
|
||||
func (m *MFACeremony) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
// TODO(danielashare): Remove when Browser MFA challenge handling is implemented
|
||||
if chal.SSOChallenge == nil {
|
||||
return nil, trace.BadParameter("no SSO challenge provided")
|
||||
}
|
||||
|
||||
if err := m.HandleRedirect(ctx, chal.SSOChallenge.RedirectUrl); err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
@@ -171,7 +176,7 @@ func NewCLIMFACeremony(rd *Redirector) *MFACeremony {
|
||||
}
|
||||
|
||||
// NewConnectMFACeremony creates a new Teleport Connect SSO ceremony from the given redirector.
|
||||
func NewConnectMFACeremony(rd *Redirector) mfa.SSOMFACeremony {
|
||||
func NewConnectMFACeremony(rd *Redirector) mfa.CallbackCeremony {
|
||||
return &MFACeremony{
|
||||
close: rd.Close,
|
||||
ClientCallbackURL: rd.ClientCallbackURL,
|
||||
|
||||
@@ -94,6 +94,9 @@ const (
|
||||
// on this page in order to capture the SSO MFA response regardless of what page the challenge
|
||||
// was requested from.
|
||||
WebMFARedirect = "/web/sso_confirm"
|
||||
|
||||
// WebBrowserMFAPath is the path for browser-based MFA flows.
|
||||
WebBrowserMFAPath = "/web/mfa/browser/"
|
||||
)
|
||||
|
||||
// RedirectorConfig is configuration for an sso redirector.
|
||||
|
||||
+22
-1
@@ -361,6 +361,9 @@ type SSHLoginMFA struct {
|
||||
SSHLogin
|
||||
// MFAPromptConstructor is a custom MFA prompt constructor to use when prompting for MFA.
|
||||
MFAPromptConstructor mfa.PromptConstructor
|
||||
// MFACeremonyConstructor is an optional MFA ceremony constructor.
|
||||
// Currently used for Browser MFA during the login process.
|
||||
MFACeremonyConstructor mfa.MFACeremonyConstructor
|
||||
// User is the login username.
|
||||
User string
|
||||
// Password is the login password.
|
||||
@@ -409,6 +412,8 @@ type MFAAuthenticateChallenge struct {
|
||||
TOTPChallenge bool `json:"totp_challenge"`
|
||||
// SSOChallenge is an SSO MFA challenge.
|
||||
SSOChallenge *SSOChallenge `json:"sso_challenge"`
|
||||
// BrowserMFAChallenge is a Browser MFA challenge.
|
||||
BrowserMFAChallenge *BrowserMFAChallenge `json:"browser_challenge"`
|
||||
}
|
||||
|
||||
// SSOChallenge is a json compatible [proto.SSOChallenge].
|
||||
@@ -453,6 +458,18 @@ type TOTPRegisterChallenge struct {
|
||||
QRCode []byte `json:"qrCode"`
|
||||
}
|
||||
|
||||
// BrowserMFAChallenge is a json compatible [proto.BrowserMFAChallenge].
|
||||
type BrowserMFAChallenge struct {
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserChallengeToProto converts an BrowserChallenge to proto format.
|
||||
func BrowserChallengeToProto(browserChal *BrowserMFAChallenge) *proto.BrowserMFAChallenge {
|
||||
return &proto.BrowserMFAChallenge{
|
||||
RequestId: browserChal.RequestID,
|
||||
}
|
||||
}
|
||||
|
||||
// initClient creates a new client to the HTTPS web proxy.
|
||||
func initClient(proxyAddr string, insecure bool, pool *x509.CertPool, extraHeaders map[string]string, opts ...roundtrip.ClientParam) (*WebClient, *url.URL, error) {
|
||||
log := slog.With(teleport.ComponentKey, teleport.ComponentClient)
|
||||
@@ -688,6 +705,9 @@ func newMFALoginCeremony(clt *WebClient, login SSHLoginMFA) *mfa.Ceremony {
|
||||
User: login.User,
|
||||
Pass: login.Password,
|
||||
}
|
||||
if req != nil && req.BrowserMFATSHRedirectURL != "" {
|
||||
beginReq.BrowserMFATSHRedirectURL = req.BrowserMFATSHRedirectURL
|
||||
}
|
||||
challengeJSON, err := clt.PostJSON(ctx, clt.Endpoint("webapi", "mfa", "login", "begin"), beginReq)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
@@ -708,7 +728,8 @@ func newMFALoginCeremony(clt *WebClient, login SSHLoginMFA) *mfa.Ceremony {
|
||||
}
|
||||
return chal, nil
|
||||
},
|
||||
PromptConstructor: login.MFAPromptConstructor,
|
||||
PromptConstructor: login.MFAPromptConstructor,
|
||||
MFACeremonyConstructor: login.MFACeremonyConstructor,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ type CreateGatewayParams struct {
|
||||
// CreateGateway creates a gateway
|
||||
func (c *Cluster) CreateGateway(ctx context.Context, params CreateGatewayParams) (gateway.Gateway, error) {
|
||||
c.clusterClient.MFAPromptConstructor = params.MFAPromptConstructor
|
||||
c.clusterClient.SSOMFACeremonyConstructor = sso.NewConnectMFACeremony
|
||||
c.clusterClient.MFACeremonyConstructor = sso.NewConnectMFACeremony
|
||||
|
||||
switch {
|
||||
case params.TargetURI.IsDB():
|
||||
|
||||
@@ -304,7 +304,7 @@ func (s *Service) ResolveClusterURI(uri uri.ResourceURI) (*clusters.Cluster, *cl
|
||||
// Custom MFAPromptConstructor gets removed during the calls to Login and LoginPasswordless RPCs.
|
||||
// Those RPCs assume that the default CLI prompt is in use.
|
||||
clusterClient.MFAPromptConstructor = s.NewMFAPromptConstructor(cluster.URI)
|
||||
clusterClient.SSOMFACeremonyConstructor = sso.NewConnectMFACeremony
|
||||
clusterClient.MFACeremonyConstructor = sso.NewConnectMFACeremony
|
||||
|
||||
return cluster, clusterClient, nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,16 @@ func (s *Service) promptAppMFA(ctx context.Context, in *api.PromptMFARequest) (*
|
||||
func (p *mfaPrompt) Run(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
promptOTP := chal.TOTP != nil
|
||||
promptWebauthn := chal.WebauthnChallenge != nil && p.cfg.WebauthnSupported
|
||||
promptSSO := chal.SSOChallenge != nil && p.cfg.SSOMFACeremony != nil
|
||||
promptSSO := chal.SSOChallenge != nil && p.cfg.MFACeremony != nil
|
||||
promptBrowser := chal.BrowserMFAChallenge != nil
|
||||
|
||||
// TODO(danielashare): Implement Browser MFA for connect
|
||||
if promptBrowser && !promptOTP && !promptWebauthn && !promptSSO {
|
||||
return nil, trace.AccessDenied(
|
||||
"Browser MFA was the only challenge returned and is not supported in Connect yet",
|
||||
)
|
||||
}
|
||||
|
||||
scope := p.cfg.Extensions.GetScope()
|
||||
// No prompt to run, no-op.
|
||||
if !promptOTP && !promptWebauthn && !promptSSO {
|
||||
@@ -166,6 +175,6 @@ func (p *mfaPrompt) promptWebauthn(ctx context.Context, chal *proto.MFAAuthentic
|
||||
}
|
||||
|
||||
func (c *mfaPrompt) promptSSO(ctx context.Context, chal *proto.MFAAuthenticateChallenge) (*proto.MFAAuthenticateResponse, error) {
|
||||
resp, err := c.cfg.SSOMFACeremony.Run(ctx, chal)
|
||||
resp, err := c.cfg.MFACeremony.Run(ctx, chal)
|
||||
return resp, trace.Wrap(err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -624,7 +624,7 @@ func (h *Handler) performSessionMFACeremony(
|
||||
|
||||
mfaCeremony := &mfa.Ceremony{
|
||||
CreateAuthenticateChallenge: sctx.cfg.RootClient.CreateAuthenticateChallenge,
|
||||
SSOMFACeremonyConstructor: func(_ context.Context) (mfa.SSOMFACeremony, error) {
|
||||
MFACeremonyConstructor: func(_ context.Context) (mfa.CallbackCeremony, error) {
|
||||
u, err := url.Parse(sso.WebMFARedirect)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
|
||||
+1
-1
@@ -632,7 +632,7 @@ func newMFACeremony(stream *terminal.WSStream, createAuthenticateChallenge mfa.C
|
||||
|
||||
return &mfa.Ceremony{
|
||||
CreateAuthenticateChallenge: createAuthenticateChallenge,
|
||||
SSOMFACeremonyConstructor: func(ctx context.Context) (mfa.SSOMFACeremony, error) {
|
||||
MFACeremonyConstructor: func(ctx context.Context) (mfa.CallbackCeremony, error) {
|
||||
|
||||
u, err := url.Parse(sso.WebMFARedirect)
|
||||
if err != nil {
|
||||
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"github.com/gravitational/teleport/lib/utils"
|
||||
"github.com/gravitational/teleport/tool/common"
|
||||
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
|
||||
tctlmfa "github.com/gravitational/teleport/tool/tctl/common/mfa"
|
||||
)
|
||||
|
||||
// InitFunc initiates connection to auth service, makes ping request and return the client instance.
|
||||
@@ -98,13 +99,20 @@ func GetInitFunc(ccf tctlcfg.GlobalCLIFlags, cfg *servicecfg.Config) InitFunc {
|
||||
return nil, nil, trace.NewAggregate(err, client.Close())
|
||||
}
|
||||
proxyAddr := resp.ProxyPublicAddr
|
||||
mfaOpts, err := tctlmfa.ParseMFAMode(ccf.MFAMode)
|
||||
if err != nil {
|
||||
return nil, nil, trace.Wrap(err)
|
||||
}
|
||||
client.SetMFAPromptConstructor(func(opts ...mfa.PromptOpt) mfa.Prompt {
|
||||
promptCfg := libmfa.NewPromptConfig(proxyAddr, opts...)
|
||||
promptCfg.AuthenticatorAttachment = mfaOpts.AuthenticatorAttachment
|
||||
return libmfa.NewCLIPrompt(&libmfa.CLIPromptConfig{
|
||||
PromptConfig: *promptCfg,
|
||||
PromptConfig: *promptCfg,
|
||||
PreferSSO: mfaOpts.PreferSSO,
|
||||
PreferBrowser: mfaOpts.PreferBrowser,
|
||||
})
|
||||
})
|
||||
client.SetSSOMFACeremonyConstructor(func(ctx context.Context) (mfa.SSOMFACeremony, error) {
|
||||
client.SetMFACeremonyConstructor(func(ctx context.Context) (mfa.CallbackCeremony, error) {
|
||||
rdConfig := sso.RedirectorConfig{
|
||||
ProxyAddr: proxyAddr,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ type GlobalCLIFlags struct {
|
||||
// Insecure, when set, skips validation of server TLS certificate when
|
||||
// connecting through a proxy (specified in AuthServerAddr).
|
||||
Insecure bool
|
||||
// MFAMode is the preferred mode for MFA assertions.
|
||||
MFAMode string
|
||||
}
|
||||
|
||||
// ApplyConfig takes configuration values from the config file and applies them
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Teleport
|
||||
// Copyright (C) 2026 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 mfa
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
|
||||
)
|
||||
|
||||
const (
|
||||
// mfaModeAuto automatically chooses the best MFA device(s), without any
|
||||
// restrictions.
|
||||
MFAModeAuto = "auto"
|
||||
// MFAModeCrossPlatform utilizes only cross-platform devices, such as
|
||||
// pluggable hardware keys.
|
||||
// Implies Webauthn.
|
||||
MFAModeCrossPlatform = "cross-platform"
|
||||
// MFAModePlatform utilizes only platform devices, such as Touch ID.
|
||||
// Implies Webauthn.
|
||||
MFAModePlatform = "platform"
|
||||
// MFAModeSSO utilizes only SSO devices.
|
||||
MFAModeSSO = "sso"
|
||||
// MFAModeBrowser utilizes browser-based WebAuthn MFA.
|
||||
MFAModeBrowser = "browser"
|
||||
)
|
||||
|
||||
type MFAModeOpts struct {
|
||||
AuthenticatorAttachment wancli.AuthenticatorAttachment
|
||||
PreferSSO bool
|
||||
PreferBrowser bool
|
||||
}
|
||||
|
||||
func ParseMFAMode(mode string) (*MFAModeOpts, error) {
|
||||
opts := &MFAModeOpts{}
|
||||
switch mode {
|
||||
case "", MFAModeAuto:
|
||||
case MFAModeCrossPlatform:
|
||||
opts.AuthenticatorAttachment = wancli.AttachmentCrossPlatform
|
||||
case MFAModePlatform:
|
||||
opts.AuthenticatorAttachment = wancli.AttachmentPlatform
|
||||
case MFAModeSSO:
|
||||
opts.PreferSSO = true
|
||||
case MFAModeBrowser:
|
||||
opts.PreferBrowser = true
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid MFA mode: %q", mode)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/gravitational/teleport/tool/common"
|
||||
commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
|
||||
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
|
||||
"github.com/gravitational/teleport/tool/tctl/common/mfa"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -52,6 +53,7 @@ const (
|
||||
const (
|
||||
identityFileEnvVar = "TELEPORT_IDENTITY_FILE"
|
||||
authAddrEnvVar = "TELEPORT_AUTH_SERVER"
|
||||
mfaModeEnvVar = "TELEPORT_MFA_MODE"
|
||||
)
|
||||
|
||||
// CLICommand interface must be implemented by every CLI command
|
||||
@@ -161,6 +163,11 @@ func TryRun(ctx context.Context, commands []CLICommand, args []string) error {
|
||||
StringVar(&ccf.IdentityFilePath)
|
||||
app.Flag("insecure", "When specifying a proxy address in --auth-server, do not verify its TLS certificate. Danger: any data you send can be intercepted or modified by an attacker.").
|
||||
BoolVar(&ccf.Insecure)
|
||||
modes := []string{mfa.MFAModeAuto, mfa.MFAModeCrossPlatform, mfa.MFAModePlatform, mfa.MFAModeSSO, mfa.MFAModeBrowser}
|
||||
app.Flag("mfa-mode", fmt.Sprintf("Preferred mode for MFA assertions (%v).", strings.Join(modes, ", "))).
|
||||
Default(mfa.MFAModeAuto).
|
||||
Envar(mfaModeEnvVar).
|
||||
EnumVar(&ccf.MFAMode, modes...)
|
||||
app.HelpFlag.Short('h')
|
||||
|
||||
// parse CLI commands+flags:
|
||||
|
||||
@@ -232,6 +232,9 @@ func TestConnect(t *testing.T) {
|
||||
// set tsh home to a fake path so that the existence of a real
|
||||
// ~/.tsh does not interfere with the test result.
|
||||
cfg.TeleportHome = t.TempDir()
|
||||
// set data dir to a fake path so that the existence of a real
|
||||
// /var/lib/teleport does not interfere with the test result.
|
||||
cfg.DataDir = t.TempDir()
|
||||
if tc.modifyConfig != nil {
|
||||
tc.modifyConfig(cfg)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ const (
|
||||
mfaModeOTP = "otp"
|
||||
// mfaModeSSO utilizes only SSO devices.
|
||||
mfaModeSSO = "sso"
|
||||
// mfaModeBrowser utilizes browser-based WebAuthn MFA via local server.
|
||||
mfaModeBrowser = "browser"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -951,7 +953,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error {
|
||||
app.Flag("bind-addr", "Override host:port used when opening a browser for cluster logins.").Envar(bindAddrEnvVar).StringVar(&cf.BindAddr)
|
||||
app.Flag("callback", "Override the base URL (host:port) of the link shown when opening a browser for cluster logins. Must be used with --bind-addr.").StringVar(&cf.CallbackAddr)
|
||||
app.Flag("browser-login", browserHelp).Hidden().Envar(browserEnvVar).StringVar(&cf.Browser)
|
||||
modes := []string{mfaModeAuto, mfaModeCrossPlatform, mfaModePlatform, mfaModeOTP, mfaModeSSO}
|
||||
modes := []string{mfaModeAuto, mfaModeCrossPlatform, mfaModePlatform, mfaModeOTP, mfaModeSSO, mfaModeBrowser}
|
||||
app.Flag("mfa-mode", fmt.Sprintf("Preferred mode for MFA and Passwordless assertions (%v).", strings.Join(modes, ", "))).
|
||||
Default(mfaModeAuto).
|
||||
Envar(mfaModeEnvVar).
|
||||
@@ -5080,6 +5082,7 @@ func loadClientConfigFromCLIConf(cf *CLIConf, proxy string) (*client.Config, err
|
||||
c.AuthenticatorAttachment = mfaOpts.AuthenticatorAttachment
|
||||
c.PreferOTP = mfaOpts.PreferOTP
|
||||
c.PreferSSO = mfaOpts.PreferSSO
|
||||
c.PreferBrowser = mfaOpts.PreferBrowser
|
||||
|
||||
// If agent forwarding was specified on the command line enable it.
|
||||
c.ForwardAgent = options.ForwardAgent
|
||||
@@ -5302,6 +5305,7 @@ type mfaModeOpts struct {
|
||||
AuthenticatorAttachment wancli.AuthenticatorAttachment
|
||||
PreferOTP bool
|
||||
PreferSSO bool
|
||||
PreferBrowser bool
|
||||
}
|
||||
|
||||
func parseMFAMode(mode string) (*mfaModeOpts, error) {
|
||||
@@ -5316,6 +5320,8 @@ func parseMFAMode(mode string) (*mfaModeOpts, error) {
|
||||
opts.PreferOTP = true
|
||||
case mfaModeSSO:
|
||||
opts.PreferSSO = true
|
||||
case mfaModeBrowser:
|
||||
opts.PreferBrowser = true
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid MFA mode: %q", mode)
|
||||
}
|
||||
|
||||
+32
-15
@@ -1508,6 +1508,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: cluster,
|
||||
},
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1668,7 +1669,8 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
RequireMFAType: types.RequireMFAType_SESSION,
|
||||
RequireMFAType: types.RequireMFAType_SESSION,
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
},
|
||||
proxyAddr: rootProxyAddr.String(),
|
||||
@@ -1696,7 +1698,8 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
RequireMFAType: types.RequireMFAType_SESSION,
|
||||
RequireMFAType: types.RequireMFAType_SESSION,
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
},
|
||||
proxyAddr: rootProxyAddr.String(),
|
||||
@@ -1721,7 +1724,8 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
RequireMFAType: types.RequireMFAType_SESSION,
|
||||
RequireMFAType: types.RequireMFAType_SESSION,
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
},
|
||||
proxyAddr: rootProxyAddr.String(),
|
||||
@@ -1741,6 +1745,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
},
|
||||
proxyAddr: rootProxyAddr.String(),
|
||||
@@ -1813,6 +1818,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
},
|
||||
proxyAddr: rootProxyAddr.String(),
|
||||
@@ -1838,6 +1844,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
},
|
||||
proxyAddr: rootProxyAddr.String(),
|
||||
@@ -1862,6 +1869,7 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
Webauthn: &types.Webauthn{
|
||||
RPID: "localhost",
|
||||
},
|
||||
AllowCLIAuthViaBrowser: types.NewBoolOption(false),
|
||||
},
|
||||
},
|
||||
proxyAddr: rootProxyAddr.String(),
|
||||
@@ -2030,6 +2038,11 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
// so we can assert how many times sign was called.
|
||||
device.SetCounter(0)
|
||||
|
||||
// Sleep before attempting to access the nodes to give them time to show
|
||||
// up on the server, otherwise a `no target host specified` error is returned.
|
||||
// 400ms was the lowest sleep that consistently fixed the test.
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
args := []string{"ssh", "-d", "--insecure"}
|
||||
if tt.headless {
|
||||
args = append(args, "--headless", "--proxy", tt.proxyAddr, "--user", user.GetName())
|
||||
@@ -2041,18 +2054,22 @@ func TestSSHOnMultipleNodes(t *testing.T) {
|
||||
}
|
||||
args = append(args, tt.target, "echo", "test", "&&", "echo", "error", ">&2")
|
||||
|
||||
err = Run(ctx,
|
||||
args,
|
||||
setHomePath(tmpHomePath),
|
||||
func(conf *CLIConf) error {
|
||||
conf.overrideStdin = stdin
|
||||
conf.OverrideStdout = stdout
|
||||
conf.overrideStderr = stderr
|
||||
conf.MockHeadlessLogin = mockHeadlessLogin(t, tt.auth, user)
|
||||
conf.WebauthnLogin = tt.webauthnLogin
|
||||
return nil
|
||||
},
|
||||
)
|
||||
var runOpts []CliOption
|
||||
runOpts = append(runOpts, setHomePath(tmpHomePath))
|
||||
// Only add SSO mock for non-headless tests
|
||||
if !tt.headless {
|
||||
runOpts = append(runOpts, setMockSSOLogin(tt.auth, user, connector.GetName()))
|
||||
}
|
||||
runOpts = append(runOpts, func(conf *CLIConf) error {
|
||||
conf.overrideStdin = stdin
|
||||
conf.OverrideStdout = stdout
|
||||
conf.overrideStderr = stderr
|
||||
conf.MockHeadlessLogin = mockHeadlessLogin(t, tt.auth, user)
|
||||
conf.WebauthnLogin = tt.webauthnLogin
|
||||
return nil
|
||||
})
|
||||
|
||||
err = Run(ctx, args, runOpts...)
|
||||
|
||||
tt.errAssertion(t, err)
|
||||
tt.stdoutAssertion(t, stdout.String())
|
||||
|
||||
Reference in New Issue
Block a user