Skip to content

Commit 8877f12

Browse files
authored
Merge pull request #197 from Caritajoe18/main
Add tenant-level rate limiting for authentication endpoints,closes #132
2 parents cd537dd + 378dc3f commit 8877f12

4 files changed

Lines changed: 87 additions & 5 deletions

File tree

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
node_modules
2-
dist
1+
node_modules\
2+
dist\
33
*.db
44
coverage
55
*.local
@@ -12,3 +12,4 @@ coverage
1212
monitoring/data/
1313
prometheus-data/
1414
grafana-data/
15+
node_modules

src/middleware/rateLimit.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Request, Response, NextFunction } from 'express'
2+
import { redisConnection } from '../cache/redis.js'
3+
4+
export interface RateLimitOptions {
5+
/** Redis key namespace, e.g. 'ratelimit:login' */
6+
namespace: string
7+
/** Max requests allowed in the window */
8+
max: number
9+
/** Window in seconds */
10+
windowSec: number
11+
/** Function to extract tenant identifier from request */
12+
getTenantId?: (req: Request) => string | undefined
13+
/** Function to extract IP address from request */
14+
getIp?: (req: Request) => string | undefined
15+
}
16+
17+
/**
18+
* Express middleware for tenant/IP rate limiting using Redis counters.
19+
*
20+
* - Supports independent limits for tenant and IP.
21+
* - Returns 429 with standard Retry-After header if exceeded.
22+
*
23+
* Usage:
24+
* app.post('/api/login', rateLimit({ ...options }), handler)
25+
*/
26+
export function rateLimit(options: RateLimitOptions) {
27+
const {
28+
namespace,
29+
max,
30+
windowSec,
31+
getTenantId = (req) => req.headers['x-api-key'] as string | undefined,
32+
getIp = (req) => req.ip,
33+
} = options
34+
35+
return async (req: Request, res: Response, next: NextFunction) => {
36+
const redis = redisConnection.getClient()
37+
const now = Math.floor(Date.now() / 1000)
38+
const windowStart = now - (now % windowSec)
39+
40+
const tenantId = getTenantId(req)
41+
const ip = getIp(req)
42+
43+
// Compose Redis keys
44+
const keys: { key: string; label: string }[] = []
45+
if (tenantId) keys.push({ key: `${namespace}:tenant:${tenantId}:${windowStart}`, label: 'tenant' })
46+
if (ip) keys.push({ key: `${namespace}:ip:${ip}:${windowStart}`, label: 'ip' })
47+
48+
let exceeded = false
49+
let retryAfter = windowSec
50+
51+
for (const { key, label } of keys) {
52+
const count = await redis.incr(key)
53+
if (count === 1) {
54+
await redis.expire(key, windowSec)
55+
}
56+
if (count > max) {
57+
exceeded = true
58+
// Calculate seconds until window resets
59+
const ttl = await redis.ttl(key)
60+
retryAfter = Math.min(retryAfter, ttl > 0 ? ttl : windowSec)
61+
res.setHeader('X-RateLimit-Limit', max)
62+
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - count))
63+
res.setHeader('X-RateLimit-Reset', now + retryAfter)
64+
res.setHeader('Retry-After', retryAfter)
65+
res.status(429).json({
66+
error: 'RateLimitExceeded',
67+
message: `Too many requests for this ${label}. Try again later.`,
68+
retryAfter,
69+
})
70+
return
71+
} else {
72+
res.setHeader('X-RateLimit-Limit', max)
73+
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - count))
74+
res.setHeader('X-RateLimit-Reset', now + windowSec)
75+
}
76+
}
77+
78+
next()
79+
}
80+
}

src/middleware/requestId.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { tracingContext, logger } from '../utils/logger.js'
55
describe('RequestId Middleware', () => {
66
let mockReq: any
77
let mockRes: any
8-
let nextFunction: ReturnType<typeof vi.fn>
8+
let nextFunction: (err?: any) => void
99

1010
beforeEach(() => {
1111
mockReq = {

tsconfig.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@
55
"moduleResolution": "NodeNext",
66

77
"rootDir": "src",
8+
"outDir": "dist",
89
"strict": true,
910
"esModuleInterop": true,
1011
"skipLibCheck": true,
1112

1213
"sourceMap": true,
1314
"allowImportingTsExtensions": true,
1415
"noEmit": true,
15-
"types": ["node", "express", "pg", "vitest"]
16+
"types": ["node", "express", "pg", "vitest/globals"]
1617
},
17-
"include": ["src"],
18+
"include": ["src", "tests"],
1819
"exclude": [
1920
"node_modules",
2021
"dist",

0 commit comments

Comments
 (0)