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
30 changes: 30 additions & 0 deletions src/api/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,37 @@ import type { Context } from 'hono';
import { setCookie } from 'hono/cookie';
import { createSession, getUserByEmail } from '../../db/repositories/usersRepository.js';
import { SESSION_COOKIE_NAME } from './cookie.js';
import { checkRateLimit, recordSuccessfulLogin } from './rateLimiter.js';

const SESSION_EXPIRY_DAYS = 30;

/**
* Extract the client IP from a Hono context.
* Checks x-forwarded-for first (for reverse-proxy deployments), then falls
* back to the raw remote address.
*/
function getClientIp(c: Context): string {
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) {
// x-forwarded-for may contain a comma-separated list; take the first value
return forwarded.split(',')[0].trim();
}
// Hono exposes the raw Request; in Node.js the remote address isn't directly
// available on Request, so fall back to a sentinel value that still works for
// rate limiting purposes in environments that don't set x-forwarded-for.
return 'unknown';
}

export async function loginHandler(c: Context) {
const ip = getClientIp(c);

// Rate-limit check (before parsing credentials to avoid wasted work)
const rateCheck = checkRateLimit(ip);
if (rateCheck.limited) {
c.header('Retry-After', String(rateCheck.retryAfterSeconds));
return c.json({ error: 'Too many login attempts. Please try again later.' }, 429);
}

const body = await c.req.json<{ email?: string; password?: string }>();
if (!body.email || !body.password) {
return c.json({ error: 'Email and password are required' }, 400);
Expand All @@ -23,6 +50,9 @@ export async function loginHandler(c: Context) {
return c.json({ error: 'Invalid credentials' }, 401);
}

// Successful login — reset the rate-limit counter for this IP
recordSuccessfulLogin(ip);

const token = randomBytes(64).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000);

Expand Down
108 changes: 108 additions & 0 deletions src/api/auth/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* In-memory sliding-window rate limiter for the login endpoint.
*
* Tracks login attempts per IP address. After MAX_ATTEMPTS within the
* WINDOW_MS window, subsequent requests are rejected with a 429 response
* that includes a Retry-After header.
*
* Only failed login attempts are counted. A successful login resets the
* counter for the IP so it is not counted against the rate limit.
*
* A cleanup interval runs every CLEANUP_INTERVAL_MS to evict expired entries
* and prevent unbounded memory growth.
*/

export const MAX_ATTEMPTS = 10;
export const WINDOW_MS = 60_000; // 1 minute

const CLEANUP_INTERVAL_MS = 5 * 60_000; // 5 minutes

interface RateLimitEntry {
count: number;
resetAt: number;
}

// Exported for testing
export const rateLimitStore = new Map<string, RateLimitEntry>();

let cleanupTimer: ReturnType<typeof setInterval> | null = null;

/**
* Start the periodic cleanup interval (idempotent).
* Called lazily on first use so tests can control timing.
*/
function ensureCleanupStarted(): void {
if (cleanupTimer !== null) return;
cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [ip, entry] of rateLimitStore) {
if (now >= entry.resetAt) {
rateLimitStore.delete(ip);
}
}
}, CLEANUP_INTERVAL_MS);
// Allow Node.js process to exit even if the interval is running
if (cleanupTimer.unref) {
cleanupTimer.unref();
}
}

/**
* Reset the cleanup timer — used in tests only.
*/
export function _resetForTesting(): void {
if (cleanupTimer !== null) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
rateLimitStore.clear();
}

/**
* Run the cleanup sweep — used in tests only.
*/
export function _runCleanup(): void {
const now = Date.now();
for (const [ip, entry] of rateLimitStore) {
if (now >= entry.resetAt) {
rateLimitStore.delete(ip);
}
}
}

/**
* Check whether the IP has exceeded the rate limit.
*
* @returns `{ limited: false }` when within the limit, or
* `{ limited: true, retryAfterSeconds: number }` when over the limit.
*/
export function checkRateLimit(
ip: string,
): { limited: false } | { limited: true; retryAfterSeconds: number } {
ensureCleanupStarted();

const now = Date.now();
const entry = rateLimitStore.get(ip);

if (!entry || now >= entry.resetAt) {
// No entry or window has expired — first attempt in a new window
rateLimitStore.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return { limited: false };
}

if (entry.count >= MAX_ATTEMPTS) {
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
return { limited: true, retryAfterSeconds };
}

entry.count += 1;
return { limited: false };
}

