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/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)) 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(), r, 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, r *http.Request, 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 := buildResetURL(r, token) 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) } func buildResetURL(r *http.Request, token string) string { scheme := "http" if r.TLS != nil { scheme = "https" } host := r.Host if host == "" { host = "localhost" } return scheme + "://" + host + "/reset-password/" + token }