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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user