mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user