From 5267e6300c28b6ea9e377cf4d1998969cfbb12f1 Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Wed, 28 Apr 2021 17:31:44 +0000 Subject: [PATCH] mfa: better OTP registration flow on CLI (#6567) (#6621) Several improvements: - show a QR code in the system image viewer - print an OTP URL in addition to individual fields (some apps accept that as input) - use cluster name as `Issuer` instead of "Teleport" --- api/types/authentication.go | 2 +- lib/auth/resetpasswordtoken.go | 8 +- tool/tsh/mfa.go | 134 ++++++++++++++++++++++++++++++--- 3 files changed, 128 insertions(+), 16 deletions(-) diff --git a/api/types/authentication.go b/api/types/authentication.go index e5955883a7840..d2270675d1cba 100644 --- a/api/types/authentication.go +++ b/api/types/authentication.go @@ -202,7 +202,7 @@ func (c *AuthPreferenceV2) SetConnectorName(cn string) { // GetU2F gets the U2F configuration settings. func (c *AuthPreferenceV2) GetU2F() (*U2F, error) { if c.Spec.U2F == nil { - return nil, trace.NotFound("U2F configuration not found") + return nil, trace.NotFound("U2F is not configured in this cluster, please contact your administrator and ask them to follow https://goteleport.com/docs/access-controls/guides/u2f/") } return c.Spec.U2F, nil } diff --git a/lib/auth/resetpasswordtoken.go b/lib/auth/resetpasswordtoken.go index 0fa934e42c819..0b25a84bd82a2 100644 --- a/lib/auth/resetpasswordtoken.go +++ b/lib/auth/resetpasswordtoken.go @@ -253,11 +253,13 @@ func (s *Server) newTOTPKey(user string) (*otp.Key, *totp.GenerateOpts, error) { if err != nil { return nil, nil, trace.Wrap(err) } + clusterName, err := s.GetClusterName() + if err != nil { + return nil, nil, trace.Wrap(err) + } opts := totp.GenerateOpts{ - // TODO(awly): use proxy public addr as "Issuer", to distinguish - // between TOTP keys for different clusters. - Issuer: "Teleport", + Issuer: clusterName.GetClusterName(), AccountName: accountName, Period: 30, // seconds Digits: otp.DigitsSix, diff --git a/tool/tsh/mfa.go b/tool/tsh/mfa.go index c9bc7377ede8e..79e5d2a51e992 100644 --- a/tool/tsh/mfa.go +++ b/tool/tsh/mfa.go @@ -18,13 +18,20 @@ package main import ( "context" + "encoding/base32" "fmt" + "image/png" + "io/ioutil" "os" + "os/exec" + "runtime" "strings" "time" "github.com/gravitational/kingpin" "github.com/gravitational/trace" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/types" @@ -278,20 +285,74 @@ func promptRegisterChallenge(ctx context.Context, proxyAddr string, c *proto.MFA } func promptTOTPRegisterChallenge(c *proto.TOTPRegisterChallenge) (*proto.MFARegisterResponse, error) { - // TODO(awly): mfa: use OS-specific image viewer to show a QR code. - // TODO(awly): mfa: print OTP URL - fmt.Println("Open your TOTP app and create a new manual entry with these fields:") - fmt.Printf("Name: %s\n", c.Account) - fmt.Printf("Issuer: %s\n", c.Issuer) - fmt.Printf("Algorithm: %s\n", c.Algorithm) - fmt.Printf("Number of digits: %d\n", c.Digits) - fmt.Printf("Period: %ds\n", c.PeriodSeconds) - fmt.Printf("Secret: %s\n", c.Secret) - fmt.Println() + secretBin, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(c.Secret) + if err != nil { + return nil, trace.BadParameter("server sent an invalid TOTP secret key %q: %v", c.Secret, err) + } + var algorithm otp.Algorithm + switch strings.ToUpper(c.Algorithm) { + case "SHA1": + algorithm = otp.AlgorithmSHA1 + case "SHA256": + algorithm = otp.AlgorithmSHA256 + case "SHA512": + algorithm = otp.AlgorithmSHA512 + case "MD5": + algorithm = otp.AlgorithmMD5 + default: + return nil, trace.BadParameter("server sent an unknown TOTP algorithm %q", c.Algorithm) + } + otpKey, err := totp.Generate(totp.GenerateOpts{ + Issuer: c.Issuer, + AccountName: c.Account, + Period: uint(c.PeriodSeconds), + Secret: secretBin, + Digits: otp.Digits(c.Digits), + Algorithm: algorithm, + }) + if err != nil { + return nil, trace.BadParameter("server sent invalid TOTP parameters: %v", err) + } - totpCode, err := prompt.Input(os.Stdout, os.Stdin, "Once created, enter an OTP code generated by the app") + // Try to show a QR code in the system image viewer. + // This is not supported on all platforms. + var showingQRCode bool + closeQR, err := showOTPQRCode(otpKey) if err != nil { - return nil, trace.Wrap(err) + log.WithError(err).Debug("Failed to show QR code") + } else { + showingQRCode = true + defer closeQR() + } + + fmt.Println() + if showingQRCode { + fmt.Println("Open your TOTP app and scan the QR code. Alternatively, you can manually enter these fields:") + } else { + fmt.Println("Open your TOTP app and create a new manual entry with these fields:") + } + fmt.Printf(` URL: %s + Account name: %s + Secret key: %s + Issuer: %s + Algorithm: %s + Number of digits: %d + Period: %ds +`, otpKey.URL(), c.Account, c.Secret, c.Issuer, c.Algorithm, c.Digits, c.PeriodSeconds) + fmt.Println() + + var totpCode string + // Help the user with typos, don't submit the code until it has the right + // length. + for { + totpCode, err = prompt.Input(os.Stdout, os.Stdin, "Once created, enter an OTP code generated by the app") + if err != nil { + return nil, trace.Wrap(err) + } + if len(totpCode) == int(c.Digits) { + break + } + fmt.Printf("TOTP code must be exactly %d digits long, try again\n", c.Digits) } return &proto.MFARegisterResponse{Response: &proto.MFARegisterResponse_TOTP{ TOTP: &proto.TOTPRegisterResponse{Code: totpCode}, @@ -398,3 +459,52 @@ func (c *mfaRemoveCommand) run(cf *CLIConf) error { fmt.Printf("MFA device %q removed.\n", c.name) return nil } + +func showOTPQRCode(k *otp.Key) (cleanup func(), retErr error) { + var imageViewer string + switch runtime.GOOS { + case "linux": + imageViewer = "xdg-open" + case "darwin": + imageViewer = "open" + default: + return func() {}, trace.NotImplemented("showing QR codes is not implemented on %s", runtime.GOOS) + } + + otpImage, err := k.Image(456, 456) + if err != nil { + return nil, trace.Wrap(err) + } + imageFile, err := ioutil.TempFile("", "teleport-otp-qr-code-*.png") + if err != nil { + return nil, trace.ConvertSystemError(err) + } + defer func() { + if retErr != nil { + imageFile.Close() + os.Remove(imageFile.Name()) + } + }() + + if err := png.Encode(imageFile, otpImage); err != nil { + return nil, trace.ConvertSystemError(err) + } + if err := imageFile.Close(); err != nil { + return nil, trace.ConvertSystemError(err) + } + log.Debugf("Wrote OTP QR code to %s", imageFile.Name()) + + cmd := exec.Command(imageViewer, imageFile.Name()) + if err := cmd.Start(); err != nil { + return nil, trace.ConvertSystemError(err) + } + log.Debugf("Opened QR code via %q", imageViewer) + return func() { + if err := os.Remove(imageFile.Name()); err != nil { + log.WithError(err).Debugf("Failed to clean up temporary QR code file %q", imageFile.Name()) + } + if err := cmd.Process.Kill(); err != nil { + log.WithError(err).Debug("Failed to stop the QR code image viewer") + } + }, nil +}