From 88fb43555880aaf371084c46f7b7ea731efcef22 Mon Sep 17 00:00:00 2001 From: Milad Rastian Date: Mon, 17 Aug 2026 15:38:19 +0200 Subject: [PATCH] feat: expose TOTP setup key during enrollment --- server/mfa.go | 18 ++++++++++++++- server/mfa_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++ server/templates.go | 5 +++-- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 server/mfa_test.go diff --git a/server/mfa.go b/server/mfa.go index efd2464258..6bbb3753e0 100644 --- a/server/mfa.go +++ b/server/mfa.go @@ -261,8 +261,16 @@ func (s *Server) renderTOTPPage(secret *storage.MFASecret, lastFail bool, issuer // Prevent browser from caching the TOTP page (contains QR code with secret). w.Header().Set("Cache-Control", "no-store") var qrCode string + var totpKey string if !secret.Confirmed { var err error + totpKey, err = totpManualSecret(secret.Secret) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to load TOTP key", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + qrCode, err = generateTOTPQRCode(secret.Secret) if err != nil { s.logger.ErrorContext(r.Context(), "failed to generate QR code", "err", err) @@ -270,11 +278,19 @@ func (s *Server) renderTOTPPage(secret *storage.MFASecret, lastFail bool, issuer return } } - if err := s.templates.totpVerify(r, w, r.URL.String(), issuer, connectorID, qrCode, lastFail); err != nil { + if err := s.templates.totpVerify(r, w, r.URL.String(), issuer, connectorID, qrCode, totpKey, lastFail); err != nil { s.logger.ErrorContext(r.Context(), "server template error", "err", err) } } +func totpManualSecret(keyURL string) (string, error) { + generated, err := otp.NewKeyFromURL(keyURL) + if err != nil { + return "", fmt.Errorf("load TOTP key: %w", err) + } + return generated.Secret(), nil +} + func generateTOTPQRCode(keyURL string) (string, error) { generated, err := otp.NewKeyFromURL(keyURL) if err != nil { diff --git a/server/mfa_test.go b/server/mfa_test.go new file mode 100644 index 0000000000..893669249f --- /dev/null +++ b/server/mfa_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "html/template" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/require" +) + +func TestTOTPManualSecret(t *testing.T) { + key, err := totp.Generate(totp.GenerateOpts{Issuer: "example", AccountName: "user@example.com"}) + require.NoError(t, err) + + manualSecret, err := totpManualSecret(key.String()) + require.NoError(t, err) + require.Equal(t, key.Secret(), manualSecret) + require.NotContains(t, manualSecret, "otpauth://") + + _, err = totpManualSecret("otpauth://totp/%ZZ") + require.Error(t, err) +} + +func TestTOTPTemplateSecretOnlyProvidedForEnrollment(t *testing.T) { + key, err := totp.Generate(totp.GenerateOpts{Issuer: "example", AccountName: "user@example.com"}) + require.NoError(t, err) + + for _, test := range []struct { + name string + totpKey string + wantSecret bool + }{ + {name: "enrollment", totpKey: key.Secret(), wantSecret: true}, + {name: "verification", wantSecret: false}, + } { + t.Run(test.name, func(t *testing.T) { + tmpl := &templates{totpVerifyTmpl: template.Must(template.New("totp").Parse("{{.TotpKey}}"))} + request := httptest.NewRequest("GET", "/mfa/totp", nil) + request.URL.RawQuery = url.Values{"req": []string{"request"}}.Encode() + response := httptest.NewRecorder() + + secret := test.totpKey + require.NoError(t, tmpl.totpVerify(request, response, "/mfa/totp", "example", "connector", "", secret, false)) + if test.wantSecret { + require.Contains(t, response.Body.String(), key.Secret()) + } else { + require.NotContains(t, response.Body.String(), key.Secret()) + } + require.False(t, strings.Contains(response.Body.String(), key.String())) + }) + } +} diff --git a/server/templates.go b/server/templates.go index 81da3d4d19..9566b40e49 100644 --- a/server/templates.go +++ b/server/templates.go @@ -361,7 +361,7 @@ func (t *templates) approval(r *http.Request, w http.ResponseWriter, authReqID, return renderTemplate(w, t.approvalTmpl, data) } -func (t *templates) totpVerify(r *http.Request, w http.ResponseWriter, postURL, issuer, connector, qrCode string, lastWasInvalid bool) error { +func (t *templates) totpVerify(r *http.Request, w http.ResponseWriter, postURL, issuer, connector, qrCode, totpKey string, lastWasInvalid bool) error { if lastWasInvalid { w.WriteHeader(http.StatusUnauthorized) } @@ -371,8 +371,9 @@ func (t *templates) totpVerify(r *http.Request, w http.ResponseWriter, postURL, Issuer string Connector string QRCode string + TotpKey string ReqPath string - }{postURL, lastWasInvalid, issuer, connector, qrCode, r.URL.Path} + }{postURL, lastWasInvalid, issuer, connector, qrCode, totpKey, r.URL.Path} return renderTemplate(w, t.totpVerifyTmpl, data) }