user-reauth: the user form asks for your account password (audit A2)
Security audit 2026-10-17, finding A2 (high), stage 0; the mockup is
2c27275. A stolen global session could set its own password on the user
form - no current password asked, the session kept - and then pass the
backup's reauth with it, or add a second global user with a known
password. Now the user form calls reauth (the backup's check: bcrypt
against the signed-in user's own hash, under auth.AllowPasswordChange) when
it adds a user and when a save sets a password, changes the e-mail (reset
links go there) or changes the role, the own account included; deleting a
user asks too, refused on its own confirmation page (renderUserDelete, a
Flash on UserDelete). Name and reach alone are not asked. Missing or wrong:
401 on the same page, "Your account password is incorrect.", nothing saved.
Tests: TestUserFormAsksForTheAccountPassword (create, each of password,
e-mail, role, own password, reach alone, delete); the user-form tests send
the field (withAccountPassword gives root a real hash and h an auth
module). go vet, go test ./internal/web/... on Windows.
Docs: security.md § Backup and domain export, guide § Users, help word for
word with the mockup; CHANGELOG [Unreleased] Security; plan line ticked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -353,11 +353,12 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
|
||||
out, _ := st.AddDomain("out.example.com", "mail")
|
||||
in, _ := st.AddInboundDomain("in.example.com")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
withAccountPassword(t, h, st, root)
|
||||
const password = "a-long-enough-password-1"
|
||||
|
||||
create := func(username string, extra url.Values) (*httptest.ResponseRecorder, store.User) {
|
||||
t.Helper()
|
||||
form := url.Values{"username": {username}, "password": {password}, "role": {"domain"}}
|
||||
form := url.Values{"username": {username}, "password": {password}, "role": {"domain"}, "account_password": {testAccountPassword}}
|
||||
for k, v := range extra {
|
||||
form[k] = v
|
||||
}
|
||||
@@ -385,6 +386,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
|
||||
update := func(u store.User, form url.Values) store.User {
|
||||
t.Helper()
|
||||
form.Set("username", u.Username)
|
||||
form.Set("account_password", testAccountPassword)
|
||||
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, form)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("update %s = %d:\n%s", u.Username, rec.Code, rec.Body.String())
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestDeletingAUserLeavesDomainAddressesAndNamesNobody(t *testing.T) {
|
||||
lacks(t, name+" for the co-administrator", body, `boss-private@example.org`, `admin's`, `s default`, `Another user`)
|
||||
}
|
||||
|
||||
rec := send(s.h.HandleUserDelete, &globalPrincipal, "POST", "/server/users/"+idStr(ops.ID)+"/delete", map[string]string{"uid": idStr(ops.ID)}, url.Values{})
|
||||
rec := send(s.h.HandleUserDelete, &globalPrincipal, "POST", "/server/users/"+idStr(ops.ID)+"/delete", map[string]string{"uid": idStr(ops.ID)}, url.Values{"account_password": {"correct-password-here!"}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("deleting a user = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ func TestUserFormSendsANewAddressItsLink(t *testing.T) {
|
||||
ml := withMail(t, h)
|
||||
out1, _ := st.AddDomain("one.example.com", "mail")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
withAccountPassword(t, h, st, root)
|
||||
|
||||
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
|
||||
"username": {"ops"}, "email": {"ops@acme.io"}, "password": {"a-long-enough-password-1"}, "role": {"domain"},
|
||||
"domain_ids": {idStr(out1.ID)},
|
||||
"domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create = %d:\n%s", rec.Code, rec.Body.String())
|
||||
@@ -42,7 +43,7 @@ func TestUserFormSendsANewAddressItsLink(t *testing.T) {
|
||||
}
|
||||
// The same address saved again keeps its mark and sends nothing; a new
|
||||
// one loses it and is sent its link.
|
||||
form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "email": {"ops@acme.io"}}
|
||||
form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "email": {"ops@acme.io"}, "account_password": {testAccountPassword}}
|
||||
if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther || len(sentBy(h, ml)) != 1 {
|
||||
t.Fatalf("the same address: %d, sent %d", rec.Code, len(sentBy(h, ml)))
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ func TestUserFormSetsTheAccountEmail(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
out1, _ := st.AddDomain("one.example.com", "mail")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
withAccountPassword(t, h, st, root)
|
||||
|
||||
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
|
||||
"username": {"ops"}, "email": {" ops@acme.io "}, "password": {"a-long-enough-password-1"}, "role": {"domain"},
|
||||
"domain_ids": {idStr(out1.ID)},
|
||||
"domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create with an e-mail = %d:\n%s", rec.Code, rec.Body.String())
|
||||
@@ -32,7 +33,7 @@ func TestUserFormSetsTheAccountEmail(t *testing.T) {
|
||||
path, pv := "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}
|
||||
post := func(extra url.Values) store.User {
|
||||
t.Helper()
|
||||
form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}}
|
||||
form := url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword}}
|
||||
for k, v := range extra {
|
||||
form[k] = v
|
||||
}
|
||||
@@ -54,7 +55,7 @@ func TestUserFormSetsTheAccountEmail(t *testing.T) {
|
||||
|
||||
// Creating without one is fine.
|
||||
rec = send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, url.Values{
|
||||
"username": {"plain"}, "password": {"a-long-enough-password-1"}, "role": {"global"}})
|
||||
"username": {"plain"}, "password": {"a-long-enough-password-1"}, "role": {"global"}, "account_password": {testAccountPassword}})
|
||||
if plain, _ := st.GetUserByUsername("plain"); rec.Code != http.StatusSeeOther || plain.Email != "" {
|
||||
t.Errorf("create without an e-mail = %d, %q", rec.Code, plain.Email)
|
||||
}
|
||||
|
||||
@@ -286,6 +286,7 @@ func TestUserDeletePageNamesTheAssignmentsAndRefusalsStayOnTheForm(t *testing.T)
|
||||
out1, _ := st.AddDomain("out.example.com", "mail")
|
||||
in1, _ := st.AddInboundDomain("in.example.com")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
withAccountPassword(t, h, st, root)
|
||||
u := userWith(t, st, "shop", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}, InboundDomainIDs: []int64{in1.ID}})
|
||||
|
||||
path := "/server/users/" + idStr(u.ID) + "/delete"
|
||||
@@ -294,6 +295,7 @@ func TestUserDeletePageNamesTheAssignmentsAndRefusalsStayOnTheForm(t *testing.T)
|
||||
`<h1 class="title is-3">Delete shop</h1>`, `<div class="box sp-danger-zone">`,
|
||||
`remove the assignments to <strong>out.example.com</strong> (outbound) and to <strong>in.example.com</strong> (inbound).`,
|
||||
`<form method="post" action="` + path + `">`, `href="/server/users/` + idStr(u.ID) + `">Keep it`,
|
||||
`name="account_password" type="password" autocomplete="current-password" required`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the delete page is missing %q", want)
|
||||
@@ -309,7 +311,7 @@ func TestUserDeletePageNamesTheAssignmentsAndRefusalsStayOnTheForm(t *testing.T)
|
||||
if _, err := st.GetUser(root.ID); err != nil {
|
||||
t.Errorf("the refused delete removed the user: %v", err)
|
||||
}
|
||||
if rec := send(h.HandleUserDelete, &root, "POST", path, map[string]string{"uid": idStr(u.ID)}, url.Values{}); rec.Code != http.StatusSeeOther {
|
||||
if rec := send(h.HandleUserDelete, &root, "POST", path, map[string]string{"uid": idStr(u.ID)}, url.Values{"account_password": {testAccountPassword}}); rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("deleting another user = %d", rec.Code)
|
||||
}
|
||||
if _, err := st.GetUser(u.ID); err == nil {
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestAccountPasswordSignsOutOnlyTheUsersOtherSessions(t *testing.T) {
|
||||
// An administrator who sets another user's password on the user form takes
|
||||
// away every login that user had under the old one.
|
||||
func TestUserFormPasswordSignsOutThatUsersSessions(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h, password := settingsServer(t)
|
||||
admin := globalPrincipal
|
||||
bob := addUser(t, h, "bob", "bobs-long-password-1")
|
||||
adminCurrent := signedIn(t, h, "admin", "admin-current")
|
||||
@@ -110,7 +110,7 @@ func TestUserFormPasswordSignsOutThatUsersSessions(t *testing.T) {
|
||||
bob2 := signedIn(t, h, "bob", "bob-2")
|
||||
|
||||
rec := postWithSession(h.HandleUserEdit, admin, adminCurrent, "/server/users/"+idStr(bob.ID), map[string]string{"uid": idStr(bob.ID)},
|
||||
url.Values{"username": {"bob"}, "role": {"global"}, "password": {"a-brand-new-long-password"}})
|
||||
url.Values{"username": {"bob"}, "role": {"global"}, "password": {"a-brand-new-long-password"}, "account_password": {password}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST user form = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -125,12 +125,12 @@ func TestUserFormPasswordSignsOutThatUsersSessions(t *testing.T) {
|
||||
|
||||
// Saving the form without a new password does not sign anyone out.
|
||||
func TestUserFormWithoutAPasswordKeepsTheSessions(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h, password := settingsServer(t)
|
||||
bob := addUser(t, h, "bob", "bobs-long-password-1")
|
||||
bob1 := signedIn(t, h, "bob", "bob-1")
|
||||
|
||||
rec := postWithSession(h.HandleUserEdit, globalPrincipal, "no-session", "/server/users/"+idStr(bob.ID), map[string]string{"uid": idStr(bob.ID)},
|
||||
url.Values{"username": {"bob"}, "role": {"global"}, "email": {"bob@example.org"}})
|
||||
url.Values{"username": {"bob"}, "role": {"global"}, "email": {"bob@example.org"}, "account_password": {password}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST user form = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -142,12 +142,12 @@ func TestUserFormWithoutAPasswordKeepsTheSessions(t *testing.T) {
|
||||
// An administrator who sets their own password through the user form is signed
|
||||
// out everywhere except in the browser they are using.
|
||||
func TestUserFormOwnPasswordKeepsTheCurrentSession(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h, password := settingsServer(t)
|
||||
current := signedIn(t, h, "admin", "admin-current")
|
||||
other := signedIn(t, h, "admin", "admin-other")
|
||||
|
||||
rec := postWithSession(h.HandleUserEdit, globalPrincipal, current, "/server/users/1", map[string]string{"uid": "1"},
|
||||
url.Values{"username": {"admin"}, "role": {"global"}, "password": {"a-brand-new-long-password"}})
|
||||
url.Values{"username": {"admin"}, "role": {"global"}, "password": {"a-brand-new-long-password"}, "account_password": {password}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST user form = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -203,6 +203,12 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
// A new user is an account whose password the creator knows: a session
|
||||
// alone must not be enough to make one (audit 2026-10-17, A2).
|
||||
if status, msg := h.reauth(r, "create user"); status != 0 {
|
||||
h.renderUserForm(w, r, status, 0, userFormView{FormErr: msg, FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
@@ -293,12 +299,25 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
}
|
||||
}
|
||||
|
||||
hash := "" // empty keeps the stored hash (SaveUser)
|
||||
if password != "" {
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
}
|
||||
// A password, an e-mail (where reset links go) or a role set here is a way
|
||||
// to an account, the signed-in one's own included: it asks for the
|
||||
// signed-in user's password, as a backup does (audit 2026-10-17, A2).
|
||||
// Name and reach alone do not.
|
||||
if needsReauth(u, password, email, role) {
|
||||
if status, msg := h.reauth(r, "user "+strconv.FormatInt(u.ID, 10)); status != 0 {
|
||||
h.renderUserForm(w, r, status, u.ID, userFormView{FormErr: msg, FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash := "" // empty keeps the stored hash (SaveUser)
|
||||
if password != "" {
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
logf("panel: update user hash: %v", err)
|
||||
@@ -336,6 +355,12 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
http.Redirect(w, r, "/server/users?done=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// needsReauth is whether a user-form save changes what reaches the account:
|
||||
// its password, its e-mail (reset links go there) or its role.
|
||||
func needsReauth(u store.User, password, email string, role store.Role) bool {
|
||||
return password != "" || email != u.Email || role != u.Role
|
||||
}
|
||||
|
||||
// endSessionsAfterPasswordSet signs out the logins a user had under the old
|
||||
// password when an administrator sets a new one on the user form. An
|
||||
// administrator who sets their own password keeps the browser they are using.
|
||||
@@ -378,6 +403,13 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderUserDelete(w, r, http.StatusOK, u, "")
|
||||
}
|
||||
|
||||
// renderUserDelete draws the confirmation, with a refusal above it when
|
||||
// errMsg is set.
|
||||
func (h *Handlers) renderUserDelete(w http.ResponseWriter, r *http.Request, status int, u store.User, errMsg string) {
|
||||
var err error
|
||||
in := view.UserDeleteInput{
|
||||
ID: u.ID, Username: u.Username, Global: u.Role == store.RoleGlobal,
|
||||
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
|
||||
@@ -389,7 +421,8 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
|
||||
in.Error = errMsg
|
||||
h.view.Render(w, status, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
|
||||
}
|
||||
|
||||
// assignedNames names the domains a domain user is assigned one by one, outbound
|
||||
@@ -456,6 +489,12 @@ func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u st
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
// Removing an administrator asks for the signed-in user's password: a
|
||||
// session alone must not lock the others out (audit 2026-10-17, A2).
|
||||
if status, msg := h.reauth(r, "delete user "+strconv.FormatInt(u.ID, 10)); status != 0 {
|
||||
h.renderUserDelete(w, r, status, u, msg)
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteUser(u.ID); err != nil {
|
||||
if errors.Is(err, store.ErrLastGlobal) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// testAccountPassword is the signed-in user's own password in the user-form
|
||||
// tests: what reauth compares account_password with.
|
||||
const testAccountPassword = "the-signed-in-users-password"
|
||||
|
||||
// withAccountPassword gives p's stored user testAccountPassword and h the
|
||||
// auth module whose limiter reauth uses (the user form asks for the
|
||||
// signed-in user's password, audit 2026-10-17 A2).
|
||||
func withAccountPassword(t *testing.T, h *Handlers, st *store.Store, p auth.Principal) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(testAccountPassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := st.GetUser(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpdateUser(u.ID, u.Username, string(hash), u.Email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.auth == nil {
|
||||
h.auth = auth.New(st, auth.Config{}, h.view, filepath.Join(t.TempDir(), "setup-token"))
|
||||
}
|
||||
}
|
||||
|
||||
// A session alone is not enough to hand out an account (audit 2026-10-17,
|
||||
// A2): adding a user, and a save that sets a password, an e-mail or a role -
|
||||
// the signed-in user's own included - ask for the signed-in user's password;
|
||||
// so does deleting a user. Missing or wrong, it is 401 on the same page and
|
||||
// nothing changes; a save of the name and the reach alone is not asked.
|
||||
func TestUserFormAsksForTheAccountPassword(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
out, _ := st.AddDomain("out.example.com", "mail")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
withAccountPassword(t, h, st, root)
|
||||
const newPassword = "a-long-enough-password-1"
|
||||
refused := func(what string, rec interface {
|
||||
Result() *http.Response
|
||||
}, body string) {
|
||||
t.Helper()
|
||||
if code := rec.Result().StatusCode; code != http.StatusUnauthorized || !strings.Contains(body, "Your account password is incorrect.") {
|
||||
t.Errorf("%s = %d, want 401 with the refusal:\n%.800s", what, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Create: refused without and with a wrong password, the form kept.
|
||||
form := url.Values{"username": {"ops"}, "password": {newPassword}, "role": {"domain"}, "domain_ids": {idStr(out.ID)}}
|
||||
for _, pw := range []string{"", "wrong-password-entirely"} {
|
||||
form.Set("account_password", pw)
|
||||
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, form)
|
||||
refused("create with account password "+pw, rec, rec.Body.String())
|
||||
if !strings.Contains(rec.Body.String(), `value="ops"`) || !strings.Contains(rec.Body.String(), `name="account_password" type="password" autocomplete="current-password" required`) {
|
||||
t.Errorf("the refused create form lost the name or the field:\n%.800s", rec.Body.String())
|
||||
}
|
||||
if u, _ := st.GetUserByUsername("ops"); u.ID != 0 {
|
||||
t.Fatalf("a user was created with account password %q", pw)
|
||||
}
|
||||
}
|
||||
form.Set("account_password", testAccountPassword)
|
||||
if rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, form); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create with the right password = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ops, _ := st.GetUserByUsername("ops")
|
||||
|
||||
// Update: each of password, e-mail, role is asked; the own password too.
|
||||
edit := func(u store.User, f url.Values) (int, string) {
|
||||
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, f)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
base := func() url.Values {
|
||||
return url.Values{"username": {"ops"}, "role": {"domain"}, "domain_ids": {idStr(out.ID)}, "email": {""}}
|
||||
}
|
||||
for name, change := range map[string]func(url.Values){
|
||||
"password": func(f url.Values) { f.Set("password", "another-long-password-2") },
|
||||
"e-mail": func(f url.Values) { f.Set("email", "thief@example.net") },
|
||||
"role": func(f url.Values) { f.Set("role", "global") },
|
||||
} {
|
||||
f := base()
|
||||
change(f)
|
||||
code, body := edit(ops, f)
|
||||
if code != http.StatusUnauthorized || !strings.Contains(body, "Your account password is incorrect.") {
|
||||
t.Errorf("a %s change without the account password = %d:\n%.600s", name, code, body)
|
||||
}
|
||||
if got, _ := st.GetUser(ops.ID); got.PasswordHash != ops.PasswordHash || got.Email != ops.Email || got.Role != ops.Role {
|
||||
t.Errorf("a refused %s change changed the user: %+v", name, got)
|
||||
}
|
||||
}
|
||||
ownForm := url.Values{"username": {"root"}, "role": {"global"}, "password": {"the-thiefs-own-password"}}
|
||||
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(root.ID), map[string]string{"uid": idStr(root.ID)}, ownForm)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("setting one's own password without the current one = %d", rec.Code)
|
||||
}
|
||||
if got, _ := st.GetUser(root.ID); bcrypt.CompareHashAndPassword([]byte(got.PasswordHash), []byte(testAccountPassword)) != nil {
|
||||
t.Error("a refused own-password change replaced the password")
|
||||
}
|
||||
|
||||
// Name and reach alone: not asked.
|
||||
if code, body := edit(ops, base()); code != http.StatusSeeOther {
|
||||
t.Errorf("a save of the reach alone = %d:\n%.600s", code, body)
|
||||
}
|
||||
// With the password: the change goes through.
|
||||
f := base()
|
||||
f.Set("password", "another-long-password-2")
|
||||
f.Set("account_password", testAccountPassword)
|
||||
if code, body := edit(ops, f); code != http.StatusSeeOther {
|
||||
t.Errorf("a password change with the account password = %d:\n%.600s", code, body)
|
||||
}
|
||||
|
||||
// Delete: refused on its own page, then done with the password.
|
||||
path, pv := "/server/users/"+idStr(ops.ID)+"/delete", map[string]string{"uid": idStr(ops.ID)}
|
||||
rec = send(h.HandleUserDelete, &root, "POST", path, pv, url.Values{"account_password": {"wrong-password-entirely"}})
|
||||
refused("delete with a wrong password", rec, rec.Body.String())
|
||||
if !strings.Contains(rec.Body.String(), "Delete ops") || !strings.Contains(rec.Body.String(), `name="account_password"`) {
|
||||
t.Errorf("the refused delete is not the confirmation page:\n%.800s", rec.Body.String())
|
||||
}
|
||||
if u, _ := st.GetUser(ops.ID); u.ID == 0 {
|
||||
t.Fatal("a refused delete removed the user")
|
||||
}
|
||||
if rec := send(h.HandleUserDelete, &root, "POST", path, pv, url.Values{"account_password": {testAccountPassword}}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete with the right password = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,10 @@ func TestUserFormSavesTheWholeChange(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
out1, _ := st.AddDomain("out.example.com", "mail")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
withAccountPassword(t, h, st, root)
|
||||
u := userWith(t, st, "shop", store.RoleDomain, store.Reach{DomainIDs: []int64{out1.ID}})
|
||||
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
|
||||
url.Values{"username": {"shop2"}, "email": {"shop@example.com"}, "role": {"global"}})
|
||||
url.Values{"username": {"shop2"}, "email": {"shop@example.com"}, "role": {"global"}, "account_password": {testAccountPassword}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -36,7 +37,7 @@ func TestUserFormSavesTheWholeChange(t *testing.T) {
|
||||
|
||||
// And back to a domain user, with an assignment.
|
||||
rec = send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
|
||||
url.Values{"username": {"shop2"}, "email": {"shop@example.com"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}})
|
||||
url.Values{"username": {"shop2"}, "email": {"shop@example.com"}, "role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -85,9 +86,10 @@ func TestUserFormCreatesWithEmailAndReach(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
out1, _ := st.AddDomain("out.example.com", "mail")
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
withAccountPassword(t, h, st, root)
|
||||
rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil,
|
||||
url.Values{"username": {"fresh"}, "password": {"a-long-enough-password"}, "email": {"fresh@example.com"},
|
||||
"role": {"domain"}, "domain_ids": {idStr(out1.ID)}})
|
||||
"role": {"domain"}, "domain_ids": {idStr(out1.ID)}, "account_password": {testAccountPassword}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user