feat(auth): throttle login, register, password reset and Subsonic auth failures (M462 #4976)
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

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>
This commit is contained in:
2026-10-06 08:28:29 -04:00
co-authored by Claude Opus 5.5
parent 516413f4ca
commit 3bfddd0862
20 changed files with 528 additions and 16 deletions
+25 -1
View File
@@ -6,12 +6,16 @@ import (
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -36,14 +40,34 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) {
// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed
// envelope using the request's f= format; downstream handlers never see an
// unauthenticated request.
func Middleware(pool *pgxpool.Pool, cfg Config) func(http.Handler) http.Handler {
//
// guard throttles wrong credentials per account and per address, the same
// limits as the native login. Only wrong-credential failures count: Subsonic
// clients authenticate on every request, so counting successes would lock
// out a client for playing an album. hops is the live trusted-proxy depth
// used to find the caller's address. A nil guard disables throttling.
func Middleware(pool *pgxpool.Pool, cfg Config, guard *auth.LoginGuard, hops func() int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addr := auth.ClientIP(r, hops())
// apiKey requests have no account to charge; they count against
// the address alone.
account := r.URL.Query().Get("u")
if blocked, wait := guard.Blocked(account, addr); blocked {
secs := int(math.Ceil(wait.Seconds()))
w.Header().Set("Retry-After", strconv.Itoa(max(secs, 1)))
WriteFail(w, r, ErrGeneric, fmt.Sprintf("Too many failed sign-in attempts; try again in %d seconds", max(secs, 1)))
return
}
user, code, msg := authenticate(r, pool, cfg)
if code == ErrWrongCredentials {
guard.Fail(account, addr)
}
if code != 0 {
WriteFail(w, r, code, msg)
return
}
guard.Succeed(account)
ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
+6 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -19,11 +20,14 @@ import (
// both /rest/foo and /rest/foo.view because client conventions vary. Both
// GET and POST are accepted; Subsonic's auth params live in the query string
// either way.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer) {
//
// hops is the live trusted-proxy depth, read per request so an admin change
// applies without a restart; it locates the caller for the auth throttle.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer, hops func() int) {
b := &browseHandlers{pool: pool}
m := newMediaHandlers(pool, events, logger)
r.Route("/rest", func(sub chi.Router) {
sub.Use(Middleware(pool, cfg))
sub.Use(Middleware(pool, cfg, auth.NewLoginGuard(), hops))
register(sub, "/ping", handlePing)
register(sub, "/getLicense", handleGetLicense)
register(sub, "/getMusicFolders", b.getMusicFolders)