Skip to content

Commit c0d5b4a

Browse files
committed
Add password rate limiting for protecting against brute force attacks
1 parent d961125 commit c0d5b4a

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

src/authentication.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Application } from './declarations';
88
import OAuthTenantStrategy from './auth/strategies/OAuthTenantStrategy';
99
import { OAuthService } from '@feathersjs/authentication-oauth/lib/service';
1010
import { dynamicOAuth } from './hooks/dynamicOAuth';
11+
import { loginThrottleBefore, loginThrottleError } from './hooks/loginThrottle';
1112

1213
declare module './declarations' {
1314
interface ServiceTypes {
@@ -26,6 +27,13 @@ export const authentication = (app: Application) => {
2627
authenticationService.register('tenant', new OAuthTenantStrategy());
2728

2829
app.use('authentication', authenticationService);
30+
31+
// Per-IP brute-force throttle + timing pad for the local strategy.
32+
app.service('authentication').hooks({
33+
before: { create: [ loginThrottleBefore ] },
34+
error: { create: [ loginThrottleError ] }
35+
});
36+
2937
// reconfigure / configure oauth this hardcodes the settings, so after change we have to unuse it and re apply
3038
app.configure(oauth());
3139

src/hooks/loginThrottle.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { TooManyRequests } from '@feathersjs/errors';
2+
import { HookContext } from '../declarations';
3+
import { logger } from '../logger';
4+
5+
const WINDOW_MS = 15 * 60 * 1000;
6+
const MAX_FAILURES = 10;
7+
const TIMING_PAD_MS = 400;
8+
const CLEANUP_INTERVAL_MS = 60 * 1000;
9+
10+
interface IpEntry {
11+
failures: number[];
12+
blockedUntil: number;
13+
}
14+
15+
const ipState = new Map<string, IpEntry>();
16+
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
17+
18+
const ensureCleanup = () => {
19+
if (cleanupTimer) return;
20+
cleanupTimer = setInterval(() => {
21+
const now = Date.now();
22+
23+
for (const [ ip, entry ] of ipState) {
24+
entry.failures = entry.failures.filter((t) => now - t < WINDOW_MS);
25+
if (entry.failures.length === 0 && entry.blockedUntil <= now) {
26+
ipState.delete(ip);
27+
}
28+
}
29+
}, CLEANUP_INTERVAL_MS);
30+
cleanupTimer.unref?.();
31+
};
32+
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
const extractIp = (params: any): string => {
35+
const ip = params?.ip;
36+
37+
if (ip) return String(ip);
38+
const xff = params?.headers?.['x-forwarded-for'];
39+
40+
if (typeof xff === 'string') {
41+
const first = xff.split(',')[0]?.trim();
42+
43+
if (first) return first;
44+
}
45+
46+
return 'unknown';
47+
};
48+
49+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
50+
const extractEmail = (data: any): string => {
51+
if (typeof data?.email === 'string') return data.email;
52+
53+
return '<no-email>';
54+
};
55+
56+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
57+
const extractUa = (params: any): string =>
58+
String(params?.headers?.['user-agent'] || '<no-ua>');
59+
60+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
61+
const isLocalLogin = (data: any): boolean => data?.strategy === 'local';
62+
63+
const padTiming = async (start: number) => {
64+
const elapsed = Date.now() - start;
65+
66+
if (elapsed < TIMING_PAD_MS) {
67+
await new Promise((r) => setTimeout(r, TIMING_PAD_MS - elapsed));
68+
}
69+
};
70+
71+
export const loginThrottleBefore = async (context: HookContext) => {
72+
if (!context.params.provider) return;
73+
if (!isLocalLogin(context.data)) return;
74+
75+
ensureCleanup();
76+
const ip = extractIp(context.params);
77+
const now = Date.now();
78+
79+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80+
(context.params as any)._loginStart = now;
81+
82+
const entry = ipState.get(ip);
83+
84+
if (entry && entry.blockedUntil > now) {
85+
const retryAfter = Math.ceil((entry.blockedUntil - now) / 1000);
86+
87+
logger.warn(
88+
'login throttle: blocked ip=%s email=%s ua=%s retryAfter=%ss',
89+
ip, extractEmail(context.data), extractUa(context.params), retryAfter
90+
);
91+
await padTiming(now);
92+
throw new TooManyRequests('Too many login attempts');
93+
}
94+
};
95+
96+
export const loginThrottleError = async (context: HookContext) => {
97+
if (!context.params.provider) return;
98+
if (!isLocalLogin(context.data)) return;
99+
100+
// Don't double-count throttle rejections we threw ourselves.
101+
const errCode = (context.error as { code?: number } | undefined)?.code;
102+
103+
if (errCode === 429) return;
104+
105+
const ip = extractIp(context.params);
106+
const email = extractEmail(context.data);
107+
const ua = extractUa(context.params);
108+
const now = Date.now();
109+
110+
ensureCleanup();
111+
let entry = ipState.get(ip);
112+
113+
if (!entry) {
114+
entry = { failures: [], blockedUntil: 0 };
115+
ipState.set(ip, entry);
116+
}
117+
entry.failures = entry.failures.filter((t) => now - t < WINDOW_MS);
118+
entry.failures.push(now);
119+
120+
if (entry.failures.length >= MAX_FAILURES) {
121+
entry.blockedUntil = now + WINDOW_MS;
122+
logger.warn(
123+
'login throttle: tripped ip=%s email=%s ua=%s failures=%d windowMs=%d',
124+
ip, email, ua, entry.failures.length, WINDOW_MS
125+
);
126+
} else {
127+
logger.warn(
128+
'login failure ip=%s email=%s ua=%s failures=%d',
129+
ip, email, ua, entry.failures.length
130+
);
131+
}
132+
133+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
134+
const start = (context.params as any)._loginStart ?? now;
135+
136+
await padTiming(start);
137+
};

0 commit comments

Comments
 (0)