From ac5e94252b6adced64a32509ad032f06ee79c662 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:31:06 +0200 Subject: [PATCH] feat(api/v2): add totp qr code endpoint Port GET /user/settings/totp/qrcode to v2 as an image/jpeg blob, modeled in the OpenAPI spec. Extract the qr-to-jpeg encoding into user.GetTOTPQrCodeAsJpegForUser so v1 and v2 share it; refactor v1 onto it. The handler reuses the existing local-account guard, rejecting non-local users with 412. --- pkg/routes/api/v1/user_totp.go | 13 ++------ pkg/routes/api/v2/user_totp.go | 49 +++++++++++++++++++++++++++-- pkg/user/totp.go | 17 ++++++++++ pkg/webtests/huma_user_totp_test.go | 13 ++++++-- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/pkg/routes/api/v1/user_totp.go b/pkg/routes/api/v1/user_totp.go index e3c0ae076..a3c9fc8c4 100644 --- a/pkg/routes/api/v1/user_totp.go +++ b/pkg/routes/api/v1/user_totp.go @@ -17,10 +17,8 @@ package v1 import ( - "bytes" "errors" "fmt" - "image/jpeg" "net/http" "code.vikunja.io/api/pkg/db" @@ -202,14 +200,7 @@ func UserTOTPQrCode(c *echo.Context) error { } defer s.Close() - qrcode, err := user.GetTOTPQrCodeForUser(s, u) - if err != nil { - _ = s.Rollback() - return err - } - - buff := &bytes.Buffer{} - err = jpeg.Encode(buff, qrcode, nil) + qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u) if err != nil { _ = s.Rollback() return err @@ -220,7 +211,7 @@ func UserTOTPQrCode(c *echo.Context) error { return err } - return c.Blob(http.StatusOK, "image/jpeg", buff.Bytes()) + return c.Blob(http.StatusOK, "image/jpeg", qrcode) } // UserTOTP returns the current totp implementation if any is enabled. diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go index d998a524e..dd3b0c575 100644 --- a/pkg/routes/api/v2/user_totp.go +++ b/pkg/routes/api/v2/user_totp.go @@ -49,10 +49,16 @@ type totpMessageBody struct { Body models.Message } +// totpQrCodeResponse carries the qr code jpeg bytes plus a fixed Content-Type. +// Huma writes the []byte Body straight to the wire; the header field overrides +// content negotiation so image/jpeg reaches the client (matching v1). +type totpQrCodeResponse struct { + ContentType string `header:"Content-Type"` + Body []byte +} + // RegisterTOTPRoutes wires the current-user totp (2FA) operations onto the Huma // API. Totp is a local-account feature; every handler rejects OIDC/LDAP users. -// The QR-code blob endpoint is intentionally not ported here (binary streaming, -// handled in a later wave). func RegisterTOTPRoutes(api huma.API) { if !config.ServiceEnableTotp.GetBool() { return @@ -100,6 +106,27 @@ func RegisterTOTPRoutes(api huma.API) { DefaultStatus: http.StatusOK, Tags: tags, }, totpDisable) + + Register(api, huma.Operation{ + OperationID: "totp-qrcode", + Summary: "Get the totp enrollment qr code", + Description: "Returns the qr code for the authenticated user's enrolled totp setting as a jpeg image, for scanning into an authenticator app. Requires a prior enrollment. Local accounts only.", + Method: http.MethodGet, + Path: "/user/settings/totp/qrcode", + Tags: tags, + // Spell out the binary response; a bare []byte Body would otherwise be + // modeled as a base64 JSON string instead of binary image data. + Responses: map[string]*huma.Response{ + "200": { + Description: "The qr code as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, totpQrCode) } func init() { AddRouteRegistrar(RegisterTOTPRoutes) } @@ -208,3 +235,21 @@ func totpDisable(ctx context.Context, in *totpDisableBody) (*totpMessageBody, er } return &totpMessageBody{Body: models.Message{Message: "TOTP was disabled successfully."}}, nil } + +func totpQrCode(ctx context.Context, _ *struct{}) (*totpQrCodeResponse, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpQrCodeResponse{ContentType: "image/jpeg", Body: qrcode}, nil +} diff --git a/pkg/user/totp.go b/pkg/user/totp.go index e18948443..98c4327cb 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -17,8 +17,10 @@ package user import ( + "bytes" "fmt" "image" + "image/jpeg" "strconv" "time" @@ -198,6 +200,21 @@ func GetTOTPQrCodeForUser(s *xorm.Session, user *User) (qrcode image.Image, err return key.Image(300, 300) } +// GetTOTPQrCodeAsJpegForUser renders the user's totp qr code to jpeg bytes, the +// wire format both API versions serve. +func GetTOTPQrCodeAsJpegForUser(s *xorm.Session, user *User) ([]byte, error) { + qrcode, err := GetTOTPQrCodeForUser(s, user) + if err != nil { + return nil, err + } + + buff := &bytes.Buffer{} + if err := jpeg.Encode(buff, qrcode, nil); err != nil { + return nil, err + } + return buff.Bytes(), nil +} + // HandleFailedTOTPAuth records a failed TOTP attempt and locks the account // after 10 consecutive failures. // diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go index d5cc82f15..df244a23c 100644 --- a/pkg/webtests/huma_user_totp_test.go +++ b/pkg/webtests/huma_user_totp_test.go @@ -34,8 +34,7 @@ import ( var testuser14 = user.User{ID: 14, Username: "user14", Issuer: "https://some.service.com"} // TestHumaTOTP mirrors v1's TestUserTOTPLocalUser and adds the enable/disable -// flows plus the local-account-only guard. The QR-code endpoint is not ported -// to v2 (binary streaming, later wave), so there is no test for it here. +// flows, the qr-code blob endpoint, and the local-account-only guard. // // Fixture topology (pkg/db/fixtures/totp.yml + users.yml): // - user1: totp enrolled, not enabled (secret HXDMVJEC…). @@ -59,6 +58,15 @@ func TestHumaTOTP(t *testing.T) { require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) }) + t.Run("Get qr code for enrolled user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp/qrcode", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) + assert.NotEmpty(t, rec.Body.Bytes(), "the qr code jpeg must have bytes") + }) + t.Run("Enroll a fresh user", func(t *testing.T) { e, err := setupTestEnv() require.NoError(t, err) @@ -123,6 +131,7 @@ func TestHumaTOTP(t *testing.T) { method, path, body string }{ {http.MethodGet, "/api/v2/user/settings/totp", ""}, + {http.MethodGet, "/api/v2/user/settings/totp/qrcode", ""}, {http.MethodPost, "/api/v2/user/settings/totp/enroll", ""}, {http.MethodPost, "/api/v2/user/settings/totp/enable", `{"passcode":"000000"}`}, {http.MethodPost, "/api/v2/user/settings/totp/disable", `{"password":"12345678"}`},