diff --git a/package-lock.json b/package-lock.json index 19a24d6..ba7fbe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "express-rate-limit": "^8.6.2", "helmet": "^8.3.0", "hpp": "^0.2.3", + "ioredis": "^6.0.0", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", "pg": "^8.23.0", @@ -32,6 +33,7 @@ }, "devDependencies": { "@types/express-rate-limit": "^5.1.3", + "@types/ioredis": "^4.28.10", "@types/jest": "^29.5.12", "@types/supertest": "^6.0.2", "jest": "^29.7.0", @@ -576,6 +578,12 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@ioredis/commands": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz", + "integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==", + "license": "MIT" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1266,6 +1274,16 @@ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "license": "MIT" }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2299,6 +2317,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2570,6 +2597,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3589,6 +3625,27 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz", + "integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "2.0.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", @@ -5569,6 +5626,15 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/require-addon": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", @@ -6005,6 +6071,12 @@ "node": ">=10" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 44f60ec..4667f8d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "express-rate-limit": "^8.6.2", "helmet": "^8.3.0", "hpp": "^0.2.3", + "ioredis": "^6.0.0", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", "pg": "^8.23.0", @@ -47,6 +48,7 @@ }, "devDependencies": { "@types/express-rate-limit": "^5.1.3", + "@types/ioredis": "^4.28.10", "@types/jest": "^29.5.12", "@types/supertest": "^6.0.2", "jest": "^29.7.0", @@ -56,4 +58,4 @@ "typedoc": "^0.28.20", "typescript": "^5.5.2" } -} \ No newline at end of file +} diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts new file mode 100644 index 0000000..d4ce512 --- /dev/null +++ b/src/middleware/rateLimit.ts @@ -0,0 +1,64 @@ +import { Request, Response, NextFunction } from 'express'; +import Redis from 'ioredis'; +import { AppError } from './error.middleware.js'; + +// Setup Redis Client +const redisClient = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', { + lazyConnect: true, + maxRetriesPerRequest: 0, +}); + +redisClient.on('error', (err) => { + console.error('Redis connection error:', err); +}); + +/** + * Redis-backed sliding window rate limiter + * + * @param windowMs Window duration in milliseconds + * @param maxRequests Maximum allowed requests per window + */ +export function slidingWindowRateLimiter(windowMs: number, maxRequests: number) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const ip = req.ip || req.socket.remoteAddress || 'unknown'; + const key = `ratelimit:${req.originalUrl}:${ip}`; + const now = Date.now(); + const windowStart = now - windowMs; + + const multi = redisClient.multi(); + // Remove old requests outside the window + multi.zremrangebyscore(key, 0, windowStart); + // Add current request timestamp + multi.zadd(key, now, `${now}-${Math.random()}`); + // Count requests in the window + multi.zcard(key); + // Expire the key after the window duration to save memory + multi.pexpire(key, windowMs); + + const results = await multi.exec(); + + if (!results) { + throw new Error('Redis transaction failed'); + } + + const requestCount = results[2][1] as number; + + res.setHeader('X-RateLimit-Limit', maxRequests); + res.setHeader('X-RateLimit-Remaining', Math.max(0, maxRequests - requestCount)); + + if (requestCount > maxRequests) { + throw new AppError(429, 'TOO_MANY_REQUESTS', 'Rate limit exceeded. Please try again later.'); + } + + next(); + } catch (err) { + if (err instanceof AppError) { + return next(err); + } + console.error('Rate limiter error, failing open', err); + // Fail open if Redis is down + next(); + } + }; +} diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 59cf847..ee1fb4c 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -16,14 +16,18 @@ const tokenSchema = z.object({ }) }); +import { slidingWindowRateLimiter } from '../middleware/rateLimit.js'; + export function createAuthRouter(authService: SEP10AuthService): Router { const router = Router(); + const authLimiter = slidingWindowRateLimiter(60000, 100); // 100 requests per minute + /** * GET /api/v1/auth/challenge * Initiates SEP-10 Web Authentication flow by generating a challenge transaction. */ - router.get('/challenge', validate(challengeSchema), (req: Request, res: Response, next: NextFunction) => { + router.get('/challenge', authLimiter, validate(challengeSchema), (req: Request, res: Response, next: NextFunction) => { try { const account = req.query.account as string; const homeDomain = req.query.home_domain as string | undefined; diff --git a/src/services/sep10.service.ts b/src/services/sep10.service.ts index 86c24d4..daa35ba 100644 --- a/src/services/sep10.service.ts +++ b/src/services/sep10.service.ts @@ -1,5 +1,5 @@ import { Keypair, Networks, WebAuth } from '@stellar/stellar-sdk'; -import jwt from 'jsonwebtoken'; +import { AuthUtil } from '../utils/AuthUtil.js'; import { AppError } from '../middleware/error.middleware.js'; import { SEP10ChallengeResponse, SEP10TokenResponse } from '../types/sep.js'; @@ -78,15 +78,7 @@ export class SEP10AuthService { } const clientAccount = signers[0]; - const token = jwt.sign( - { - iss: `https://${this.anchorDomain}/auth`, - sub: clientAccount, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 86400 // 24 hours - }, - this.jwtSecret - ); + const token = AuthUtil.generateSep10Token(clientAccount, this.anchorDomain); return { token }; } catch (err: unknown) { diff --git a/src/utils/AuthUtil.ts b/src/utils/AuthUtil.ts new file mode 100644 index 0000000..b2cb55e --- /dev/null +++ b/src/utils/AuthUtil.ts @@ -0,0 +1,49 @@ +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; + +// Generate an ephemeral RSA keypair for testing/dev if env vars are missing +const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY || privateKey; +export const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY || publicKey; + +export interface Sep10JwtPayload { + iss: string; // The URL of the authorization server + sub: string; // The Stellar account ID of the client + iat: number; // Issued at + exp: number; // Expiration + client_domain?: string; +} + +export class AuthUtil { + /** + * Generates a SEP-10 compliant JWT using the RS256 algorithm. + */ + static generateSep10Token(accountId: string, domain: string, clientDomain?: string): string { + const payload: Partial = { + iss: `https://${domain}/auth`, + sub: accountId, + }; + + if (clientDomain) { + payload.client_domain = clientDomain; + } + + // Use RS256 algorithm as required by the overhaul + return jwt.sign(payload, PRIVATE_KEY, { + algorithm: 'RS256', + expiresIn: '24h' // 24 hours as specified in original sep10.service.ts + }); + } + + /** + * Verifies an RS256 JWT token. + */ + static verifySep10Token(token: string): Sep10JwtPayload { + return jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] }) as Sep10JwtPayload; + } +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts deleted file mode 100644 index 1d47779..0000000 --- a/src/utils/jwt.ts +++ /dev/null @@ -1,29 +0,0 @@ -import jwt from 'jsonwebtoken'; - -const JWT_SECRET = process.env.JWT_SECRET || 'fallback-dev-secret-do-not-use-in-prod'; -const JWT_EXPIRES_IN = '15m'; // SEP-10 tokens must be short-lived - -export interface Sep10JwtPayload { - iss: string; // The URL of the authorization server - sub: string; // The Stellar account ID of the client - iat: number; // Issued at - exp: number; // Expiration - client_domain?: string; -} - -export function generateSep10Token(accountId: string, domain: string, clientDomain?: string): string { - const payload: Partial = { - iss: domain, - sub: accountId, - }; - - if (clientDomain) { - payload.client_domain = clientDomain; - } - - return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); -} - -export function verifySep10Token(token: string): Sep10JwtPayload { - return jwt.verify(token, JWT_SECRET) as Sep10JwtPayload; -} diff --git a/tests/rateLimit.test.ts b/tests/rateLimit.test.ts new file mode 100644 index 0000000..02ae911 --- /dev/null +++ b/tests/rateLimit.test.ts @@ -0,0 +1,43 @@ +import request from 'supertest'; +import app from '../src/app'; + +// Note: ioredis needs to be mocked or a local redis instance must be running. +// If redis is down, it fails open, so for a unit test, we should mock the redis client or the middleware. +// For true integration test, we can mock the ioredis package. + +jest.mock('ioredis', () => { + const RedisMock = jest.fn().mockImplementation(() => { + let requests = 0; + return { + on: jest.fn(), + multi: jest.fn().mockReturnValue({ + zremrangebyscore: jest.fn().mockReturnThis(), + zadd: jest.fn().mockReturnThis(), + zcard: jest.fn().mockReturnThis(), + pexpire: jest.fn().mockReturnThis(), + exec: jest.fn().mockImplementation(async () => { + requests++; + return [[null, 1], [null, 1], [null, requests], [null, 1]]; + }) + }) + }; + }); + return RedisMock; +}); + +describe('SEP-10 Rate Limiting Integration', () => { + it('should return 429 Too Many Requests after 100 requests', async () => { + // Send 100 successful requests + for (let i = 0; i < 100; i++) { + const res = await request(app) + .get('/api/v1/auth/challenge?account=GCXKG6RN4ONIEPCMNFB732A436Z5IGJUQYA8QW5FN6HP3SG6HQBQVAAK'); + expect(res.status).toBe(200); + } + + // 101st request should be rate limited + const resLimited = await request(app) + .get('/api/v1/auth/challenge?account=GCXKG6RN4ONIEPCMNFB732A436Z5IGJUQYA8QW5FN6HP3SG6HQBQVAAK'); + expect(resLimited.status).toBe(429); + expect(resLimited.body.message).toBe('Rate limit exceeded. Please try again later.'); + }); +});