Files
minstrel/internal/api/auth_forgot.go
T
bvandeusen cbe838cbe3 feat(server/m7-user-mgmt): forgot + reset password endpoints (U3)
POST /api/auth/forgot-password and POST /api/auth/reset-password.

Forgot-password ALWAYS returns 200 with empty JSON to prevent
enumeration of registered emails. Side effect: when email matches
a user with email-on-file, generates a 32-byte hex token (24h
TTL), inserts into password_resets, and sends the reset email via
the mailer. Mailer failures are logged (not surfaced) and the
audit log carries metadata.email_match for operator visibility.

Reset-password atomically claims the token via UsePasswordReset
(:execrows; concurrent calls can't both succeed). On rows=1,
hashes the new password and writes via ChangeUserPassword.
Returns 204 on success, 400 invalid_token on stale/used/missing
tokens, 400 password_too_short for short passwords. Audits
ActionPasswordResetByEmail.

Wires the mailer.Sender into the handlers struct via Mount;
production sender (NewSMTPSender) constructed in server.Router();
tests inject FakeSender via testHandlers default. The reset
URL embedded in the email is derived from r.Host (no PublicURL
config setting in v1; self-hosted operators see their own
hostname).

Tests cover happy-path send + token-row insertion, unknown email
returns 200 with no send, mailer failure still returns 200, reset
happy path verifies bcrypt match + used_at set, already-used
token 400, expired token 400, short password 400, bogus token 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:49:30 -04:00

129 lines
3.8 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).
if err := audit.Write(r.Context(), h.pool, pgtype.UUID{}, auditTarget, audit.ActionForgotPasswordInit,
map[string]any{"email_match": matched}); err != nil {
h.logger.Warn("forgot-password: audit failed", "err", err)
}
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
}