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