Merge pull request #204 from writefreely/T319-user-delete-acct

T319 user delete acct
pull/460/head
Matt Baer 4 years ago committed by GitHub
commit affcd270bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 30
      account.go
  2. 1
      admin.go
  3. 28
      app.go
  4. 3
      config/config.go
  5. 2
      go.mod
  6. 4
      go.sum
  7. 10
      key/key.go
  8. 2
      keys.go
  9. 4
      routes.go
  10. 8
      templates/user/admin/app-settings.tmpl
  11. 64
      templates/user/settings.tmpl

@ -20,6 +20,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/gorilla/csrf"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/guregu/null/zero" "github.com/guregu/null/zero"
@ -1082,6 +1083,7 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
HasPass bool HasPass bool
IsLogOut bool IsLogOut bool
Silenced bool Silenced bool
CSRFField template.HTML
OauthSection bool OauthSection bool
OauthAccounts []oauthAccountInfo OauthAccounts []oauthAccountInfo
OauthSlack bool OauthSlack bool
@ -1098,6 +1100,7 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
HasPass: passIsSet, HasPass: passIsSet,
IsLogOut: r.FormValue("logout") == "1", IsLogOut: r.FormValue("logout") == "1",
Silenced: fullUser.IsSilenced(), Silenced: fullUser.IsSilenced(),
CSRFField: csrf.TemplateField(r),
OauthSection: displayOauthSection, OauthSection: displayOauthSection,
OauthAccounts: oauthAccounts, OauthAccounts: oauthAccounts,
OauthSlack: enableOauthSlack, OauthSlack: enableOauthSlack,
@ -1152,6 +1155,32 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
return s return s
} }
func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
if !app.cfg.App.OpenDeletion {
return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."}
}
confirmUsername := r.PostFormValue("confirm-username")
if u.Username != confirmUsername {
return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."}
}
// Check for account deletion safeguards in place
if u.IsAdmin() {
return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."}
}
err := app.db.DeleteAccount(u.ID)
if err != nil {
log.Error("user delete account: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)}
}
// FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset
_ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil)
return impart.HTTPError{http.StatusFound, "/me/logout"}
}
func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error { func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
provider := r.FormValue("provider") provider := r.FormValue("provider")
clientID := r.FormValue("client_id") clientID := r.FormValue("client_id")
@ -1173,6 +1202,7 @@ func prepareUserEmail(input string, emailKey []byte) zero.String {
log.Error("Unable to encrypt email: %s\n", err) log.Error("Unable to encrypt email: %s\n", err)
} else { } else {
email.String = string(encEmail) email.String = string(encEmail)
} }
} }
return email return email

