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>
148 lines
4.8 KiB
Go
148 lines
4.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/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
|
||
}
|