User account deletion (#937)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/937 Co-authored-by: konrad <konrad@kola-entertainments.de> Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
parent
cd21c5fc6e
commit
27119ad6d4
28 changed files with 1402 additions and 41 deletions
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -95,6 +95,8 @@ func FullInit() {
|
|||
models.RegisterReminderCron()
|
||||
models.RegisterOverdueReminderCron()
|
||||
user.RegisterTokenCleanupCron()
|
||||
user.RegisterDeletionNotificationCron()
|
||||
models.RegisterUserDeletionCron()
|
||||
|
||||
// Start processing events
|
||||
go func() {
|
||||
|
|
|
@ -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:"-"`
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
46
pkg/migration/20210802081716.go
Normal file
46
pkg/migration/20210802081716.go
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
if len(listIDs) == 0 {
|
||||
return events.Dispatch(&NamespaceDeletedEvent{
|
||||
Namespace: n,
|
||||
Doer: a,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete tasks
|
||||
_, err = s.In("list_id", listIDs).Delete(&Task{})
|
||||
// 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
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 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
275
pkg/models/user_delete.go
Normal 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
|
||||
}
|
47
pkg/models/user_delete_test.go
Normal file
47
pkg/models/user_delete_test.go
Normal 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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
46
pkg/notifications/testing.go
Normal file
46
pkg/notifications/testing.go
Normal 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.")
|
||||
}
|
|
@ -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(),
|
||||
|
|
201
pkg/routes/api/v1/user_deletion.go
Normal file
201
pkg/routes/api/v1/user_deletion.go
Normal 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."})
|
||||
}
|
|
@ -18,6 +18,7 @@ package v1
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
|
@ -33,6 +34,7 @@ import (
|
|||
type userWithSettings struct {
|
||||
user.User
|
||||
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)
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
131
pkg/user/delete.go
Normal 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
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue