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 the operator 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 → Integrations → Public address); reset email not sent") // buildResetURL builds the emailed link from the operator-set public URL. // It deliberately ignores the request: the Host header is whatever the // requester sent, and building from it 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 }