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
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:
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-1
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<unknow
|
||||
message = env.message ?? res.statusText;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return body;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { forgotPassword } from '$lib/auth/store.svelte';
|
||||
import { rateLimitMessage } from '$lib/api/client';
|
||||
|
||||
let email = $state('');
|
||||
let submitted = $state(false);
|
||||
let submitting = $state(false);
|
||||
let throttled = $state<string | null>(null);
|
||||
|
||||
async function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitting = true;
|
||||
throttled = null;
|
||||
try {
|
||||
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;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +59,9 @@
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
{#if throttled}
|
||||
<p class="text-sm text-danger" role="alert">{throttled}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<p class="text-sm text-text-primary">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
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 password = $state('');
|
||||
@@ -30,7 +30,10 @@
|
||||
goto(dest, { replaceState: true });
|
||||
} catch (err) {
|
||||
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.';
|
||||
password = '';
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { register } from '$lib/auth/store.svelte';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { rateLimitMessage } from '$lib/api/client';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
@@ -47,7 +48,7 @@
|
||||
});
|
||||
await goto('/', { replaceState: true });
|
||||
} catch (err: unknown) {
|
||||
error = errorMessageFor(errCode(err));
|
||||
error = rateLimitMessage(err) ?? errorMessageFor(errCode(err));
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { resetPassword } from '$lib/auth/store.svelte';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { rateLimitMessage } from '$lib/api/client';
|
||||
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
@@ -29,7 +30,10 @@
|
||||
await goto('/login?reset=ok', { replaceState: true });
|
||||
} catch (e: unknown) {
|
||||
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.';
|
||||
} else if (code === 'password_too_short') {
|
||||
error = 'Password must be at least 8 characters.';
|
||||
|
||||
Reference in New Issue
Block a user