965df28127
Add audit.WriteOrLog: a one-line wrapper around Write that logs at
Warn and swallows the error, matching the package contract that
audit failures must not break user-facing operations.
Migrate the 13 call sites across 7 files in internal/api/ from the
3-line "if err != nil { logger.Warn(...) }" shape to a single call.
audit.Write stays exported for tests + any future caller that
needs strict semantics.
Adds three tests: success (no log), failure-via-closed-pool (Warn
record with action+err keys), and nil-logger (no panic). Tests
skip when MINSTREL_TEST_DATABASE_URL is unset, matching the
existing harness convention.
127 lines
3.7 KiB
Go
127 lines
3.7 KiB
Go
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
|
|
}
|