From 59a12961d5d4d7c6cc3f80dc62c7e406e15df03c Mon Sep 17 00:00:00 2001 From: ayomidearegbeshola29-dev Date: Sun, 28 Jun 2026 16:17:17 +0000 Subject: [PATCH] feat: IP-based rate limiting for login endpoint - Create loginThrottle middleware for /auth/wallet endpoint - Add environment configuration (LOGIN_RATE_LIMIT_MAX_REQUESTS, LOGIN_RATE_LIMIT_WINDOW_MS) - Apply sliding window rate limit per IP address - Support proxy headers for accurate IP detection when TRUST_PROXY_HEADERS=true - Return 429 with Retry-After header when limit exceeded - Use standardized error envelope (code, message, requestId) - Add comprehensive unit tests for middleware and limiter class --- src/config/env.ts | 5 +- src/config/index.ts | 5 + src/controllers/authController.ts | 39 +++++++- src/middleware/loginThrottle.test.ts | 135 +++++++++++++++++++++++++++ src/middleware/loginThrottle.ts | 92 ++++++++++++++++++ src/routes/authRoutes.ts | 23 +++++ 6 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/middleware/loginThrottle.test.ts create mode 100644 src/middleware/loginThrottle.ts diff --git a/src/config/env.ts b/src/config/env.ts index 20ab6ca..79ed1c0 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -89,12 +89,15 @@ export const envSchema = z WEBHOOK_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().optional(), WEBHOOK_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().optional(), WEBHOOK_SECRET_ROTATION_GRACE_MS: z.coerce.number().int().positive().default(24 * 60 * 60 * 1000), - // Generic rate limiter (optional legacy config) RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().optional(), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().optional(), RATE_LIMIT_STORE: z.string().optional(), RATE_LIMIT_PG_TABLE: z.string().optional(), + // Login rate limiting (IP-based throttling for auth attempts) + LOGIN_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(5), + LOGIN_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), // 1 minute sliding window + // CORS CORS_ALLOWED_ORIGINS: z.string().default("http://localhost:5173"), diff --git a/src/config/index.ts b/src/config/index.ts index 8fcdf5f..4b9f795 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -150,6 +150,11 @@ export const config = { secretRotationGraceMs: env.WEBHOOK_SECRET_ROTATION_GRACE_MS, }, + loginRateLimit: { + windowMs: env.LOGIN_RATE_LIMIT_WINDOW_MS, + maxRequests: env.LOGIN_RATE_LIMIT_MAX_REQUESTS, + }, + rateLimiter: { maxRequests: env.RATE_LIMIT_MAX_REQUESTS, windowMs: env.RATE_LIMIT_WINDOW_MS, diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index d816693..bc2793b 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -3,6 +3,7 @@ import { RefreshTokenService } from '../services/refreshTokenService.js'; import type { RefreshTokenRepository } from '../repositories/refreshTokenRepository.js'; import { logger } from '../logger.js'; import { UnauthorizedError } from '../errors/index.js'; +import { getClientIp, DEFAULT_PROXY_HEADERS } from '../lib/clientIp.js'; export interface AuthControllerOptions { refreshTokenService: RefreshTokenService; @@ -19,7 +20,43 @@ export class AuthController { } /** - * Refresh access token using a valid refresh token. + * Wallet-based login with IP-based rate limiting applied at the route level. + * Returns JWT on successful signature verification. + * + * POST /auth/wallet + * Rate limited to 5 requests per minute per IP by loginThrottle middleware. + */ + async walletLogin(req: Request, res: Response, next: NextFunction): Promise { + try { + const { walletAddress, signature, message } = req.body; + + if (!walletAddress || !signature || !message) { + next(new UnauthorizedError('Missing required fields', 'MISSING_AUTH_FIELDS')); + return; + } + + // Extract client IP for structured logging + const clientIp = getClientIp(req, process.env.TRUST_PROXY_HEADERS === 'true', DEFAULT_PROXY_HEADERS); + + logger.info('[AuthController] Wallet login attempt', { + walletAddress, + clientIp, + }); + + // TODO: Implement actual Stellar signature verification + // This would typically call a service to verify the wallet signature + // against the message and derive a user ID for JWT generation + + next(new UnauthorizedError('Wallet login not fully implemented', 'AUTH_NOT_IMPLEMENTED')); + + } catch (error) { + logger.error('[AuthController] Error during wallet login', { error }); + next(new UnauthorizedError('Login failed', 'REFRESH_FAILED')); + } + } + + /** + * Refresh access token using a valid refresh token. * * On success the consumed refresh token is revoked and a fresh token pair * (access + refresh) is returned. Single-use enforcement means a reused diff --git a/src/middleware/loginThrottle.test.ts b/src/middleware/loginThrottle.test.ts new file mode 100644 index 0000000..cf9da2c --- /dev/null +++ b/src/middleware/loginThrottle.test.ts @@ -0,0 +1,135 @@ +import express from 'express'; +import request from 'supertest'; +import { createLoginThrottle, InMemoryLoginRateLimiter } from './loginThrottle.js'; +import { errorHandler } from './errorHandler.js'; + +function buildThrottleApp(limiter?: InMemoryLoginRateLimiter, trustProxy = false) { + const app = express(); + const options = trustProxy + ? { windowMs: 60_000, maxRequests: 3, trustProxy: true } + : { windowMs: 60_000, maxRequests: 3 }; + const throttle = createLoginThrottle(options, limiter); + + app.use(express.json()); + app.post('/login', throttle, (_req, res) => { + res.status(200).json({ message: 'Login accepted' }); + }); + app.use(errorHandler); + return app; +} + +describe('loginThrottle middleware', () => { + it('allows requests under the limit', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 3); + const app = buildThrottleApp(limiter); + + // All requests from same IP (socket) should be allowed + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + }); + + it('returns 429 with Retry-After header after limit is exceeded', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 3); + const app = buildThrottleApp(limiter); + + // Exhaust the limit using the same IP (will use socket address) + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + + const response = await request(app).post('/login'); + + expect(response.status).toBe(429); + expect(response.body.code).toBe('TOO_MANY_REQUESTS'); + expect(response.headers['retry-after']).toBeDefined(); + expect(typeof response.body.retryAfterMs).toBe('number'); + }); + + it('tracks limits separately per IP with trustProxy enabled', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 2); + const app = buildThrottleApp(limiter, true); + + // First IP + await request(app).post('/login').set('X-Forwarded-For', '10.0.0.1').expect(200); + await request(app).post('/login').set('X-Forwarded-For', '10.0.0.1').expect(200); + + // Second IP - should be allowed + await request(app).post('/login').set('X-Forwarded-For', '10.0.0.2').expect(200); + + // First IP should be throttled now + const response = await request(app).post('/login').set('X-Forwarded-For', '10.0.0.1'); + + expect(response.status).toBe(429); + expect(response.body.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('resets the window after expiry', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 2); + const app = buildThrottleApp(limiter); + + // Use direct limiter manipulation for time travel test + limiter.check('10.0.0.1'); + limiter.check('10.0.0.1'); + + // Fast-forward time past the window by passing a future timestamp + const futureTime = Date.now() + 61_000; + const result = limiter.check('10.0.0.1', futureTime); + + // Should be allowed after window expiry + expect(result.allowed).toBe(true); + }); + + it('returns consistent retryAfterMs and Retry-After values', async () => { + const app = buildThrottleApp(); + + // Exhaust the limit + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + + const response = await request(app).post('/login'); + + const retryAfterSeconds = Number(response.headers['retry-after']); + const retryAfterMs = response.body.retryAfterMs; + + // retryAfterMs should be consistent with Retry-After header + expect(Math.ceil(retryAfterMs / 1000) * 1000).toBeLessThanOrEqual(retryAfterSeconds * 1000); + expect(retryAfterMs).toBeGreaterThan(0); + }); +}); + +describe('InMemoryLoginRateLimiter', () => { + it('creates a bucket on first check', () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 5); + const result = limiter.check('192.168.1.1'); + + expect(result.allowed).toBe(true); + expect(limiter.getBucket('192.168.1.1')).toEqual({ + count: 1, + resetAt: expect.any(Number), + }); + }); + + it('rejects when limit is exceeded', () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 2); + + limiter.check('10.0.0.1'); + limiter.check('10.0.0.1'); + const result = limiter.check('10.0.0.1'); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + }); + + it('clears all buckets on reset', () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 5); + + limiter.check('192.168.1.1'); + limiter.check('192.168.1.2'); + limiter.reset(); + + expect(limiter.getBucket('192.168.1.1')).toBeUndefined(); + expect(limiter.getBucket('192.168.1.2')).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/middleware/loginThrottle.ts b/src/middleware/loginThrottle.ts new file mode 100644 index 0000000..41aeb3a --- /dev/null +++ b/src/middleware/loginThrottle.ts @@ -0,0 +1,92 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { getClientIp, DEFAULT_PROXY_HEADERS } from '../lib/clientIp.js'; + +export interface LoginThrottleOptions { + windowMs: number; + maxRequests: number; + trustProxy?: boolean; +} + +interface LoginAttemptRecord { + count: number; + resetAt: number; +} + +export class InMemoryLoginRateLimiter { + private readonly buckets = new Map(); + + constructor( + private readonly windowMs: number, + private readonly maxRequests: number, + ) {} + + check(ip: string, now = Date.now()): { allowed: boolean; retryAfterMs?: number } { + const bucket = this.buckets.get(ip); + + if (!bucket || now >= bucket.resetAt) { + this.buckets.set(ip, { + count: 1, + resetAt: now + this.windowMs, + }); + return { allowed: true }; + } + + if (bucket.count >= this.maxRequests) { + return { + allowed: false, + retryAfterMs: Math.max(bucket.resetAt - now, 0), + }; + } + + bucket.count += 1; + return { allowed: true }; + } + + reset(): void { + this.buckets.clear(); + } + + getBucket(ip: string): LoginAttemptRecord | undefined { + return this.buckets.get(ip); + } +} + +export function createLoginThrottle( + options: LoginThrottleOptions, + limiter = new InMemoryLoginRateLimiter(options.windowMs, options.maxRequests), +): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const trustProxy = options.trustProxy ?? false; + const ip = getClientIp(req, trustProxy, DEFAULT_PROXY_HEADERS); + + // Skip throttling for missing IP (should not happen in production) + if (!ip) { + next(); + return; + } + + // In test mode (trustProxy=false), use req.ip if set (simulated socket address) + const clientIp = ip || req.ip || ''; + + const result = limiter.check(clientIp); + + if (!result.allowed) { + const retryAfterMs = result.retryAfterMs ?? options.windowMs; + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)); + const requestId: string = (req as Request & { id?: string }).id ?? 'unknown'; + + res.set('Retry-After', String(retryAfterSeconds)); + res.status(429).json({ + code: 'TOO_MANY_REQUESTS', + message: 'Too Many Requests', + requestId, + retryAfterMs, + }); + return; + } + + next(); + }; +} + +export type { LoginThrottleOptions }; \ No newline at end of file diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts index d43f712..e6b8838 100644 --- a/src/routes/authRoutes.ts +++ b/src/routes/authRoutes.ts @@ -2,15 +2,38 @@ import { Router } from 'express'; import { AuthController } from '../controllers/authController.js'; import { requireAuth } from '../middleware/requireAuth.js'; import { bodyValidator } from '../middleware/validate.js'; +import { createLoginThrottle } from '../middleware/loginThrottle.js'; +import { config } from '../config/index.js'; import { z } from 'zod'; const refreshTokenSchema = z.object({ refreshToken: z.string().min(1, 'Refresh token is required') }); +// Login throttle with proxy support for accurate IP detection behind load balancers +const loginThrottle = createLoginThrottle({ + windowMs: config.loginRateLimit.windowMs, + maxRequests: config.loginRateLimit.maxRequests, + trustProxy: process.env.TRUST_PROXY_HEADERS === 'true', +}); + +const walletLoginSchema = z.object({ + walletAddress: z.string().min(1, 'Wallet address is required'), + signature: z.string().min(1, 'Signature is required'), + message: z.string().min(1, 'Message is required'), +}); + export function createAuthRoutes(authController: AuthController): Router { const router = Router(); + // POST /auth/wallet - Wallet-based login with IP throttling + // Rate limited to prevent brute force attacks + router.post('/wallet', + loginThrottle, + bodyValidator(walletLoginSchema), + (req, res, next) => authController.walletLogin(req, res, next) + ); + // Refresh access token router.post('/refresh', bodyValidator(refreshTokenSchema),