Files
selfpost/internal/web/handlers/handlers_users.go
T
mixandClaude Opus 5.5 b8e156c9f4 password-reset: a confirmed e-mail, then a link to reset the password
Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.

Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).

internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).

Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.

Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.

Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:03:27 +03:00

523 lines
19 KiB
Go

package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
"golang.org/x/crypto/bcrypt"
)
// userFormView is what the user form is drawn with: a refusal, and the values
// the form holds — the stored user's, or what was typed into a form that was
// refused.
type userFormView struct {
FormErr string
FormUsername string
FormEmail string
FormRole string
Reach store.Reach
}
// HandleUsers lists panel users (global only).
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
p, ok := h.requireGlobal(w, r)
if !ok {
return
}
rows, err := h.store.ListUserRows()
if err != nil {
logf("panel: list users: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
list := make([]view.UserRow, len(rows))
for i, row := range rows {
u := row.User
list[i] = view.NewUserRow(view.UserRowInput{
ID: u.ID, Username: u.Username, Email: u.Email, Global: u.Role == store.RoleGlobal, You: u.ID == p.ID,
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
Outbound: row.DomainNames, Inbound: row.InboundDomainNames,
})
}
page := view.NewUsers(h.shellMeta(r), h.cfg.InboundEnabled, usersFlash(r)).WithRows(list)
h.view.Render(w, http.StatusOK, "users", page)
}
func usersFlash(r *http.Request) string {
switch r.URL.Query().Get("done") {
case "created":
return "User created."
case "updated":
return "User updated."
case "deleted":
return "User deleted."
default:
return ""
}
}
// HandleUserNew creates a panel user (global only).
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
// The routes are method-bound (GET and POST), so it is one or the other.
if r.Method == http.MethodPost {
h.submitUserCreate(w, r)
return
}
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
}
// HandleUserEdit edits or deletes a panel user (global only).
func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// The routes are method-bound (GET and POST), so it is one or the other.
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
h.submitUserUpdate(w, r, u)
return
}
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
FormUsername: u.Username,
FormEmail: u.Email,
FormRole: string(u.Role),
Reach: u.Reach(),
})
}
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, form userFormView) {
domains, err := h.store.ListDomains()
if err != nil {
logf("panel: user form: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in := view.UserFormInput{
ID: userID, Username: form.FormUsername, Email: form.FormEmail, Role: form.FormRole,
RoleLocked: lastGlobalLocked(h, userID, form.FormRole),
PasswordMin: validate.MinAdminPasswordLen,
ShowInbound: h.cfg.InboundEnabled,
AllOut: form.Reach.AllDomains, AllIn: form.Reach.AllInbound,
Error: form.FormErr,
}
// The head names the user as stored, not as typed into a form that was
// refused; Delete user is not offered for the only global user.
if userID != 0 {
in.Name = form.FormUsername
if u, err := h.store.GetUser(userID); err == nil {
in.Name = u.Username
}
in.CanDelete = !in.RoleLocked
}
out := idSet(form.Reach.DomainIDs)
for _, d := range domains {
in.OutDomains = append(in.OutDomains, view.DomainChoice{ID: d.ID, Name: d.Name, Checked: out[d.ID]})
}
// The two lists of the form, each with its All tick. Inbound is offered
// only where the feature is on.
if h.cfg.InboundEnabled {
inboundDomains, err := h.store.ListInboundDomains()
if err != nil {
logf("panel: user form: list inbound domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
inb := idSet(form.Reach.InboundDomainIDs)
for _, d := range inboundDomains {
in.InDomains = append(in.InDomains, view.DomainChoice{ID: d.ID, Name: d.Name, Checked: inb[d.ID]})
}
}
page := view.NewUserForm(h.shellMeta(r), in)
if userID != 0 {
if u, err := h.store.GetUser(userID); err == nil {
page.WithEmailState(u.Email, !u.EmailConfirmed.IsZero())
}
}
h.view.Render(w, status, "user", page)
}
// lastGlobalLocked is whether the form's user is the only global one, whose role
// cannot be changed (the form shows it read-only).
func lastGlobalLocked(h *Handlers, userID int64, formRole string) bool {
if userID == 0 || formRole != string(store.RoleGlobal) {
return false
}
n, err := h.store.CountGlobalUsers()
return err == nil && n <= 1
}
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
email := strings.TrimSpace(r.PostFormValue("email"))
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
reach := h.reachFromForm(r, store.Reach{})
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if err := validate.AccountEmail(email); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if role == store.RoleDomain && reach.Empty() {
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
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
logf("panel: create user hash: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
return
}
// The user, the e-mail and the reach in one transaction: a failure leaves
// no user rather than half of one (audit 2026-10-12, M9).
id, err := h.store.AddUser(store.UserChange{Username: username, PasswordHash: string(hash), Email: email, Role: role, Reach: reach})
if err != nil {
if errors.Is(err, store.ErrUserExists) {
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if errors.Is(err, store.ErrNoSuchDomain) {
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "A domain chosen here has been deleted meanwhile. Choose again.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
logf("panel: create user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
return
}
// An address typed for a user is unconfirmed until that user opens its
// link (roadmap password-reset).
if email != "" {
h.sendConfirmation(id)
}
http.Redirect(w, r, "/server/users?done=created", http.StatusSeeOther)
}
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
if err := r.ParseForm(); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
// The form always sends the e-mail, empty when cleared. A post without the
// field at all is not a request to clear it.
email := u.Email
if _, sent := r.PostForm["email"]; sent {
email = strings.TrimSpace(r.PostFormValue("email"))
}
password := r.PostFormValue("password")
role := store.Role(r.PostFormValue("role"))
reach := h.reachFromForm(r, u.Reach())
if username == "" {
username = u.Username
}
if err := validate.Username(username); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if err := validate.AccountEmail(email); err != nil {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if role != store.RoleGlobal && role != store.RoleDomain {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if role == store.RoleDomain && reach.Empty() {
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
lastGlobal := func() {
msg := "Cannot demote the last global administrator."
if u.ID == p.ID {
msg = "You cannot demote yourself without another global administrator."
}
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: msg, FormUsername: username, FormEmail: email, FormRole: string(u.Role), Reach: reach})
}
// Asked here to answer before a password is hashed; SaveUser decides,
// inside its transaction.
if u.Role == store.RoleGlobal && role == store.RoleDomain {
n, err := h.store.CountGlobalUsers()
if err != nil || n <= 1 {
lastGlobal()
return
}
}
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
}
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
logf("panel: update user hash: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
hash = string(newHash)
}
// Name, password, e-mail, role and reach in one transaction (audit
// 2026-10-12, M9). Sessions end only once it has committed.
err := h.store.SaveUser(store.UserChange{ID: u.ID, Username: username, PasswordHash: hash, Email: email, Role: role, Reach: reach})
switch {
case errors.Is(err, store.ErrUserExists):
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
case errors.Is(err, store.ErrLastGlobal):
lastGlobal()
return
case errors.Is(err, store.ErrNoSuchDomain):
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "A domain chosen here has been deleted meanwhile. Choose again.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
case err != nil:
logf("panel: update user: %v", err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
if password != "" {
h.endSessionsAfterPasswordSet(r, p, u)
h.endResetLinks(u.ID)
}
if email != "" && email != u.Email {
h.sendConfirmation(u.ID)
}
http.Redirect(w, r, "/server/users?done=updated", http.StatusSeeOther)
}
// 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.
func (h *Handlers) endSessionsAfterPasswordSet(r *http.Request, by auth.Principal, u store.User) {
if u.ID == by.ID {
if token, ok := h.auth.SessionToken(r); ok {
h.auth.DestroyOtherSessions(u.ID, token)
return
}
}
h.auth.DestroyUserSessions(u.ID)
}
// endResetLinks removes the reset links of a user whose password was just set
// on the user form: a password set another way leaves none behind it.
func (h *Handlers) endResetLinks(userID int64) {
if err := h.store.DeleteUserLinks(userID, store.LinkReset); err != nil {
logf("panel: user form: %v", err)
}
}
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
// removed — the same pattern as HandleDeleteConfirm for domains, so a single
// mis-click on Delete cannot remove a user (P3, code-review.md).
func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
in := view.UserDeleteInput{
ID: u.ID, Username: u.Username, Global: u.Role == store.RoleGlobal,
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
}
if !in.Global {
if in.Outbound, in.Inbound, err = h.assignedNames(u); err != nil {
logf("panel: delete user %d: assigned domains: %v", u.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
}
// assignedNames names the domains a domain user is assigned one by one, outbound
// and inbound: what deleting the user takes away. A direction the user holds as
// All has no names.
func (h *Handlers) assignedNames(u store.User) (outbound, inbound []string, err error) {
if len(u.DomainIDs) > 0 && !u.AllDomains {
domains, err := h.store.ListDomains()
if err != nil {
return nil, nil, err
}
assigned := idSet(u.DomainIDs)
for _, d := range domains {
if assigned[d.ID] {
outbound = append(outbound, d.Name)
}
}
}
if len(u.InboundDomainIDs) > 0 && !u.AllInboundDomains {
domains, err := h.store.ListInboundDomains()
if err != nil {
return nil, nil, err
}
assigned := idSet(u.InboundDomainIDs)
for _, d := range domains {
if assigned[d.ID] {
inbound = append(inbound, d.Name)
}
}
}
return outbound, inbound, nil
}
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
// and returns to the user list.
func (h *Handlers) HandleUserDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
uid, ok := parseUserID(w, r)
if !ok {
return
}
u, err := h.store.GetUser(uid)
if err != nil {
if errors.Is(err, store.ErrUserNotFound) {
http.NotFound(w, r)
return
}
logf("panel: get user %d: %v", uid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.submitUserDelete(w, r, u)
}
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if u.ID == p.ID {
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
}
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)})
return
}
logf("panel: delete user %d: %v", u.ID, err)
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
return
}
http.Redirect(w, r, "/server/users?done=deleted", http.StatusSeeOther)
}
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
// reachFromForm reads the two assignment lists of the user form. With an All
// box ticked the rows under it are ignored — the form works without scripts,
// so both may arrive — and All then also covers domains added later. Where
// inbound is switched off the form has no inbound list, and what the user
// already had there (current) is kept rather than wiped by an unrelated save.
func (h *Handlers) reachFromForm(r *http.Request, current store.Reach) store.Reach {
reach := store.Reach{
AllDomains: r.PostFormValue("all_domains") != "",
AllInbound: current.AllInbound, InboundDomainIDs: current.InboundDomainIDs,
}
if !reach.AllDomains {
reach.DomainIDs = parseIDs(r, "domain_ids")
}
if h.cfg.InboundEnabled {
reach.AllInbound = r.PostFormValue("all_inbound_domains") != ""
reach.InboundDomainIDs = nil
if !reach.AllInbound {
reach.InboundDomainIDs = parseIDs(r, "inbound_domain_ids")
}
}
return reach
}
func parseIDs(r *http.Request, field string) []int64 {
var ids []int64
seen := make(map[int64]bool)
for _, v := range r.PostForm[field] {
id, err := strconv.ParseInt(v, 10, 64)
if err == nil && id > 0 && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids
}
func idSet(ids []int64) map[int64]bool {
m := make(map[int64]bool, len(ids))
for _, id := range ids {
m[id] = true
}
return m
}