This implements changing passwords for users by the admin or users themselves. Feature request initially at https://github.com/bouncepaw/mycorrhiza/issues/211 Does not implement the well-known endpoint for changing passwords, as the spec says "It is provided for discussion only and may change at any moment." (https://w3c.github.io/webappsec-change-password-url/) The code quality is very much not up to scratch, so suggest improvements if there is any. The code has been tested and it functions. Jackson (4): implement changing user password function move form errors out of change group thing implement admin form to change a user's password implement user facing password change page admin/admin.go | 43 +++++++++++++++++++++ admin/view.go | 5 +++ admin/view_edit_user.html | 24 +++++++++++- settings/settings.go | 62 ++++++++++++++++++++++++++++++ settings/view.go | 47 ++++++++++++++++++++++ settings/view_change_password.html | 37 ++++++++++++++++++ user/user.go | 15 ++++++++ web/web.go | 6 +++ 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 settings/settings.go create mode 100644 settings/view.go create mode 100644 settings/view_change_password.html -- 2.43.0
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.sr.ht/~bouncepaw/mycorrhiza-devel/patches/47102/mbox | git am -3Learn more about email & git
--- user/user.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/user/user.go b/user/user.go index 2854e6d..72a2c25 100644 --- a/user/user.go +++ b/user/user.go @@ -1,6 +1,7 @@ package user import ( + "fmt" "net/http" "strings" "sync" @@ -135,6 +136,20 @@ func (user *User) ShowLockMaybe(w http.ResponseWriter, rq *http.Request) bool { return false } +// Sets a new password for the user. +func (user *User) ChangePassword(password string) error { + if user.Source != "local" { + return fmt.Errorf("Only local users can change their passwords.") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + user.Password = string(hash) + return SaveUserDatabase() +} + // IsValidUsername checks if the given username is valid. func IsValidUsername(username string) bool { for _, r := range username { -- 2.43.0
there are multiple form fields now, so the error could apply to any one of the forms --- admin/view_edit_user.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/view_edit_user.html b/admin/view_edit_user.html index 6d804a9..c6d6993 100644 --- a/admin/view_edit_user.html +++ b/admin/view_edit_user.html @@ -7,8 +7,6 @@ {{.U.Name}} </h1> - <h2>{{block "change group" .}}Change group{{end}}</h2> - {{if .Form.HasError}} <div class="notice notice--error"> <strong>{{template "error"}}:</strong> @@ -16,6 +14,8 @@ </div> {{end}} + <h2>{{block "change group" .}}Change group{{end}}</h2> + <form action="" method="post"> <div class="form-field"> <select id="group" name="group" aria-label="{{block "group" .}}Group{{end}}"> -- 2.43.0
--- Russian strings left untranslated. admin/admin.go | 43 +++++++++++++++++++++++++++++++++++++++ admin/view.go | 5 +++++ admin/view_edit_user.html | 20 ++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/admin/admin.go b/admin/admin.go index ef05f05..ef5ac3d 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -93,6 +93,49 @@ func handlerAdminUserEdit(w http.ResponseWriter, rq *http.Request) { viewEditUser(viewutil.MetaFrom(w, rq), f, u) } +func handlerAdminUserChangePassword(w http.ResponseWriter, rq *http.Request) { + vars := mux.Vars(rq) + u := user.ByName(vars["username"]) + if u == nil { + util.HTTP404Page(w, "404 page not found") + return + } + + f := util.FormDataFromRequest(rq, []string{"password", "password_confirm"}) + + password := f.Get("password") + passwordConfirm := f.Get("password_confirm") + // server side validation + if password == "" { + err := fmt.Errorf("passwords should not be empty") + f = f.WithError(err) + } + if password == passwordConfirm { + previousPassword := u.Password // for rollback + if err := u.ChangePassword(password); err != nil { + f = f.WithError(err) + } else { + if err := user.SaveUserDatabase(); err != nil { + u.Password = previousPassword + f = f.WithError(err) + } else { + http.Redirect(w, rq, "/admin/users/", http.StatusSeeOther) + return + } + } + } else { + err := fmt.Errorf("passwords do not match") + f = f.WithError(err) + } + + if f.HasError() { + w.WriteHeader(http.StatusBadRequest) + } + w.Header().Set("Content-Type", mime.TypeByExtension(".html")) + + viewEditUser(viewutil.MetaFrom(w, rq), f, u) +} + func handlerAdminUserDelete(w http.ResponseWriter, rq *http.Request) { vars := mux.Vars(rq) u := user.ByName(vars["username"]) diff --git a/admin/view.go b/admin/view.go index d3ce063..e7537c9 100644 --- a/admin/view.go +++ b/admin/view.go @@ -10,6 +10,7 @@ import ( "net/http" ) +// TODO: translate some untranslated strings const adminTranslationRu = ` {{define "panel title"}}Панель админстратора{{end}} {{define "panel safe section title"}}Безопасная секция{{end}} @@ -33,6 +34,9 @@ const adminTranslationRu = ` {{define "new user"}}Новый пользователь{{end}} {{define "password"}}Пароль{{end}} +{{define "confirm password"}}Confirm password{{end}} +{{define "change password"}}Change password{{end}} +{{define "non local password change"}}Non-local accounts cannot have their passwords changed.{{end}} {{define "create"}}Создать{{end}} {{define "change group"}}Изменить группу{{end}} @@ -57,6 +61,7 @@ func Init(rtr *mux.Router) { rtr.HandleFunc("/new-user", handlerAdminUserNew).Methods(http.MethodGet, http.MethodPost) rtr.HandleFunc("/users/{username}/edit", handlerAdminUserEdit).Methods(http.MethodGet, http.MethodPost) + rtr.HandleFunc("/users/{username}/change-password", handlerAdminUserChangePassword).Methods(http.MethodPost) rtr.HandleFunc("/users/{username}/delete", handlerAdminUserDelete).Methods(http.MethodGet, http.MethodPost) rtr.HandleFunc("/users", handlerAdminUsers) diff --git a/admin/view_edit_user.html b/admin/view_edit_user.html index c6d6993..63d99f4 100644 --- a/admin/view_edit_user.html +++ b/admin/view_edit_user.html @@ -33,6 +33,26 @@ </div> </form> + <h2>{{block "change password" .}}Change password{{end}}</h2> + + {{if eq .U.Source "local"}} + <form action="/admin/users/{{.U.Name}}/change-password" method="post"> + <div class="form-field"> + <label for="pass">{{block "password" .}}Password{{end}}</label> + <input required type="password" autocomplete="new-password" id="pass" name="password"> + <br> + <label for="pass_confirm">{{block "confirm password" .}}Confirm password{{end}}</label> + <input required type="password" autocomplete="new-password" id="pass_confirm" name="password_confirm"> + </div> + + <div class="form-field"> + <input class="btn" type="submit" value='{{block "submit" .}}Submit{{end}}'> + </div> + </form> + {{else}} + <p>{{block "non local password change" .}}Non-local accounts cannot have their passwords changed.{{end}}</p> + {{end}} + <h2>{{block "delete user" .}}Delete user{{end}}</h2> <p>{{block "delete user tip" .}}Remove the user from the database. Changes made by the user will be preserved. It will be possible to take this username later.{{end}}</p> <a class="btn btn_destructive" href="/admin/users/{{.U.Name}}/delete">{{template "delete"}}</a> -- 2.43.0
similar to the admin password change, but with a few changes: - require current password verification the following still included: - empty password check - confirm password check --- Russian strings left untranslated. settings/settings.go | 62 ++++++++++++++++++++++++++++++ settings/view.go | 47 ++++++++++++++++++++++ settings/view_change_password.html | 37 ++++++++++++++++++ web/web.go | 6 +++ 4 files changed, 152 insertions(+) create mode 100644 settings/settings.go create mode 100644 settings/view.go create mode 100644 settings/view_change_password.html diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..fbb3eca --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,62 @@ +package settings + +import ( + "fmt" + "mime" + "net/http" + "reflect" + + "github.com/bouncepaw/mycorrhiza/viewutil" + + "github.com/bouncepaw/mycorrhiza/user" + "github.com/bouncepaw/mycorrhiza/util" +) + +func handlerUserChangePassword(w http.ResponseWriter, rq *http.Request) { + u := user.FromRequest(rq) + // TODO: is there a better way? + if reflect.DeepEqual(u, user.EmptyUser()) || u == nil { + util.HTTP404Page(w, "404 page not found") + return + } + + f := util.FormDataFromRequest(rq, []string{"current_password", "password", "password_confirm"}) + currentPassword := f.Get("current_password") + + if user.CredentialsOK(u.Name, currentPassword) { + password := f.Get("password") + passwordConfirm := f.Get("password_confirm") + // server side validation + if password == "" { + err := fmt.Errorf("passwords should not be empty") + f = f.WithError(err) + } + if password == passwordConfirm { + previousPassword := u.Password // for rollback + if err := u.ChangePassword(password); err != nil { + f = f.WithError(err) + } else { + if err := user.SaveUserDatabase(); err != nil { + u.Password = previousPassword + f = f.WithError(err) + } else { + http.Redirect(w, rq, "/", http.StatusSeeOther) + return + } + } + } else { + err := fmt.Errorf("passwords do not match") + f = f.WithError(err) + } + } else { + err := fmt.Errorf("incorrect password") + f = f.WithError(err) + } + + if f.HasError() { + w.WriteHeader(http.StatusBadRequest) + } + w.Header().Set("Content-Type", mime.TypeByExtension(".html")) + + changePasswordPage(viewutil.MetaFrom(w, rq), f, u) +} diff --git a/settings/view.go b/settings/view.go new file mode 100644 index 0000000..338447c --- /dev/null +++ b/settings/view.go @@ -0,0 +1,47 @@ +package settings + +import ( + "embed" + "github.com/bouncepaw/mycorrhiza/util" + "github.com/bouncepaw/mycorrhiza/viewutil" + "github.com/gorilla/mux" + "net/http" + + "github.com/bouncepaw/mycorrhiza/user" +) + +// TODO: translate untranslated strings +const settingsTranslationRu = ` +{{define "change password"}}Change password{{end}} +{{define "confirm password"}}Confirm password{{end}} +{{define "current password"}}Current password{{end}} +{{define "non local password change"}}Non-local accounts cannot have their passwords changed.{{end}} +{{define "password"}}Password{{end}} +{{define "submit"}}Submit{{end}} +` + +var ( + //go:embed *.html + fs embed.FS + changePassowrdChain viewutil.Chain +) + +func Init(rtr *mux.Router) { + rtr.HandleFunc("/change-password", handlerUserChangePassword).Methods(http.MethodGet, http.MethodPost) + + changePassowrdChain = viewutil.CopyEnRuWith(fs, "view_change_password.html", settingsTranslationRu) +} + +func changePasswordPage(meta viewutil.Meta, form util.FormData, u *user.User) { + viewutil.ExecutePage(meta, changePassowrdChain, changePasswordData{ + BaseData: &viewutil.BaseData{}, + Form: form, + U: u, + }) +} + +type changePasswordData struct { + *viewutil.BaseData + Form util.FormData + U *user.User +} diff --git a/settings/view_change_password.html b/settings/view_change_password.html new file mode 100644 index 0000000..26a15af --- /dev/null +++ b/settings/view_change_password.html @@ -0,0 +1,37 @@ +{{/* TODO: translate title? */}} +{{define "title"}}Change password{{end}} +{{define "body"}} + <main class="main-width form-wrap"> + {{if .Form.HasError}} + <div class="notice notice--error"> + <strong>{{template "error"}}:</strong> + {{.Form.Error}} + </div> + {{end}} + + <h2>{{block "change password" .}}Change password{{end}}</h2> + + {{if eq .U.Source "local"}} + <form action="/settings/change-password" method="post"> + <div class="form-field"> + <label for="current_pass">{{block "current password" .}}Current password{{end}}</label> + <input required type="password" autocomplete="current-password" id="current_pass" name="current_password"> + <br> + <br> + <label for="pass">{{block "password" .}}Password{{end}}</label> + <input required type="password" autocomplete="new-password" id="pass" name="password"> + <br> + <br> + <label for="pass_confirm">{{block "confirm password" .}}Confirm password{{end}}</label> + <input required type="password" autocomplete="new-password" id="pass_confirm" name="password_confirm"> + </div> + + <div class="form-field"> + <input class="btn" type="submit" value='{{block "submit" .}}Submit{{end}}'> + </div> + </form> + {{else}} + <p>{{block "non local password change" .}}Non-local accounts cannot have their passwords changed.{{end}}</p> + {{end}} + </main> +{{end}} diff --git a/web/web.go b/web/web.go index 299f9e5..3ebfc14 100644 --- a/web/web.go +++ b/web/web.go @@ -7,6 +7,7 @@ import ( "net/url" "github.com/bouncepaw/mycorrhiza/admin" + "github.com/bouncepaw/mycorrhiza/settings" "github.com/bouncepaw/mycorrhiza/auth" "github.com/bouncepaw/mycorrhiza/backlinks" "github.com/bouncepaw/mycorrhiza/categories" @@ -67,6 +68,11 @@ func Handler() http.Handler { adminRouter := wikiRouter.PathPrefix("/admin").Subrouter() adminRouter.Use(groupMiddleware("admin")) admin.Init(adminRouter) + + settingsRouter := wikiRouter.PathPrefix("/settings").Subrouter() + // TODO: check if necessary? + //settingsRouter.Use(groupMiddleware("settings")) + settings.Init(settingsRouter) } // Index page -- 2.43.0