From b2c5c8ecb0547d1595d5cfa5e886eeb3929cd10f Mon Sep 17 00:00:00 2001 From: Alan Parra Date: Wed, 23 Mar 2022 15:38:10 -0300 Subject: [PATCH] Add FIDO2 passwordless login and registration to `tsh` (#11321) Passwordless login is enabled by the global `--pwdless` flag. Registration gets a new prompt and an `--allow-passwordless` flag. UX messages were tweaked to follow the descriptions on RFD 53: Passwordless FIDO2[1]. Passwordless login requires two touches for all devices (both PIN and biometric). I'd like to get it down to a single touch, at least for the most common situations, but that'll be a follow up to this work. Passwordless support requires `tsh` to be compiled with the `libfido2` tag, try `go build -tags=libfido2 ./tool/tsh`. #9160 [1] https://github.com/gravitational/teleport/blob/master/rfd/0053-passwordless-fido2.md#ux * Allow reuse of devices for passwordless * Implement passwordless registration in tsh * Add better tracing to FIDO2 filters * Implement passwordless logins in tsh * Make --pwdless a global flag * Fix lint errors * Fix U2F tests * Use initClient's URL as origin * Distinguish whether --allow-passwordless is set or unset --- lib/auth/webauthn/login.go | 12 + lib/auth/webauthn/register.go | 13 + lib/auth/webauthn/register_test.go | 84 ++++++ lib/auth/webauthncli/fido2.go | 20 +- lib/auth/webauthncli/u2f_register_test.go | 14 +- lib/client/api.go | 342 ++++++++++++++-------- lib/client/api_login_test.go | 35 ++- tool/tsh/mfa.go | 49 +++- tool/tsh/tsh.go | 10 + 9 files changed, 434 insertions(+), 145 deletions(-) diff --git a/lib/auth/webauthn/login.go b/lib/auth/webauthn/login.go index 609b6d07d11..cfe0a2d038d 100644 --- a/lib/auth/webauthn/login.go +++ b/lib/auth/webauthn/login.go @@ -21,6 +21,7 @@ import ( "context" "encoding/base64" "encoding/json" + "sort" "time" "github.com/duo-labs/webauthn/protocol" @@ -81,6 +82,17 @@ func (f *loginFlow) begin(ctx context.Context, user string, passwordless bool) ( if err != nil { return nil, trace.Wrap(err) } + + // Sort non-resident keys first, which may cause clients to favor them for + // MFA in some scenarios (eg, tsh). + sort.Slice(devices, func(i, j int) bool { + dev1, dev2 := devices[i], devices[j] + web1, web2 := dev1.GetWebauthn(), dev2.GetWebauthn() + resident1 := web1 != nil && web1.ResidentKey + resident2 := web2 != nil && web2.ResidentKey + return !resident1 && resident2 + }) + u = newWebUser(user, webID, true /* credentialIDOnly */, devices) // Let's make sure we have at least one registered credential here, since we diff --git a/lib/auth/webauthn/register.go b/lib/auth/webauthn/register.go index 6ae37d8b2fb..13927f9f8ba 100644 --- a/lib/auth/webauthn/register.go +++ b/lib/auth/webauthn/register.go @@ -137,6 +137,19 @@ func (f *RegistrationFlow) Begin(ctx context.Context, user string, passwordless } var exclusions []protocol.CredentialDescriptor for _, dev := range devices { + // Skip existing U2F devices, letting users "upgrade" their registration is + // good for us. + if dev.GetU2F() != nil { + continue + } + // Skip resident/non-resident keys depending on whether it's a passwordless + // registration. + // Letting users have both allows them to "swap" between key types in the + // same device. + if webDev := dev.GetWebauthn(); webDev != nil && webDev.ResidentKey != passwordless { + continue + } + cred, ok := deviceToCredential(dev, true /* idOnly */) if !ok { continue diff --git a/lib/auth/webauthn/register_test.go b/lib/auth/webauthn/register_test.go index ee5eeaa619f..614428f05f9 100644 --- a/lib/auth/webauthn/register_test.go +++ b/lib/auth/webauthn/register_test.go @@ -15,8 +15,10 @@ package webauthn_test import ( + "bytes" "context" "encoding/pem" + "sort" "testing" "github.com/duo-labs/webauthn/protocol" @@ -120,6 +122,88 @@ func TestRegistrationFlow_BeginFinish(t *testing.T) { } } +func TestRegistrationFlow_Begin_excludeList(t *testing.T) { + const user = "llama" + const rpID = "localhost" + + dev1ID := []byte{1, 1, 1} // U2F + web1ID := []byte{1, 1, 2} // WebAuthn / MFA + rk1ID := []byte{1, 1, 3} // WebAuthn / passwordless + dev1 := &types.MFADevice{ + Device: &types.MFADevice_U2F{ + U2F: &types.U2FDevice{ + KeyHandle: dev1ID, + }, + }, + } + web1 := &types.MFADevice{ + Device: &types.MFADevice_Webauthn{ + Webauthn: &types.WebauthnDevice{ + CredentialId: web1ID, + }, + }, + } + rk1 := &types.MFADevice{ + Device: &types.MFADevice_Webauthn{ + Webauthn: &types.WebauthnDevice{ + CredentialId: rk1ID, + ResidentKey: true, + }, + }, + } + identity := newFakeIdentity(user, dev1, web1, rk1) + + rf := wanlib.RegistrationFlow{ + Webauthn: &types.Webauthn{ + RPID: rpID, + }, + Identity: identity, + } + + ctx := context.Background() + tests := []struct { + name string + passwordless bool + wantExcludeList [][]byte + }{ + { + name: "MFA", + wantExcludeList: [][]byte{web1ID}, // U2F and resident excluded + }, + { + name: "passwordless", + passwordless: true, + wantExcludeList: [][]byte{rk1ID}, // U2F and MFA excluded + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cc, err := rf.Begin(ctx, user, test.passwordless) + require.NoError(t, err, "Begin") + + got := cc.Response.CredentialExcludeList + sort.Slice(got, func(i, j int) bool { + return bytes.Compare(got[i].CredentialID, got[j].CredentialID) == -1 + }) + + want := make([]protocol.CredentialDescriptor, len(test.wantExcludeList)) + for i, id := range test.wantExcludeList { + want[i] = protocol.CredentialDescriptor{ + Type: protocol.PublicKeyCredentialType, + CredentialID: id, + } + } + sort.Slice(want, func(i, j int) bool { + return bytes.Compare(want[i].CredentialID, want[j].CredentialID) == -1 + }) + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Begin() mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestRegistrationFlow_Begin_webID(t *testing.T) { const rpID = "localhost" ctx := context.Background() diff --git a/lib/auth/webauthncli/fido2.go b/lib/auth/webauthncli/fido2.go index 418b6070110..0d6f6c43a06 100644 --- a/lib/auth/webauthncli/fido2.go +++ b/lib/auth/webauthncli/fido2.go @@ -129,8 +129,10 @@ func fido2Login( filter := func(dev FIDODevice, info *deviceInfo) (bool, error) { switch { case uv && !info.uvCapable(): + log.Debugf("FIDO2: Device %v: filtered due to lack of UV", info.path) return false, nil case passwordless && !info.rk: + log.Debugf("FIDO2: Device %v: filtered due to lack of RK", info.path) return false, nil case len(allowedCreds) == 0: // Nothing else to check return true, nil @@ -148,10 +150,13 @@ func fido2Login( if appID == "" { return false, nil } - _, err = dev.Assertion(appID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ + if _, err := dev.Assertion(appID, ccdHash[:], allowedCreds, pin, &libfido2.AssertionOpts{ UP: libfido2.False, - }) - return err == nil, nil + }); err != nil { + log.Debugf("FIDO2: Device %v: filtered due to lack of allowed credential", info.path) + return false, nil + } + return true, nil } deviceCallback := func(dev FIDODevice, info *deviceInfo, pin string) error { @@ -398,6 +403,7 @@ func fido2Register( filter := func(dev FIDODevice, info *deviceInfo) (bool, error) { switch { case (plat && !info.plat) || (rrk && !info.rk) || (uv && !info.uvCapable()): + log.Debugf("FIDO2: Device %v: filtered due to options", info.path) return false, nil case len(excludeList) == 0: return true, nil @@ -411,6 +417,7 @@ func fido2Register( case errors.Is(err, libfido2.ErrNoCredentials): return true, nil case err == nil: + log.Debugf("FIDO2: Device %v: filtered due to presence of excluded credential", info.path) return false, nil default: // unexpected error return false, trace.Wrap(err) @@ -630,7 +637,7 @@ func findSuitableDevices(filter deviceFilterFunc, knownPaths map[string]struct{} } log.Debugf("FIDO2: Info for device %v: %#v", path, info) - di := makeDevInfo(info) + di := makeDevInfo(path, info) switch ok, err := filter(dev, di); { case err != nil: return nil, trace.Wrap(err, "device %v: filter", path) @@ -736,6 +743,7 @@ func selectDevice(ctx context.Context, devices []deviceWithInfo, deviceCallback // Various fields match options under // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorGetInfo. type deviceInfo struct { + path string plat bool rk bool clientPinCapable, clientPinSet bool @@ -747,8 +755,8 @@ func (di *deviceInfo) uvCapable() bool { return di.uv || di.clientPinSet } -func makeDevInfo(info *libfido2.DeviceInfo) *deviceInfo { - di := &deviceInfo{} +func makeDevInfo(path string, info *libfido2.DeviceInfo) *deviceInfo { + di := &deviceInfo{path: path} for _, opt := range info.Options { // See // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorGetInfo. diff --git a/lib/auth/webauthncli/u2f_register_test.go b/lib/auth/webauthncli/u2f_register_test.go index 51e37f5d41b..f45a39d9569 100644 --- a/lib/auth/webauthncli/u2f_register_test.go +++ b/lib/auth/webauthncli/u2f_register_test.go @@ -35,7 +35,6 @@ func TestRegister(t *testing.T) { const rpID = "example.com" const origin = "https://example.com" - // Prepare a few fake devices. u2fKey, err := newFakeDevice("u2fkey" /* name */, "" /* appID */) require.NoError(t, err) registeredKey, err := newFakeDevice("regkey" /* name */, rpID /* appID */) @@ -48,8 +47,17 @@ func TestRegister(t *testing.T) { RPID: rpID, }, Identity: &fakeIdentity{ - User: user, - Devices: []*types.MFADevice{registeredKey.mfaDevice}, + User: user, + Devices: []*types.MFADevice{ + // Fake a WebAuthn device record, as U2F devices are not excluded from registration. + { + Device: &types.MFADevice_Webauthn{ + Webauthn: &types.WebauthnDevice{ + CredentialId: registeredKey.key.KeyHandle, + }, + }, + }, + }, }, } diff --git a/lib/client/api.go b/lib/client/api.go index 7b1e5599b66..3571d5ad4cc 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -55,6 +55,7 @@ import ( apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/lib/auth" + wanlib "github.com/gravitational/teleport/lib/auth/webauthn" "github.com/gravitational/teleport/lib/client/terminal" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" @@ -70,6 +71,7 @@ import ( "github.com/gravitational/trace" + "github.com/duo-labs/webauthn/protocol" "github.com/jonboulle/clockwork" "github.com/sirupsen/logrus" ) @@ -359,6 +361,11 @@ type Config struct { // ExtraProxyHeaders is a collection of http headers to be included in requests to the WebProxy. ExtraProxyHeaders map[string]string + + // Passwordless enables passwordless authentication for TeleportClient. + // Affects the TeleportClient.Login and, indirectly, RetryWithRelogin + // functions. + Passwordless bool } // CachePolicy defines cache policy for local clients @@ -525,8 +532,8 @@ func (p *ProfileStatus) AppNames() (result []string) { return result } -// RetryWithRelogin is a helper error handling method, -// attempts to relogin and retry the function once +// RetryWithRelogin is a helper error handling method, attempts to relogin and +// retry the function once. func RetryWithRelogin(ctx context.Context, tc *TeleportClient, fn func() error) error { err := fn() if err == nil { @@ -2457,9 +2464,10 @@ func (tc *TeleportClient) PingAndShowMOTD(ctx context.Context) (*webclient.PingR // Login logs the user into a Teleport cluster by talking to a Teleport proxy. // +// If tc.Passwordless is set, then the passwordless authentication flow is used. +// // The returned Key should typically be passed to ActivateKey in order to // update local agent state. -// func (tc *TeleportClient) Login(ctx context.Context) (*Key, error) { // Ping the endpoint to see if it's up and find the type of authentication // supported, also show the message of the day if available. @@ -2477,13 +2485,27 @@ func (tc *TeleportClient) Login(ctx context.Context) (*Key, error) { var response *auth.SSHLoginResponse - switch pr.Auth.Type { - case constants.Local: + switch authType := pr.Auth.Type; { + case tc.Passwordless: // Takes precedence over other methods if set. + // Do a few sanity checks before obeying. + switch { + case authType != constants.Local: + return nil, trace.BadParameter("Passwordless is only available for local authentication") + case pr.Auth.Webauthn == nil: + return nil, trace.BadParameter( + "Webauthn is not configured in this cluster, please contact your administrator and ask them to follow https://goteleport.com/docs/access-controls/guides/webauthn/") + } + response, err = tc.pwdlessLogin(ctx, key.Pub) + if err != nil { + return nil, trace.Wrap(err) + } + tc.Username = response.Username + case authType == constants.Local: response, err = tc.localLogin(ctx, pr.Auth.SecondFactor, key.Pub) if err != nil { return nil, trace.Wrap(err) } - case constants.OIDC: + case authType == constants.OIDC: response, err = tc.ssoLogin(ctx, pr.Auth.OIDC.Name, key.Pub, constants.OIDC) if err != nil { return nil, trace.Wrap(err) @@ -2493,7 +2515,7 @@ func (tc *TeleportClient) Login(ctx context.Context) (*Key, error) { if tc.localAgent != nil { tc.localAgent.username = response.Username } - case constants.SAML: + case authType == constants.SAML: response, err = tc.ssoLogin(ctx, pr.Auth.SAML.Name, key.Pub, constants.SAML) if err != nil { return nil, trace.Wrap(err) @@ -2503,7 +2525,7 @@ func (tc *TeleportClient) Login(ctx context.Context) (*Key, error) { if tc.localAgent != nil { tc.localAgent.username = response.Username } - case constants.Github: + case authType == constants.Github: response, err = tc.ssoLogin(ctx, pr.Auth.Github.Name, key.Pub, constants.Github) if err != nil { return nil, trace.Wrap(err) @@ -2544,6 +2566,195 @@ func (tc *TeleportClient) Login(ctx context.Context) (*Key, error) { return key, nil } +type pwdlessPrompt struct { + Out io.Writer +} + +func (l pwdlessPrompt) PromptPIN() (string, error) { + fmt.Fprintln(l.Out, "Enter your security key PIN:") + pwd, err := passwordFromConsoleFn() + if err != nil { + fmt.Fprintln(l.Out, err) + return "", trace.Wrap(err) + } + return pwd, nil +} + +func (l pwdlessPrompt) PromptAdditionalTouch() error { + fmt.Fprintln(l.Out, "Tap your security key again to complete login") + return nil +} + +func (tc *TeleportClient) pwdlessLogin(ctx context.Context, pubKey []byte) (*auth.SSHLoginResponse, error) { + webClient, webURL, err := initClient(tc.WebProxyAddr, tc.InsecureSkipVerify, loopbackPool(tc.WebProxyAddr)) + if err != nil { + return nil, trace.Wrap(err) + } + + challengeJSON, err := webClient.PostJSON( + ctx, webClient.Endpoint("webapi", "mfa", "login", "begin"), + &MFAChallengeRequest{ + Passwordless: true, + }) + if err != nil { + return nil, trace.Wrap(err) + } + challenge := &MFAAuthenticateChallenge{} + if err := json.Unmarshal(challengeJSON.Bytes(), challenge); err != nil { + return nil, trace.Wrap(err) + } + // Sanity check WebAuthn challenge. + switch { + case challenge.WebauthnChallenge == nil: + return nil, trace.BadParameter("passwordless: webauthn challenge missing") + case challenge.WebauthnChallenge.Response.UserVerification == protocol.VerificationDiscouraged: + return nil, trace.BadParameter("passwordless: user verification requirement too lax (%v)", challenge.WebauthnChallenge.Response.UserVerification) + } + + prompt := pwdlessPrompt{Out: tc.Stderr} + fmt.Fprintln(tc.Stderr, "Tap your security key") + mfaResp, _, err := prompts.Webauthn(ctx, webURL.String(), tc.Username, challenge.WebauthnChallenge, prompt) + if err != nil { + return nil, trace.Wrap(err) + } + + loginRespJSON, err := webClient.PostJSON( + ctx, webClient.Endpoint("webapi", "mfa", "login", "finish"), + &AuthenticateSSHUserRequest{ + User: "", // User carried on WebAuthn assertion. + WebauthnChallengeResponse: wanlib.CredentialAssertionResponseFromProto(mfaResp.GetWebauthn()), + PubKey: pubKey, + TTL: tc.KeyTTL, + Compatibility: tc.CertificateFormat, + RouteToCluster: tc.SiteName, + KubernetesCluster: tc.KubernetesCluster, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + loginResp := &auth.SSHLoginResponse{} + if err := json.Unmarshal(loginRespJSON.Bytes(), loginResp); err != nil { + return nil, trace.Wrap(err) + } + return loginResp, nil +} + +func (tc *TeleportClient) localLogin(ctx context.Context, secondFactor constants.SecondFactorType, pub []byte) (*auth.SSHLoginResponse, error) { + var err error + var response *auth.SSHLoginResponse + + // TODO(awly): mfa: ideally, clients should always go through mfaLocalLogin + // (with a nop MFA challenge if no 2nd factor is required). That way we can + // deprecate the direct login endpoint. + switch secondFactor { + case constants.SecondFactorOff, constants.SecondFactorOTP: + response, err = tc.directLogin(ctx, secondFactor, pub) + if err != nil { + return nil, trace.Wrap(err) + } + case constants.SecondFactorU2F, constants.SecondFactorWebauthn, constants.SecondFactorOn, constants.SecondFactorOptional: + response, err = tc.mfaLocalLogin(ctx, pub) + if err != nil { + return nil, trace.Wrap(err) + } + default: + return nil, trace.BadParameter("unsupported second factor type: %q", secondFactor) + } + + return response, nil +} + +// directLogin asks for a password + HOTP token, makes a request to CA via proxy +func (tc *TeleportClient) directLogin(ctx context.Context, secondFactorType constants.SecondFactorType, pub []byte) (*auth.SSHLoginResponse, error) { + password, err := tc.AskPassword() + if err != nil { + return nil, trace.Wrap(err) + } + + // only ask for a second factor if it's enabled + var otpToken string + if secondFactorType == constants.SecondFactorOTP { + otpToken, err = tc.AskOTP() + if err != nil { + return nil, trace.Wrap(err) + } + } + + // ask the CA (via proxy) to sign our public key: + response, err := SSHAgentLogin(ctx, SSHLoginDirect{ + SSHLogin: SSHLogin{ + ProxyAddr: tc.WebProxyAddr, + PubKey: pub, + TTL: tc.KeyTTL, + Insecure: tc.InsecureSkipVerify, + Pool: loopbackPool(tc.WebProxyAddr), + Compatibility: tc.CertificateFormat, + RouteToCluster: tc.SiteName, + KubernetesCluster: tc.KubernetesCluster, + }, + User: tc.Config.Username, + Password: password, + OTPToken: otpToken, + }) + + return response, trace.Wrap(err) +} + +// mfaLocalLogin asks for a password and performs the challenge-response authentication +func (tc *TeleportClient) mfaLocalLogin(ctx context.Context, pub []byte) (*auth.SSHLoginResponse, error) { + password, err := tc.AskPassword() + if err != nil { + return nil, trace.Wrap(err) + } + + response, err := SSHAgentMFALogin(ctx, SSHLoginMFA{ + SSHLogin: SSHLogin{ + ProxyAddr: tc.WebProxyAddr, + PubKey: pub, + TTL: tc.KeyTTL, + Insecure: tc.InsecureSkipVerify, + Pool: loopbackPool(tc.WebProxyAddr), + Compatibility: tc.CertificateFormat, + RouteToCluster: tc.SiteName, + KubernetesCluster: tc.KubernetesCluster, + }, + User: tc.Config.Username, + Password: password, + }) + + return response, trace.Wrap(err) +} + +// SSOLoginFunc is a function used in tests to mock SSO logins. +type SSOLoginFunc func(ctx context.Context, connectorID string, pub []byte, protocol string) (*auth.SSHLoginResponse, error) + +// samlLogin opens browser window and uses OIDC or SAML redirect cycle with browser +func (tc *TeleportClient) ssoLogin(ctx context.Context, connectorID string, pub []byte, protocol string) (*auth.SSHLoginResponse, error) { + if tc.MockSSOLogin != nil { + // sso login response is being mocked for testing purposes + return tc.MockSSOLogin(ctx, connectorID, pub, protocol) + } + // ask the CA (via proxy) to sign our public key: + response, err := SSHAgentSSOLogin(ctx, SSHLoginSSO{ + SSHLogin: SSHLogin{ + ProxyAddr: tc.WebProxyAddr, + PubKey: pub, + TTL: tc.KeyTTL, + Insecure: tc.InsecureSkipVerify, + Pool: loopbackPool(tc.WebProxyAddr), + Compatibility: tc.CertificateFormat, + RouteToCluster: tc.SiteName, + KubernetesCluster: tc.KubernetesCluster, + }, + ConnectorID: connectorID, + Protocol: protocol, + BindAddr: tc.BindAddr, + Browser: tc.Browser, + }) + return response, trace.Wrap(err) +} + // ActivateKey saves the target session cert into the local // keystore (and into the ssh-agent) for future use. func (tc *TeleportClient) ActivateKey(ctx context.Context, key *Key) error { @@ -2871,31 +3082,6 @@ func (tc *TeleportClient) applyProxySettings(proxySettings webclient.ProxySettin return nil } -func (tc *TeleportClient) localLogin(ctx context.Context, secondFactor constants.SecondFactorType, pub []byte) (*auth.SSHLoginResponse, error) { - var err error - var response *auth.SSHLoginResponse - - // TODO(awly): mfa: ideally, clients should always go through mfaLocalLogin - // (with a nop MFA challenge if no 2nd factor is required). That way we can - // deprecate the direct login endpoint. - switch secondFactor { - case constants.SecondFactorOff, constants.SecondFactorOTP: - response, err = tc.directLogin(ctx, secondFactor, pub) - if err != nil { - return nil, trace.Wrap(err) - } - case constants.SecondFactorU2F, constants.SecondFactorWebauthn, constants.SecondFactorOn, constants.SecondFactorOptional: - response, err = tc.mfaLocalLogin(ctx, pub) - if err != nil { - return nil, trace.Wrap(err) - } - default: - return nil, trace.BadParameter("unsupported second factor type: %q", secondFactor) - } - - return response, nil -} - // AddTrustedCA adds a new CA as trusted CA for this client, used in tests func (tc *TeleportClient) AddTrustedCA(ca types.CertAuthority) error { if tc.localAgent == nil { @@ -2929,96 +3115,6 @@ func (tc *TeleportClient) AddKey(key *Key) (*agent.AddedKey, error) { return tc.localAgent.AddKey(key) } -// directLogin asks for a password + HOTP token, makes a request to CA via proxy -func (tc *TeleportClient) directLogin(ctx context.Context, secondFactorType constants.SecondFactorType, pub []byte) (*auth.SSHLoginResponse, error) { - password, err := tc.AskPassword() - if err != nil { - return nil, trace.Wrap(err) - } - - // only ask for a second factor if it's enabled - var otpToken string - if secondFactorType == constants.SecondFactorOTP { - otpToken, err = tc.AskOTP() - if err != nil { - return nil, trace.Wrap(err) - } - } - - // ask the CA (via proxy) to sign our public key: - response, err := SSHAgentLogin(ctx, SSHLoginDirect{ - SSHLogin: SSHLogin{ - ProxyAddr: tc.WebProxyAddr, - PubKey: pub, - TTL: tc.KeyTTL, - Insecure: tc.InsecureSkipVerify, - Pool: loopbackPool(tc.WebProxyAddr), - Compatibility: tc.CertificateFormat, - RouteToCluster: tc.SiteName, - KubernetesCluster: tc.KubernetesCluster, - }, - User: tc.Config.Username, - Password: password, - OTPToken: otpToken, - }) - - return response, trace.Wrap(err) -} - -// SSOLoginFunc is a function used in tests to mock SSO logins. -type SSOLoginFunc func(ctx context.Context, connectorID string, pub []byte, protocol string) (*auth.SSHLoginResponse, error) - -// samlLogin opens browser window and uses OIDC or SAML redirect cycle with browser -func (tc *TeleportClient) ssoLogin(ctx context.Context, connectorID string, pub []byte, protocol string) (*auth.SSHLoginResponse, error) { - if tc.MockSSOLogin != nil { - // sso login response is being mocked for testing purposes - return tc.MockSSOLogin(ctx, connectorID, pub, protocol) - } - // ask the CA (via proxy) to sign our public key: - response, err := SSHAgentSSOLogin(ctx, SSHLoginSSO{ - SSHLogin: SSHLogin{ - ProxyAddr: tc.WebProxyAddr, - PubKey: pub, - TTL: tc.KeyTTL, - Insecure: tc.InsecureSkipVerify, - Pool: loopbackPool(tc.WebProxyAddr), - Compatibility: tc.CertificateFormat, - RouteToCluster: tc.SiteName, - KubernetesCluster: tc.KubernetesCluster, - }, - ConnectorID: connectorID, - Protocol: protocol, - BindAddr: tc.BindAddr, - Browser: tc.Browser, - }) - return response, trace.Wrap(err) -} - -// mfaLocalLogin asks for a password and performs the challenge-response authentication -func (tc *TeleportClient) mfaLocalLogin(ctx context.Context, pub []byte) (*auth.SSHLoginResponse, error) { - password, err := tc.AskPassword() - if err != nil { - return nil, trace.Wrap(err) - } - - response, err := SSHAgentMFALogin(ctx, SSHLoginMFA{ - SSHLogin: SSHLogin{ - ProxyAddr: tc.WebProxyAddr, - PubKey: pub, - TTL: tc.KeyTTL, - Insecure: tc.InsecureSkipVerify, - Pool: loopbackPool(tc.WebProxyAddr), - Compatibility: tc.CertificateFormat, - RouteToCluster: tc.SiteName, - KubernetesCluster: tc.KubernetesCluster, - }, - User: tc.Config.Username, - Password: password, - }) - - return response, trace.Wrap(err) -} - // SendEvent adds a events.EventFields to the channel. func (tc *TeleportClient) SendEvent(ctx context.Context, e events.EventFields) error { // Try and send the event to the eventsCh. If blocking, keep blocking until diff --git a/lib/client/api_login_test.go b/lib/client/api_login_test.go index 0ac1db5c669..e9201cc5d2b 100644 --- a/lib/client/api_login_test.go +++ b/lib/client/api_login_test.go @@ -47,7 +47,7 @@ import ( log "github.com/sirupsen/logrus" ) -func TestTeleportClient_Login_localMFALogin(t *testing.T) { +func TestTeleportClient_Login_local(t *testing.T) { // Silence logging during this test. lvl := log.GetLevel() t.Cleanup(func() { @@ -61,6 +61,7 @@ func TestTeleportClient_Login_localMFALogin(t *testing.T) { sa := newStandaloneTeleport(t, clock) username := sa.Username password := sa.Password + webID := sa.WebAuthnID device := sa.Device otpKey := sa.OTPKey @@ -111,6 +112,13 @@ func TestTeleportClient_Login_localMFALogin(t *testing.T) { }, }, nil } + solvePwdless := func(ctx context.Context, origin string, assertion *wanlib.CredentialAssertion) (*proto.MFAAuthenticateResponse, error) { + resp, err := solveWebauthn(ctx, origin, assertion) + if err == nil { + resp.GetWebauthn().Response.UserHandle = webID + } + return resp, err + } ctx := context.Background() tests := []struct { @@ -118,19 +126,27 @@ func TestTeleportClient_Login_localMFALogin(t *testing.T) { secondFactor constants.SecondFactorType solveOTP func(context.Context) (string, error) solveWebauthn func(ctx context.Context, origin string, assertion *wanlib.CredentialAssertion) (*proto.MFAAuthenticateResponse, error) + pwdless bool }{ { - name: "OK OTP device login", + name: "OTP device login", secondFactor: constants.SecondFactorOptional, solveOTP: solveOTP, solveWebauthn: promptWebauthnNoop, }, { - name: "OK Webauthn device login", + name: "WebAuthn device login", secondFactor: constants.SecondFactorOptional, solveOTP: promptOTPNoop, solveWebauthn: solveWebauthn, }, + { + name: "passwordless login", + secondFactor: constants.SecondFactorOptional, + solveOTP: promptOTPNoop, + solveWebauthn: solvePwdless, + pwdless: true, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -157,6 +173,7 @@ func TestTeleportClient_Login_localMFALogin(t *testing.T) { tc, err := client.NewClient(cfg) require.NoError(t, err) + tc.Passwordless = test.pwdless clock.Advance(30 * time.Second) _, err = tc.Login(ctx) @@ -168,6 +185,7 @@ func TestTeleportClient_Login_localMFALogin(t *testing.T) { type standaloneBundle struct { AuthAddr, ProxyWebAddr string Username, Password string + WebAuthnID []byte Device *mocku2f.Key OTPKey string Auth, Proxy *service.TeleportProcess @@ -246,14 +264,18 @@ func newStandaloneTeleport(t *testing.T, clock clockwork.Clock) *standaloneBundl require.NoError(t, err) tokenID := token.GetName() res, err := authServer.CreateRegisterChallenge(ctx, &proto.CreateRegisterChallengeRequest{ - TokenID: tokenID, - DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN, + TokenID: tokenID, + DeviceType: proto.DeviceType_DEVICE_TYPE_WEBAUTHN, + DeviceUsage: proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS, }) require.NoError(t, err) + cc := wanlib.CredentialCreationFromProto(res.GetWebauthn()) + webID := cc.Response.User.ID device, err := mocku2f.Create() require.NoError(t, err) + device.SetPasswordless() const origin = "https://localhost" - ccr, err := device.SignCredentialCreation(origin, wanlib.CredentialCreationFromProto(res.GetWebauthn())) + ccr, err := device.SignCredentialCreation(origin, cc) require.NoError(t, err) _, err = authServer.ChangeUserAuthentication(ctx, &proto.ChangeUserAuthenticationRequest{ TokenID: tokenID, @@ -298,6 +320,7 @@ func newStandaloneTeleport(t *testing.T, clock clockwork.Clock) *standaloneBundl ProxyWebAddr: proxyWebAddr.String(), Username: username, Password: password, + WebAuthnID: webID, Device: device, OTPKey: otpKey, Auth: authProcess, diff --git a/tool/tsh/mfa.go b/tool/tsh/mfa.go index 2ce00e07a3e..6e46c2f11ac 100644 --- a/tool/tsh/mfa.go +++ b/tool/tsh/mfa.go @@ -148,6 +148,10 @@ type mfaAddCommand struct { *kingpin.CmdClause devName string devType string + // pwdless is nil if unset, true/false if explicitly set. + // If passwordless is not supported it's always set to false. + // The default behavior is the same as false. + pwdless *bool } func newMFAAddCommand(parent *kingpin.CmdClause) *mfaAddCommand { @@ -157,6 +161,22 @@ func newMFAAddCommand(parent *kingpin.CmdClause) *mfaAddCommand { c.Flag("name", "Name of the new MFA device").StringVar(&c.devName) c.Flag("type", fmt.Sprintf("Type of the new MFA device (%s)", strings.Join(defaultDeviceTypes, ", "))). StringVar(&c.devType) + + if wancli.IsFIDO2Available() { + var allowPwdless bool + c.Flag("allow-passwordless", "Allow passwordless logins"). + Action(func(_ *kingpin.ParseContext) error { + // If the callback is called it means that the flag was explicitly set, + // so we can copy its contents to the command. + c.pwdless = &allowPwdless + return nil + }). + BoolVar(&allowPwdless) + } else { + allowPwdless := false + c.pwdless = &allowPwdless + } + return c } @@ -211,7 +231,18 @@ func (c *mfaAddCommand) run(cf *CLIConf) error { return trace.BadParameter("device name can not be empty") } - dev, err := c.addDeviceRPC(ctx, tc, c.devName, devType, stdin) + // If passwordless is supported but unset then ask the user. + if c.pwdless == nil { + answer, err := prompt.PickOne(ctx, os.Stdout, stdin, "Allow passwordless logins", []string{"YES", "NO"}) + if err != nil { + return trace.Wrap(err) + } + val := answer == "YES" + c.pwdless = &val + } + pwdless := c.pwdless != nil && *c.pwdless + + dev, err := c.addDeviceRPC(ctx, tc, c.devName, devType, pwdless, stdin) if err != nil { return trace.Wrap(err) } @@ -240,7 +271,7 @@ func deviceTypesFromPreferredMFA(preferredMFA constants.SecondFactorType) []stri func (c *mfaAddCommand) addDeviceRPC( ctx context.Context, - tc *client.TeleportClient, devName string, devType proto.DeviceType, r prompt.Reader) (*types.MFADevice, error) { + tc *client.TeleportClient, devName string, devType proto.DeviceType, passwordless bool, r prompt.Reader) (*types.MFADevice, error) { var dev *types.MFADevice if err := client.RetryWithRelogin(ctx, tc, func() error { pc, err := tc.ConnectToProxy(ctx) @@ -261,10 +292,15 @@ func (c *mfaAddCommand) addDeviceRPC( return trace.Wrap(err) } // Init. + usage := proto.DeviceUsage_DEVICE_USAGE_MFA + if passwordless { + usage = proto.DeviceUsage_DEVICE_USAGE_PASSWORDLESS + } if err := stream.Send(&proto.AddMFADeviceRequest{Request: &proto.AddMFADeviceRequest_Init{ Init: &proto.AddMFADeviceRequestInit{ - DeviceName: devName, - DeviceType: devType, + DeviceName: devName, + DeviceType: devType, + DeviceUsage: usage, }, }}); err != nil { return trace.Wrap(err) @@ -417,9 +453,8 @@ func promptTOTPRegisterChallenge(ctx context.Context, c *proto.TOTPRegisterChall type mfaAddPrompt struct{} func (m mfaAddPrompt) PromptPIN() (string, error) { - fmt.Printf("Enter the PIN for your *new* security key: ") + fmt.Println("Enter your *new* security key PIN") pwd, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() // '\n' gets swallowed by ReadPassword. if err != nil { return "", trace.Wrap(err) } @@ -427,7 +462,7 @@ func (m mfaAddPrompt) PromptPIN() (string, error) { } func (m mfaAddPrompt) PromptAdditionalTouch() error { - fmt.Println("Tap your *new* security key again") + fmt.Println("Tap your *new* security key again to complete registration") return nil } diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index 4f1cecf8cdc..d24e890d6fd 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -45,6 +45,7 @@ import ( apisshutils "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth" + wancli "github.com/gravitational/teleport/lib/auth/webauthncli" "github.com/gravitational/teleport/lib/benchmark" "github.com/gravitational/teleport/lib/client" dbprofile "github.com/gravitational/teleport/lib/client/db" @@ -293,6 +294,9 @@ type CLIConf struct { // JoinMode is the participant mode someone is joining a session as. JoinMode string + // Passwordless instructs tsh to do passwordless login. + Passwordless bool + // displayParticipantRequirements is set if verbose participant requirement information should be printed for moderated sessions. displayParticipantRequirements bool @@ -382,6 +386,9 @@ func Run(args []string, opts ...cliOption) error { app.Flag("proxy", "SSH proxy address").Envar(proxyEnvVar).StringVar(&cf.Proxy) app.Flag("nocache", "do not cache cluster discovery locally").Hidden().BoolVar(&cf.NoCache) app.Flag("user", fmt.Sprintf("SSH proxy user [%s]", localUser)).Envar(userEnvVar).StringVar(&cf.Username) + if wancli.IsFIDO2Available() { + app.Flag("pwdless", "Do passwordless login").BoolVar(&cf.Passwordless) + } app.Flag("option", "").Short('o').Hidden().AllowDuplicate().PreAction(func(ctx *kingpin.ParseContext) error { return trace.BadParameter("invalid flag, perhaps you want to use this flag as tsh ssh -o?") }).String() @@ -415,7 +422,9 @@ func Run(args []string, opts ...cliOption) error { BoolVar(&cf.EnableEscapeSequences) app.Flag("bind-addr", "Override host:port used when opening a browser for cluster logins").Envar(bindAddrEnvVar).StringVar(&cf.BindAddr) app.HelpFlag.Short('h') + ver := app.Command("version", "Print the version") + // ssh ssh := app.Command("ssh", "Run shell or execute a command on a remote SSH node") ssh.Arg("[user@]host", "Remote hostname and the login to use").Required().StringVar(&cf.UserHost) @@ -1972,6 +1981,7 @@ func makeClient(cf *CLIConf, useProfileLogin bool) (*client.TeleportClient, erro if cf.Username != "" { c.Username = cf.Username } + c.Passwordless = cf.Passwordless // if proxy is set, and proxy is not equal to profile's // loaded addresses, override the values if err := setClientWebProxyAddr(cf, c); err != nil {