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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/ISSUE_TEMPLATE/testplan.md
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,9 @@ With a default Postgres DB instance, a Teleport instance configured with DB acce
(`auth_service.authentication` in the cluster config):
- [ ] `type: local`, `second_factor: "off"`
- [ ] `type: local`, `second_factor: "otp"`
- [ ] `type: local`, `second_factor: "webauthn"`
- [ ] `type: local`, `second_factor: "webauthn"`,
- [ ] `type: local`, `second_factor: "webauthn"`, log in passwordlessly with hardware key
- [ ] `type: local`, `second_factor: "webauthn"`, log in passwordlessly with touch ID
- [ ] `type: local`, `second_factor: "optional"`, log in without MFA
- [ ] `type: local`, `second_factor: "optional"`, log in with OTP
- [ ] `type: local`, `second_factor: "optional"`, log in with hardware key
Expand Down
16 changes: 12 additions & 4 deletions lib/auth/webauthncli/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ type UserInfo struct {
}

// LoginPrompt is the user interface for FIDO2Login.
//
// Prompts can have remote implementations, thus all methods may error.
type LoginPrompt interface {
// PromptPIN prompts the user for their PIN.
PromptPIN() (string, error)
// PromptTouch prompts the user for a security key touch.
Comment thread
codingllama marked this conversation as resolved.
// In certain situations multiple touches may be required (PIN-protected
// devices, passwordless flows, etc).
PromptTouch()
PromptTouch() error
// PromptCredential prompts the user to choose a credential, in case multiple
// credentials are available.
// Callers are free to modify the slice, such as by sorting the credentials,
Expand Down Expand Up @@ -136,7 +138,9 @@ func crossPlatformLogin(
return FIDO2Login(ctx, origin, assertion, prompt, opts)
}

prompt.PromptTouch()
if err := prompt.PromptTouch(); err != nil {
return nil, "", trace.Wrap(err)
}
resp, err := U2FLogin(ctx, origin, assertion)
return resp, "" /* credentialUser */, err
}
Expand All @@ -154,13 +158,15 @@ func platformLogin(origin, user string, assertion *wanlib.CredentialAssertion, p
}

// RegisterPrompt is the user interface for FIDO2Register.
//
// Prompts can have remote implementations, thus all methods may error.
type RegisterPrompt interface {
// PromptPIN prompts the user for their PIN.
PromptPIN() (string, error)
// PromptTouch prompts the user for a security key touch.
// In certain situations multiple touches may be required (eg, PIN-protected
// devices)
PromptTouch()
PromptTouch() error
}

// Register performs client-side, U2F-compatible, Webauthn registration.
Expand All @@ -178,6 +184,8 @@ func Register(
return FIDO2Register(ctx, origin, cc, prompt)
}