@ -555,6 +555,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
apper.App().cfg.App.SiteDesc = r.FormValue("site_desc") apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
apper.App().cfg.App.Landing = r.FormValue("landing") apper.App().cfg.App.Landing = r.FormValue("landing")
apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on" apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
apper.App().cfg.App.OpenDeletion = r.FormValue("open_deletion") == "on"
mul, err := strconv.Atoi(r.FormValue("min_username_len")) mul, err := strconv.Atoi(r.FormValue("min_username_len"))
if err == nil { if err == nil {
apper.App().cfg.App.MinUsernameLen = mul apper.App().cfg.App.MinUsernameLen = mul

@ -166,6 +166,14 @@ func (app *App) LoadKeys() error {
if debugging { if debugging {
log.Info(" %s", emailKeyPath) log.Info(" %s", emailKeyPath)
} }
executable, err := os.Executable()
if err != nil {
executable = "writefreely"
} else {
executable = filepath.Base(executable)
}
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath) app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
if err != nil { if err != nil {
return err return err
@ -187,6 +195,22 @@ func (app *App) LoadKeys() error {
return err return err
} }
if debugging {
log.Info(" %s", csrfKeyPath)
}
app.keys.CSRFKey, err = ioutil.ReadFile(csrfKeyPath)
if err != nil {
if os.IsNotExist(err) {
log.Error(`Missing key: %s.
Run this command to generate missing keys:
%s keys generate
`, csrfKeyPath, executable)
}
return err
}
return nil return nil
} }
@ -637,6 +661,10 @@ func GenerateKeyFiles(app *App) error {
if err != nil { if err != nil {
keyErrs = err keyErrs = err
} }
err = generateKey(csrfKeyPath)
if err != nil {
keyErrs = err
}
return keyErrs return keyErrs
} }

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2020 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -139,6 +139,7 @@ type (
// Users // Users
SingleUser bool `ini:"single_user"` SingleUser bool `ini:"single_user"`
OpenRegistration bool `ini:"open_registration"` OpenRegistration bool `ini:"open_registration"`
OpenDeletion bool `ini:"open_deletion"`
MinUsernameLen int `ini:"min_username_len"` MinUsernameLen int `ini:"min_username_len"`
MaxBlogs int `ini:"max_blogs"` MaxBlogs int `ini:"max_blogs"`

@ -7,6 +7,7 @@ require (
github.com/go-sql-driver/mysql v1.6.0 github.com/go-sql-driver/mysql v1.6.0
github.com/go-test/deep v1.0.1 // indirect github.com/go-test/deep v1.0.1 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/csrf v1.7.0
github.com/gorilla/feeds v1.1.1 github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0 github.com/gorilla/schema v1.2.0
@ -22,7 +23,6 @@ require (
github.com/microcosm-cc/bluemonday v1.0.5 github.com/microcosm-cc/bluemonday v1.0.5
github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/go-wordwrap v1.0.1
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
github.com/pkg/errors v0.8.1 // indirect
github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect

@ -44,6 +44,8 @@ github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/csrf v1.7.0 h1:mMPjV5/3Zd460xCavIkppUdvnl5fPXMpv2uz2Zyg7/Y=
github.com/gorilla/csrf v1.7.0/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
@ -99,6 +101,8 @@ github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 h1:rAbv2gekFbUcjhUkruwo0vMJ0JqhUgg9tz7t+bxHbN4= github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 h1:rAbv2gekFbUcjhUkruwo0vMJ0JqhUgg9tz7t+bxHbN4=

@ -1,5 +1,5 @@
/* /*
* Copyright © 2019 A Bunch Tell LLC. * Copyright © 2019, 2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -20,7 +20,7 @@ const (
) )
type Keychain struct { type Keychain struct {
EmailKey, CookieAuthKey, CookieKey []byte EmailKey, CookieAuthKey, CookieKey, CSRFKey []byte
} }
// GenerateKeys generates necessary keys for the app on the given Keychain, // GenerateKeys generates necessary keys for the app on the given Keychain,
@ -47,6 +47,12 @@ func (keys *Keychain) GenerateKeys() error {
keyErrs = err keyErrs = err
} }
} }
if len(keys.CSRFKey) == 0 {
keys.CSRFKey, err = GenerateBytes(EncKeysBytes)
if err != nil {
keyErrs = err
}
}
return keyErrs return keyErrs
} }

@ -26,6 +26,7 @@ var (
emailKeyPath = filepath.Join(keysDir, "email.aes256") emailKeyPath = filepath.Join(keysDir, "email.aes256")
cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256") cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256")
cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256") cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256")
csrfKeyPath = filepath.Join(keysDir, "csrf.aes256")
) )
// InitKeys loads encryption keys into memory via the given Apper interface // InitKeys loads encryption keys into memory via the given Apper interface
@ -42,6 +43,7 @@ func initKeyPaths(app *App) {
emailKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, emailKeyPath) emailKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, emailKeyPath)
cookieAuthKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieAuthKeyPath) cookieAuthKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieAuthKeyPath)
cookieKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieKeyPath) cookieKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieKeyPath)
csrfKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, csrfKeyPath)
} }
// generateKey generates a key at the given path used for the encryption of // generateKey generates a key at the given path used for the encryption of

@ -16,6 +16,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/gorilla/csrf"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/go-webfinger" "github.com/writeas/go-webfinger"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
@ -98,6 +99,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET") me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET") me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET") me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(handleUserDelete))).Methods("POST")
me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET") me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET") me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET") me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
@ -106,7 +108,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET") me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET") me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
me.HandleFunc("/import", handler.User(viewImport)).Methods("GET") me.HandleFunc("/import", handler.User(viewImport)).Methods("GET")
me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET") me.Path("/settings").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(viewSettings))).Methods("GET")
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET") me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET") me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")

@ -75,6 +75,14 @@ select {
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_registration" id="open_registration" {{if .Config.OpenRegistration}}checked="checked"{{end}} /> <div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_registration" id="open_registration" {{if .Config.OpenRegistration}}checked="checked"{{end}} />
</div> </div>
</div> </div>
<div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="open_deletion">
Allow account deletion
<p>Allow all users to delete their account. Admins can always delete users.</p>
</label></div>
<div{{if .Config.SingleUser}} class="invisible"{{end}}><input type="checkbox" name="open_deletion" id="open_deletion" {{if .Config.OpenDeletion}}checked="checked"{{end}} />
</div>
</div>
<div class="features row"> <div class="features row">
<div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites"> <div{{if .Config.SingleUser}} class="invisible"{{end}}><label for="user_invites">
Allow invitations from... Allow invitations from...

@ -3,6 +3,9 @@
<style type="text/css"> <style type="text/css">
.option { margin: 2em 0em; } .option { margin: 2em 0em; }
h2 {
margin-top: 2.5em;
}
h3 { font-weight: normal; } h3 { font-weight: normal; }
.section p, .section label { .section p, .section label {
font-size: 0.86em; font-size: 0.86em;
@ -11,8 +14,13 @@ h3 { font-weight: normal; }
max-height: 2.75em; max-height: 2.75em;
vertical-align: middle; vertical-align: middle;
} }
.modal {
position: fixed;
}
</style> </style>
<div class="content-container snug"> <div class="content-container snug">
<div id="overlay"></div>
{{if .Silenced}} {{if .Silenced}}
{{template "user-silenced"}} {{template "user-silenced"}}
{{end}} {{end}}
@ -76,8 +84,6 @@ h3 { font-weight: normal; }
{{end}} {{end}}
{{ if .OauthSection }} {{ if .OauthSection }}
<hr />
{{ if .OauthAccounts }} {{ if .OauthAccounts }}
<div class="option"> <div class="option">
<h2>Linked Accounts</h2> <h2>Linked Accounts</h2>
@ -151,8 +157,41 @@ h3 { font-weight: normal; }
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ if and .OpenDeletion (not .IsAdmin) }}
<h2>Incinerator</h2>
<div class="alert danger">
<div class="row">
<div>
<h3>Delete your account</h3>
<p>Permanently erase all your data, with no way to recover it.</p>
</div>
<button class="cta danger" onclick="prepareDeleteUser()">Delete your account...</button>
</div>
</div>
{{end}}
</div>
<div id="modal-delete-user" class="modal">
<h2>Are you sure?</h2>
<div class="body">
<p style="text-align:left">This action <strong>cannot</strong> be undone. It will immediately and permanently erase your account, including your blogs and posts. Before continuing, you might want to <a href="/me/export">export your data</a>.</p>
<p>If you're sure, please type <strong>{{.Username}}</strong> to confirm.</p>
<ul id="delete-errors" class="errors"></ul>
<form action="/me/delete" method="post" onsubmit="confirmDeletion()">
{{ .CSRFField }}
<input id="confirm-text" placeholder="{{.Username}}" type="text" class="confirm boxy" name="confirm-username" style="margin-top: 0.5em;" />
<div style="text-align:right; margin-top: 1em;">
<a id="cancel-delete" style="margin-right:2em" href="#">Cancel</a>
<input class="danger" type="submit" id="confirm-delete" value="Delete your account" disabled />
</div>
</div>
</div> </div>
<script src="/js/h.js"></script>
<script src="/js/modals.js"></script>
<script> <script>
var showChecks = document.querySelectorAll('input.show'); var showChecks = document.querySelectorAll('input.show');
for (var i=0; i<showChecks.length; i++) { for (var i=0; i<showChecks.length; i++) {
@ -165,6 +204,27 @@ for (var i=0; i<showChecks.length; i++) {
} }
}); });
} }
{{ if and .OpenDeletion (not .IsAdmin) }}
H.getEl('cancel-delete').on('click', closeModals);
let $confirmDelBtn = document.getElementById('confirm-delete');
let $confirmText = document.getElementById('confirm-text')
$confirmText.addEventListener('input', function() {
$confirmDelBtn.disabled = this.value !== '{{.Username}}'
});
function prepareDeleteUser() {
$confirmText.value = ''
showModal('delete-user')
$confirmText.focus()
}
function confirmDeletion() {
$confirmDelBtn.disabled = true
$confirmDelBtn.value = 'Deleting...'
}
{{ end }}
</script> </script>
{{template "footer" .}} {{template "footer" .}}

Loading…
Cancel
Save