~bouncepaw/mycorrhiza-devel

implement changing password v1 APPLIED

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
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH 1/4] implement changing user password function Export this patch

---
 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

[PATCH 2/4] move form errors out of change group thing Export this patch

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

[PATCH 3/4] implement admin form to change a user's password Export this patch

---
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

[PATCH 4/4] implement user facing password change page Export this patch

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