mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-31 20:39:37 +08:00
* Allow connections to nodes when Auth is offline
`tsh ssh user@foo` currently fails if Auth is unreachable. Prior to
connecting to the target, `tsh` sends a `proto.AuthService/IsMFARequired`
request to determine if per-session MFA is required. If necessary, the MFA
ceremony then occurs before `tsh` attempts to connect to the node.
However, nodes ultimately makes the decision on whether the user has
access. To both increase availability of nodes and reduce initial
connection latency, `tsh` can first attempt to connect to a node and
fallback to checking if per-session MFA is required and then performing
the ceremony if necessary followed by attempting another connection to the
node. The major tradeoff is now when per-session MFA is required, `tsh`
will connect to the target **twice**. The added latency is likely to be
minimized by the amount of time it takes to perform the MFA ceremony.
## Connection flows prior to this change:
### Auth online and per-session MFA required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Auth: IsMFARequired
Auth-->>-tsh: yes
tsh->>+Auth: perform mfa ceremony
Auth-->>+tsh: issue challenge
tsh-->>+Auth: challenge response
Auth-->>+tsh: issue certificates
tsh->>+Node: Connect
Node-->>+tsh: Success
```
### Auth online and per-session MFA not required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Auth: IsMFARequired
Auth-->>-tsh: no
tsh->>+Node: Connect
Node-->>+tsh: Success
```
### Auth offline and per-session MFA not required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Auth: IsMFARequired
Auth-->>-tsh: Error
```
### Auth offline and per-session MFA required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Auth: IsMFARequired
Auth-->>-tsh: Error
```
## Connection flows after this change:
### Auth online and per-session MFA required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Node: Connect
Node-->>+tsh: Access Denied
tsh->>+Auth: IsMFARequired
Auth-->>-tsh: yes
tsh->>+Auth: perform mfa ceremony
Auth-->>+tsh: issue challenge
tsh-->>+Auth: challenge response
Auth-->>+tsh: issue certificates
tsh->>+Node: Connect
Node-->>+tsh: Success
```
### Auth online and per-session MFA not required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Node: Connect
Node-->>+tsh: Success
```
### Auth offline and per-session MFA not required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Node: Connect
Node-->>+tsh: Success
```
### Auth offline and per-session MFA required
```mermaid
sequenceDiagram
participant tsh
participant Auth
participant Node
tsh->>+Node: Connect
Node-->>+tsh: Access Denied
tsh->>+Auth: IsMFARequired
Auth-->>-tsh: Error
```
321 lines
9.9 KiB
Go
321 lines
9.9 KiB
Go
/*
|
|
Copyright 2021 Gravitational, Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/gravitational/trace"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
oteltrace "go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
|
|
wancli "github.com/gravitational/teleport/lib/auth/webauthncli"
|
|
"github.com/gravitational/teleport/lib/utils/prompt"
|
|
)
|
|
|
|
// promptWebauthn provides indirection for tests.
|
|
var promptWebauthn = wancli.Login
|
|
|
|
// mfaPrompt implements wancli.LoginPrompt for MFA logins.
|
|
// In most cases authenticators shouldn't require PINs or additional touches for
|
|
// MFA, but the implementation exists in case we find some unusual
|
|
// authenticators out there.
|
|
type mfaPrompt struct {
|
|
wancli.LoginPrompt
|
|
otpCancelAndWait func()
|
|
}
|
|
|
|
func (p *mfaPrompt) PromptPIN() (string, error) {
|
|
p.otpCancelAndWait()
|
|
return p.LoginPrompt.PromptPIN()
|
|
}
|
|
|
|
// PromptMFAChallengeOpts groups optional settings for PromptMFAChallenge.
|
|
type PromptMFAChallengeOpts struct {
|
|
// HintBeforePrompt is an optional hint message to print before an MFA prompt.
|
|
// It is used to provide context about why the user is being prompted where it may
|
|
// not be obvious.
|
|
HintBeforePrompt string
|
|
// PromptDevicePrefix is an optional prefix printed before "security key" or
|
|
// "device". It is used to emphasize between different kinds of devices, like
|
|
// registered vs new.
|
|
PromptDevicePrefix string
|
|
// Quiet suppresses users prompts.
|
|
Quiet bool
|
|
// AllowStdinHijack allows stdin hijack during MFA prompts.
|
|
// Stdin hijack provides a better login UX, but it can be difficult to reason
|
|
// about and is often a source of bugs.
|
|
// Do not set this options unless you deeply understand what you are doing.
|
|
// If false then only the strongest auth method is prompted.
|
|
AllowStdinHijack bool
|
|
// AuthenticatorAttachment specifies the desired authenticator attachment.
|
|
AuthenticatorAttachment wancli.AuthenticatorAttachment
|
|
// PreferOTP favors OTP challenges, if applicable.
|
|
// Takes precedence over AuthenticatorAttachment settings.
|
|
PreferOTP bool
|
|
}
|
|
|
|
// promptMFAStandalone is used to mock PromptMFAChallenge for tests.
|
|
var promptMFAStandalone = PromptMFAChallenge
|
|
|
|
// hasPlatformSupport is used to mock wancli.HasPlatformSupport for tests.
|
|
var hasPlatformSupport = wancli.HasPlatformSupport
|
|
|
|
// PromptMFAChallenge prompts the user to complete MFA authentication
|
|
// challenges.
|
|
// If proxyAddr is empty, the TeleportClient.WebProxyAddr is used.
|
|
// See client.PromptMFAChallenge.
|
|
func (tc *TeleportClient) PromptMFAChallenge(ctx context.Context, proxyAddr string, c *proto.MFAAuthenticateChallenge, applyOpts func(opts *PromptMFAChallengeOpts)) (*proto.MFAAuthenticateResponse, error) {
|
|
ctx, span := tc.Tracer.Start(
|
|
ctx,
|
|
"teleportClient/PromptMFAChallenge",
|
|
oteltrace.WithSpanKind(oteltrace.SpanKindClient),
|
|
oteltrace.WithAttributes(
|
|
attribute.String("cluster", tc.SiteName),
|
|
attribute.Bool("prefer_otp", tc.PreferOTP),
|
|
),
|
|
)
|
|
defer span.End()
|
|
|
|
addr := proxyAddr
|
|
if addr == "" {
|
|
addr = tc.WebProxyAddr
|
|
}
|
|
|
|
opts := &PromptMFAChallengeOpts{
|
|
AuthenticatorAttachment: tc.AuthenticatorAttachment,
|
|
PreferOTP: tc.PreferOTP,
|
|
}
|
|
if applyOpts != nil {
|
|
applyOpts(opts)
|
|
}
|
|
|
|
return promptMFAStandalone(ctx, c, addr, opts)
|
|
}
|
|
|
|
// PromptMFAChallenge prompts the user to complete MFA authentication
|
|
// challenges.
|
|
func PromptMFAChallenge(ctx context.Context, c *proto.MFAAuthenticateChallenge, proxyAddr string, opts *PromptMFAChallengeOpts) (*proto.MFAAuthenticateResponse, error) {
|
|
// Is there a challenge present?
|
|
if c.TOTP == nil && c.WebauthnChallenge == nil {
|
|
return &proto.MFAAuthenticateResponse{}, nil
|
|
}
|
|
if opts == nil {
|
|
opts = &PromptMFAChallengeOpts{}
|
|
}
|
|
writer := os.Stderr
|
|
if opts.HintBeforePrompt != "" {
|
|
fmt.Fprintln(writer, opts.HintBeforePrompt)
|
|
}
|
|
promptDevicePrefix := opts.PromptDevicePrefix
|
|
quiet := opts.Quiet
|
|
|
|
hasTOTP := c.TOTP != nil
|
|
hasWebauthn := c.WebauthnChallenge != nil
|
|
|
|
// Does the current platform support hardware MFA? Adjust accordingly.
|
|
switch {
|
|
case !hasTOTP && !hasPlatformSupport():
|
|
return nil, trace.BadParameter("hardware device MFA not supported by your platform, please register an OTP device")
|
|
case !hasPlatformSupport():
|
|
// Do not prompt for hardware devices, it won't work.
|
|
hasWebauthn = false
|
|
}
|
|
|
|
// Tweak enabled/disabled methods according to opts.
|
|
switch {
|
|
case hasTOTP && opts.PreferOTP:
|
|
hasWebauthn = false
|
|
case hasWebauthn && opts.AuthenticatorAttachment != wancli.AttachmentAuto:
|
|
// Prefer Webauthn if an specific attachment was requested.
|
|
hasTOTP = false
|
|
case hasWebauthn && !opts.AllowStdinHijack:
|
|
// Use strongest auth if hijack is not allowed.
|
|
hasTOTP = false
|
|
}
|
|
|
|
var numGoroutines int
|
|
if hasTOTP && hasWebauthn {
|
|
numGoroutines = 2
|
|
} else {
|
|
numGoroutines = 1
|
|
}
|
|
|
|
type response struct {
|
|
kind string
|
|
resp *proto.MFAAuthenticateResponse
|
|
err error
|
|
}
|
|
respC := make(chan response, numGoroutines)
|
|
|
|
// Use ctx and wg to clean up after ourselves.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
var wg sync.WaitGroup
|
|
cancelAndWait := func() {
|
|
cancel()
|
|
wg.Wait()
|
|
}
|
|
|
|
// Use variables below to cancel OTP reads and make sure the goroutine exited.
|
|
otpWait := &sync.WaitGroup{}
|
|
otpCtx, otpCancel := context.WithCancel(ctx)
|
|
defer otpCancel()
|
|
|
|
// Fire TOTP goroutine.
|
|
if hasTOTP {
|
|
otpWait.Add(1)
|
|
wg.Add(1)
|
|
go func() {
|
|
defer otpWait.Done()
|
|
defer wg.Done()
|
|
const kind = "TOTP"
|
|
|
|
// Let Webauthn take the prompt, it knows better if it's necessary.
|
|
var msg string
|
|
if !quiet && !hasWebauthn {
|
|
msg = fmt.Sprintf("Enter an OTP code from a %sdevice", promptDevicePrefix)
|
|
}
|
|
|
|
otp, err := prompt.Password(otpCtx, writer, prompt.Stdin(), msg)
|
|
if err != nil {
|
|
respC <- response{kind: kind, err: err}
|
|
return
|
|
}
|
|
respC <- response{
|
|
kind: kind,
|
|
resp: &proto.MFAAuthenticateResponse{
|
|
Response: &proto.MFAAuthenticateResponse_TOTP{
|
|
TOTP: &proto.TOTPResponse{Code: otp},
|
|
},
|
|
},
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Fire Webauthn goroutine.
|
|
if hasWebauthn {
|
|
origin := proxyAddr
|
|
if !strings.HasPrefix(origin, "https://") {
|
|
origin = "https://" + origin
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
log.Debugf("WebAuthn: prompting devices with origin %q", origin)
|
|
|
|
prompt := wancli.NewDefaultPrompt(ctx, writer)
|
|
prompt.SecondTouchMessage = fmt.Sprintf("Tap your %ssecurity key to complete login", promptDevicePrefix)
|
|
switch {
|
|
case quiet:
|
|
// Do not prompt.
|
|
prompt.FirstTouchMessage = ""
|
|
prompt.SecondTouchMessage = ""
|
|
case hasTOTP: // Webauthn + OTP
|
|
prompt.FirstTouchMessage = fmt.Sprintf("Tap any %ssecurity key or enter a code from a %sOTP device", promptDevicePrefix, promptDevicePrefix)
|
|
default: // Webauthn only
|
|
prompt.FirstTouchMessage = fmt.Sprintf("Tap any %ssecurity key", promptDevicePrefix)
|
|
}
|
|
mfaPrompt := &mfaPrompt{LoginPrompt: prompt, otpCancelAndWait: func() {
|
|
otpCancel()
|
|
otpWait.Wait()
|
|
}}
|
|
|
|
resp, _, err := promptWebauthn(ctx, origin, wanlib.CredentialAssertionFromProto(c.WebauthnChallenge), mfaPrompt, &wancli.LoginOpts{
|
|
AuthenticatorAttachment: opts.AuthenticatorAttachment,
|
|
})
|
|
respC <- response{kind: "WEBAUTHN", resp: resp, err: err}
|
|
}()
|
|
}
|
|
|
|
for i := 0; i < numGoroutines; i++ {
|
|
select {
|
|
case resp := <-respC:
|
|
if err := resp.err; err != nil {
|
|
log.WithError(err).Debugf("%s authentication failed", resp.kind)
|
|
continue
|
|
}
|
|
|
|
// Cleanup in-flight goroutines.
|
|
cancelAndWait()
|
|
return resp.resp, nil
|
|
case <-ctx.Done():
|
|
cancelAndWait()
|
|
return nil, trace.Wrap(ctx.Err())
|
|
}
|
|
}
|
|
cancelAndWait()
|
|
return nil, trace.BadParameter(
|
|
"failed to authenticate using all MFA devices, rerun the command with '-d' to see error details for each device")
|
|
}
|
|
|
|
// MFAAuthenticateChallenge is an MFA authentication challenge sent on user
|
|
// login / authentication ceremonies.
|
|
type MFAAuthenticateChallenge struct {
|
|
// WebauthnChallenge contains a WebAuthn credential assertion used for
|
|
// login/authentication ceremonies.
|
|
WebauthnChallenge *wanlib.CredentialAssertion `json:"webauthn_challenge"`
|
|
// TOTPChallenge specifies whether TOTP is supported for this user.
|
|
TOTPChallenge bool `json:"totp_challenge"`
|
|
}
|
|
|
|
// MakeAuthenticateChallenge converts proto to JSON format.
|
|
func MakeAuthenticateChallenge(protoChal *proto.MFAAuthenticateChallenge) *MFAAuthenticateChallenge {
|
|
chal := &MFAAuthenticateChallenge{
|
|
TOTPChallenge: protoChal.GetTOTP() != nil,
|
|
}
|
|
if protoChal.GetWebauthnChallenge() != nil {
|
|
chal.WebauthnChallenge = wanlib.CredentialAssertionFromProto(protoChal.WebauthnChallenge)
|
|
}
|
|
return chal
|
|
}
|
|
|
|
type TOTPRegisterChallenge struct {
|
|
QRCode []byte `json:"qrCode"`
|
|
}
|
|
|
|
// MFARegisterChallenge is an MFA register challenge sent on new MFA register.
|
|
type MFARegisterChallenge struct {
|
|
// Webauthn contains webauthn challenge.
|
|
Webauthn *wanlib.CredentialCreation `json:"webauthn"`
|
|
// TOTP contains TOTP challenge.
|
|
TOTP *TOTPRegisterChallenge `json:"totp"`
|
|
}
|
|
|
|
// MakeRegisterChallenge converts proto to JSON format.
|
|
func MakeRegisterChallenge(protoChal *proto.MFARegisterChallenge) *MFARegisterChallenge {
|
|
switch protoChal.GetRequest().(type) {
|
|
case *proto.MFARegisterChallenge_TOTP:
|
|
return &MFARegisterChallenge{
|
|
TOTP: &TOTPRegisterChallenge{
|
|
QRCode: protoChal.GetTOTP().GetQRCode(),
|
|
},
|
|
}
|
|
case *proto.MFARegisterChallenge_Webauthn:
|
|
return &MFARegisterChallenge{
|
|
Webauthn: wanlib.CredentialCreationFromProto(protoChal.GetWebauthn()),
|
|
}
|
|
}
|
|
return nil
|
|
}
|