User account deletion (#937)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#937
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-08-11 19:08:10 +00:00
parent cd21c5fc6e
commit 27119ad6d4
28 changed files with 1402 additions and 41 deletions

View File

@ -43,6 +43,10 @@ service:
# If enabled, vikunja will send an email to everyone who is either assigned to a task or created it when a task reminder
# is due.
enableemailreminders: true
# If true, will allow users to request the complete deletion of their account. When using external authentication methods
# it may be required to coordinate with them in order to delete the account. This setting will not affect the cli commands
# for user deletion.
enableuserdeletion: true
database:
# Database type to use. Supported types are mysql, postgres and sqlite.

View File

@ -108,6 +108,13 @@ return
The `mail` package provides a `Fake()` method which you should call in the `MainTest` functions of your package.
If it was called, no mails are being sent and you can instead assert they have been sent with the `AssertSent` method.
When testing, you should call the `notifications.Fake()` method in the `TestMain` function of the package you want to test.
This prevents any notifications from being sent and lets you assert a notifications has been sent like this:
{{< highlight golang >}}
notifications.AssertSent(t, &ReminderDueNotification{})
{{< /highlight >}}
## Example
Take a look at the [pkg/user/notifications.go](https://code.vikunja.io/api/src/branch/main/pkg/user/notifications.go) file for a good example.

View File

@ -17,6 +17,7 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strconv"
@ -42,6 +43,7 @@ var (
userFlagResetPasswordDirectly bool
userFlagEnableUser bool
userFlagDisableUser bool
userFlagDeleteNow bool
)
func init() {
@ -66,7 +68,10 @@ func init() {
userChangeEnabledCmd.Flags().BoolVarP(&userFlagDisableUser, "disable", "d", false, "Disable the user.")
userChangeEnabledCmd.Flags().BoolVarP(&userFlagEnableUser, "enable", "e", false, "Enable the user.")
userCmd.AddCommand(userListCmd, userCreateCmd, userUpdateCmd, userResetPasswordCmd, userChangeEnabledCmd)
// User deletion flags
userDeleteCmd.Flags().BoolVarP(&userFlagDeleteNow, "now", "n", false, "If provided, deletes the user immediately instead of sending them an email first.")
userCmd.AddCommand(userListCmd, userCreateCmd, userUpdateCmd, userResetPasswordCmd, userChangeEnabledCmd, userDeleteCmd)
rootCmd.AddCommand(userCmd)
}
@ -300,3 +305,61 @@ var userChangeEnabledCmd = &cobra.Command{
fmt.Printf("User status successfully changed, status is now \"%s\"\n", u.Status)
},
}
var userDeleteCmd = &cobra.Command{
Use: "delete [user id]",
Short: "Delete an existing user.",
Long: "Kick off the user deletion process. If call without the --now flag, this command will only trigger an email to the user in order for them to confirm and start the deletion process. With the flag the user is deleted immediately. USE WITH CAUTION.",
Args: cobra.ExactArgs(1),
PreRun: func(cmd *cobra.Command, args []string) {
initialize.FullInit()
},
Run: func(cmd *cobra.Command, args []string) {
if userFlagDeleteNow {
fmt.Println("You requested to delete the user immediately. Are you sure?")
fmt.Println(`To confirm, please type "yes, I confirm" in all uppercase:`)
cr := bufio.NewReader(os.Stdin)
text, err := cr.ReadString('\n')
if err != nil {
log.Fatalf("could not read confirmation message: %s", err)
}
if text != "YES, I CONFIRM\n" {
log.Fatalf("invalid confirmation message")
}
}
s := db.NewSession()
defer s.Close()
if err := s.Begin(); err != nil {
log.Fatalf("Count not start transaction: %s", err)
}
u := getUserFromArg(s, args[0])
if userFlagDeleteNow {
err := models.DeleteUser(s, u)
if err != nil {
_ = s.Rollback()
log.Fatalf("Error removing the user: %s", err)
}
} else {
err := user.RequestDeletion(s, u)
if err != nil {
_ = s.Rollback()
log.Fatalf("Could not request user deletion: %s", err)
}
}
if err := s.Commit(); err != nil {
log.Fatalf("Error saving everything: %s", err)
}
if userFlagDeleteNow {
fmt.Println("User deleted successfully.")
} else {
fmt.Println("User scheduled for deletion successfully.")
}
},
}

View File

@ -56,6 +56,7 @@ const (
ServiceSentryDsn Key = `service.sentrydsn`
ServiceTestingtoken Key = `service.testingtoken`
ServiceEnableEmailReminders Key = `service.enableemailreminders`
ServiceEnableUserDeletion Key = `service.enableuserdeletion`
AuthLocalEnabled Key = `auth.local.enabled`
AuthOpenIDEnabled Key = `auth.openid.enabled`
@ -246,6 +247,7 @@ func InitDefaultConfig() {
ServiceEnableTaskComments.setDefault(true)
ServiceEnableTotp.setDefault(true)
ServiceEnableEmailReminders.setDefault(true)
ServiceEnableUserDeletion.setDefault(true)
// Auth
AuthLocalEnabled.setDefault(true)

View File

@ -232,3 +232,13 @@
position: 23
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
id: 24
title: Test24
description: Lorem Ipsum
identifier: test6
owner_id: 6
namespace_id: 6
position: 7
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

@ -95,6 +95,8 @@ func FullInit() {
models.RegisterReminderCron()
models.RegisterOverdueReminderCron()
user.RegisterTokenCleanupCron()
user.RegisterDeletionNotificationCron()
models.RegisterUserDeletionCron()
// Start processing events
go func() {

View File

@ -22,7 +22,7 @@ import (
)
type users20210713213622 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
ID int64 `xorm:"bigint autoincr not null" json:"id"`
IsActive bool `xorm:"null" json:"-"`
Status int `xorm:"default 0" json:"-"`
}

View File

@ -24,7 +24,7 @@ import (
)
type tasks20210725153703 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"listtask"`
ID int64 `xorm:"bigint autoincr not null" json:"id" param:"listtask"`
Position float64 `xorm:"double null" json:"position"`
KanbanPosition float64 `xorm:"double null" json:"kanban_position"`
}

View File

@ -24,7 +24,7 @@ import (
)
type lists20210727204942 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"list"`
ID int64 `xorm:"bigint autoincr not null" json:"id" param:"list"`
Position float64 `xorm:"double null" json:"position"`
}

View File

@ -24,7 +24,7 @@ import (
)
type buckets20210727211037 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"list"`
ID int64 `xorm:"bigint autoincr not null" json:"id" param:"list"`
Position float64 `xorm:"double null" json:"position"`
}

View File

@ -0,0 +1,46 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type users20210802081716 struct {
DeletionScheduledAt time.Time `xorm:"datetime null" json:"-"`
DeletionLastReminderSent time.Time `xorm:"datetime null" json:"-"`
}
func (users20210802081716) TableName() string {
return "users"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20210802081716",
Description: "Add account deletion schedule timestamps",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(users20210802081716{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -762,11 +762,19 @@ func (l *List) Delete(s *xorm.Session, a web.Auth) (err error) {
}
// Delete all tasks on that list
_, err = s.Where("list_id = ?", l.ID).Delete(&Task{})
// Using the loop to make sure all related entities to all tasks are properly deleted as well.
tasks, _, _, err := getRawTasksForLists(s, []*List{l}, a, &taskOptions{})
if err != nil {
return
}
for _, task := range tasks {
err = task.Delete(s, a)
if err != nil {
return err
}
}
return events.Dispatch(&ListDeletedEvent{
List: l,
Doer: a,

View File

@ -650,7 +650,10 @@ func CreateNewNamespaceForUser(s *xorm.Session, user *user.User) (err error) {
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces/{id} [delete]
func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) {
return deleteNamespace(s, n, a, true)
}
func deleteNamespace(s *xorm.Session, n *Namespace, a web.Auth, withLists bool) (err error) {
// Check if the namespace exists
_, err = GetNamespaceByID(s, n.ID)
if err != nil {
@ -663,6 +666,15 @@ func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) {
return
}
namespaceDeleted := &NamespaceDeletedEvent{
Namespace: n,
Doer: a,
}
if !withLists {
return events.Dispatch(namespaceDeleted)
}
// Delete all lists with their tasks
lists, err := GetListsByNamespaceID(s, n.ID, &user.User{})
if err != nil {
@ -670,43 +682,18 @@ func (n *Namespace) Delete(s *xorm.Session, a web.Auth) (err error) {
}
if len(lists) == 0 {
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
return events.Dispatch(namespaceDeleted)
}
var listIDs []int64
// We need to do that for here because we need the list ids to delete two times:
// 1) to delete the lists itself
// 2) to delete the list tasks
for _, l := range lists {
listIDs = append(listIDs, l.ID)
// Looping over all lists to let the list handle properly cleaning up the tasks and everything else associated with it.
for _, list := range lists {
err = list.Delete(s, a)
if err != nil {
return err
}
}
if len(listIDs) == 0 {
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
}
// Delete tasks
_, err = s.In("list_id", listIDs).Delete(&Task{})
if err != nil {
return
}
// Delete the lists
_, err = s.In("id", listIDs).Delete(&List{})
if err != nil {
return
}
return events.Dispatch(&NamespaceDeletedEvent{
Namespace: n,
Doer: a,
})
return events.Dispatch(namespaceDeleted)
}
// Update implements the update method via the interface

275
pkg/models/user_delete.go Normal file
View File

@ -0,0 +1,275 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"time"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"xorm.io/builder"
"xorm.io/xorm"
)
// User deletion must happen here in this packaage because we want to delete everything associated to this user.
// Because most of these things are managed in the models package, using them has to happen here.
// RegisterUserDeletionCron registers the cron job that actually removes users who are scheduled to delete.
func RegisterUserDeletionCron() {
err := cron.Schedule("0 * * * *", deleteUsers)
if err != nil {
log.Errorf("Could not register deletion cron: %s", err.Error())
}
}
func deleteUsers() {
s := db.NewSession()
users := []*user.User{}
err := s.Where(builder.Lt{"deletion_scheduled_at": time.Now()}).
Find(&users)
if err != nil {
log.Errorf("Could not get users scheduled for deletion: %s", err)
return
}
if len(users) == 0 {
return
}
for _, u := range users {
if u.DeletionScheduledAt.Before(time.Now()) {
log.Debugf("User %d is not yet scheduled for deletion.", u.ID)
continue
}
err = s.Begin()
if err != nil {
log.Errorf("Could not start transaction: %s", err)
return
}
err = DeleteUser(s, u)
if err != nil {
_ = s.Rollback()
log.Errorf("Could not delete u %d: %s", u.ID, err)
return
}
err = s.Commit()
if err != nil {
log.Errorf("Could not commit transaction: %s", err)
return
}
}
}
// DeleteUser completely removes a user and all their associated lists, namespaces and tasks.
// This action is irrevocable.
// Public to allow deletion from the CLI.
func DeleteUser(s *xorm.Session, u *user.User) (err error) {
namespacesToDelete := []*Namespace{}
// Get all namespaces and lists this u has access to
nm := &Namespace{IsArchived: true}
res, _, _, err := nm.ReadAll(s, u, "", 1, -1)
if err != nil {
return err
}
namespaces := res.([]*NamespaceWithLists)
for _, n := range namespaces {
if n.ID < 0 {
continue
}
hadUsers, err := ensureNamespaceAdminUser(s, &n.Namespace)
if err != nil {
return err
}
if hadUsers {
continue
}
hadTeams, err := ensureNamespaceAdminTeam(s, &n.Namespace)
if err != nil {
return err
}
if hadTeams {
continue
}
namespacesToDelete = append(namespacesToDelete, &n.Namespace)
}
// Get all lists to delete
listsToDelete := []*List{}
lm := &List{IsArchived: true}
res, _, _, err = lm.ReadAll(s, u, "", 0, -1)
if err != nil {
return err
}
lists := res.([]*List)
for _, l := range lists {
if l.ID < 0 {
continue
}
hadUsers, err := ensureListAdminUser(s, l)
if err != nil {
return err
}
if hadUsers {
continue
}
hadTeams, err := ensureListAdminTeam(s, l)
if err != nil {
return err
}
if hadTeams {
continue
}
listsToDelete = append(listsToDelete, l)
}
// Delete everything not shared with anybody else
for _, n := range namespacesToDelete {
err = deleteNamespace(s, n, u, false)
if err != nil {
return err
}
}
for _, l := range listsToDelete {
err = l.Delete(s, u)
if err != nil {
return err
}
}
_, err = s.Where("id = ?", u.ID).Delete(u)
if err != nil {
return err
}
return notifications.Notify(u, &user.AccountDeletedNotification{})
}
func ensureNamespaceAdminUser(s *xorm.Session, n *Namespace) (hadUsers bool, err error) {
namespaceUsers := []*NamespaceUser{}
err = s.Where("namespace_id = ?", n.ID).Find(&namespaceUsers)
if err != nil {
return
}
if len(namespaceUsers) == 0 {
return false, nil
}
for _, lu := range namespaceUsers {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstUser := namespaceUsers[0]
firstUser.Right = RightAdmin
_, err = s.Where("id = ?", firstUser.ID).
Cols("right").
Update(firstUser)
return true, err
}
func ensureNamespaceAdminTeam(s *xorm.Session, n *Namespace) (hadTeams bool, err error) {
namespaceTeams := []*TeamNamespace{}
err = s.Where("namespace_id = ?", n.ID).Find(&namespaceTeams)
if err != nil {
return
}
if len(namespaceTeams) == 0 {
return false, nil
}
for _, lu := range namespaceTeams {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstTeam := namespaceTeams[0]
firstTeam.Right = RightAdmin
_, err = s.Where("id = ?", firstTeam.ID).
Cols("right").
Update(firstTeam)
return true, err
}
func ensureListAdminUser(s *xorm.Session, l *List) (hadUsers bool, err error) {
listUsers := []*ListUser{}
err = s.Where("list_id = ?", l.ID).Find(&listUsers)
if err != nil {
return
}
if len(listUsers) == 0 {
return false, nil
}
for _, lu := range listUsers {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstUser := listUsers[0]
firstUser.Right = RightAdmin
_, err = s.Where("id = ?", firstUser.ID).
Cols("right").
Update(firstUser)
return true, err
}
func ensureListAdminTeam(s *xorm.Session, l *List) (hadTeams bool, err error) {
listTeams := []*TeamList{}
err = s.Where("list_id = ?", l.ID).Find(&listTeams)
if err != nil {
return
}
if len(listTeams) == 0 {
return false, nil
}
for _, lu := range listTeams {
if lu.Right == RightAdmin {
// List already has more than one admin, no need to do anything
return true, nil
}
}
firstTeam := listTeams[0]
firstTeam.Right = RightAdmin
_, err = s.Where("id = ?", firstTeam.ID).
Cols("right").
Update(firstTeam)
return true, err
}

View File

@ -0,0 +1,47 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
)
func TestDeleteUser(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
notifications.Fake()
u := &user.User{ID: 6}
err := DeleteUser(s, u)
assert.NoError(t, err)
db.AssertMissing(t, "users", map[string]interface{}{"id": u.ID})
db.AssertMissing(t, "lists", map[string]interface{}{"id": 24}) // only user6 had access to this list
db.AssertExists(t, "lists", map[string]interface{}{"id": 6}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 7}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 8}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 9}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 10}, false)
db.AssertExists(t, "lists", map[string]interface{}{"id": 11}, false)
}

View File

@ -48,6 +48,10 @@ type Notifiable interface {
// Notify notifies a notifiable of a notification
func Notify(notifiable Notifiable, notification Notification) (err error) {
if isUnderTest {
sentTestNotifications = append(sentTestNotifications, notification)
return nil
}
err = notifyMail(notifiable, notification)
if err != nil {

View File

@ -0,0 +1,46 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"testing"
"github.com/stretchr/testify/assert"
)
var (
isUnderTest bool
sentTestNotifications []Notification
)
func Fake() {
isUnderTest = true
sentTestNotifications = nil
}
// AssertSent asserts a notification has been sent
func AssertSent(t *testing.T, n Notification) {
var found bool
for _, testNotification := range sentTestNotifications {
if n.Name() == testNotification.Name() {
found = true
break
}
}
assert.True(t, found, "Failed to assert "+n.Name()+" has been sent.")
}

View File

@ -48,6 +48,7 @@ type vikunjaInfos struct {
CaldavEnabled bool `json:"caldav_enabled"`
AuthInfo authInfo `json:"auth"`
EmailRemindersEnabled bool `json:"email_reminders_enabled"`
UserDeletionEnabled bool `json:"user_deletion_enabled"`
}
type authInfo struct {
@ -89,6 +90,7 @@ func Info(c echo.Context) error {
TotpEnabled: config.ServiceEnableTotp.GetBool(),
CaldavEnabled: config.ServiceEnableCaldav.GetBool(),
EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(),
UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(),
Legal: legalInfo{
ImprintURL: config.LegalImprintURL.GetString(),
PrivacyPolicyURL: config.LegalPrivacyURL.GetString(),

View File

@ -0,0 +1,201 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web/handler"
"github.com/labstack/echo/v4"
)
type UserDeletionRequest struct {
Password string `json:"password" valid:"required"`
}
type UserDeletionRequestConfirm struct {
Token string `json:"token" valid:"required"`
}
// UserRequestDeletion is the handler to request a user deletion process (sends a mail)
// @Summary Request the deletion of the user
// @Description Requests the deletion of the current user. It will trigger an email which has to be confirmed to start the deletion.
// @tags user
// @Accept json
// @Produce json
// @Param credentials body v1.UserDeletionRequest true "The user password."
// @Success 200 {object} models.Message
// @Failure 412 {object} web.HTTPError "Bad password provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/request [post]
func UserRequestDeletion(c echo.Context) error {
var deletionRequest UserDeletionRequest
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err := c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
u, err := user.GetCurrentUserFromDB(s, c)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.RequestDeletion(s, u)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusOK, models.Message{Message: "Successfully requested deletion."})
}
// UserConfirmDeletion is the handler to confirm a user deletion process and start it
// @Summary Confirm a user deletion request
// @Description Confirms the deletion request of a user sent via email.
// @tags user
// @Accept json
// @Produce json
// @Param credentials body v1.UserDeletionRequestConfirm true "The token."
// @Success 200 {object} models.Message
// @Failure 412 {object} web.HTTPError "Bad token provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/confirm [post]
func UserConfirmDeletion(c echo.Context) error {
var deleteConfirmation UserDeletionRequestConfirm
if err := c.Bind(&deleteConfirmation); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No token provided.")
}
err := c.Validate(deleteConfirmation)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
u, err := user.GetCurrentUserFromDB(s, c)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.ConfirmDeletion(s, u, deleteConfirmation.Token)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusNoContent, models.Message{Message: "Successfully confirmed the deletion request."})
}
// UserCancelDeletion is the handler to abort a user deletion process
// @Summary Abort a user deletion request
// @Description Aborts an in-progress user deletion.
// @tags user
// @Accept json
// @Produce json
// @Param credentials body v1.UserDeletionRequest true "The user password to confirm."
// @Success 200 {object} models.Message
// @Failure 412 {object} web.HTTPError "Bad password provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /user/deletion/cancel [post]
func UserCancelDeletion(c echo.Context) error {
var deletionRequest UserDeletionRequest
if err := c.Bind(&deletionRequest); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
}
err := c.Validate(deletionRequest)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err)
}
s := db.NewSession()
defer s.Close()
err = s.Begin()
if err != nil {
return handler.HandleHTTPError(err, c)
}
u, err := user.GetCurrentUserFromDB(s, c)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.CheckUserPassword(u, deletionRequest.Password)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = user.CancelDeletion(s, u)
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
return handler.HandleHTTPError(err, c)
}
return c.JSON(http.StatusNoContent, models.Message{Message: "Successfully confirmed the deletion request."})
}

View File

@ -18,6 +18,7 @@ package v1
import (
"net/http"
"time"
"code.vikunja.io/api/pkg/user"
@ -32,7 +33,8 @@ import (
type userWithSettings struct {
user.User
Settings *UserSettings `json:"settings"`
Settings *UserSettings `json:"settings"`
DeletionScheduledAt time.Time `json:"deletion_scheduled_at"`
}
// UserShow gets all informations about the current user
@ -71,6 +73,7 @@ func UserShow(c echo.Context) error {
DefaultListID: u.DefaultListID,
WeekStart: u.WeekStart,
},
DeletionScheduledAt: u.DeletionScheduledAt,
}
return c.JSON(http.StatusOK, us)

View File

@ -312,6 +312,13 @@ func registerAPIRoutes(a *echo.Group) {
u.GET("/settings/totp/qrcode", apiv1.UserTOTPQrCode)
}
// User deletion
if config.ServiceEnableUserDeletion.GetBool() {
u.POST("/deletion/request", apiv1.UserRequestDeletion)
u.POST("/deletion/confirm", apiv1.UserConfirmDeletion)
u.POST("/deletion/cancel", apiv1.UserCancelDeletion)
}
listHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.List{}

View File

@ -6315,6 +6315,144 @@ var doc = `{
}
}
},
"/user/deletion/cancel": {
"post": {
"description": "Aborts an in-progress user deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Abort a user deletion request",
"parameters": [
{
"description": "The user password to confirm.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/confirm": {
"post": {
"description": "Confirms the deletion request of a user sent via email.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Confirm a user deletion request",
"parameters": [
{
"description": "The token.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequestConfirm"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad token provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/request": {
"post": {
"description": "Requests the deletion of the current user. It will trigger an email which has to be confirmed to start the deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Request the deletion of the user",
"parameters": [
{
"description": "The user password.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/password": {
"post": {
"security": [
@ -8556,6 +8694,22 @@ var doc = `{
}
}
},
"v1.UserDeletionRequest": {
"type": "object",
"properties": {
"password": {
"type": "string"
}
}
},
"v1.UserDeletionRequestConfirm": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"v1.UserPassword": {
"type": "object",
"properties": {
@ -8695,6 +8849,9 @@ var doc = `{
"totp_enabled": {
"type": "boolean"
},
"user_deletion_enabled": {
"type": "boolean"
},
"version": {
"type": "string"
}

View File

@ -6298,6 +6298,144 @@
}
}
},
"/user/deletion/cancel": {
"post": {
"description": "Aborts an in-progress user deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Abort a user deletion request",
"parameters": [
{
"description": "The user password to confirm.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/confirm": {
"post": {
"description": "Confirms the deletion request of a user sent via email.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Confirm a user deletion request",
"parameters": [
{
"description": "The token.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequestConfirm"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad token provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/deletion/request": {
"post": {
"description": "Requests the deletion of the current user. It will trigger an email which has to be confirmed to start the deletion.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Request the deletion of the user",
"parameters": [
{
"description": "The user password.",
"name": "credentials",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.UserDeletionRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Message"
}
},
"412": {
"description": "Bad password provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/user/password": {
"post": {
"security": [
@ -8539,6 +8677,22 @@
}
}
},
"v1.UserDeletionRequest": {
"type": "object",
"properties": {
"password": {
"type": "string"
}
}
},
"v1.UserDeletionRequestConfirm": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"v1.UserPassword": {
"type": "object",
"properties": {
@ -8678,6 +8832,9 @@
"totp_enabled": {
"type": "boolean"
},
"user_deletion_enabled": {
"type": "boolean"
},
"version": {
"type": "string"
}

View File

@ -1185,6 +1185,16 @@ definitions:
email), `upload`, `initials`, `default`.
type: string
type: object
v1.UserDeletionRequest:
properties:
password:
type: string
type: object
v1.UserDeletionRequestConfirm:
properties:
token:
type: string
type: object
v1.UserPassword:
properties:
new_password:
@ -1284,6 +1294,8 @@ definitions:
type: boolean
totp_enabled:
type: boolean
user_deletion_enabled:
type: boolean
version:
type: string
type: object
@ -5531,6 +5543,97 @@ paths:
summary: Confirm the email of a new user
tags:
- user
/user/deletion/cancel:
post:
consumes:
- application/json
description: Aborts an in-progress user deletion.
parameters:
- description: The user password to confirm.
in: body
name: credentials
required: true
schema:
$ref: '#/definitions/v1.UserDeletionRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Message'
"412":
description: Bad password provided.
schema:
$ref: '#/definitions/web.HTTPError'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
summary: Abort a user deletion request
tags:
- user
/user/deletion/confirm:
post:
consumes:
- application/json
description: Confirms the deletion request of a user sent via email.
parameters:
- description: The token.
in: body
name: credentials
required: true
schema:
$ref: '#/definitions/v1.UserDeletionRequestConfirm'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Message'
"412":
description: Bad token provided.
schema:
$ref: '#/definitions/web.HTTPError'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
summary: Confirm a user deletion request
tags:
- user
/user/deletion/request:
post:
consumes:
- application/json
description: Requests the deletion of the current user. It will trigger an email
which has to be confirmed to start the deletion.
parameters:
- description: The user password.
in: body
name: credentials
required: true
schema:
$ref: '#/definitions/v1.UserDeletionRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Message'
"412":
description: Bad password provided.
schema:
$ref: '#/definitions/web.HTTPError'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
summary: Request the deletion of the user
tags:
- user
/user/password:
post:
consumes:

131
pkg/user/delete.go Normal file
View File

@ -0,0 +1,131 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"time"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/notifications"
"xorm.io/builder"
"xorm.io/xorm"
)
func RegisterDeletionNotificationCron() {
err := cron.Schedule("0 * * * *", notifyUsersScheduledForDeletion)
if err != nil {
log.Errorf("Could not register deletion cron: %s", err.Error())
}
}
func notifyUsersScheduledForDeletion() {
s := db.NewSession()
users := []*User{}
err := s.Where(builder.NotNull{"deletion_scheduled_at"}).
Find(&users)
if err != nil {
log.Errorf("Could not get users scheduled for deletion: %s", err)
return
}
if len(users) == 0 {
return
}
log.Debugf("Found %d users scheduled for deletion", len(users))
for _, user := range users {
if time.Since(user.DeletionLastReminderSent) < time.Hour*24 {
continue
}
var number = 2
if user.DeletionLastReminderSent.IsZero() {
number = 1
}
if user.DeletionScheduledAt.Sub(user.DeletionLastReminderSent) < time.Hour*24 {
number = 3
}
err = notifications.Notify(user, &AccountDeletionNotification{
User: user,
NotificationNumber: number,
})
if err != nil {
log.Errorf("Could not notify user %d of their deletion: %s", user.ID, err)
continue
}
user.DeletionLastReminderSent = time.Now()
_, err = s.Where("id = ?", user.ID).
Cols("deletion_last_reminder_sent").
Update(user)
if err != nil {
log.Errorf("Could update user %d last deletion reminder sent date: %s", user.ID, err)
}
}
}
// RequestDeletion creates a user deletion confirm token and sends a notification to the user
func RequestDeletion(s *xorm.Session, user *User) (err error) {
token, err := generateNewToken(s, user, TokenAccountDeletion)
if err != nil {
return err
}
return notifications.Notify(user, &AccountDeletionConfirmNotification{
User: user,
ConfirmToken: token.Token,
})
}
// ConfirmDeletion ConformDeletion checks a token and schedules the user for deletion
func ConfirmDeletion(s *xorm.Session, user *User, token string) (err error) {
tk, err := getToken(s, token, TokenAccountDeletion)
if err != nil {
return err
}
if tk == nil {
// TODO: return invalid token error
return
}
err = removeTokens(s, user, TokenAccountDeletion)
if err != nil {
return err
}
user.DeletionScheduledAt = time.Now().Add(3 * 24 * time.Hour)
_, err = s.Where("id = ?", user.ID).
Cols("deletion_scheduled_at").
Update(user)
return err
}
// CancelDeletion cancels the deletion of a user
func CancelDeletion(s *xorm.Session, user *User) (err error) {
user.DeletionScheduledAt = time.Time{}
user.DeletionLastReminderSent = time.Time{}
_, err = s.Where("id = ?", user.ID).
Cols("deletion_scheduled_at", "deletion_last_reminder_sent").
Update(user)
return
}

View File

@ -17,6 +17,8 @@
package user
import (
"strconv"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
)
@ -186,3 +188,86 @@ func (n *FailedLoginAttemptNotification) ToDB() interface{} {
func (n *FailedLoginAttemptNotification) Name() string {
return "failed.login.attempt"
}
// AccountDeletionConfirmNotification represents a AccountDeletionConfirmNotification notification
type AccountDeletionConfirmNotification struct {
User *User
ConfirmToken string
}
// ToMail returns the mail notification for AccountDeletionConfirmNotification
func (n *AccountDeletionConfirmNotification) ToMail() *notifications.Mail {
return notifications.NewMail().
Subject("Please confirm the deletion of your Vikunja account").
Greeting("Hi "+n.User.GetName()+",").
Line("You have requested the deletion of your account. To confirm this, please click the link below:").
Action("Confirm the deletion of my account", config.ServiceFrontendurl.GetString()+"?accountDeletionConfirm="+n.ConfirmToken).
Line("This link will be valid for 24 hours.").
Line("Once you confirm the deletion we will schedule the deletion of your account in three days and send you another email until then.").
Line("If you proceed with the deletion of your account, we will remove all of your namespaces, lists and tasks you created. Everything you shared with another user or team will transfer ownership to them.").
Line("If you did not requested the deletion or changed your mind, you can simply ignore this email.").
Line("Have a nice day!")
}
// ToDB returns the AccountDeletionConfirmNotification notification in a format which can be saved in the db
func (n *AccountDeletionConfirmNotification) ToDB() interface{} {
return nil
}
// Name returns the name of the notification
func (n *AccountDeletionConfirmNotification) Name() string {
return "user.deletion.confirm"
}
// AccountDeletionNotification represents a AccountDeletionNotification notification
type AccountDeletionNotification struct {
User *User
NotificationNumber int
}
// ToMail returns the mail notification for AccountDeletionNotification
func (n *AccountDeletionNotification) ToMail() *notifications.Mail {
return notifications.NewMail().
Subject("Your Vikunja account will be deleted in "+strconv.Itoa(n.NotificationNumber)+" days").
Greeting("Hi "+n.User.GetName()+",").
Line("You recently requested the deletion of your Vikunja account.").
Line("We will delete your account in "+strconv.Itoa(n.NotificationNumber)+" days.").
Line("If you changed your mind, simply click the link below to cancel the deletion and follow the instructions there:").
Action("Abort the deletion", config.ServiceFrontendurl.GetString()).
Line("Have a nice day!")
}
// ToDB returns the AccountDeletionNotification notification in a format which can be saved in the db
func (n *AccountDeletionNotification) ToDB() interface{} {
return nil
}
// Name returns the name of the notification
func (n *AccountDeletionNotification) Name() string {
return "user.deletion"
}
// AccountDeletedNotification represents a AccountDeletedNotification notification
type AccountDeletedNotification struct {
User *User
}
// ToMail returns the mail notification for AccountDeletedNotification
func (n *AccountDeletedNotification) ToMail() *notifications.Mail {
return notifications.NewMail().
Subject("Your Vikunja Account has been deleted").
Greeting("Hi " + n.User.GetName() + ",").
Line("As requested, we've deleted your Vikunja account.").
Line("This deletion is permanent. If did not create a backup and need your data back now, talk to your administrator.").
Line("Have a nice day!")
}
// ToDB returns the AccountDeletedNotification notification in a format which can be saved in the db
func (n *AccountDeletedNotification) ToDB() interface{} {
return nil
}
// Name returns the name of the notification
func (n *AccountDeletedNotification) Name() string {
return "user.deleted"
}

View File

@ -33,6 +33,7 @@ const (
TokenUnknown TokenKind = iota
TokenPasswordReset
TokenEmailConfirm
TokenAccountDeletion
tokenSize = 64
)
@ -88,7 +89,7 @@ func RegisterTokenCleanupCron() {
defer s.Close()
deleted, err := s.
Where("created > ? AND kind = ?", time.Now().Add(time.Hour*24*-1), TokenPasswordReset).
Where("created > ? AND (kind = ? OR kind = ?)", time.Now().Add(time.Hour*24*-1), TokenPasswordReset, TokenAccountDeletion).
Delete(&Token{})
if err != nil {
log.Errorf(logPrefix+"Error removing old password reset tokens: %s", err)

View File

@ -95,6 +95,9 @@ type User struct {
DefaultListID int64 `xorm:"bigint null index" json:"-"`
WeekStart int `xorm:"null" json:"-"`
DeletionScheduledAt time.Time `xorm:"datetime null" json:"-"`
DeletionLastReminderSent time.Time `xorm:"datetime null" json:"-"`
// A timestamp when this task was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this task was last updated. You cannot change this value.
@ -367,6 +370,16 @@ func CheckUserPassword(user *User, password string) error {
return nil
}
// GetCurrentUserFromDB gets a user from jwt claims and returns the full user from the db.
func GetCurrentUserFromDB(s *xorm.Session, c echo.Context) (user *User, err error) {
u, err := GetCurrentUser(c)
if err != nil {
return nil, err
}
return GetUserByID(s, u.ID)
}
// GetCurrentUser returns the current user based on its jwt token
func GetCurrentUser(c echo.Context) (user *User, err error) {
jwtinf := c.Get("user").(*jwt.Token)