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
This commit is contained in:
Alan Parra
2022-03-23 18:38:10 +00:00
committed by GitHub
parent 575f583355
commit b2c5c8ecb0
9 changed files with 434 additions and 145 deletions
+12
View File
@@ -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
+13
View File
@@ -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
+84
View File
@@ -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()
+14 -6
View File
@@ -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.
+11 -3
View File
@@ -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,
},
},
},
},
},
}
+219 -123
View File
@@ -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
+29 -6
View File
@@ -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,
+42 -7
View File
@@ -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
}
+10
View File
@@ -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 {