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:
+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))
|
||||
|
||||
Reference in New Issue
Block a user