-
Notifications
You must be signed in to change notification settings - Fork 1.7k
/
mfa.go
332 lines (292 loc) · 10.4 KB
/
mfa.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
/*
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"
"errors"
"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/auth/webauthnwin"
"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)
// Customize Windows prompt directly.
// Note that the platform popup is a modal and will only go away if
// canceled.
webauthnwin.PromptPlatformMessage = "Follow the OS dialogs for platform authentication, or enter an OTP code here:"
defer webauthnwin.ResetPromptPlatformMessage()
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:
switch err := resp.err; {
case errors.Is(err, wancli.ErrUsingNonRegisteredDevice):
// Surface error immediately.
case err != nil:
log.WithError(err).Debugf("%s authentication failed", resp.kind)
continue
}
// Cleanup in-flight goroutines.
cancelAndWait()
return resp.resp, trace.Wrap(resp.err)
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
}