prompt.PromptTouch()
if err := prompt.PromptTouch(); err != nil {
return nil, trace.Wrap(err)
}
return U2FRegister(ctx, origin, cc)
}
13 changes: 10 additions & 3 deletions lib/auth/webauthncli/fido2.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,9 @@ func runOnFIDO2Devices(
if errors.Is(err, errNoSuitableDevices) {
// No readily available devices means we need to prompt, otherwise the
// user gets no feedback whatsoever.
prompt.PromptTouch()
if err := prompt.PromptTouch(); err != nil {
return trace.Wrap(err)
}
prompted = true

devices, err = findSuitableDevicesOrTimeout(ctx, filter, knownPaths)
Expand All @@ -561,7 +563,10 @@ func runOnFIDO2Devices(
}

if !prompted {
prompt.PromptTouch() // about to select
// about to select
if err := prompt.PromptTouch(); err != nil {
return trace.Wrap(err)
}
}
dev, requiresPIN, err := selectDevice(ctx, "" /* pin */, devices, deviceCallback)
switch {
Expand All @@ -582,7 +587,9 @@ func runOnFIDO2Devices(
}

// Prompt a second touch after reading the PIN.
prompt.PromptTouch()
if err := prompt.PromptTouch(); err != nil {
return trace.Wrap(err)
}

// Run the callback again with the informed PIN.
// selectDevice is used since it correctly deals with cancellation.
Expand Down
12 changes: 7 additions & 5 deletions lib/auth/webauthncli/fido2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func (p noopPrompt) PromptPIN() (string, error) {
return "", nil
}

func (p noopPrompt) PromptTouch() {}
func (p noopPrompt) PromptTouch() error { return nil }

// pinCancelPrompt exercises cancellation after device selection.
type pinCancelPrompt struct {
Expand All @@ -135,8 +135,9 @@ func (p *pinCancelPrompt) PromptPIN() (string, error) {
return p.pin, nil
}

func (p pinCancelPrompt) PromptTouch() {
func (p pinCancelPrompt) PromptTouch() error {
// 2nd touch never happens
return nil
}

func TestIsFIDO2Available(t *testing.T) {
Expand Down Expand Up @@ -811,9 +812,9 @@ type countingPrompt struct {
count int
}

func (cp *countingPrompt) PromptTouch() {
func (cp *countingPrompt) PromptTouch() error {
cp.count++
cp.LoginPrompt.PromptTouch()
return cp.LoginPrompt.PromptTouch()
}

func TestFIDO2Login_PromptTouch(t *testing.T) {
Expand Down Expand Up @@ -1706,8 +1707,9 @@ func (f *fakeFIDO2Device) PromptPIN() (string, error) {
return f.pin, nil
}

func (f *fakeFIDO2Device) PromptTouch() {
func (f *fakeFIDO2Device) PromptTouch() error {
f.setUP()
return nil
}

func (f *fakeFIDO2Device) credentialID() []byte {
Expand Down
12 changes: 7 additions & 5 deletions lib/auth/webauthncli/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ type DefaultPrompt struct {
FirstTouchMessage, SecondTouchMessage string
PromptCredentialMessage string

ctx context.Context
out io.Writer
ctx context.Context
out io.Writer

count int
}

Expand All @@ -59,18 +60,19 @@ func (p *DefaultPrompt) PromptPIN() (string, error) {
}

// PromptTouch prompts the user for a security key touch, using different
// messages for first and second prompts.
func (p *DefaultPrompt) PromptTouch() {
// messages for first and second prompts. Error is always nil.
func (p *DefaultPrompt) PromptTouch() error {
if p.count == 0 {
p.count++
if p.FirstTouchMessage != "" {
fmt.Fprintln(p.out, p.FirstTouchMessage)
}
return
return nil
}
if p.SecondTouchMessage != "" {
fmt.Fprintln(p.out, p.SecondTouchMessage)
}
return nil
}

// PromptCredential prompts the user to choose a credential, in case multiple
Expand Down
65 changes: 13 additions & 52 deletions lib/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ 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"
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
"github.com/gravitational/teleport/lib/client/terminal"
"github.com/gravitational/teleport/lib/defaults"
Expand All @@ -71,7 +70,6 @@ import (
"github.com/gravitational/teleport/lib/utils/prompt"
"github.com/gravitational/teleport/lib/utils/proxy"

"github.com/duo-labs/webauthn/protocol"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -3289,67 +3287,30 @@ func (tc *TeleportClient) Login(ctx context.Context) (*Key, error) {
}

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)
}

// Only pass on the user if explicitly set, otherwise let the credential
// picker kick in.
user := ""
if tc.ExplicitUsername {
user = tc.Username
}

prompt := wancli.NewDefaultPrompt(ctx, tc.Stderr)
mfaResp, _, err := promptWebauthn(ctx, webURL.String(), challenge.WebauthnChallenge, prompt, &wancli.LoginOpts{
response, err := SSHAgentPasswordlessLogin(ctx, SSHLoginPasswordless{
SSHLogin: SSHLogin{
ProxyAddr: tc.WebProxyAddr,
PubKey: pubKey,
TTL: tc.KeyTTL,
Insecure: tc.InsecureSkipVerify,
Pool: loopbackPool(tc.WebProxyAddr),
Compatibility: tc.CertificateFormat,
RouteToCluster: tc.SiteName,
KubernetesCluster: tc.KubernetesCluster,
},
User: user,
AuthenticatorAttachment: tc.AuthenticatorAttachment,
StderrOverride: tc.Stderr,
})
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
return response, trace.Wrap(err)
}

func (tc *TeleportClient) localLogin(ctx context.Context, secondFactor constants.SecondFactorType, pub []byte) (*auth.SSHLoginResponse, error) {
Expand Down
27 changes: 18 additions & 9 deletions lib/client/api_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,7 @@ import (
)

func TestTeleportClient_Login_local(t *testing.T) {
// Silence logging during this test.
lvl := log.GetLevel()
t.Cleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(lvl)
})
log.SetOutput(io.Discard)
log.SetLevel(log.PanicLevel)
silenceLogger(t)

clock := clockwork.NewFakeClockAt(time.Now())
sa := newStandaloneTeleport(t, clock)
Expand Down Expand Up @@ -134,7 +127,12 @@ func TestTeleportClient_Login_local(t *testing.T) {
case got != pin:
return nil, errors.New("invalid PIN")
}
prompt.PromptTouch() // Realistically, this would happen too.

// Realistically, this would happen too.
if err := prompt.PromptTouch(); err != nil {
return nil, err
}

return solveWebauthn(ctx, origin, assertion, prompt)
}

Expand Down Expand Up @@ -491,3 +489,14 @@ func startAndWait(t *testing.T, cfg *service.Config, eventName string) *service.

return instance
}

// silenceLogger silences logger during testing.
func silenceLogger(t *testing.T) {
lvl := log.GetLevel()
t.Cleanup(func() {
log.SetOutput(os.Stderr)
log.SetLevel(lvl)
})
log.SetOutput(io.Discard)
log.SetLevel(log.PanicLevel)
}
Loading