Files
minstrel/internal/api/auth_forgot.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

148 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
)
const (
resetTokenTTL = 24 * time.Hour
resetTokenSize = 32 // bytes; 64 chars hex on the wire
)
type forgotPasswordReq struct {
Email string `json:"email"`
}
// handleForgotPassword implements POST /api/auth/forgot-password.
//
// Always returns 200 with an empty JSON body so the response shape
// doesn't reveal whether the email is registered. The actual side
// effect (token + email send) only happens when the email matches a
// user with email-on-file. SMTP send failures are logged but don't
// propagate to the response.
//
// Audits ActionForgotPasswordInit with metadata.email_match so the
// operator can correlate audit log entries with successful sends
// even though the response is opaque.
func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
var req forgotPasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Decode failures are also opaque — return 200 even on bad JSON.
writeJSON(w, http.StatusOK, struct{}{})
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
// Throttled per address (a spray) and per email (a mailbomb of one
// inbox). Applied whether or not the email matches, so a 429 says
// nothing about which addresses are registered.
addr := auth.ClientIP(r, h.netSettings.Hops())
blockedAddr, waitAddr := h.forgotAddressLimit.Blocked(addr)
blockedEmail, waitEmail := h.forgotEmailLimit.Blocked(email)
if blockedAddr || blockedEmail {
writeRateLimited(w, max(waitAddr, waitEmail))
return
}
h.forgotAddressLimit.Record(addr)
h.forgotEmailLimit.Record(email)
q := dbq.New(h.pool)
var matched bool
var auditTarget pgtype.UUID
if email != "" {
user, err := q.GetUserByEmail(r.Context(), email)
if err == nil && user.Email != nil && *user.Email != "" {
matched = true
auditTarget = user.ID
if sendErr := h.sendResetEmail(r.Context(), user); sendErr != nil {
h.logger.Warn("forgot-password: send failed",
"email", email, "err", sendErr)
// fall through; response is still 200
}
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
h.logger.Warn("forgot-password: lookup failed", "err", err)
}
}
// Audit (best-effort).
audit.WriteOrLog(r.Context(), h.pool, h.logger, pgtype.UUID{}, auditTarget, audit.ActionForgotPasswordInit,
map[string]any{"email_match": matched})
writeJSON(w, http.StatusOK, struct{}{})
}
// sendResetEmail generates a token, persists it, renders + sends the
// email. Returns the underlying error (caller logs it but doesn't
// surface to the HTTP response).
func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
if h.mailer == nil {
return errors.New("forgot-password: no mailer configured")
}
tokenBytes := make([]byte, resetTokenSize)
if _, err := rand.Read(tokenBytes); err != nil {
return err
}
token := hex.EncodeToString(tokenBytes)
expiresAt := time.Now().Add(resetTokenTTL)
q := dbq.New(h.pool)
if err := q.CreatePasswordReset(ctx, dbq.CreatePasswordResetParams{
Token: token,
UserID: user.ID,
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
}); err != nil {
return err
}
resetURL, err := buildResetURL(h.netSettings.PublicURL(), token)
if err != nil {
return err
}
textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{
Username: user.Username,
ResetURL: resetURL,
ExpiryHours: int(resetTokenTTL.Hours()),
})
if err != nil {
return err
}
if user.Email == nil || *user.Email == "" {
// Defensive — sendResetEmail is only called when email is set,
// but we re-check to avoid sending to nil.
return errors.New("forgot-password: user has no email")
}
return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody)
}
// errNoPublicURL stops a reset email from going out before an admin has
// said where Minstrel lives. It surfaces in the log, not the response, which
// stays the same opaque 200 either way.
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin › Email & network › Public address); reset email not sent")
// buildResetURL builds the emailed link from an admin-set public URL.
// It deliberately ignores the request: the Host header is whatever the
// requester sent, and building from it would let a forged Host plant a real
// reset token on a link to someone else's server.
func buildResetURL(publicURL, token string) (string, error) {
if publicURL == "" {
return "", errNoPublicURL
}
return publicURL + "/reset-password/" + token, nil
}