diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go new file mode 100644 index 000000000..d998a524e --- /dev/null +++ b/pkg/routes/api/v2/user_totp.go @@ -0,0 +1,210 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type totpStatusBody struct { + Body *user.TOTP +} + +type totpEnableBody struct { + Body struct { + Passcode string `json:"passcode" doc:"The current totp passcode, used to confirm the authenticator is set up correctly."` + } +} + +type totpDisableBody struct { + Body struct { + Password string `json:"password" doc:"The current user's password, required to disable totp."` + } +} + +type totpMessageBody struct { + Body models.Message +} + +// 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 + } + + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "totp-get", + Summary: "Get totp status", + Description: "Returns the authenticated user's current totp setting. Fails with 412 if totp was never enrolled. Local accounts only.", + Method: http.MethodGet, + Path: "/user/settings/totp", + Tags: tags, + }, totpGet) + + Register(api, huma.Operation{ + OperationID: "totp-enroll", + Summary: "Enroll into totp", + Description: "Creates the totp secret for the authenticated user. The setup must still be confirmed via the enable endpoint before it takes effect. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/enroll", + // v1 returns 200 here, not 201: enrollment is an inactive draft, not a usable resource yet. + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpEnroll) + + Register(api, huma.Operation{ + OperationID: "totp-enable", + Summary: "Enable totp", + Description: "Activates a previously enrolled totp setting by confirming a passcode. All existing sessions are invalidated. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/enable", + // Confirms an existing enrollment; creates no new resource. + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpEnable) + + Register(api, huma.Operation{ + OperationID: "totp-disable", + Summary: "Disable totp", + Description: "Removes all totp settings for the authenticated user. Requires the current password for confirmation. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/disable", + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpDisable) +} + +func init() { AddRouteRegistrar(RegisterTOTPRoutes) } + +// localUserFromCtx resolves the authenticated user and refuses anything that is +// not a local account, mirroring v1's getLocalUserFromContext. The caller owns +// the returned session. CheckUserPassword and IsLocalUser need the full DB +// record (password hash, issuer), so this loads it rather than trusting the +// token claims. +func localUserFromCtx(ctx context.Context) (*user.User, *xorm.Session, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, nil, err + } + + s := db.NewSession() + u, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + s.Close() + return nil, nil, translateDomainError(err) + } + // A link share resolves to a synthetic, non-local user; any other auth type + // yields nil. Both must be refused — totp is a real-account-only feature. + if u == nil || !u.IsLocalUser() { + s.Close() + return nil, nil, translateDomainError(&user.ErrAccountIsNotLocal{}) + } + + return u, s, nil +} + +func totpGet(ctx context.Context, _ *struct{}) (*totpStatusBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + t, err := user.GetTOTPForUser(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpStatusBody{Body: t}, nil +} + +func totpEnroll(ctx context.Context, _ *struct{}) (*totpStatusBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + t, err := user.EnrollTOTP(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpStatusBody{Body: t}, nil +} + +func totpEnable(ctx context.Context, in *totpEnableBody) (*totpMessageBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + if err := user.EnableTOTP(s, &user.TOTPPasscode{User: u, Passcode: in.Body.Passcode}); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := models.DeleteAllUserSessions(s, u.ID); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpMessageBody{Body: models.Message{Message: "TOTP was enabled successfully."}}, nil +} + +func totpDisable(ctx context.Context, in *totpDisableBody) (*totpMessageBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := user.DisableTOTP(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpMessageBody{Body: models.Message{Message: "TOTP was disabled successfully."}}, nil +} diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go new file mode 100644 index 000000000..d5cc82f15 --- /dev/null +++ b/pkg/webtests/huma_user_totp_test.go @@ -0,0 +1,135 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + "time" + + "code.vikunja.io/api/pkg/user" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testuser14 is a non-local (OIDC) account; totp is local-only, so every totp +// route must refuse it. See pkg/db/fixtures/users.yml. +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. +// +// Fixture topology (pkg/db/fixtures/totp.yml + users.yml): +// - user1: totp enrolled, not enabled (secret HXDMVJEC…). +// - user10: totp enabled (secret JBSWY3DP…), local, password 12345678. +// - user15: local, no totp enrollment. +// - user14: non-local (OIDC) account. +func TestHumaTOTP(t *testing.T) { + t.Run("Get status 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", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"secret"`) + assert.Contains(t, rec.Body.String(), `"enabled":false`) + }) + + t.Run("Get status without enrollment returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Enroll a fresh user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user15 has no totp enrollment in the fixtures. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"secret"`) + assert.Contains(t, rec.Body.String(), `"url"`) + assert.Contains(t, rec.Body.String(), `"enabled":false`) + }) + + t.Run("Enroll when already enrolled returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Enable with a valid passcode", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user1's fixture secret; generate a passcode that is valid right now. + passcode, err := totp.GenerateCode("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", time.Now()) + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable", + fmt.Sprintf(`{"passcode":%q}`, passcode), humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "enabled successfully") + }) + + t.Run("Enable with an invalid passcode returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable", + `{"passcode":"000000"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Disable with the correct password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user10 has totp enabled; 12345678 is their fixture password. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable", + `{"password":"12345678"}`, humaTokenFor(t, &testuser10), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "disabled successfully") + }) + + t.Run("Disable with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser10), "") + require.NotEqual(t, http.StatusOK, rec.Code, "wrong password must not disable totp; body: %s", rec.Body.String()) + }) + + t.Run("Non-local user is refused on every route", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser14) + for _, tc := range []struct { + method, path, body string + }{ + {http.MethodGet, "/api/v2/user/settings/totp", ""}, + {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"}`}, + } { + rec := humaRequest(t, e, tc.method, tc.path, tc.body, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, + "%s %s must refuse a non-local account; body: %s", tc.method, tc.path, rec.Body.String()) + } + }) +}