diff --git a/pkg/routes/api/v2/user_deletion.go b/pkg/routes/api/v2/user_deletion.go new file mode 100644 index 000000000..1ecc5e009 --- /dev/null +++ b/pkg/routes/api/v2/user_deletion.go @@ -0,0 +1,172 @@ +// 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/db" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type userDeletionPasswordBody struct { + Body struct { + Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."` + } +} + +type userDeletionConfirmBody struct { + Body struct { + Token string `json:"token" required:"true" minLength:"1" doc:"The deletion confirmation token from the email sent by the request-deletion endpoint."` + } +} + +func RegisterUserDeletionRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-deletion-request", + Summary: "Request account deletion", + Description: "Starts deletion of the authenticated user's account. Local users must provide their password; a confirmation email is then sent and deletion only proceeds once confirmed.", + Method: http.MethodPost, + Path: "/user/deletion/request", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionRequest) + + Register(api, huma.Operation{ + OperationID: "user-deletion-confirm", + Summary: "Confirm account deletion", + Description: "Confirms a requested account deletion using the token from the confirmation email and schedules the account for deletion.", + Method: http.MethodPost, + Path: "/user/deletion/confirm", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionConfirm) + + Register(api, huma.Operation{ + OperationID: "user-deletion-cancel", + Summary: "Cancel account deletion", + Description: "Cancels a scheduled account deletion. Local users must provide their password.", + Method: http.MethodPost, + Path: "/user/deletion/cancel", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionCancel) +} + +func init() { AddRouteRegistrar(RegisterUserDeletionRoutes) } + +// authUserFromCtx resolves the full DB user for the authenticated caller, refusing +// link shares (which have no account to delete) with a 403. +func authUserFromCtx(ctx context.Context, s *xorm.Session) (*user.User, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + authUser, is := a.(*user.User) + if !is { + return nil, huma.Error403Forbidden("only users can manage account deletion") + } + // The auth user from the JWT claims is partial; re-fetch for the password hash. + u, err := user.GetUserByID(s, authUser.ID) + if err != nil { + return nil, translateDomainError(err) + } + return u, nil +} + +func userDeletionRequest(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.RequestDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionConfirm(ctx context.Context, in *userDeletionConfirmBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := user.ConfirmDeletion(s, u, in.Body.Token); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionCancel(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.CancelDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/webtests/huma_user_deletion_test.go b/pkg/webtests/huma_user_deletion_test.go new file mode 100644 index 000000000..081db594d --- /dev/null +++ b/pkg/webtests/huma_user_deletion_test.go @@ -0,0 +1,194 @@ +// 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 ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + userDeletionRequestPath = "/api/v2/user/deletion/request" + userDeletionConfirmPath = "/api/v2/user/deletion/confirm" + userDeletionCancelPath = "/api/v2/user/deletion/cancel" + // testUserPassword is the plaintext password for every local fixture user. + testUserPassword = "12345678" +) + +// deletionTokenFor reads the cleartext account-deletion token RequestDeletion +// stored for the user. RequestDeletion only mails the token, so the test pulls +// it straight from user_tokens (kind 3 = TokenAccountDeletion). +func deletionTokenFor(t *testing.T, userID int64) string { + t.Helper() + s := db.NewSession() + defer s.Close() + tok := struct { + Token string `xorm:"token"` + }{} + has, err := s.Table("user_tokens"). + Where("user_id = ? AND kind = ?", userID, 3). + Get(&tok) + require.NoError(t, err) + require.True(t, has, "RequestDeletion must have stored a deletion token for user %d", userID) + return tok.Token +} + +func deletionScheduledFor(t *testing.T, userID int64) bool { + t.Helper() + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, userID) + require.NoError(t, err) + return !u.DeletionScheduledAt.IsZero() +} + +// TestHumaUserDeletion ports v1's account-deletion flow (request → confirm → +// cancel) to v2. v1 returned 200/204 with a confirmation message body; v2 +// normalises all three to an empty 204 (the action returns no resource), so +// every success here asserts 204 + empty body. +func TestHumaUserDeletion(t *testing.T) { + t.Run("Request - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "a rejected request must not schedule deletion") + }) + + t.Run("Confirm - invalid token rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":"not-a-real-token"}`, token, "") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID)) + }) + + t.Run("Confirm - missing token is a validation error", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":""}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Request then confirm schedules deletion", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + assert.Empty(t, req.Body.String(), "v2 normalises the request action to an empty 204") + assert.False(t, deletionScheduledFor(t, testuser1.ID), "request alone must not schedule; confirmation does") + + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + assert.Empty(t, confirm.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "confirm must schedule the deletion") + }) + + t.Run("Cancel - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Schedule first so there is something to cancel. + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, cancel.Code, "body: %s", cancel.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "a rejected cancel must leave the deletion scheduled") + }) + + t.Run("Cancel - correct password clears the schedule", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, cancel.Code, "body: %s", cancel.Body.String()) + assert.Empty(t, cancel.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "cancel must clear the scheduled deletion") + }) + + t.Run("Unauthenticated", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + for _, path := range []string{userDeletionRequestPath, userDeletionConfirmPath, userDeletionCancelPath} { + rec := humaRequest(t, e, http.MethodPost, path, `{}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "%s body: %s", path, rec.Body.String()) + } + }) +} + +// TestHumaUserDeletion_LinkShareForbidden asserts a link share — which has no +// account — is refused (403) on every deletion action. +func TestHumaUserDeletion_LinkShareForbidden(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + }) + require.NoError(t, err) + + for _, tc := range []struct { + name string + path string + body string + }{ + {"request", userDeletionRequestPath, `{"password":"` + testUserPassword + `"}`}, + {"confirm", userDeletionConfirmPath, `{"token":"x"}`}, + {"cancel", userDeletionCancelPath, `{"password":"` + testUserPassword + `"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, tc.path, tc.body, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + } +}