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:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user