diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt index 3f345851..9a7314ad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt @@ -75,6 +75,7 @@ object ErrorCopy { "forbidden" to "You don't have permission to do that.", "not_authorized" to "You don't have permission to do that.", "invalid_credentials" to "Wrong username or password.", + "rate_limited" to "Too many attempts. Wait a few minutes and try again.", "wrong_password" to "Current password is incorrect.", "password_too_short" to "Password must be at least 8 characters.", "username_invalid" to "That username isn't valid.", diff --git a/internal/api/api.go b/internal/api/api.go index 6f21467c..f1519c9e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,6 +7,7 @@ package api import ( "log/slog" "math/rand" + "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -58,6 +59,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev reacqSettings: reacqSettings, fingerprintSettings: fpSettings, librarySize: recommendation.NewLibrarySize(nil), + loginGuard: auth.NewLoginGuard(), + registerLimit: auth.NewAttemptLimiter(registerPerAddressMax, time.Hour), + forgotAddressLimit: auth.NewAttemptLimiter(forgotPerAddressMax, time.Hour), + forgotEmailLimit: auth.NewAttemptLimiter(forgotPerEmailMax, time.Hour), + resetLimit: auth.NewAttemptLimiter(resetFailuresPerAddressMax, 15*time.Minute), } r.Route("/api", func(api chi.Router) { @@ -313,6 +319,14 @@ type handlers struct { // instance the scanner and the fingerprint workers read, so a save from the // admin card reaches them without a restart. Nil serves the defaults. fingerprintSettings *library.FingerprintSettingsService + // loginGuard throttles failed logins per account and per address, and + // the limiters below cap the other unauthenticated auth routes. All are + // nil-safe, so tests that build handlers directly run unthrottled. + loginGuard *auth.LoginGuard + registerLimit *auth.AttemptLimiter + forgotAddressLimit *auth.AttemptLimiter + forgotEmailLimit *auth.AttemptLimiter + resetLimit *auth.AttemptLimiter // netSettings caches the trusted reverse-proxy depth read by the auth // middleware on every request and edited from the admin network card. netSettings *netsettings.Service diff --git a/internal/api/auth.go b/internal/api/auth.go index ac125463..6624ed39 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -19,6 +19,20 @@ import ( // so an abandoned laptop doesn't stay logged in forever. const sessionCookieMaxAge = 30 * 24 * time.Hour +// Limits on the unauthenticated auth routes other than login (which uses +// auth.LoginGuard). Per address, per window as wired in Mount. +const ( + // registerPerAddressMax: 10 registrations an hour. Every attempt counts, + // typos included, which is still far above a household's need. + registerPerAddressMax = 10 + // forgotPerAddressMax / forgotPerEmailMax: reset emails an hour. The + // per-email cap is what keeps one inbox from being mailbombed. + forgotPerAddressMax = 5 + forgotPerEmailMax = 3 + // resetFailuresPerAddressMax: wrong or expired reset tokens per 15 min. + resetFailuresPerAddressMax = 20 +) + func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) { // The session token can be on the cookie OR bearer header — RequireUser // accepted either. Re-resolve it here so we can delete the row. @@ -70,10 +84,22 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { return } + // Checked before any lookup or bcrypt, so a throttled guess costs the + // server nothing and learns nothing. + addr := auth.ClientIP(r, h.netSettings.Hops()) + if blocked, wait := h.loginGuard.Blocked(req.Username, addr); blocked { + writeRateLimited(w, wait) + return + } + q := dbq.New(h.pool) user, err := q.GetUserByUsername(r.Context(), req.Username) if err != nil { if errors.Is(err, pgx.ErrNoRows) { + // Same bcrypt time as a wrong password, so timing doesn't say + // which usernames exist. + auth.DummyVerify(req.Password) + h.loginGuard.Fail(req.Username, addr) writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password")) return } @@ -82,9 +108,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { return } if !auth.VerifyPassword(user.PasswordHash, req.Password) { + h.loginGuard.Fail(req.Username, addr) writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password")) return } + h.loginGuard.Succeed(req.Username) token, err := auth.MintSessionToken() if err != nil { @@ -100,7 +128,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { // the active-sessions surface: a session that was born somewhere the // user recognises but is being used from somewhere they don't is the // case this whole surface exists to surface. - Ip: auth.ClientIP(r, h.netSettings.Hops()), + Ip: addr, }); err != nil { h.logger.Error("api: insert session failed", "err", err) writeErr(w, apierror.InternalMsg("insert failed", err)) diff --git a/internal/api/auth_forgot.go b/internal/api/auth_forgot.go index 45aa26f0..7491f6d3 100644 --- a/internal/api/auth_forgot.go +++ b/internal/api/auth_forgot.go @@ -14,6 +14,7 @@ import ( "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" ) @@ -47,6 +48,19 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) } 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 diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index 6f098787..710f7602 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -55,6 +55,15 @@ type registerReq struct { // - 409 username_taken (PG unique violation on users.username) // - 500 server_error otherwise func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { + // Every attempt counts, not only failures: a successful registration is + // the thing being sprayed when the mode is open. + addr := auth.ClientIP(r, h.netSettings.Hops()) + if blocked, wait := h.registerLimit.Blocked(addr); blocked { + writeRateLimited(w, wait) + return + } + h.registerLimit.Record(addr) + var req registerReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body")) @@ -175,7 +184,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { UserID: user.ID, TokenHash: auth.HashSessionToken(sessionToken), UserAgent: r.UserAgent(), - Ip: auth.ClientIP(r, h.netSettings.Hops()), + Ip: addr, }); err != nil { h.logger.Error("register: insert session failed", "err", err) writeErr(w, apierror.Internal(err)) diff --git a/internal/api/auth_reset.go b/internal/api/auth_reset.go index 48a7b070..3982d918 100644 --- a/internal/api/auth_reset.go +++ b/internal/api/auth_reset.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -42,6 +43,15 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { 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 @@ -50,6 +60,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { 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 } @@ -69,6 +80,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { return } if rows == 0 { + h.resetLimit.Record(addr) writeErr(w, apierror.BadRequest("invalid_token", "")) return } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 171c0f8a..4e445e6f 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -179,6 +179,36 @@ func TestHandleLogin_UnknownUserReturns401(t *testing.T) { } } +// A guesser who reaches the account limit is refused with 429 before any +// password check, so even the right password is turned away until the window +// passes, and the response says when to come back. +func TestHandleLogin_ThrottledAfterRepeatedFailures(t *testing.T) { + h, pool := testHandlers(t) + h.loginGuard = auth.NewLoginGuard() + seedUser(t, pool, "alice", "hunter2", false) + + login := func(password string) *httptest.ResponseRecorder { + body := strings.NewReader(`{"username":"test-alice","password":"` + password + `"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.handleLogin(w, req) + return w + } + for i := 0; i < 10; i++ { + if w := login("wrong"); w.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code) + } + } + w := login("hunter2") + if w.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429 once the account limit is reached", w.Code) + } + if w.Header().Get("Retry-After") == "" { + t.Error("429 without Retry-After") + } +} + func TestHandleLogin_MalformedBodyReturns400(t *testing.T) { h, _ := testHandlers(t) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", diff --git a/internal/api/errors.go b/internal/api/errors.go index 58410641..be7c0427 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -3,7 +3,10 @@ package api import ( "encoding/json" "log/slog" + "math" "net/http" + "strconv" + "time" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" ) @@ -36,6 +39,17 @@ func writeErr(w http.ResponseWriter, err error) { }}) } +// writeRateLimited answers 429 with Retry-After in whole seconds, rounded +// up so a client that honours it never arrives a moment early. +func writeRateLimited(w http.ResponseWriter, wait time.Duration) { + secs := int(math.Ceil(wait.Seconds())) + if secs < 1 { + secs = 1 + } + w.Header().Set("Retry-After", strconv.Itoa(secs)) + writeErr(w, apierror.TooManyRequests("rate_limited", "too many attempts; try again later")) +} + // writeErrWithLog logs the error at Error level and writes the response. // Use for 500-class errors where the operator needs the cause in logs. func writeErrWithLog(w http.ResponseWriter, logger *slog.Logger, msg string, err error) { diff --git a/internal/apierror/apierror.go b/internal/apierror/apierror.go index 3b876499..7d776809 100644 --- a/internal/apierror/apierror.go +++ b/internal/apierror/apierror.go @@ -54,6 +54,10 @@ func Unauthorized(code, message string) *Error { return &Error{Status: 401, Code: code, Message: message} } +func TooManyRequests(code, message string) *Error { + return &Error{Status: 429, Code: code, Message: message} +} + func Internal(cause error) *Error { return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause} } diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 00000000..849aad73 --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,205 @@ +package auth + +import ( + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// AttemptLimiter caps how many attempts a key may make inside a fixed +// window. It is the throttle in front of every password-shaped check: +// native login, register, forgot/reset password and Subsonic /rest auth. +// +// In memory on purpose. Minstrel is a single process, the counts only need +// to outlive a guessing run rather than a restart, and a table would put a +// write on every failed login. Each key costs one small struct, and expired +// keys are swept as the map grows, so a spray across many addresses cannot +// hold memory past one window. +type AttemptLimiter struct { + max int + window time.Duration + now func() time.Time + + mu sync.Mutex + buckets map[string]*attemptBucket + nextSweep int +} + +type attemptBucket struct { + count int + start time.Time +} + +// sweepFloor is the map size below which expired keys are left in place; +// past it, a sweep runs whenever the map doubles from its last swept size. +const sweepFloor = 1024 + +// NewAttemptLimiter returns a limiter allowing max attempts per key per +// window. +func NewAttemptLimiter(max int, window time.Duration) *AttemptLimiter { + return &AttemptLimiter{ + max: max, + window: window, + now: time.Now, + buckets: map[string]*attemptBucket{}, + nextSweep: sweepFloor, + } +} + +// Blocked reports whether key has used its attempts for the current window, +// and if so how long until the window resets. It records nothing, so a check +// can run before the expensive work and the outcome be recorded after. +func (l *AttemptLimiter) Blocked(key string) (bool, time.Duration) { + if l == nil || key == "" { + return false, 0 + } + l.mu.Lock() + defer l.mu.Unlock() + b, ok := l.buckets[key] + if !ok { + return false, 0 + } + now := l.now() + if now.Sub(b.start) >= l.window { + delete(l.buckets, key) + return false, 0 + } + if b.count < l.max { + return false, 0 + } + return true, b.start.Add(l.window).Sub(now) +} + +// Record counts one attempt against key. +func (l *AttemptLimiter) Record(key string) { + if l == nil || key == "" { + return + } + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + b, ok := l.buckets[key] + if !ok || now.Sub(b.start) >= l.window { + l.buckets[key] = &attemptBucket{count: 1, start: now} + l.maybeSweep(now) + return + } + b.count++ +} + +// Reset forgets key, as after a successful login: the user who finally got +// their password right should not carry their typos into the next window. +func (l *AttemptLimiter) Reset(key string) { + if l == nil || key == "" { + return + } + l.mu.Lock() + defer l.mu.Unlock() + delete(l.buckets, key) +} + +// maybeSweep drops expired buckets once the map has doubled since the last +// sweep. Callers hold l.mu. +func (l *AttemptLimiter) maybeSweep(now time.Time) { + if len(l.buckets) < l.nextSweep { + return + } + for k, b := range l.buckets { + if now.Sub(b.start) >= l.window { + delete(l.buckets, k) + } + } + l.nextSweep = max(sweepFloor, 2*len(l.buckets)) +} + +// LoginGuard pairs a per-account and a per-address limiter, the shape every +// password check uses. The account limit stops a slow guess at one user from +// many addresses; the address limit stops one address spraying many users. +// Only failures are recorded, so a user who signs in correctly is never +// counted at all. +type LoginGuard struct { + account *AttemptLimiter + address *AttemptLimiter +} + +// Login limits: 10 failures per account and 50 per address per 15 minutes, +// the same numbers ThoughtSync settled on. Generous enough that a user +// fumbling a password manager never meets them, tight enough that an online +// guess against bcrypt gets ~1,000 tries a day per account. +const ( + loginWindow = 15 * time.Minute + loginAccountMax = 10 + loginAddressMax = 50 +) + +// NewLoginGuard returns a guard with the default login limits. +func NewLoginGuard() *LoginGuard { + return &LoginGuard{ + account: NewAttemptLimiter(loginAccountMax, loginWindow), + address: NewAttemptLimiter(loginAddressMax, loginWindow), + } +} + +// Blocked reports whether either the account or the address is over its +// limit, with the longer of the two waits. Check it BEFORE verifying the +// password, so a blocked guess costs no bcrypt. +func (g *LoginGuard) Blocked(account, address string) (bool, time.Duration) { + if g == nil { + return false, 0 + } + aBlocked, aWait := g.account.Blocked(accountKey(account)) + ipBlocked, ipWait := g.address.Blocked(address) + return aBlocked || ipBlocked, max(aWait, ipWait) +} + +// Fail records a failed attempt against both the account and the address. +// An unknown username counts against its name all the same, so the limit +// gives away nothing about which accounts exist. +func (g *LoginGuard) Fail(account, address string) { + if g == nil { + return + } + g.account.Record(accountKey(account)) + g.address.Record(address) +} + +// Succeed clears the account's failures. The address keeps its count: one +// address that guessed fifty accounts and got one right is still spraying. +func (g *LoginGuard) Succeed(account string) { + if g == nil { + return + } + g.account.Reset(accountKey(account)) +} + +// accountKey folds case so "Admin" and "admin" share one budget. Usernames +// are compared exactly by the lookup, but the guesser shouldn't get a fresh +// allowance per capitalisation. +func accountKey(account string) string { + return strings.ToLower(strings.TrimSpace(account)) +} + +var ( + dummyHashOnce sync.Once + dummyHash []byte +) + +// DummyVerify spends the same bcrypt time a real password check would, for +// the path where the username doesn't exist. Without it, an unknown user +// answers in microseconds and a known one in ~50ms, and the uniform error +// message hides nothing. +func DummyVerify(plaintext string) { + dummyHashOnce.Do(func() { + // What the hash is of doesn't matter — no account carries it. Its + // cost does: DefaultCost, the same as every stored password. + h, err := bcrypt.GenerateFromPassword([]byte("minstrel-dummy-password"), bcrypt.DefaultCost) + if err == nil { + dummyHash = h + } + }) + if dummyHash != nil { + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(plaintext)) + } +} diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go new file mode 100644 index 00000000..6560fb74 --- /dev/null +++ b/internal/auth/ratelimit_test.go @@ -0,0 +1,114 @@ +package auth + +import ( + "testing" + "time" +) + +// fakeClock lets a test step through a window without sleeping. +type fakeClock struct{ t time.Time } + +func (c *fakeClock) now() time.Time { return c.t } + +func newTestLimiter(max int, window time.Duration) (*AttemptLimiter, *fakeClock) { + c := &fakeClock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)} + l := NewAttemptLimiter(max, window) + l.now = c.now + return l, c +} + +func TestAttemptLimiter_BlocksAtMaxAndReportsWait(t *testing.T) { + l, c := newTestLimiter(3, 15*time.Minute) + for i := 0; i < 3; i++ { + if blocked, _ := l.Blocked("k"); blocked { + t.Fatalf("blocked after %d attempts, want allowed below max", i) + } + l.Record("k") + } + c.t = c.t.Add(5 * time.Minute) + blocked, wait := l.Blocked("k") + if !blocked { + t.Fatal("not blocked after max attempts") + } + if wait != 10*time.Minute { + t.Errorf("wait = %v, want the 10m left in the window", wait) + } +} + +func TestAttemptLimiter_WindowExpiryClears(t *testing.T) { + l, c := newTestLimiter(1, time.Minute) + l.Record("k") + if blocked, _ := l.Blocked("k"); !blocked { + t.Fatal("want blocked inside the window") + } + c.t = c.t.Add(time.Minute) + if blocked, _ := l.Blocked("k"); blocked { + t.Fatal("still blocked once the window has passed") + } +} + +func TestAttemptLimiter_KeysAreIndependentAndResetClears(t *testing.T) { + l, _ := newTestLimiter(1, time.Minute) + l.Record("a") + if blocked, _ := l.Blocked("b"); blocked { + t.Fatal("one key's attempts blocked another") + } + l.Reset("a") + if blocked, _ := l.Blocked("a"); blocked { + t.Fatal("Reset did not clear the key") + } +} + +func TestAttemptLimiter_SweepDropsExpiredKeys(t *testing.T) { + l, c := newTestLimiter(5, time.Minute) + // One short of the floor: no sweep yet, however stale these become. + for i := 0; i < sweepFloor-1; i++ { + l.Record("k" + time.Duration(i).String()) + } + c.t = c.t.Add(2 * time.Minute) + l.Record("fresh") // reaches the floor and triggers a sweep + if n := len(l.buckets); n != 1 { + t.Errorf("buckets after sweep = %d, want only the fresh key", n) + } +} + +func TestAttemptLimiter_NilAndEmptyKeyAreNoOps(t *testing.T) { + var l *AttemptLimiter + l.Record("k") + if blocked, _ := l.Blocked("k"); blocked { + t.Error("nil limiter blocked") + } + real, _ := newTestLimiter(1, time.Minute) + real.Record("") + if blocked, _ := real.Blocked(""); blocked { + t.Error("empty key was counted") + } +} + +func TestLoginGuard_AccountLimitSpansAddressesAndFoldsCase(t *testing.T) { + g := NewLoginGuard() + for i := 0; i < loginAccountMax; i++ { + g.Fail("Alice", "10.0.0."+string(rune('0'+i%10))) + } + if blocked, _ := g.Blocked("alice", "192.0.2.1"); !blocked { + t.Fatal("account limit should hold from a fresh address and any capitalisation") + } + g.Succeed("ALICE") + if blocked, _ := g.Blocked("alice", "192.0.2.1"); blocked { + t.Fatal("Succeed should clear the account's failures") + } +} + +func TestLoginGuard_AddressLimitSpansAccounts(t *testing.T) { + g := NewLoginGuard() + for i := 0; i < loginAddressMax; i++ { + g.Fail("user"+time.Duration(i).String(), "203.0.113.9") + } + if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked { + t.Fatal("address that sprayed many accounts should be blocked") + } + g.Succeed("someone-new") + if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked { + t.Fatal("a success must not clear the address count") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 94c59ddd..b0398379 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -220,7 +220,7 @@ func (s *Server) Router() http.Handler { r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()). Post("/api/admin/scan", s.handleAdminScan) } - subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer) + subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer, netSettings.Hops) } spa := web.Handler(s.BrandingCfg) diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go index b651588d..0973011f 100644 --- a/internal/subsonic/auth.go +++ b/internal/subsonic/auth.go @@ -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)) }) diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index 75260ff2..3e5e6d95 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -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) diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index aa0d168e..b984d252 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -2,8 +2,25 @@ export type ApiError = { code: string; message: string; status: number; + /** Seconds until a 429'd request may be retried, from Retry-After. */ + retryAfter?: number; }; +/** + * The sign-in, register and reset screens' wording for a throttled attempt, + * or null when err isn't one. Rounds up to whole minutes: the server's + * windows are minutes long, and "try again in 1 second" after a lockout + * would be untrue the moment it was read. + */ +export function rateLimitMessage(err: unknown): string | null { + const apiErr = err as ApiError | undefined; + if (apiErr?.status !== 429) return null; + const secs = apiErr.retryAfter; + if (!secs || secs <= 0) return 'Too many attempts. Try again in a few minutes.'; + const mins = Math.ceil(secs / 60); + return `Too many attempts. Try again in ${mins} minute${mins === 1 ? '' : 's'}.`; +} + export type User = { id: string; username: string; @@ -45,6 +62,10 @@ export async function apiFetch(path: string, init?: RequestInit): Promise 0) err.retryAfter = retryAfter; + } throw err; } return body; diff --git a/web/src/lib/styles/error-copy.json b/web/src/lib/styles/error-copy.json index 237d999a..d223bf84 100644 --- a/web/src/lib/styles/error-copy.json +++ b/web/src/lib/styles/error-copy.json @@ -4,6 +4,7 @@ "forbidden": "You don't have permission to do that.", "not_authorized": "You don't have permission to do that.", "invalid_credentials": "Wrong username or password.", + "rate_limited": "Too many attempts. Wait a few minutes and try again.", "wrong_password": "Current password is incorrect.", "password_too_short": "Password must be at least 8 characters.", "username_invalid": "That username isn't valid.", diff --git a/web/src/routes/forgot-password/+page.svelte b/web/src/routes/forgot-password/+page.svelte index a10b7fdd..4aa90b5c 100644 --- a/web/src/routes/forgot-password/+page.svelte +++ b/web/src/routes/forgot-password/+page.svelte @@ -1,23 +1,29 @@