Files
2026-08-09 09:33:47 -04:00

250 lines
9.1 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
mailer "github.com/MeshTender/MeshTender/internal/mail"
"github.com/MeshTender/MeshTender/internal/store"
)
// ErrResetNotAllowed reports that a redeemed reset token can no longer be used to set
// a password, because the account no longer has one. The token was valid; the account
// changed underneath it (someone removed their password after asking for the reset).
// Honouring it anyway would ADD a password to a passkey-only account from a mailbox,
// which is exactly the demotion the whole design refuses.
var ErrResetNotAllowed = errors.New("auth: account is not recoverable by email")
// RequestPasswordReset acts on a "forgot password" submission for identifier, which
// may be either a username or an email address (people forget usernames too).
//
// It returns nil in every case a visitor could observe — unknown address, known
// address, passkey-only account — because the response must never reveal whether an
// account exists. A non-nil error means the send itself failed, which the caller
// reports as a generic problem, not as information about the account.
//
// What actually goes out:
// - one message per *address*, never one per account, listing every resettable
// account with its own link (the address column is deliberately not unique, so a
// personal and an ops account may share one mailbox);
// - accounts on that address with no password are named as NOT resettable rather
// than silently omitted — the reader already controls the mailbox, so it leaks
// nothing, and it answers "why isn't my other account listed";
// - nothing at all when no verified address matches.
func (s *Service) RequestPasswordReset(ctx context.Context, r *http.Request, identifier string) error {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return nil
}
candidates, err := s.resetCandidates(ctx, identifier)
if err != nil {
return err
}
if len(candidates) == 0 {
// No verified address matches. Send nothing, say nothing.
return nil
}
// Every candidate shares the address (case-insensitively), so one message covers
// them all. Use the stored form for display and delivery.
addr := *candidates[0].Email
var resettable, unrecoverable []*store.User
for _, u := range candidates {
if u.PasswordHash != nil {
resettable = append(resettable, u)
continue
}
unrecoverable = append(unrecoverable, u)
}
// Per-account budget, on top of the caller's per-IP and per-address throttles. An
// account already at its limit is dropped from this message rather than failing
// the request: the person has recent links in the same mailbox already.
links := make([]resetLink, 0, len(resettable))
for _, u := range resettable {
n, err := s.store.CountRecentEmailTokens(ctx, u.ID, store.PurposeResetPassword, sendBudgetWindow)
if err != nil {
return err
}
if n >= maxResetSends {
continue
}
token, err := s.store.CreateEmailToken(ctx, u.ID, store.PurposeResetPassword, "", store.ResetTokenTTL)
if err != nil {
return err
}
links = append(links, resetLink{user: u, url: s.authOrigin(r) + "/reset/" + token, token: token})
}
// Everything eligible is over budget: the mail we would send is the mail we
// already sent. Staying quiet here is the point of the budget.
if len(links) == 0 && len(resettable) > 0 {
return nil
}
body, key := s.resetMessage(addr, links, unrecoverable)
return s.send(ctx, mailer.Message{
To: addr,
Subject: "Reset your MeshTender password",
Kind: mailer.KindPasswordReset,
IdempotencyKey: key,
Text: body,
})
}
// resetLink pairs an account with the single-use URL that resets it.
type resetLink struct {
user *store.User
url string
token string
}
// resetCandidates resolves a submitted identifier to the accounts that hold a
// verified address. A username is accepted as well as an address, and either way the
// account must have a confirmed address — there's nowhere else to send the link.
func (s *Service) resetCandidates(ctx context.Context, identifier string) ([]*store.User, error) {
if strings.Contains(identifier, "@") {
return s.store.FindUsersByVerifiedEmail(ctx, identifier)
}
u, err := s.store.GetUserByUsername(ctx, NormalizeUsername(identifier))
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if !u.EmailVerified() {
return nil, nil
}
return []*store.User{u}, nil
}
// resetMessage composes the body and the provider de-duplication key.
//
// The single-account case gets its own wording: the fan-out phrasing ("choose the
// account") reads as a warning sign to anyone who only has one.
func (s *Service) resetMessage(addr string, links []resetLink, unrecoverable []*store.User) (body, idempotencyKey string) {
var b strings.Builder
switch {
case len(links) == 1:
fmt.Fprintf(&b, `Someone asked to reset the password for the MeshTender account %s.
Open this link to choose a new password:
%s
The link works once and expires in %s.
`, atName(links[0].user), links[0].url, humanDuration(store.ResetTokenTTL))
case len(links) > 1:
fmt.Fprintf(&b, `Someone asked to reset a MeshTender password for %s.
More than one account uses this address. Open the link for the account you want:
`, addr)
for _, l := range links {
fmt.Fprintf(&b, "\n%s\n%s\n", atName(l.user), l.url)
}
fmt.Fprintf(&b, "\nEach link works once and expires in %s.\n",
humanDuration(store.ResetTokenTTL))
default:
// Nothing resettable at all. Explaining beats silence: without this the person
// re-submits the form forever, and it's the request that generates the support
// email we're trying to avoid.
fmt.Fprintf(&b, `Someone asked to reset a MeshTender password for %s.
There's no password to reset here — this address is on an account that signs in with
a passkey only.
`, addr)
}
if len(unrecoverable) > 0 && len(links) > 0 {
b.WriteString("\nThese accounts also use this address but sign in with a passkey and have\nno password, so there's nothing to reset for them:\n")
}
for _, u := range unrecoverable {
fmt.Fprintf(&b, "\n%s\n", atName(u))
}
if len(unrecoverable) > 0 {
b.WriteString(`
To sign in to a passkey-only account, use its passkey. If you've lost the device
that held it, check whether your passkey manager (iCloud Keychain, Google Password
Manager, Windows Hello) synced it to another device — a passkey-only account can't
be recovered by email, deliberately, so that a mailbox alone can never take it over.
`)
}
b.WriteString(`
If you didn't ask for this, ignore this message. Nothing has changed and your
existing password still works.
`)
// Keyed on the tokens in the message, so a resubmitted form can't mail twice —
// and so a genuinely new request (new tokens) is never mistaken for a duplicate.
var keySeed strings.Builder
keySeed.WriteString(addr)
for _, l := range links {
keySeed.WriteString(l.token)
}
return b.String(), hashForIdempotency(keySeed.String())
}
// PeekResetToken looks up a reset token without spending it, so the form can name the
// account it belongs to. ok is false for an unknown, expired, or already-used token.
func (s *Service) PeekResetToken(ctx context.Context, token string) (*store.User, bool, error) {
tok, ok, err := s.store.PeekEmailToken(ctx, store.PurposeResetPassword, token)
if err != nil || !ok {
return nil, false, err
}
u, err := s.store.GetUserByID(ctx, tok.UserID)
if errors.Is(err, store.ErrNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return u, true, nil
}
// CompletePasswordReset spends a reset token and sets the new password.
//
// Order matters and is the reason this is one function: the token is consumed first
// (atomically, so it can't be spent twice even concurrently), and only then is the
// password written. Callers must validate the password BEFORE calling — a rejected
// password after consumption would burn the link and force the user to start over.
//
// On success every other outstanding reset link for the account dies, and every
// existing login is revoked: if someone else got in with the old password, this is
// the moment they're evicted.
func (s *Service) CompletePasswordReset(ctx context.Context, token, password string) (*store.User, error) {
tok, ok, err := s.store.ConsumeEmailToken(ctx, store.PurposeResetPassword, token)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrInvalidCredentials
}
u, err := s.store.GetUserByID(ctx, tok.UserID)
if err != nil {
return nil, err
}
// Re-checked at redemption, not just when the link was minted: the account may
// have dropped its password in between, and email must never be the thing that
// puts a password back onto a passkey-only account.
if u.PasswordHash == nil {
return nil, ErrResetNotAllowed
}
if err := s.SetPassword(ctx, u.ID, password); err != nil {
return nil, err
}
// Best-effort cleanup: the password is already changed, so failing here must not
// present as a failed reset. Both are re-attempted by the janitor / next login.
_ = s.store.DeleteEmailTokens(ctx, u.ID, store.PurposeResetPassword)
_ = s.store.RevokeAllUserLogins(ctx, u.ID)
return u, nil
}