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
@@ -75,6 +75,7 @@ object ErrorCopy {
"forbidden" to "You don't have permission to do that.", "forbidden" to "You don't have permission to do that.",
"not_authorized" 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.", "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.", "wrong_password" to "Current password is incorrect.",
"password_too_short" to "Password must be at least 8 characters.", "password_too_short" to "Password must be at least 8 characters.",
"username_invalid" to "That username isn't valid.", "username_invalid" to "That username isn't valid.",
+14
View File
@@ -7,6 +7,7 @@ package api
import ( import (
"log/slog" "log/slog"
"math/rand" "math/rand"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool" "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, reacqSettings: reacqSettings,
fingerprintSettings: fpSettings, fingerprintSettings: fpSettings,
librarySize: recommendation.NewLibrarySize(nil), 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) { 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 // instance the scanner and the fingerprint workers read, so a save from the
// admin card reaches them without a restart. Nil serves the defaults. // admin card reaches them without a restart. Nil serves the defaults.
fingerprintSettings *library.FingerprintSettingsService 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 // netSettings caches the trusted reverse-proxy depth read by the auth
// middleware on every request and edited from the admin network card. // middleware on every request and edited from the admin network card.
netSettings *netsettings.Service netSettings *netsettings.Service
+29 -1
View File
@@ -19,6 +19,20 @@ import (
// so an abandoned laptop doesn't stay logged in forever. // so an abandoned laptop doesn't stay logged in forever.
const sessionCookieMaxAge = 30 * 24 * time.Hour 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) { func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
// The session token can be on the cookie OR bearer header — RequireUser // The session token can be on the cookie OR bearer header — RequireUser
// accepted either. Re-resolve it here so we can delete the row. // 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 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) q := dbq.New(h.pool)
user, err := q.GetUserByUsername(r.Context(), req.Username) user, err := q.GetUserByUsername(r.Context(), req.Username)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { 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")) writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
return return
} }
@@ -82,9 +108,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
if !auth.VerifyPassword(user.PasswordHash, req.Password) { if !auth.VerifyPassword(user.PasswordHash, req.Password) {
h.loginGuard.Fail(req.Username, addr)
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password")) writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
return return
} }
h.loginGuard.Succeed(req.Username)
token, err := auth.MintSessionToken() token, err := auth.MintSessionToken()
if err != nil { 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 // the active-sessions surface: a session that was born somewhere the
// user recognises but is being used from somewhere they don't is the // user recognises but is being used from somewhere they don't is the
// case this whole surface exists to surface. // case this whole surface exists to surface.
Ip: auth.ClientIP(r, h.netSettings.Hops()), Ip: addr,
}); err != nil { }); err != nil {
h.logger.Error("api: insert session failed", "err", err) h.logger.Error("api: insert session failed", "err", err)
writeErr(w, apierror.InternalMsg("insert failed", err)) writeErr(w, apierror.InternalMsg("insert failed", err))
+14
View File
@@ -14,6 +14,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit" "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/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "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)) 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) q := dbq.New(h.pool)
var matched bool var matched bool
var auditTarget pgtype.UUID var auditTarget pgtype.UUID
+10 -1
View File
@@ -55,6 +55,15 @@ type registerReq struct {
// - 409 username_taken (PG unique violation on users.username) // - 409 username_taken (PG unique violation on users.username)
// - 500 server_error otherwise // - 500 server_error otherwise
func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { 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 var req registerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body")) 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, UserID: user.ID,
TokenHash: auth.HashSessionToken(sessionToken), TokenHash: auth.HashSessionToken(sessionToken),
UserAgent: r.UserAgent(), UserAgent: r.UserAgent(),
Ip: auth.ClientIP(r, h.netSettings.Hops()), Ip: addr,
}); err != nil { }); err != nil {
h.logger.Error("register: insert session failed", "err", err) h.logger.Error("register: insert session failed", "err", err)
writeErr(w, apierror.Internal(err)) writeErr(w, apierror.Internal(err))
+12
View File
@@ -10,6 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit" "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/db/dbq"
) )
@@ -42,6 +43,15 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
return 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) q := dbq.New(h.pool)
// Look up the reset record so we know which user to update before // 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) reset, err := q.GetPasswordReset(r.Context(), req.Token)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
h.resetLimit.Record(addr)
writeErr(w, apierror.BadRequest("invalid_token", "")) writeErr(w, apierror.BadRequest("invalid_token", ""))
return return
} }
@@ -69,6 +80,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
return return
} }
if rows == 0 { if rows == 0 {
h.resetLimit.Record(addr)
writeErr(w, apierror.BadRequest("invalid_token", "")) writeErr(w, apierror.BadRequest("invalid_token", ""))
return return
} }
+30
View File
@@ -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) { func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
h, _ := testHandlers(t) h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
+14
View File
@@ -3,7 +3,10 @@ package api
import ( import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"math"
"net/http" "net/http"
"strconv"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "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. // writeErrWithLog logs the error at Error level and writes the response.
// Use for 500-class errors where the operator needs the cause in logs. // 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) { func writeErrWithLog(w http.ResponseWriter, logger *slog.Logger, msg string, err error) {
+4
View File
@@ -54,6 +54,10 @@ func Unauthorized(code, message string) *Error {
return &Error{Status: 401, Code: code, Message: message} 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 { func Internal(cause error) *Error {
return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause} return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause}
} }
+205
View File
@@ -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))
}
}
+114
View File
@@ -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")
}
}
+1 -1
View File
@@ -220,7 +220,7 @@ func (s *Server) Router() http.Handler {
r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()). r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()).
Post("/api/admin/scan", s.handleAdminScan) 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) spa := web.Handler(s.BrandingCfg)
+25 -1
View File
@@ -6,12 +6,16 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"math"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "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 // 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 // envelope using the request's f= format; downstream handlers never see an
// unauthenticated request. // 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) user, code, msg := authenticate(r, pool, cfg)
if code == ErrWrongCredentials {
guard.Fail(account, addr)
}
if code != 0 { if code != 0 {
WriteFail(w, r, code, msg) WriteFail(w, r, code, msg)
return return
} }
guard.Succeed(account)
ctx := context.WithValue(r.Context(), userCtxKey, user) ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
+6 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
) )
@@ -19,11 +20,14 @@ import (
// both /rest/foo and /rest/foo.view because client conventions vary. Both // 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 // GET and POST are accepted; Subsonic's auth params live in the query string
// either way. // 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} b := &browseHandlers{pool: pool}
m := newMediaHandlers(pool, events, logger) m := newMediaHandlers(pool, events, logger)
r.Route("/rest", func(sub chi.Router) { 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, "/ping", handlePing)
register(sub, "/getLicense", handleGetLicense) register(sub, "/getLicense", handleGetLicense)
register(sub, "/getMusicFolders", b.getMusicFolders) register(sub, "/getMusicFolders", b.getMusicFolders)
+21
View File
@@ -2,8 +2,25 @@ export type ApiError = {
code: string; code: string;
message: string; message: string;
status: number; 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 = { export type User = {
id: string; id: string;
username: string; username: string;
@@ -45,6 +62,10 @@ export async function apiFetch(path: string, init?: RequestInit): Promise<unknow
message = env.message ?? res.statusText; message = env.message ?? res.statusText;
} }
const err: ApiError = { code, message, status: res.status }; const err: ApiError = { code, message, status: res.status };
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After'));
if (Number.isFinite(retryAfter) && retryAfter > 0) err.retryAfter = retryAfter;
}
throw err; throw err;
} }
return body; return body;
+1
View File
@@ -4,6 +4,7 @@
"forbidden": "You don't have permission to do that.", "forbidden": "You don't have permission to do that.",
"not_authorized": "You don't have permission to do that.", "not_authorized": "You don't have permission to do that.",
"invalid_credentials": "Wrong username or password.", "invalid_credentials": "Wrong username or password.",
"rate_limited": "Too many attempts. Wait a few minutes and try again.",
"wrong_password": "Current password is incorrect.", "wrong_password": "Current password is incorrect.",
"password_too_short": "Password must be at least 8 characters.", "password_too_short": "Password must be at least 8 characters.",
"username_invalid": "That username isn't valid.", "username_invalid": "That username isn't valid.",
+15 -6
View File
@@ -1,23 +1,29 @@
<script lang="ts"> <script lang="ts">
import { pageTitle } from '$lib/branding'; import { pageTitle } from '$lib/branding';
import { forgotPassword } from '$lib/auth/store.svelte'; import { forgotPassword } from '$lib/auth/store.svelte';
import { rateLimitMessage } from '$lib/api/client';
let email = $state(''); let email = $state('');
let submitted = $state(false); let submitted = $state(false);
let submitting = $state(false); let submitting = $state(false);
let throttled = $state<string | null>(null);
async function onSubmit(e: SubmitEvent) { async function onSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
submitting = true; submitting = true;
throttled = null;
try { try {
await forgotPassword(email); await forgotPassword(email);
} catch {
// Swallow — the page always shows the same success message
// regardless of outcome, to mirror the server's no-enumeration
// posture. A failed call must not surface as an unhandled
// rejection in the browser console.
} finally {
submitted = true; submitted = true;
} catch (err) {
// A throttled request says so: the server applies the limit whether
// or not the email is registered, so it reveals nothing, and a
// "check your inbox" for a mail that was never sent would mislead.
// Every other failure is swallowed and shows the same success
// message, mirroring the server's no-enumeration posture.
throttled = rateLimitMessage(err);
submitted = throttled === null;
} finally {
submitting = false; submitting = false;
} }
} }
@@ -53,6 +59,9 @@
> >
{submitting ? 'Sending…' : 'Send reset link'} {submitting ? 'Sending…' : 'Send reset link'}
</button> </button>
{#if throttled}
<p class="text-sm text-danger" role="alert">{throttled}</p>
{/if}
</form> </form>
{:else} {:else}
<p class="text-sm text-text-primary"> <p class="text-sm text-text-primary">
+5 -2
View File
@@ -3,7 +3,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { login } from '$lib/auth/store.svelte'; import { login } from '$lib/auth/store.svelte';
import type { ApiError } from '$lib/api/client'; import { rateLimitMessage, type ApiError } from '$lib/api/client';
let username = $state(''); let username = $state('');
let password = $state(''); let password = $state('');
@@ -30,7 +30,10 @@
goto(dest, { replaceState: true }); goto(dest, { replaceState: true });
} catch (err) { } catch (err) {
const apiErr = err as ApiError; const apiErr = err as ApiError;
if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') { const throttled = rateLimitMessage(err);
if (throttled) {
error = throttled;
} else if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') {
error = 'Invalid username or password.'; error = 'Invalid username or password.';
password = ''; password = '';
} else { } else {
+2 -1
View File
@@ -3,6 +3,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { register } from '$lib/auth/store.svelte'; import { register } from '$lib/auth/store.svelte';
import { errCode } from '$lib/api/errors'; import { errCode } from '$lib/api/errors';
import { rateLimitMessage } from '$lib/api/client';
let username = $state(''); let username = $state('');
let password = $state(''); let password = $state('');
@@ -47,7 +48,7 @@
}); });
await goto('/', { replaceState: true }); await goto('/', { replaceState: true });
} catch (err: unknown) { } catch (err: unknown) {
error = errorMessageFor(errCode(err)); error = rateLimitMessage(err) ?? errorMessageFor(errCode(err));
} finally { } finally {
submitting = false; submitting = false;
} }
@@ -4,6 +4,7 @@
import { pageTitle } from '$lib/branding'; import { pageTitle } from '$lib/branding';
import { resetPassword } from '$lib/auth/store.svelte'; import { resetPassword } from '$lib/auth/store.svelte';
import { errCode } from '$lib/api/errors'; import { errCode } from '$lib/api/errors';
import { rateLimitMessage } from '$lib/api/client';
let newPassword = $state(''); let newPassword = $state('');
let confirmPassword = $state(''); let confirmPassword = $state('');
@@ -29,7 +30,10 @@
await goto('/login?reset=ok', { replaceState: true }); await goto('/login?reset=ok', { replaceState: true });
} catch (e: unknown) { } catch (e: unknown) {
const code = errCode(e); const code = errCode(e);
if (code === 'invalid_token') { const throttled = rateLimitMessage(e);
if (throttled) {
error = throttled;
} else if (code === 'invalid_token') {
error = 'This reset link is invalid, expired, or already used. Request a new one.'; error = 'This reset link is invalid, expired, or already used. Request a new one.';
} else if (code === 'password_too_short') { } else if (code === 'password_too_short') {
error = 'Password must be at least 8 characters.'; error = 'Password must be at least 8 characters.';