Files
minstrel/internal/api/auth_forgot.go
T
bvandeusenandClaude Opus 5.5 7e1cfb8d08
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates)
- Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr
- Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair
- Email & network: outgoing email, the notification schedule, public address, proxies
- Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes
- Duplicates and Missing files say what their setting is and link to where it is set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:21:48 -04:00

148 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 › Email & network › 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
}