/**
* Record a successful login for the IP — resets the counter so successful
* logins are not counted against the rate limit.
*/
export function recordSuccessfulLogin(ip: string): void {
rateLimitStore.delete(ip);
}
103 changes: 100 additions & 3 deletions tests/unit/api/auth/login.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockGetUserByEmail = vi.fn();
const mockCreateSession = vi.fn();
const mockBcryptCompare = vi.fn();
const mockCheckRateLimit = vi.fn();
const mockRecordSuccessfulLogin = vi.fn();

vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({
getUserByEmail: (...args: unknown[]) => mockGetUserByEmail(...args),
Expand All @@ -16,6 +18,11 @@ vi.mock('bcrypt', () => ({
},
}));

vi.mock('../../../../src/api/auth/rateLimiter.js', () => ({
checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args),
recordSuccessfulLogin: (...args: unknown[]) => mockRecordSuccessfulLogin(...args),
}));

import { SESSION_COOKIE_NAME } from '../../../../src/api/auth/cookie.js';
import { loginHandler } from '../../../../src/api/auth/login.js';

Expand All @@ -25,10 +32,10 @@ function createTestApp() {
return app;
}

function postLogin(app: Hono, body: Record<string, unknown>) {
function postLogin(app: Hono, body: Record<string, unknown>, headers?: Record<string, string>) {
return app.request('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
}
Expand All @@ -43,6 +50,16 @@ const mockUser = {
};

describe('loginHandler', () => {
beforeEach(() => {
// Default: not rate-limited
mockCheckRateLimit.mockReturnValue({ limited: false });
mockRecordSuccessfulLogin.mockReturnValue(undefined);
});

afterEach(() => {
vi.clearAllMocks();
});

it('returns 400 when email is missing', async () => {
const app = createTestApp();
const res = await postLogin(app, { password: 'pass' });
Expand Down Expand Up @@ -123,4 +140,84 @@ describe('loginHandler', () => {
const expectedExpiry = Date.now() + thirtyDaysMs;
expect(Math.abs(expiresAt.getTime() - expectedExpiry)).toBeLessThan(5000);
});

describe('rate limiting', () => {
it('returns 429 when the rate limit is exceeded', async () => {
mockCheckRateLimit.mockReturnValue({ limited: true, retryAfterSeconds: 45 });
const app = createTestApp();

const res = await postLogin(app, { email: 'test@example.com', password: 'pass' });

expect(res.status).toBe(429);
const body = await res.json();
expect(body.error).toMatch(/too many/i);
});

it('includes Retry-After header when rate-limited', async () => {
mockCheckRateLimit.mockReturnValue({ limited: true, retryAfterSeconds: 30 });
const app = createTestApp();

const res = await postLogin(app, { email: 'test@example.com', password: 'pass' });

expect(res.headers.get('Retry-After')).toBe('30');
});

it('does not call getUserByEmail when rate-limited', async () => {
mockCheckRateLimit.mockReturnValue({ limited: true, retryAfterSeconds: 10 });
const app = createTestApp();

await postLogin(app, { email: 'test@example.com', password: 'pass' });

expect(mockGetUserByEmail).not.toHaveBeenCalled();
});

it('calls checkRateLimit with the IP from x-forwarded-for header', async () => {
const app = createTestApp();

await postLogin(
app,
{ email: 'test@example.com', password: 'pass' },
{ 'x-forwarded-for': '203.0.113.42' },
);

expect(mockCheckRateLimit).toHaveBeenCalledWith('203.0.113.42');
});

it('uses the first IP when x-forwarded-for is a comma-separated list', async () => {
const app = createTestApp();

await postLogin(
app,
{ email: 'test@example.com', password: 'pass' },
{ 'x-forwarded-for': '203.0.113.42, 10.0.0.1, 192.168.1.1' },
);

expect(mockCheckRateLimit).toHaveBeenCalledWith('203.0.113.42');
});

it('calls recordSuccessfulLogin on successful authentication', async () => {
mockGetUserByEmail.mockResolvedValue(mockUser);
mockBcryptCompare.mockResolvedValue(true);
mockCreateSession.mockResolvedValue('session-id');
const app = createTestApp();

await postLogin(
app,
{ email: 'test@example.com', password: 'correct' },
{ 'x-forwarded-for': '203.0.113.42' },
);

expect(mockRecordSuccessfulLogin).toHaveBeenCalledWith('203.0.113.42');
});

it('does not call recordSuccessfulLogin on failed authentication', async () => {
mockGetUserByEmail.mockResolvedValue(mockUser);
mockBcryptCompare.mockResolvedValue(false);
const app = createTestApp();

await postLogin(app, { email: 'test@example.com', password: 'wrong' });

expect(mockRecordSuccessfulLogin).not.toHaveBeenCalled();
});
});
});
Loading
Loading