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
- 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>
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 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
|
||
}
|