Files
minstrel/internal/api/auth_reset.go
T
bvandeusenandClaude Opus 5.5 3bfddd0862
test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
Every password-shaped check was mounted bare, so guessing was limited only
by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them:

- login: 10 failures per account and 50 per address per 15 min, checked
  before the user lookup and bcrypt; 429 with Retry-After. A success clears
  the account's count but not the address's.
- unknown usernames run a dummy bcrypt compare, so timing no longer says
  which accounts exist.
- register: 10 per address per hour; forgot-password: 5 per address and 3
  per email per hour (applied whether or not the email matches); reset: 20
  failed tokens per address per 15 min.
- Subsonic /rest: same limits as login, counting only wrong credentials,
  since clients authenticate on every request.

Web login, register, reset and forgot-password screens say how long to
wait; web and Android carry copy for the rate_limited code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:28:29 -04:00

111 lines
3.4 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
type resetPasswordReq struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
// handleResetPassword implements POST /api/auth/reset-password.
//
// Atomically claims the reset token and writes the new password hash.
// Returns:
// - 204 on successful reset
// - 400 password_too_short if new_password < 8 chars
// - 400 invalid_token if the token doesn't exist, was already used,
// or has expired
// - 500 on internal errors
func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("invalid_body", ""))
return
}
if len(req.NewPassword) < minPasswordLength {
writeErr(w, apierror.BadRequest("password_too_short", "password must be at least 8 chars"))
return
}
if req.Token == "" {
writeErr(w, apierror.BadRequest("invalid_token", ""))
return
}
// Tokens carry 256 bits, so guessing one is hopeless; the limit is on
// failed tries per address so that nobody gets to find that out at our
// expense.
addr := auth.ClientIP(r, h.netSettings.Hops())
if blocked, wait := h.resetLimit.Blocked(addr); blocked {
writeRateLimited(w, wait)
return
}
q := dbq.New(h.pool)
// Look up the reset record so we know which user to update before
// we claim the token. We can't reverse-lookup user_id after
// UsePasswordReset (it returns rows-affected, not the row).
reset, err := q.GetPasswordReset(r.Context(), req.Token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
h.resetLimit.Record(addr)
writeErr(w, apierror.BadRequest("invalid_token", ""))
return
}
h.logger.Error("reset password: lookup failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
// Atomically claim the token. Returns rows-affected (1 if
// claimable, 0 if already used / expired). We do this BEFORE
// hashing the password so concurrent reset attempts can't both
// succeed.
rows, err := q.UsePasswordReset(r.Context(), req.Token)
if err != nil {
h.logger.Error("reset password: use token failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
if rows == 0 {
h.resetLimit.Record(addr)
writeErr(w, apierror.BadRequest("invalid_token", ""))
return
}
// Hash and update. If hashing or update fails after the token
// claim, the token is "burned" but the password isn't reset —
// user has to request another. That's acceptable; bcrypt and
// UPDATE rarely fail in healthy systems.
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
h.logger.Error("reset password: hash failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
if err := q.ChangeUserPassword(r.Context(), dbq.ChangeUserPasswordParams{
ID: reset.UserID,
PasswordHash: string(hash),
}); err != nil {
h.logger.Error("reset password: update failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil)
w.WriteHeader(http.StatusNoContent)
}