Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),

Expand Down
5 changes: 5 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 38 additions & 1 deletion src/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<void> {
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
Expand Down
135 changes: 135 additions & 0 deletions src/middleware/loginThrottle.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
92 changes: 92 additions & 0 deletions src/middleware/loginThrottle.ts
Original file line number Diff line number Diff line change
@@ -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<string, LoginAttemptRecord>();

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 };
23 changes: 23 additions & 0 deletions src/routes/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down