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>
This commit is contained in:
mix
2026-10-17 12:03:27 +03:00
co-authored by Claude Opus 5.5
parent 09edc0fac6
commit b8e156c9f4
43 changed files with 1966 additions and 61 deletions
+21 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/mixeme/selfpost/internal/store"
@@ -33,6 +34,16 @@ type Module struct {
loginLimiter *rateLimiter
setupLimiter *rateLimiter
passwordLimiter *rateLimiter
// resetLimiter counts the requests for a reset link per client address,
// linkLimiter the requests a link's page gets per client address, and
// mailLimiter the links mailed to one user's address (links.go).
resetLimiter *rateLimiter
linkLimiter *rateLimiter
mailLimiter *rateLimiter
mailer Mailer
// mailing counts the links being mailed after their answer, so a stop
// waits for them (Wait) before the database closes.
mailing sync.WaitGroup
trustedProxies []*net.IPNet
}
@@ -56,11 +67,20 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
// out of signing in (audit 2026-10-12, L15), and the one guessing
// is whoever holds that user's session, wherever from.
passwordLimiter: newRateLimiter(10, 15*time.Minute),
trustedProxies: cfg.TrustedProxyCIDRs,
resetLimiter: newRateLimiter(5, 15*time.Minute),
linkLimiter: newRateLimiter(30, 15*time.Minute),
// Five links an hour to one address, of either kind: three resets
// asked for and a couple of confirmations is ordinary use, a mailbox
// full of them is not.
mailLimiter: newRateLimiter(5, time.Hour),
trustedProxies: cfg.TrustedProxyCIDRs,
}
m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper()
m.passwordLimiter.startSweeper()
m.resetLimiter.startSweeper()
m.linkLimiter.startSweeper()
m.mailLimiter.startSweeper()
// Made now rather than at the first unknown name, which would otherwise
// pay for two bcrypt runs (review of audit stage 4).
go noSuchUserHash()
+1 -1
View File
@@ -88,7 +88,7 @@ func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
m.renderLogin(w, http.StatusOK, "")
m.view.Render(w, http.StatusOK, "login", view.NewLogin(m.cfg.Hostname, "").WithInfo(loginInfo(r)))
case http.MethodPost:
m.submitLogin(w, r)
default:
+402
View File
@@ -0,0 +1,402 @@
package auth
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
"golang.org/x/crypto/bcrypt"
)
// The one-time links of password-reset (owner, 2026-10-13: a reset link goes
// only to a confirmed address; 2026-10-17: notifications go to one confirmed
// or not). A link is 256 random bits in a mail; the database keeps its
// SHA-256 only, as a session's (store.CreateUserLink), so a copy of the
// database or a backup opens nothing. Its address is the server's own name:
// the Host a request names is the requester's to choose, and a reset link
// built from it would carry the token to whichever host they chose.
const (
confirmLinkLife = 24 * time.Hour
resetLinkLife = 30 * time.Minute
// resetSpacing is how long after a reset link another is not sent: the
// one in the mailbox works, and a stranger repeating a username cannot
// spend the account's links an hour in a minute (security review,
// 2026-10-17).
resetSpacing = 10 * time.Minute
)
// later runs f after the answer, counted for Wait.
func (m *Module) later(f func()) {
m.mailing.Add(1)
go func() {
defer m.mailing.Done()
f()
}()
}
// Wait waits, until ctx ends, for the links still being mailed: the panel's
// stop calls it before the database closes.
func (m *Module) Wait(ctx context.Context) {
done := make(chan struct{})
go func() {
m.mailing.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
logf("panel: stopped with a link still being mailed")
}
}
// Mailer sends the panel's own mail (notify.Mailer).
type Mailer interface {
Send(to, subject, body string) error
}
// errNoMail is a link that cannot be sent: no address, no mailer, or no name
// to put in it.
var errNoMail = errors.New("no address to send a link to")
// SetMailer gives the module the mailer its links go out by.
func (m *Module) SetMailer(ml Mailer) { m.mailer = ml }
// CanMail reports whether a link can be sent at all: a mailer, and the
// server's name to put in it.
func (m *Module) CanMail() bool { return m.mailer != nil && m.cfg.Hostname != "" }
// linkURL is the address of a link: https:// the server's own name, the path
// and the token.
func (m *Module) linkURL(path, token string) string {
return "https://" + m.cfg.Hostname + path + token
}
// AllowConfirmationLink says whether another confirmation link may be sent
// to the user's address now: five an hour, whoever asks (the user from
// Account, the global administrator from the user form) - a link is a mail
// to that address, and nobody is to fill a mailbox with them.
func (m *Module) AllowConfirmationLink(userID int64) bool {
return m.mailLimiter.Allow("confirm:" + strconv.FormatInt(userID, 10))
}
// SendConfirmation mails u's address a link that confirms it, replacing the
// link sent before. The caller has asked AllowConfirmationLink.
func (m *Module) SendConfirmation(u store.User) error {
if u.Email == "" || m.mailer == nil || m.cfg.Hostname == "" {
return errNoMail
}
token := randomToken(32)
now := time.Now()
if err := m.store.CreateUserLink(hashToken(token), u.ID, store.LinkConfirm, u.Email, now, now.Add(confirmLinkLife)); err != nil {
return err
}
// The account is not named: the address may be a mistyped one, a
// stranger's (security review, 2026-10-17); confirming also takes being
// signed in as the account (HandleConfirm).
body := "The address " + u.Email + " was given for an account of the SelfPost panel at " + m.cfg.Hostname +
". Sign in to that account, then open this link to confirm it:\n\n" + m.linkURL("/confirm/", token) +
"\n\nIt works once, for 24 hours. A confirmed address can be sent a link to reset the account's password. " +
"If you did not expect this message, ignore it: nothing changes.\n"
if err := m.mailer.Send(u.Email, "[SelfPost] Confirm your e-mail", body); err != nil {
return err
}
logf("panel: user %d: a confirmation link was sent", u.ID)
return nil
}
// SendConfirmationLater is SendConfirmation after the answer: a stuck
// Postfix does not hold the form that saved the address (sendmail's own
// time-out is 20 s). A link that cannot go is logged, and Account offers to
// send it again.
func (m *Module) SendConfirmationLater(u store.User) {
m.later(func() {
if err := m.SendConfirmation(u); err != nil {
logf("panel: user %d: confirmation link not sent: %v", u.ID, err)
}
})
}
// HandleReset serves /login/reset: the form that asks for a reset link (GET)
// and the request (POST). The answer to a request is the sign-in page with
// the same words whatever the name - one that exists or not, with a
// confirmed address or not - and the link, when there is one, goes out after
// the answer, so the time to answer tells nothing either.
func (m *Module) HandleReset(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
m.view.Render(w, http.StatusOK, "login-reset", view.NewReset(m.cfg.Hostname, ""))
case http.MethodPost:
if !m.resetLimiter.Allow(clientIP(r, m.trustedProxies)) {
m.view.Render(w, http.StatusTooManyRequests, "login-reset", view.NewReset(m.cfg.Hostname, "Too many attempts. Please wait and try again."))
return
}
if err := r.ParseForm(); err != nil {
m.view.Render(w, http.StatusBadRequest, "login-reset", view.NewReset(m.cfg.Hostname, "Invalid form submission."))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
m.later(func() { m.sendReset(username) })
http.Redirect(w, r, "/login?reset=sent", http.StatusSeeOther)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// sendReset mails the account named username a reset link if its address is
// confirmed, at most three an hour.
func (m *Module) sendReset(username string) {
if username == "" || m.mailer == nil || m.cfg.Hostname == "" {
return
}
u, err := m.store.GetUserByUsername(username)
if err != nil {
if !errors.Is(err, store.ErrUserNotFound) {
logf("panel: reset: get user: %v", err)
}
return
}
if u.Email == "" || u.EmailConfirmed.IsZero() {
logf("panel: user %d asked for a reset link and has no confirmed e-mail; none was sent", u.ID)
return
}
if l, err := m.store.LatestUserLink(u.ID, store.LinkReset, time.Now()); err == nil && time.Since(l.CreatedAt) < resetSpacing {
logf("panel: user %d: a reset link went less than %s ago; none was sent", u.ID, resetSpacing)
return
}
if !m.mailLimiter.Allow("reset:" + strconv.FormatInt(u.ID, 10)) {
logf("panel: user %d: reset links asked for too often; none was sent", u.ID)
return
}
token := randomToken(32)
now := time.Now()
if err := m.store.CreateUserLink(hashToken(token), u.ID, store.LinkReset, u.Email, now, now.Add(resetLinkLife)); err != nil {
logf("panel: user %d: reset link: %v", u.ID, err)
return
}
body := "Someone asked to reset the password of the SelfPost account " + u.Username + " at " + m.cfg.Hostname +
". Open this link to set a new one:\n\n" + m.linkURL("/login/reset/", token) +
"\n\nIt works once, for 30 minutes. If it was not you, ignore this message: the password stays as it is.\n"
if err := m.mailer.Send(u.Email, "[SelfPost] Reset your password", body); err != nil {
logf("panel: user %d: reset link not sent: %v", u.ID, err)
return
}
logf("panel: user %d: a reset link was sent", u.ID)
}
// linkToken is the token of a link path under prefix; "" for anything else.
func linkToken(path, prefix string) string {
token := strings.TrimPrefix(path, prefix)
if token == path || token == "" || strings.Contains(token, "/") {
return ""
}
return token
}
const deadLink = "This link has expired or was already used."
// HandleResetLink serves /login/reset/<token>: the new password's form (GET)
// and setting it (POST). Setting it takes the link, ends every session of the
// account and every other reset link, and leaves the person at sign-in.
func (m *Module) HandleResetLink(w http.ResponseWriter, r *http.Request) {
if !m.linkLimiter.Allow(clientIP(r, m.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := linkToken(r.URL.Path, "/login/reset/")
if token == "" {
http.NotFound(w, r)
return
}
dead := func() {
m.view.Render(w, http.StatusNotFound, "login-reset", view.NewReset(m.cfg.Hostname, deadLink+" Ask for a new one."))
}
switch r.Method {
case http.MethodGet:
l, err := m.store.PeekUserLink(hashToken(token), store.LinkReset, time.Now())
if err != nil {
dead()
return
}
u, ok := m.linkUser(l)
if !ok {
dead()
return
}
m.view.Render(w, http.StatusOK, "login-reset-set", view.NewResetSet(m.cfg.Hostname, u.Username, token, ""))
case http.MethodPost:
m.setPasswordFromLink(w, r, token, dead)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// linkUser is the user of a reset link while the link still holds: the
// account's address is the one it was sent to, and confirmed.
func (m *Module) linkUser(l store.UserLink) (store.User, bool) {
u, err := m.store.GetUser(l.UserID)
if err != nil {
return store.User{}, false
}
return u, u.Email == l.Email && !u.EmailConfirmed.IsZero()
}
func (m *Module) setPasswordFromLink(w http.ResponseWriter, r *http.Request, token string, dead func()) {
l, err := m.store.PeekUserLink(hashToken(token), store.LinkReset, time.Now())
if err != nil {
dead()
return
}
u, ok := m.linkUser(l)
if !ok {
dead()
return
}
fail := func(status int, msg string) {
m.view.Render(w, status, "login-reset-set", view.NewResetSet(m.cfg.Hostname, u.Username, token, msg))
}
if err := r.ParseForm(); err != nil {
fail(http.StatusBadRequest, "Invalid form submission.")
return
}
password := r.PostFormValue("new_password")
if password != r.PostFormValue("new_password_confirm") {
fail(http.StatusBadRequest, "Passwords do not match.")
return
}
if err := validate.AdminPassword(password); err != nil {
fail(http.StatusBadRequest, err.Error())
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
logf("panel: reset: hashing password failed: %v", err)
fail(http.StatusInternalServerError, "Internal error. Please try again.")
return
}
// Taken only now, so a mistyped repeat does not use the link up; taken
// and the password set in one transaction, which also checks the
// address again (store.TakeResetLink): of two requests with the link one
// sets it, and a write that fails leaves the link to try again.
if _, err := m.store.TakeResetLink(hashToken(token), string(hash), time.Now()); err != nil {
if errors.Is(err, store.ErrNoLink) {
dead()
return
}
logf("panel: reset: set password of user %d: %v", u.ID, err)
fail(http.StatusInternalServerError, "Internal error. Please try again.")
return
}
m.sessions.DestroyAll(u.ID)
logf("panel: user %d set a new password from a reset link; every session was ended", u.ID)
http.Redirect(w, r, "/login?reset=done", http.StatusSeeOther)
}
// HandleConfirm serves /confirm/<token>: the address and a button (GET), and
// confirming it (POST). The token proves its holder reads the mailbox; being
// signed in as the account proves the mailbox is the account's - without it
// a mistyped address would hand the account to whoever reads it, by a reset
// link (security review, 2026-10-17).
func (m *Module) HandleConfirm(w http.ResponseWriter, r *http.Request) {
if !m.linkLimiter.Allow(clientIP(r, m.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := linkToken(r.URL.Path, "/confirm/")
if token == "" {
http.NotFound(w, r)
return
}
dead := func(msg string) {
p := view.NewConfirm(m.cfg.Hostname, "", "", "")
p.Error = msg
m.view.Render(w, http.StatusNotFound, "confirm-email", p)
}
signIn := func(w http.ResponseWriter) {
p := view.NewConfirm(m.cfg.Hostname, "", "", "")
p.Error = "Sign in to the account in this browser, then open the link again."
p.SignIn = true
m.view.Render(w, http.StatusForbidden, "confirm-email", p)
}
switch r.Method {
case http.MethodGet:
l, err := m.store.PeekUserLink(hashToken(token), store.LinkConfirm, time.Now())
if err != nil {
dead(deadLink + " Send a new one from Account.")
return
}
u, err := m.store.GetUser(l.UserID)
if err != nil || u.Email != l.Email {
dead("The account's e-mail has changed since this link was sent.")
return
}
if !m.signedInAs(r, l.UserID) {
signIn(w)
return
}
m.view.Render(w, http.StatusOK, "confirm-email", view.NewConfirm(m.cfg.Hostname, u.Username, u.Email, token))
case http.MethodPost:
// Looked at first and taken only for its own account's session, so
// a stranger's press does not use the link up.
l, err := m.store.PeekUserLink(hashToken(token), store.LinkConfirm, time.Now())
if err != nil {
dead(deadLink + " Send a new one from Account.")
return
}
if !m.signedInAs(r, l.UserID) {
signIn(w)
return
}
if l, err = m.store.TakeUserLink(hashToken(token), store.LinkConfirm, time.Now()); err != nil {
dead(deadLink + " Send a new one from Account.")
return
}
ok, err := m.store.ConfirmEmail(l.UserID, l.Email, time.Now())
if err != nil {
logf("panel: confirm: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
dead("The account's e-mail has changed since this link was sent.")
return
}
u, _ := m.store.GetUser(l.UserID)
logf("panel: user %d confirmed their e-mail", l.UserID)
p := view.NewConfirm(m.cfg.Hostname, u.Username, l.Email, "")
p.Done = true
m.view.Render(w, http.StatusOK, "confirm-email", p)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// signedInAs reports whether the request carries a live session of userID.
func (m *Module) signedInAs(r *http.Request, userID int64) bool {
token, ok := m.sessionToken(r)
if !ok {
return false
}
id, ok := m.sessions.Lookup(token)
return ok && id == userID
}
// loginInfo is the sign-in page's success flash for what brought the person
// back to it.
func loginInfo(r *http.Request) string {
switch r.URL.Query().Get("reset") {
case "sent":
return "If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes."
case "done":
return "Password set. Sign in with it."
}
return ""
}
+301
View File
@@ -0,0 +1,301 @@
package auth
import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// sentMail is one message a recordingMailer was given.
type sentMail struct{ to, subject, body string }
// recordingMailer keeps what would have been sent.
type recordingMailer struct{ sent chan sentMail }
func (r *recordingMailer) Send(to, subject, body string) error {
r.sent <- sentMail{to, subject, body}
return nil
}
// next is the next message, or none within a moment: a reset link goes out
// after the answer.
func (r *recordingMailer) next(t *testing.T) (sentMail, bool) {
t.Helper()
select {
case m := <-r.sent:
return m, true
case <-time.After(500 * time.Millisecond):
return sentMail{}, false
}
}
// linkModule is a panel named mail.example.org with the administrator of
// moduleWithAdmin, its address admin@example.org, confirmed when confirmed.
func linkModule(t *testing.T, confirmed bool) (*Module, *recordingMailer) {
t.Helper()
m := moduleWithAdmin(t)
m.cfg.Hostname = "mail.example.org"
ml := &recordingMailer{sent: make(chan sentMail, 8)}
m.SetMailer(ml)
u, err := m.store.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if err := m.store.UpdateUser(u.ID, u.Username, u.PasswordHash, "admin@example.org"); err != nil {
t.Fatal(err)
}
if confirmed {
if ok, err := m.store.ConfirmEmail(u.ID, "admin@example.org", time.Now()); err != nil || !ok {
t.Fatalf("confirm: %v %v", ok, err)
}
}
return m, ml
}
func postForm(h http.HandlerFunc, target, remoteAddr string, form url.Values) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
h(rec, r)
return rec
}
func get(h http.HandlerFunc, target string) *httptest.ResponseRecorder {
return getAs(h, target, "")
}
// getAs is get with the session cookie of session, none for "".
func getAs(h http.HandlerFunc, target, session string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, target, nil)
r.RemoteAddr = "192.0.2.1:1234"
if session != "" {
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: session})
}
rec := httptest.NewRecorder()
h(rec, r)
return rec
}
// postAs is a POST of no fields with the session cookie of session.
func postAs(h http.HandlerFunc, target, session string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, target, nil)
r.RemoteAddr = "192.0.2.1:1234"
if session != "" {
r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: session})
}
rec := httptest.NewRecorder()
h(rec, r)
return rec
}
var resetLinkRe = regexp.MustCompile(`https://mail\.example\.org/login/reset/([A-Za-z0-9_-]{43})\n`)
// The answer is the same for every name; only an account with a confirmed
// address is sent a link, and the link names the server, whatever Host the
// request named.
func TestResetSendsALinkOnlyToAConfirmedAddress(t *testing.T) {
for _, c := range []struct {
name string
confirmed bool
username string
mail bool
}{
{"unconfirmed", false, "admin", false},
{"unknown name", true, "nobody", false},
{"confirmed", true, "admin", true},
} {
t.Run(c.name, func(t *testing.T) {
m, ml := linkModule(t, c.confirmed)
r := httptest.NewRequest(http.MethodPost, "http://evil.example.net/login/reset",
strings.NewReader(url.Values{"username": {c.username}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "192.0.2.1:1234"
rec := httptest.NewRecorder()
m.HandleReset(rec, r)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login?reset=sent" {
t.Fatalf("answer %d %q", rec.Code, rec.Header().Get("Location"))
}
got, sent := ml.next(t)
if sent != c.mail {
t.Fatalf("mail sent = %v, want %v", sent, c.mail)
}
if !sent {
return
}
if got.to != "admin@example.org" || got.subject != "[SelfPost] Reset your password" ||
!resetLinkRe.MatchString(got.body) || strings.Contains(got.body, "evil") {
t.Errorf("mail = %+v", got)
}
})
}
// The sign-in page it lands on says the same whatever happened.
m, _ := linkModule(t, false)
if body := get(m.HandleLogin, "/login?reset=sent").Body.String(); !strings.Contains(body,
"If that account has a confirmed e-mail, the link is on its way. It works once, for 30 minutes.") {
t.Errorf("sign-in after a request:\n%s", body)
}
}
func askReset(t *testing.T, m *Module, ml *recordingMailer) string {
t.Helper()
postForm(m.HandleReset, "/login/reset", "192.0.2.1:1234", url.Values{"username": {"admin"}})
got, ok := ml.next(t)
if !ok {
t.Fatal("no reset link was sent")
}
return resetLinkRe.FindStringSubmatch(got.body)[1]
}
// A link opens the form; a mistyped repeat does not use it up; the password
// it sets is the one that signs in, every session ends, and the link works
// once.
func TestResetLinkSetsThePasswordOnce(t *testing.T) {
m, ml := linkModule(t, true)
session := mustCreate(t, m.sessions, "admin")
token := askReset(t, m, ml)
path := "/login/reset/" + token
page := get(m.HandleResetLink, path)
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "mail.example.org · admin") ||
!strings.Contains(page.Body.String(), `action="`+path+`"`) {
t.Fatalf("the link's page: %d\n%s", page.Code, page.Body.String())
}
const fresh = "a-new-password-of-mine"
if rec := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {fresh}, "new_password_confirm": {"other"}}); rec.Code != http.StatusBadRequest {
t.Fatalf("a mistyped repeat: %d", rec.Code)
}
if rec := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {"short"}, "new_password_confirm": {"short"}}); rec.Code != http.StatusBadRequest {
t.Fatalf("a short password: %d", rec.Code)
}
rec := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {fresh}, "new_password_confirm": {fresh}})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login?reset=done" {
t.Fatalf("set: %d %q\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
u, _ := m.store.GetUserByUsername("admin")
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(fresh)) != nil {
t.Error("the new password does not sign in")
}
if _, found, _ := m.store.LookupSession(hashToken(session)); found {
t.Error("a session survived the reset")
}
if again := get(m.HandleResetLink, path); again.Code != http.StatusNotFound || !strings.Contains(again.Body.String(), deadLink) {
t.Errorf("the link worked twice: %d", again.Code)
}
if again := postForm(m.HandleResetLink, path, "192.0.2.1:1234", url.Values{"new_password": {fresh + "x"}, "new_password_confirm": {fresh + "x"}}); again.Code != http.StatusNotFound {
t.Errorf("the link set a password twice: %d", again.Code)
}
}
// A link sent to an address the account no longer has does not work.
func TestResetLinkDiesWithTheAddress(t *testing.T) {
m, ml := linkModule(t, true)
token := askReset(t, m, ml)
u, _ := m.store.GetUserByUsername("admin")
if err := m.store.UpdateUser(u.ID, u.Username, u.PasswordHash, "other@example.org"); err != nil {
t.Fatal(err)
}
if rec := get(m.HandleResetLink, "/login/reset/"+token); rec.Code != http.StatusNotFound {
t.Errorf("a link of the old address opened: %d", rec.Code)
}
}
// Requests for a link are counted per address: the sixth in 15 minutes is
// refused, and it says so without saying anything of the name.
func TestResetRequestsAreLimited(t *testing.T) {
m, _ := linkModule(t, false)
for i := range 5 {
if rec := postForm(m.HandleReset, "/login/reset", "192.0.2.9:1", url.Values{"username": {"x"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("request %d: %d", i+1, rec.Code)
}
}
if rec := postForm(m.HandleReset, "/login/reset", "192.0.2.9:1", url.Values{"username": {"x"}}); rec.Code != http.StatusTooManyRequests {
t.Errorf("the sixth request: %d", rec.Code)
}
}
var confirmLinkRe = regexp.MustCompile(`https://mail\.example\.org/confirm/([A-Za-z0-9_-]{43})\n`)
// The confirmation link's page changes nothing; its button confirms the
// address, once - and only for its own account's session: a mistyped address
// is a stranger's, and the mail does not name the account.
func TestConfirmLink(t *testing.T) {
m, ml := linkModule(t, false)
u, _ := m.store.GetUserByUsername("admin")
if err := m.SendConfirmation(u); err != nil {
t.Fatal(err)
}
got, ok := ml.next(t)
if !ok || got.to != "admin@example.org" || got.subject != "[SelfPost] Confirm your e-mail" || !confirmLinkRe.MatchString(got.body) {
t.Fatalf("mail = %+v %v", got, ok)
}
if strings.Contains(got.body, "admin ") || strings.Contains(got.body, "account admin") {
t.Errorf("the confirmation mail names the account:\n%s", got.body)
}
path := "/confirm/" + confirmLinkRe.FindStringSubmatch(got.body)[1]
// Without a session, or with another account's, the link asks to sign in
// and is not used up.
mustCreate(t, m.sessions, "other")
other := mustCreate(t, m.sessions, "other")
for name, session := range map[string]string{"no session": "", "another account": other} {
if rec := getAs(m.HandleConfirm, path, session); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), `href="/login"`) {
t.Errorf("%s: GET %d", name, rec.Code)
}
if rec := postAs(m.HandleConfirm, path, session); rec.Code != http.StatusForbidden {
t.Errorf("%s: POST %d", name, rec.Code)
}
}
session := mustCreate(t, m.sessions, "admin")
page := getAs(m.HandleConfirm, path, session)
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `action="`+path+`"`) ||
!strings.Contains(page.Body.String(), "admin@example.org") {
t.Fatalf("the link's page: %d\n%s", page.Code, page.Body.String())
}
if u, _ = m.store.GetUser(u.ID); !u.EmailConfirmed.IsZero() {
t.Fatal("opening the link confirmed the address")
}
if rec := postAs(m.HandleConfirm, path, session); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "E-mail confirmed") {
t.Fatalf("confirm: %d\n%s", rec.Code, rec.Body.String())
}
if u, _ = m.store.GetUser(u.ID); u.EmailConfirmed.IsZero() {
t.Fatal("the button did not confirm the address")
}
if rec := postAs(m.HandleConfirm, path, session); rec.Code != http.StatusNotFound {
t.Errorf("the link worked twice: %d", rec.Code)
}
}
// A second request within resetSpacing sends nothing and leaves the first
// link working: a stranger repeating a username neither kills the link in
// the mailbox nor spends the account's links.
func TestResetRequestsAreSpaced(t *testing.T) {
m, ml := linkModule(t, true)
first := askReset(t, m, ml)
postForm(m.HandleReset, "/login/reset", "192.0.2.2:1234", url.Values{"username": {"admin"}})
if got, sent := ml.next(t); sent {
t.Fatalf("a second link went within %s: %+v", resetSpacing, got)
}
if rec := get(m.HandleResetLink, "/login/reset/"+first); rec.Code != http.StatusOK {
t.Errorf("the first link died: %d", rec.Code)
}
}
// A link path that is not one is not found.
func TestLinkPathsWithoutAToken(t *testing.T) {
m, _ := linkModule(t, true)
for _, p := range []string{"/login/reset/", "/login/reset/a/b", "/confirm/"} {
h := m.HandleResetLink
if strings.HasPrefix(p, "/confirm/") {
h = m.HandleConfirm
}
if rec := get(h, p); rec.Code != http.StatusNotFound {
t.Errorf("%s: %d", p, rec.Code)
}
}
}
+78
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
@@ -60,6 +61,11 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
page := view.NewAccount(h.shellMeta(r), string(u.Role))
page.Username, page.Email, page.Notify = f.Username, f.Email, f.Notify
var sent time.Time
if l, err := h.store.LatestUserLink(u.ID, store.LinkConfirm, time.Now()); err == nil && l.Email == u.Email {
sent = l.CreatedAt
}
page.WithEmailState(u.Email, !u.EmailConfirmed.IsZero(), sent)
page.WithResult(accountFlash(r), f.Err)
h.view.Render(w, status, "account", page)
}
@@ -68,6 +74,10 @@ func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("done") {
case "profile":
return "Profile saved."
case "profile-link":
return "Profile saved. A link to confirm the new address is on its way."
case "link":
return "A new link is on its way."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
}
@@ -108,6 +118,25 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
fail(http.StatusBadRequest, err.Error())
return
}
// The e-mail is where a reset link goes once it is confirmed, so
// changing it asks for the password, as changing the password does
// (roadmap password-reset; 2.0 security review), on the same limit.
emailChanged := f.Email != u.Email
if emailChanged {
if !h.auth.AllowPasswordChange(u.ID) {
fail(http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
current := r.PostFormValue("current_password")
if current == "" {
fail(http.StatusUnauthorized, "Your current password is needed to change the e-mail.")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(current)); err != nil {
fail(http.StatusUnauthorized, "Current password is incorrect.")
return
}
}
if err := h.store.UpdateUser(u.ID, f.Username, u.PasswordHash, f.Email); err != nil {
if errors.Is(err, store.ErrUserExists) {
fail(http.StatusConflict, "That username is already in use.")
@@ -125,9 +154,54 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
}
}
logf("panel: user %d profile updated (username: %t, e-mail: %t, notifications: %t)", u.ID, f.Username != u.Username, f.Email != u.Email, f.Notify)
if emailChanged && f.Email != "" && h.sendConfirmation(u.ID) {
http.Redirect(w, r, "/account?done=profile-link", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
}
// HandleAccountConfirmLink sends the account's unconfirmed address its link
// again (Account, "Send the link again").
func (h *Handlers) HandleAccountConfirmLink(w http.ResponseWriter, r *http.Request) {
u, ok := h.accountUser(w, r)
if !ok {
return
}
if u.Email == "" || !u.EmailConfirmed.IsZero() {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
f := formOf(u)
if !h.auth.AllowConfirmationLink(u.ID) {
f.Err = "Too many links asked for. Please wait and try again."
h.renderAccount(w, r, http.StatusTooManyRequests, u, f)
return
}
h.auth.SendConfirmationLater(u)
http.Redirect(w, r, "/account?done=link", http.StatusSeeOther)
}
// sendConfirmation mails the user's unconfirmed address its link after the
// answer, within the limit of links to one address; whether one was sent off.
// A link that cannot go is logged: the address is saved all the same, and
// Account offers to send it again.
func (h *Handlers) sendConfirmation(userID int64) bool {
if h.auth == nil || !h.auth.CanMail() {
return false
}
u, err := h.store.GetUser(userID)
if err != nil || u.Email == "" || !u.EmailConfirmed.IsZero() {
return false
}
if !h.auth.AllowConfirmationLink(u.ID) {
logf("panel: user %d: confirmation links asked for too often; none was sent", u.ID)
return false
}
h.auth.SendConfirmationLater(u)
return true
}
// HandleAccountPassword changes the password. It asks for the current one and
// is rate-limited like a sign-in: the form is a place to guess a password from
// an unattended session.
@@ -178,6 +252,10 @@ func (h *Handlers) HandleAccountPassword(w http.ResponseWriter, r *http.Request)
if token, ok := h.auth.SessionToken(r); ok {
h.auth.DestroyOtherSessions(u.ID, token)
}
// A password set another way leaves no reset link behind it.
if err := h.store.DeleteUserLinks(u.ID, store.LinkReset); err != nil {
logf("panel: account: %v", err)
}
logf("panel: user %d changed their password", u.ID)
http.Redirect(w, r, "/account?done=password", http.StatusSeeOther)
}
+95 -6
View File
@@ -1,13 +1,17 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"golang.org/x/crypto/bcrypt"
)
@@ -32,10 +36,11 @@ func TestAccountPageShowsTwoForms(t *testing.T) {
}
}
// The profile form saves the name and the e-mail without asking for the
// password and without changing the password or any domain's report address.
// The profile form saves the name and the e-mail - a new e-mail with the
// current password - without changing the password or any domain's report
// address.
func TestAccountProfile(t *testing.T) {
h, _ := settingsServer(t)
h, password := settingsServer(t)
before, _ := h.store.GetUser(globalPrincipal.ID)
d, _ := h.store.AddDomain("example.com", "mail")
if err := h.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
@@ -43,7 +48,7 @@ func TestAccountProfile(t *testing.T) {
}
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
url.Values{"username": {"operator"}, "email": {"mix@example.org"}})
url.Values{"username": {"operator"}, "email": {"mix@example.org"}, "current_password": {password}})
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/account?done=profile" {
t.Fatalf("POST /account/profile = %d to %q:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
@@ -70,12 +75,91 @@ func TestAccountProfile(t *testing.T) {
if _, err := h.store.CreateUser("taken", "h", store.RoleGlobal, store.Reach{}); err != nil {
t.Fatal(err)
}
rec = postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"taken"}, "email": {"other@example.org"}})
rec = postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"taken"}, "email": {"other@example.org"}, "current_password": {password}})
if u, _ = h.store.GetUser(globalPrincipal.ID); rec.Code != http.StatusConflict || u.Username != "operator" || u.Email != "mix@example.org" {
t.Errorf("a taken username = %d, profile now %q / %q", rec.Code, u.Username, u.Email)
}
}
// linkMailer keeps the links the panel would have mailed.
type linkMailer struct{ sent []string }
func (r *linkMailer) Send(to, subject, body string) error {
r.sent = append(r.sent, to+" | "+subject+" | "+body)
return nil
}
// sentBy is what ml has been given once h's links sent after their answers
// have gone.
func sentBy(h *Handlers, ml *linkMailer) []string {
h.auth.Wait(context.Background())
return ml.sent
}
// withMail gives h an auth module that mails, as mail.example.org, to a
// linkMailer.
func withMail(t *testing.T, h *Handlers) *linkMailer {
t.Helper()
h.auth = auth.New(h.store, auth.Config{Hostname: "mail.example.org"}, h.view, filepath.Join(t.TempDir(), "setup-token"))
ml := &linkMailer{}
h.auth.SetMailer(ml)
return ml
}
// The e-mail is where a reset link goes once confirmed, so changing it asks
// for the password; the new address is sent its link and shows as not
// confirmed, with Send the link again; a confirmed address shows as such and
// keeps its mark when only the name changes.
func TestAccountEmailIsConfirmedByItsLink(t *testing.T) {
h, password := settingsServer(t)
ml := withMail(t, h)
change := url.Values{"username": {"admin"}, "email": {"mix@example.org"}}
for name, pw := range map[string]string{"no password": "", "wrong password": "nope"} {
form := url.Values{"username": change["username"], "email": change["email"], "current_password": {pw}}
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, form); rec.Code != http.StatusUnauthorized {
t.Errorf("%s: %d", name, rec.Code)
}
}
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Email != "" || len(sentBy(h, ml)) != 0 {
t.Fatalf("a refused change saved %q, sent %d", u.Email, len(sentBy(h, ml)))
}
change.Set("current_password", password)
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, change)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/account?done=profile-link" {
t.Fatalf("change: %d %q\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
if len(sentBy(h, ml)) != 1 || !strings.HasPrefix(sentBy(h, ml)[0], "mix@example.org | [SelfPost] Confirm your e-mail | ") ||
!strings.Contains(sentBy(h, ml)[0], "https://mail.example.org/confirm/") {
t.Fatalf("sent = %q", sentBy(h, ml))
}
out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{`<span class="tag is-warning is-light">not confirmed</span>`, `action="/account/email/confirm"`, `A link was sent to`} {
if !strings.Contains(out, want) {
t.Errorf("account page missing %q", want)
}
}
// Send the link again: a second link.
if rec := postFormAs(h.HandleAccountConfirmLink, globalPrincipal, "/account/email/confirm", nil, nil); rec.Code != http.StatusSeeOther || len(sentBy(h, ml)) != 2 {
t.Fatalf("send again: %d, sent %d", rec.Code, len(sentBy(h, ml)))
}
// Confirmed: the tag says so, the form is gone, and a new name keeps it
// without the password.
if ok, _ := h.store.ConfirmEmail(globalPrincipal.ID, "mix@example.org", time.Now()); !ok {
t.Fatal("confirm")
}
out = getBody(t, h.HandleAccount, "/account")
if !strings.Contains(out, `<span class="tag is-success is-light">confirmed</span>`) || strings.Contains(out, `action="/account/email/confirm"`) {
t.Errorf("a confirmed address:\n%s", out)
}
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
url.Values{"username": {"operator"}, "email": {"mix@example.org"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("a new name: %d", rec.Code)
}
if u, _ := h.store.GetUser(globalPrincipal.ID); u.EmailConfirmed.IsZero() || len(sentBy(h, ml)) != 2 {
t.Errorf("a new name: confirmed %v, sent %d", u.EmailConfirmed, len(sentBy(h, ml)))
}
}
func TestAccountPassword(t *testing.T) {
h, password := settingsServer(t)
const next = "another-long-password-2"
@@ -110,7 +194,12 @@ func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
h, _ := settingsServer(t)
d, _ := h.store.AddDomain("example.com", "mail")
p := domainAdmin(t, h.store, "ops", d.ID)
rec := postFormAs(h.HandleAccountProfile, p, "/account/profile", nil, url.Values{"username": {"ops"}, "email": {"ops@example.org"}})
// A new e-mail asks for the password: the administrator's is set here.
hash, _ := bcrypt.GenerateFromPassword([]byte("ops-password-1234"), bcrypt.MinCost)
if err := h.store.SetUserPassword(p.ID, string(hash)); err != nil {
t.Fatal(err)
}
rec := postFormAs(h.HandleAccountProfile, p, "/account/profile", nil, url.Values{"username": {"ops"}, "email": {"ops@example.org"}, "current_password": {"ops-password-1234"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /account/profile as a domain administrator = %d:\n%s", rec.Code, rec.Body.String())
}
@@ -0,0 +1,56 @@
package handlers
import (
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
// An address the global administrator types for a user is unconfirmed until
// that user opens its link: creating the user with one, or saving a new one,
// sends it; the form tags it either way.
func TestUserFormSendsANewAddressItsLink(t *testing.T) {
h, st := inboundHandlers(t)
ml := withMail(t, h)
out1, _ := st.AddDomain("one.example.com", "mail")
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
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)},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("create = %d:\n%s", rec.Code, rec.Body.String())
}
if len(sentBy(h, ml)) != 1 || !strings.HasPrefix(sentBy(h, ml)[0], "ops@acme.io | [SelfPost] Confirm your e-mail | ") {
t.Fatalf("sent = %q", sentBy(h, ml))
}
u, _ := st.GetUserByUsername("ops")
path, pv := "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}
if out := send(h.HandleUserEdit, &root, "GET", path, pv, nil).Body.String(); !strings.Contains(out, `<span class="tag is-warning is-light">not confirmed</span>`) {
t.Errorf("an unconfirmed address is not tagged:\n%s", out)
}
if ok, _ := st.ConfirmEmail(u.ID, "ops@acme.io", time.Now()); !ok {
t.Fatal("confirm")
}
if out := send(h.HandleUserEdit, &root, "GET", path, pv, nil).Body.String(); !strings.Contains(out, `<span class="tag is-success is-light">confirmed</span>`) {
t.Errorf("a confirmed address is not tagged:\n%s", out)
}
// 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"}}
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)))
}
form.Set("email", "new@acme.io")
if rec := send(h.HandleUserEdit, &root, "POST", path, pv, form); rec.Code != http.StatusSeeOther {
t.Fatalf("a new address: %d", rec.Code)
}
if got, _ := st.GetUser(u.ID); !got.EmailConfirmed.IsZero() || len(sentBy(h, ml)) != 2 || !strings.HasPrefix(sentBy(h, ml)[1], "new@acme.io | ") {
t.Errorf("a new address: confirmed %v, sent %q", got.EmailConfirmed, sentBy(h, ml))
}
}
@@ -26,14 +26,14 @@ const notifyHost = "mail.example.org"
// stored, Save profile writes it, and an unchecked box is a form without the
// field, which means off. Saving it changes nothing else of the profile.
func TestAccountNotifyCheckbox(t *testing.T) {
h, _ := settingsServer(t)
h, password := settingsServer(t)
notify := func() bool {
u, _ := h.store.GetUser(globalPrincipal.ID)
return u.Notify
}
save := func(extra url.Values) {
t.Helper()
form := url.Values{"username": {"admin"}, "email": {"mix@example.org"}}
form := url.Values{"username": {"admin"}, "email": {"mix@example.org"}, "current_password": {password}}
for k, v := range extra {
form[k] = v
}
+26 -2
View File
@@ -153,7 +153,13 @@ func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status
in.InDomains = append(in.InDomains, view.DomainChoice{ID: d.ID, Name: d.Name, Checked: inb[d.ID]})
}
}
h.view.Render(w, status, "user", view.NewUserForm(h.shellMeta(r), in))
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
@@ -206,7 +212,8 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
}
// 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).
if _, err := h.store.AddUser(store.UserChange{Username: username, PasswordHash: string(hash), Email: email, Role: role, Reach: reach}); err != nil {
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
@@ -219,6 +226,11 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -316,6 +328,10 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
}
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)
}
@@ -333,6 +349,14 @@ func (h *Handlers) endSessionsAfterPasswordSet(r *http.Request, by auth.Principa
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).
+19
View File
@@ -0,0 +1,19 @@
package web
import "testing"
// A rejected request's path is logged without the token of a link
// (security review, 2026-10-17).
func TestLoggedPathHidesTokens(t *testing.T) {
for in, want := range map[string]string{
"/login/reset/abcDEF123": "/login/reset/…",
"/confirm/abcDEF123": "/confirm/…",
"/login/reset": "/login/reset",
"/confirm/": "/confirm/",
"/account/profile": "/account/profile",
} {
if got := loggedPath(in); got != want {
t.Errorf("loggedPath(%q) = %q, want %q", in, got, want)
}
}
}
+14 -1
View File
@@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/url"
"strings"
)
// contentSecurityPolicy is the panel's CSP. Everything the pages load —
@@ -96,7 +97,7 @@ func (s *Server) secure(next http.Handler) http.Handler {
// saving anything".
logf("panel: rejected %s %s: cross-origin request (Sec-Fetch-Site=%q Origin=%q Host=%q); "+
"if this was a legitimate request, check that the reverse proxy passes the original Host header through",
r.Method, r.URL.Path, r.Header.Get("Sec-Fetch-Site"), r.Header.Get("Origin"), r.Host)
r.Method, loggedPath(r.URL.Path), r.Header.Get("Sec-Fetch-Site"), r.Header.Get("Origin"), r.Host)
http.Error(w, "cross-origin request rejected", http.StatusForbidden)
return
}
@@ -168,3 +169,15 @@ func originAllowed(r *http.Request) bool {
}
return u.Host == r.Host
}
// loggedPath is a request's path as the log may hold it: the token of a
// password-reset link is a secret, and the log is read by more than the
// person the link went to (security review, 2026-10-17).
func loggedPath(p string) string {
for _, prefix := range []string{"/login/reset/", "/confirm/"} {
if strings.HasPrefix(p, prefix) && len(p) > len(prefix) {
return prefix + "…"
}
}
return p
}
+31
View File
@@ -27,10 +27,41 @@ type Account struct {
// Notify is whether the panel writes to this user: the checkbox under the
// e-mail.
Notify bool
// EmailTag says whether the stored address is confirmed (nil without
// one); Resend is the form that sends its link again, nil once it is
// confirmed (password-reset).
EmailTag *Tag
Resend *ResendLink
Password Box
}
// ResendLink is Account's "Send the link again": the address and when its
// live link went ("" for none).
type ResendLink struct {
Email string
Sent string
}
// WithEmailState sets the stored address's tag and, while it is not
// confirmed, the resend form: sentAt is when its live link went, zero for
// none.
func (a *Account) WithEmailState(email string, confirmed bool, sentAt time.Time) *Account {
if email == "" {
return a
}
if confirmed {
a.EmailTag = &Tag{Status: "ok", Label: "confirmed"}
return a
}
a.EmailTag = &Tag{Status: "warn", Label: "not confirmed"}
a.Resend = &ResendLink{Email: email}
if !sentAt.IsZero() {
a.Resend.Sent = sentAt.Local().Format("15:04")
}
return a
}
// NewAccount builds the page for a user. role is "global" or "domain" and
// appears in the kicker.
func NewAccount(m Meta, role string) *Account {
+17 -8
View File
@@ -21,13 +21,20 @@ import (
// This file belongs to the implementation, not to the guards: the step that
// restyles a page adds its fixture here in the same commit.
var pageFixtures = map[string]func() any{
"components": func() any { return KitPage() },
"login": func() any { return NewLogin("mail.example.org", "") },
"setup": func() any { return NewSetup("0123456789abcdef", "") },
"overview": func() any { return overviewFixture() },
"health": func() any { return healthFixture(false) },
"account": func() any { return accountFixture() },
"settings": func() any { return settingsFixture() },
"components": func() any { return KitPage() },
"login": func() any { return NewLogin("mail.example.org", "") },
"setup": func() any { return NewSetup("0123456789abcdef", "") },
"login-reset": func() any { return NewReset("mail.example.org", "") },
"login-reset-set": func() any {
return NewResetSet("mail.example.org", "admin", "0123456789abcdef", "")
},
"confirm-email": func() any {
return NewConfirm("mail.example.org", "admin", "mix@example.org", "0123456789abcdef")
},
"overview": func() any { return overviewFixture() },
"health": func() any { return healthFixture(false) },
"account": func() any { return accountFixture() },
"settings": func() any { return settingsFixture() },
"out-domains": func() any { return outDomainsFixture() },
"out-domain": func() any { return outDomainFixture() },
@@ -141,6 +148,8 @@ func healthFixture(refresh bool) *Health {
func accountFixture() *Account {
a := NewAccount(admin(), "global")
a.Username, a.Email, a.Notify = "admin", "mix@example.org", true
// The mockup's address: not confirmed, its link sent at 14:02.
a.WithEmailState("mix@example.org", false, time.Date(2026, 10, 17, 14, 2, 0, 0, time.UTC))
return a
}
@@ -438,7 +447,7 @@ func userFixture() *UserForm {
AllOut: true,
OutDomains: []DomainChoice{{ID: 1, Name: "example.org"}, {ID: 2, Name: "shop.example.org"}, {ID: 3, Name: "notify.acme.io"}},
InDomains: []DomainChoice{{ID: 1, Name: "lists.example.org"}, {ID: 2, Name: "acme.io", Checked: true}},
})
}).WithEmailState("ops@acme.io", true)
}
// userDeleteFixture is the mockup's confirmation: acme-ops, who has all
+2 -1
View File
@@ -305,6 +305,7 @@ var serverTopics = []HelpTopic{
var accountTopics = []HelpTopic{
{ID: HelpAccount, Title: "Your account", Body: []Text{
Plain("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."),
Rich("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected. A new e-mail is sent a link that confirms it, which works once, for 24 hours; changing the e-mail asks for your password. Notifications go to the address whether it is confirmed or not, but only a confirmed one can get a link to reset a forgotten password: ",
Em("Forgot your password?"), " on the sign-in page sends it, for 30 minutes and once, and setting the new password signs every session of the account out. Without a confirmed e-mail the global administrator sets a new password under Users, and the only global administrator recovers from the container."),
}},
}
+7 -3
View File
@@ -290,14 +290,18 @@ func TestAccountHasTwoFormsEachItsOwnPost(t *testing.T) {
`<h1 class="title is-3">Account</h1>`, `Signed in as admin · global`,
`action="/account/profile"`, `name="username"`, `name="email"`, `value="mix@example.org"`,
`action="/account/password"`, `name="current_password"`, `name="new_password"`, `name="new_password_confirm"`,
`Your own address. A domain's report address can be filled with it from Domain settings.`,
`Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.`,
`E-mail <span class="tag is-warning is-light">not confirmed</span>`,
`<input class="input" id="profile_password" name="current_password" type="password" autocomplete="current-password">`,
`action="/account/email/confirm"`, `A link was sent to <span class="sp-mono">mix@example.org</span> at 14:02; it works once, for 24 hours.`,
)
// Account is Profile and Password; where a domain's reports go is the
// domain's own setting.
pageLacks(t, "Account", out, `send_log_retention_days`, `/account/dmarc`, `dmarc_default`, `<h2>DMARC reports</h2>`,
`Report authorization`, `default report address`, `id="dmarc"`)
// No form asks for another's fields: two forms, two submit buttons.
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 2 {
// No form asks for another's fields: the profile, the password and,
// while the address is not confirmed, its link again.
if got := strings.Count(out, `<form class="sp-form`); got != 3 {
t.Errorf("Account has %d forms, want 2", got)
}
// Two equal columns, one box each.
+19 -3
View File
@@ -231,9 +231,12 @@ type UserForm struct {
Submit string
CancelHref string
Account Box
Username string
Email string
Account Box
Username string
Email string
// EmailTag says whether the stored address is confirmed (nil without
// one).
EmailTag *Tag
NewUser bool
PasswordMin int
Roles []Option
@@ -420,3 +423,16 @@ func names(list []string) Text {
}
return out
}
// WithEmailState tags the user's stored address confirmed or not; nothing
// for a user without one.
func (p *UserForm) WithEmailState(email string, confirmed bool) *UserForm {
switch {
case email == "":
case confirmed:
p.EmailTag = &Tag{Status: "ok", Label: "confirmed"}
default:
p.EmailTag = &Tag{Status: "warn", Label: "not confirmed"}
}
return p
}
+4 -3
View File
@@ -152,7 +152,7 @@ func TestUserFormShowsWhatIsStoredUnderTheHandlersFieldNames(t *testing.T) {
pageHas(t, "User", out,
`<h1 class="title is-3">acme-ops</h1>`, `href="/server/users">Users</a>`, `href="/server/users/3/delete"`,
`<form class="sp-form" method="post" action="/server/users/3">`,
`name="username" value="acme-ops"`, `<label class="label" for="email">E-mail</label>`,
`name="username" value="acme-ops"`, `<label class="label" for="email">E-mail <span class="tag is-success is-light">confirmed</span></label>`,
`<input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off">`,
`name="password" type="password" autocomplete="new-password">`,
`Leave empty to keep the current one.`,
@@ -316,8 +316,9 @@ func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
// change it under Account, and it can receive DMARC reports if they choose.
func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) {
out := renderSignedIn(t, "user", userFixture())
pageHas(t, "User", out, `The user's own address. They can change it under Account.`)
pageLacks(t, "User", out, `notification`, `reset`)
pageHas(t, "User", out, `The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.`,
`E-mail <span class="tag is-success is-light">confirmed</span>`)
pageLacks(t, "User", out, `notification`)
}
// A domain's report address belongs to the domain, not to a user: deleting a
+91
View File
@@ -16,6 +16,9 @@ type Login struct {
Host string
SetupHint bool
Error string
// Info is the result of what brought the person back here - a reset link
// asked for, a password set from one - shown as a success flash.
Info string
}
// NewLogin returns the sign-in page's data. host and formErr may be empty.
@@ -33,6 +36,94 @@ func NewLoginSetupHint(host string) *Login {
// Error is set.
func (l *Login) Notice() Flash { return Flash{Error: true, Text: Plain(l.Error)} }
// WithInfo sets the success flash.
func (l *Login) WithInfo(info string) *Login {
l.Info = info
return l
}
// InfoNotice is Info as the flash partial's input.
func (l *Login) InfoNotice() Flash { return Flash{Text: Plain(l.Info)} }
// Reset is the page that asks for a reset link (templates/login_reset.html,
// mockup login-reset.html): a username, and what will happen. Error is a
// refusal, or a link that no longer works.
type Reset struct {
Meta
Host string
Error string
}
// NewReset returns the reset request page's data.
func NewReset(host, formErr string) *Reset {
return &Reset{Meta: Meta{Title: "Reset password"}, Host: host, Error: formErr}
}
// Notice is the page's error as the flash partial's input.
func (p *Reset) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
// ResetSet is the page a reset link opens (templates/login_reset_set.html,
// mockup login-reset-set.html): the new password, for User, posted back to
// the link's own path.
type ResetSet struct {
Meta
Host string
User string
Token string
Error string
}
// NewResetSet returns the new-password page's data.
func NewResetSet(host, user, token, formErr string) *ResetSet {
return &ResetSet{Meta: Meta{Title: "New password"}, Host: host, User: user, Token: token, Error: formErr}
}
// Kicker is the host and the account the password is for.
func (p *ResetSet) Kicker() string { return kickerOf(p.Host, p.User) }
// Notice is the page's error as the flash partial's input.
func (p *ResetSet) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
// Confirm is the page a confirmation link opens (templates/confirm_email.html,
// mockup confirm-email.html): the address and a button - a mail scanner
// opens a link, so the GET changes nothing. Done is the page after the
// button; Error a link that does not work, with no button.
type Confirm struct {
Meta
Host string
User string
Email string
Token string
Error string
Done bool
// SignIn is the error of a link opened without its account's session:
// the page points at sign-in.
SignIn bool
}
// NewConfirm returns the confirmation page's data.
func NewConfirm(host, user, email, token string) *Confirm {
return &Confirm{Meta: Meta{Title: "Confirm e-mail"}, Host: host, User: user, Email: email, Token: token}
}
// Kicker is the host and the account the address is of.
func (p *Confirm) Kicker() string { return kickerOf(p.Host, p.User) }
// Notice is the page's error as the flash partial's input.
func (p *Confirm) Notice() Flash { return Flash{Error: true, Text: Plain(p.Error)} }
// kickerOf joins the host and an account's name, either of which may be
// unknown.
func kickerOf(host, user string) string {
switch {
case host == "":
return user
case user == "":
return host
}
return host + " · " + user
}
// Setup is the data of the first-run page that creates the administrator
// (templates/setup.html), reached by the one-time link /setup/<Token>. Token is
// the path segment the form posts back to; Error is the refusal of the last
+8 -2
View File
@@ -12,12 +12,18 @@
{{template "body_open"}}
<form class="sp-form" method="post" action="/account/profile">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="label" for="email">E-mail{{with .EmailTag}} {{template "tag" .}}{{end}}</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. Saving a new one sends it a link to confirm it; once confirmed it is where a link to reset your password can go. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="label" for="profile_password">Current password</label><div class="control"><input class="input" id="profile_password" name="current_password" type="password" autocomplete="current-password"></div>
<p class="help">Needed only to change the e-mail.</p></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send me notifications</label>
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="/help#notifications">What is sent and when</a></p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
{{with .Resend}}<form class="sp-form mt-4" method="post" action="/account/email/confirm">
<p class="help mb-3">{{if .Sent}}A link was sent to <span class="sp-mono">{{.Email}}</span> at {{.Sent}}; it works once, for 24 hours.{{else}}No link is waiting: send one to confirm <span class="sp-mono">{{.Email}}</span>.{{end}}</p>
<button type="submit" class="button">Send the link again</button>
</form>{{end}}
{{template "body_close"}}
{{template "box_close"}}
</div>
@@ -0,0 +1,28 @@
{{/* The page a confirmation link opens (mockup confirm-email.html): the
address and a button, since a mail scanner opens links and a GET must
change nothing. After the button, the same column says it is done; a link
that does not work says so and has no button. */}}
{{define "content"}}
{{if .Done}}
<div class="sp-form">
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
<h1 class="title is-3">E-mail confirmed</h1>
<p class="sp-muted mb-4"><span class="sp-mono">{{.Email}}</span> is the confirmed address of this account.</p>
<p><a href="/account">Account</a></p>
</div>
{{else if .Error}}
<div class="sp-form">
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
<h1 class="title is-3">Confirm e-mail</h1>
{{template "flash" .Notice}}
{{if .SignIn}}<p><a href="/login">Sign in</a></p>{{else}}<p><a href="/account">Account</a></p>{{end}}
</div>
{{else}}
<form class="sp-form" method="post" action="/confirm/{{.Token}}">
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
<h1 class="title is-3">Confirm e-mail</h1>
<p class="sp-muted mb-4"><span class="sp-mono">{{.Email}}</span> becomes the confirmed address of this account: where a link to reset its password can be sent.</p>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Confirm</button></div>
</form>
{{end}}
{{end}}
+2 -1
View File
@@ -14,7 +14,7 @@
<form class="sp-form" method="post" action="/login">
{{if .Host}}<p>{{template "kicker" .Host}}</p>{{end}}
<h1 class="title is-3">Sign in</h1>
{{if .Error}}{{template "flash" .Notice}}{{end}}
{{if .Error}}{{template "flash" .Notice}}{{else if .Info}}{{template "flash" .InfoNotice}}{{end}}
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" autofocus required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
@@ -24,6 +24,7 @@
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
<p class="mt-4"><a href="/login/reset">Forgot your password?</a></p>
</form>
{{end}}
{{end}}
@@ -0,0 +1,17 @@
{{/* Asking for a reset link (mockup login-reset.html): the form column of the
signed-out screen. The answer to the form is the sign-in page, the same
for any name (mockup login-reset-sent.html). */}}
{{define "content"}}
<form class="sp-form" method="post" action="/login/reset">
{{if .Host}}<p>{{template "kicker" .Host}}</p>{{end}}
<h1 class="title is-3">Reset password</h1>
<p class="sp-muted mb-4">A link to set a new password goes to the e-mail of the account, once that address is confirmed under Account. It works once, for 30 minutes.</p>
{{if .Error}}{{template "flash" .Notice}}{{end}}
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" autofocus required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Send the link</button></div>
<p class="mt-4"><a href="/login">Back to sign in</a></p>
</form>
{{end}}
@@ -0,0 +1,19 @@
{{/* The page a reset link opens (mockup login-reset-set.html): the new
password, posted back to the link's own path. */}}
{{define "content"}}
<form class="sp-form" method="post" action="/login/reset/{{.Token}}">
{{with .Kicker}}<p>{{template "kicker" .}}</p>{{end}}
<h1 class="title is-3">New password</h1>
<p class="sp-muted mb-4">Setting it signs every session of this account out; sign in with it afterwards.</p>
{{if .Error}}{{template "flash" .Notice}}{{end}}
<div class="field">
<label class="label" for="new_password">New password</label>
<div class="control has-icons-left"><input class="input" id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="12" autofocus required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field">
<label class="label" for="new_password_confirm">Repeat</label>
<div class="control has-icons-left"><input class="input" id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" minlength="12" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Set password</button></div>
</form>
{{end}}
+2 -2
View File
@@ -15,8 +15,8 @@
{{template "box_open" .Account}}
{{template "body_open"}}
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
<p class="help">The user's own address. They can change it under Account.</p></div>
<div class="field"><label class="label" for="email">E-mail{{with .EmailTag}} {{template "tag" .}}{{end}}</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
<p class="help">The user's own address. They can change it under Account. A new one is sent a link to confirm it; until it is confirmed no reset link goes to it.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"{{if .NewUser}} required{{end}}></div>
<p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div>
<div class="field"><label class="label" for="role">Role</label>
+1 -1
View File
@@ -40,7 +40,7 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) {
"Source",
"No warranty",
}
signedOut := map[string]bool{"login": true, "setup": true}
signedOut := map[string]bool{"login": true, "setup": true, "login-reset": true, "login-reset-set": true, "confirm-email": true}
for name, fixture := range pageFixtures {
rec := httptest.NewRecorder()
engine.Render(rec, http.StatusOK, name, fixture())
+3
View File
@@ -35,6 +35,9 @@ type Engine struct {
var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
"login-reset": {"templates/login_reset.html"},
"login-reset-set": {"templates/login_reset_set.html"},
"confirm-email": {"templates/confirm_email.html"},
"overview": {"templates/overview.html", "templates/overview_cards.html"},
"health": {"templates/health.html", "templates/health_body.html"},
"account": {"templates/account.html"},
+15
View File
@@ -4,6 +4,7 @@
package web
import (
"context"
"log"
"net"
"net/http"
@@ -17,6 +18,7 @@ import (
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/legal"
"github.com/mixeme/selfpost/internal/notify"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
@@ -141,6 +143,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
SessionIdleDays: cfg.SessionIdleDays,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}, v, setupTokenPath)
a.SetMailer(notify.NewMailer(cfg.Hostname))
h := handlers.New(st, domains, apps, inboundSvc, lists, dmarcSvc, handlers.Config{
Hostname: cfg.Hostname,
SubmissionEnabled: cfg.SubmissionEnabled,
@@ -179,6 +182,13 @@ func (s *Server) Start() error {
return s.auth.Bootstrap()
}
// Drain waits, until ctx ends, for what the panel still does after its
// answers - the links of password-reset being mailed - so the database is not
// closed under them.
func (s *Server) Drain(ctx context.Context) {
s.auth.Wait(ctx)
}
// Handler returns the panel's HTTP handler (router).
func (s *Server) Handler() http.Handler {
mux, authed := s.muxes()
@@ -200,6 +210,10 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
mux.Handle("/static/", view.StaticHandler())
mux.HandleFunc("/setup/", s.auth.HandleSetup)
mux.HandleFunc("/login", s.auth.HandleLogin)
// The links of password-reset, opened from a mail, signed in or not.
mux.HandleFunc("/login/reset", s.auth.HandleReset)
mux.HandleFunc("/login/reset/", s.auth.HandleResetLink)
mux.HandleFunc("/confirm/", s.auth.HandleConfirm)
mux.HandleFunc("/logout", s.auth.HandleLogout)
authed = http.NewServeMux()
@@ -213,6 +227,7 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
authed.HandleFunc("GET /account", h.HandleAccount)
authed.HandleFunc("POST /account/profile", h.HandleAccountProfile)
authed.HandleFunc("POST /account/password", h.HandleAccountPassword)
authed.HandleFunc("POST /account/email/confirm", h.HandleAccountConfirmLink)
// Overview. Its handler checks the role itself: the page is global-only
// but does not live under /server/.