From bbd292c085ce6a7b1f98b714df6a814af100d2d7 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Sun, 3 May 2026 03:41:51 -0700 Subject: [PATCH] feat(auth): wire lockout, rate-limit, and constant-time login into both routes Both POST /login (HTML form) and POST /api/v1/auth/login now flow through the shared attemptLogin() handler. Locked accounts respond with 401 + Retry-After (generic body "Invalid credentials" / "Invalid username or password") so attackers can't use lockout state as a username-existence oracle. @fastify/rate-limit registered with global=false; only the two login routes opt in via per-route rateLimit config. File uploads and downloads keep full throughput. Custom errorResponseBuilder logs AUTH_RATE_LIMITED fire-and-forget so fail2ban can pick it up. createTestApp now accepts Partial overrides so integration tests can dial thresholds down without env-var mutation. --- src/routes/api/v1/auth.ts | 61 ++++++---- src/routes/pages.ts | 52 ++++++--- src/server.ts | 17 ++- tests/helpers/setup.ts | 9 +- tests/integration/auth-lockout.test.ts | 135 ++++++++++++++++++++++ tests/integration/auth-rate-limit.test.ts | 71 ++++++++++++ 6 files changed, 307 insertions(+), 38 deletions(-) create mode 100644 tests/integration/auth-lockout.test.ts create mode 100644 tests/integration/auth-rate-limit.test.ts diff --git a/src/routes/api/v1/auth.ts b/src/routes/api/v1/auth.ts index 181bcc2..7b11200 100644 --- a/src/routes/api/v1/auth.ts +++ b/src/routes/api/v1/auth.ts @@ -2,14 +2,15 @@ import type { FastifyPluginAsync } from 'fastify'; import type Database from 'better-sqlite3'; import type { Config } from '../../../config.ts'; import type { Logger } from '../../../middleware/logging.ts'; -import { getUserByUsername } from '../../../db/users.ts'; -import { verifyPassword } from '../../../services/auth.ts'; +import type { LockoutService } from '../../../services/lockout.ts'; +import { attemptLogin } from '../../../services/login-handler.ts'; import { requireAuth, tokenCookieOptions } from '../../../middleware/auth.ts'; interface Deps { db: Database.Database; config: Config; logger: Logger; + lockout: LockoutService; } interface LoginBody { @@ -18,30 +19,50 @@ interface LoginBody { } export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { - const { db, config, logger } = deps; + const { config } = deps; - app.post<{ Body: LoginBody }>('/login', async (request, reply) => { - const { username, password } = request.body ?? {}; - const ip = request.ip; - const userAgent = request.headers['user-agent'] ?? ''; + app.post<{ Body: LoginBody }>( + '/login', + { + config: { + rateLimit: { + max: config.loginRateLimitMax, + timeWindow: config.loginRateLimitWindowSeconds * 1000, + }, + }, + }, + async (request, reply) => { + const { username, password } = request.body ?? {}; - if (!username || !password) { - return reply.status(400).send({ error: 'username and password are required' }); - } + const result = await attemptLogin(deps, { + username: username ?? '', + password: password ?? '', + ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', + }); - const user = getUserByUsername(db, username); - const valid = user ? await verifyPassword(password, user.password_hash) : false; + if (result.kind === 'bad_request') { + return reply.status(400).send({ error: 'username and password are required' }); + } - if (!user || !valid) { - await logger.authFailure({ ip, userAgent, username }); - return reply.status(401).send({ error: 'Invalid credentials' }); - } + if (result.kind === 'locked') { + return reply + .status(401) + .header('Retry-After', String(result.retryAfterSeconds)) + .send({ error: 'Invalid credentials' }); + } - await logger.authSuccess({ ip, userAgent, username }); + if (result.kind === 'bad_credentials') { + return reply.status(401).send({ error: 'Invalid credentials' }); + } - const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); - reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true }); - }); + const token = app.jwt.sign( + { sub: result.user.id, username: result.user.username }, + { expiresIn: config.jwtExpiry }, + ); + reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true }); + }, + ); app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { reply.clearCookie('token', { path: '/' }).send({ ok: true }); diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 5e71162..0800896 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -6,10 +6,10 @@ import type Database from 'better-sqlite3'; import type { Config } from '../config.ts'; import type { Logger } from '../middleware/logging.ts'; import type { JwtPayload } from '../types.ts'; -import { getUserByUsername } from '../db/users.ts'; +import type { LockoutService } from '../services/lockout.ts'; import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts'; -import { verifyPassword } from '../services/auth.ts'; import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; +import { attemptLogin } from '../services/login-handler.ts'; import { requireAuth, tokenCookieOptions } from '../middleware/auth.ts'; import { loginPage } from '../views/login.ts'; import { uploadPage, uploadResultPage } from '../views/upload.ts'; @@ -21,6 +21,7 @@ interface Deps { db: Database.Database; config: Config; logger: Logger; + lockout: LockoutService; } function parseRangeHeader(header: string, fileSize: number): { start: number; end: number } | null { @@ -48,6 +49,12 @@ function parseRangeHeader(header: string, fileSize: number): { start: number; en export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { const { db, config, logger } = deps; + const loginRateLimit = { + rateLimit: { + max: config.loginRateLimitMax, + timeWindow: config.loginRateLimitWindowSeconds * 1000, + }, + }; // GET / — login page or redirect if authed app.get('/', async (request, reply) => { @@ -60,24 +67,37 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }); // POST /login — form login - app.post<{ Body: { username?: string; password?: string } }>('/login', async (request, reply) => { - const { username = '', password = '' } = request.body ?? {}; - const ip = request.ip; - const userAgent = request.headers['user-agent'] ?? ''; + app.post<{ Body: { username?: string; password?: string } }>( + '/login', + { config: loginRateLimit }, + async (request, reply) => { + const { username = '', password = '' } = request.body ?? {}; - const user = getUserByUsername(db, username); - const valid = user ? await verifyPassword(password, user.password_hash) : false; + const result = await attemptLogin(deps, { + username, + password, + ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', + }); - if (!user || !valid) { - await logger.authFailure({ ip, userAgent, username }); - return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); - } + if (result.kind === 'locked') { + return reply + .type('text/html') + .header('Retry-After', String(result.retryAfterSeconds)) + .send(loginPage({ error: 'Invalid username or password' })); + } - await logger.authSuccess({ ip, userAgent, username }); + if (result.kind !== 'success') { + return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); + } - const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); - reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload'); - }); + const token = app.jwt.sign( + { sub: result.user.id, username: result.user.username }, + { expiresIn: config.jwtExpiry }, + ); + reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload'); + }, + ); // POST /logout app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { diff --git a/src/server.ts b/src/server.ts index ec5d2a2..cbc0ce3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,11 +4,13 @@ import fastifyJwt from '@fastify/jwt'; import fastifyMultipart from '@fastify/multipart'; import fastifyFormbody from '@fastify/formbody'; import fastifyStatic from '@fastify/static'; +import fastifyRateLimit from '@fastify/rate-limit'; import { join } from 'path'; import { fileURLToPath } from 'url'; import type Database from 'better-sqlite3'; import type { Config } from './config.ts'; import { createLogger } from './middleware/logging.ts'; +import { createLockoutService } from './services/lockout.ts'; import { authApiRoutes } from './routes/api/v1/auth.ts'; import { filesApiRoutes } from './routes/api/v1/files.ts'; import { pageRoutes } from './routes/pages.ts'; @@ -23,6 +25,7 @@ interface ServerDeps { export function createServer({ config, db }: ServerDeps) { const app = Fastify({ logger: false, trustProxy: config.trustProxy }); const logger = createLogger(config.logFile); + const lockout = createLockoutService({ db, config }); app.register(fastifyCookie); app.register(fastifyJwt, { @@ -35,8 +38,20 @@ export function createServer({ config, db }: ServerDeps) { root: join(__dirname, '..', 'public'), prefix: '/public/', }); + app.register(fastifyRateLimit, { + global: false, + keyGenerator: (req) => req.ip, + errorResponseBuilder: (req) => { + void logger.authRateLimited({ + ip: req.ip, + userAgent: req.headers['user-agent'] ?? '', + route: req.url, + }); + return { statusCode: 429, error: 'Too many requests' }; + }, + }); - const deps = { db, config, logger }; + const deps = { db, config, logger, lockout }; app.register(authApiRoutes, { prefix: '/api/v1/auth', deps }); app.register(filesApiRoutes, { prefix: '/api/v1/files', deps }); diff --git a/tests/helpers/setup.ts b/tests/helpers/setup.ts index 1e18d6c..a105ee6 100644 --- a/tests/helpers/setup.ts +++ b/tests/helpers/setup.ts @@ -53,7 +53,7 @@ export interface TestContext { cleanup: () => void; } -export function createTestApp(): TestContext { +export function createTestApp(overrides: Partial = {}): TestContext { const tmpDir = mkdtempSync(join(tmpdir(), 'nanodrop-int-')); const uploadDir = join(tmpDir, 'uploads'); const logFile = join(tmpDir, 'test.log'); @@ -74,6 +74,13 @@ export function createTestApp(): TestContext { baseUrl: 'http://localhost:3000', cookieSecure: false, trustProxy: false, + lockoutThreshold: 5, + lockoutBaseSeconds: 30, + lockoutMaxSeconds: 3600, + loginMinResponseMs: 0, + loginRateLimitMax: 1000, + loginRateLimitWindowSeconds: 60, + ...overrides, }; const app = createServer({ config, db }); diff --git a/tests/integration/auth-lockout.test.ts b/tests/integration/auth-lockout.test.ts new file mode 100644 index 0000000..2feacef --- /dev/null +++ b/tests/integration/auth-lockout.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { createTestApp, type TestContext } from '../helpers/setup.ts'; +import { createUser } from '../../src/db/users.ts'; +import { hashPassword } from '../../src/services/auth.ts'; + +async function attempt( + ctx: TestContext, + username: string, + password: string, + ip = '203.0.113.7', +) { + return ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json', 'x-forwarded-for': ip }, + body: JSON.stringify({ username, password }), + }); +} + +describe('account lockout — JSON login', () => { + let ctx: TestContext; + + beforeEach(async () => { + ctx = createTestApp({ + lockoutThreshold: 3, + lockoutBaseSeconds: 60, + loginMinResponseMs: 0, + loginRateLimitMax: 1000, // effectively off for these cases + }); + const hash = await hashPassword('correct-pw'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + it('locks after threshold failed attempts and emits AUTH_LOCKOUT_TRIGGERED', async () => { + await attempt(ctx, 'alice', 'wrong'); + await attempt(ctx, 'alice', 'wrong'); + const third = await attempt(ctx, 'alice', 'wrong'); + expect(third.statusCode).toBe(401); + + const log = readFileSync(ctx.logFile, 'utf-8'); + expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/); + expect(log).toMatch(/duration_seconds=60/); + }); + + it('rejects correct password while locked with Retry-After header', async () => { + await attempt(ctx, 'alice', 'wrong'); + await attempt(ctx, 'alice', 'wrong'); + await attempt(ctx, 'alice', 'wrong'); + + const blocked = await attempt(ctx, 'alice', 'correct-pw'); + expect(blocked.statusCode).toBe(401); + expect(blocked.headers['retry-after']).toBeDefined(); + expect(parseInt(String(blocked.headers['retry-after']), 10)).toBeGreaterThan(0); + + // No success log was written for the locked attempt + const log = readFileSync(ctx.logFile, 'utf-8'); + expect(log).not.toMatch(/AUTH_SUCCESS/); + }); + + it('successful login resets the counter', async () => { + await attempt(ctx, 'alice', 'wrong'); + await attempt(ctx, 'alice', 'wrong'); + const ok = await attempt(ctx, 'alice', 'correct-pw'); + expect(ok.statusCode).toBe(200); + + // After reset, two more wrong attempts should NOT lock (threshold is 3) + await attempt(ctx, 'alice', 'wrong'); + const second = await attempt(ctx, 'alice', 'wrong'); + expect(second.statusCode).toBe(401); + expect(second.headers['retry-after']).toBeUndefined(); + }); + + it('canonicalizes username — ALICE and alice share the same lockout row', async () => { + await attempt(ctx, 'ALICE', 'wrong'); + await attempt(ctx, 'Alice', 'wrong'); + const third = await attempt(ctx, 'alice', 'wrong'); + expect(third.statusCode).toBe(401); + const log = readFileSync(ctx.logFile, 'utf-8'); + expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/); + }); + + it('unknown user accumulates failures (no enumeration via bypass)', async () => { + await attempt(ctx, 'ghost', 'x'); + await attempt(ctx, 'ghost', 'x'); + await attempt(ctx, 'ghost', 'x'); + const fourth = await attempt(ctx, 'ghost', 'x'); + expect(fourth.statusCode).toBe(401); + expect(fourth.headers['retry-after']).toBeDefined(); + }); +}); + +describe('account lockout — form login', () => { + let ctx: TestContext; + + beforeEach(async () => { + ctx = createTestApp({ + lockoutThreshold: 2, + lockoutBaseSeconds: 30, + loginMinResponseMs: 0, + loginRateLimitMax: 1000, + }); + const hash = await hashPassword('correct-pw'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + async function formAttempt(username: string, password: string) { + return ctx.app.inject({ + method: 'POST', + url: '/login', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`, + }); + } + + it('locks the form login and renders generic error with Retry-After', async () => { + await formAttempt('alice', 'wrong'); + await formAttempt('alice', 'wrong'); + + const blocked = await formAttempt('alice', 'correct-pw'); + expect(blocked.statusCode).toBe(200); // login page re-render, not redirect + expect(blocked.body).toContain('Invalid username or password'); + expect(blocked.headers['retry-after']).toBeDefined(); + }); +}); diff --git a/tests/integration/auth-rate-limit.test.ts b/tests/integration/auth-rate-limit.test.ts new file mode 100644 index 0000000..bfb898b --- /dev/null +++ b/tests/integration/auth-rate-limit.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { createTestApp, type TestContext } from '../helpers/setup.ts'; +import { createUser } from '../../src/db/users.ts'; +import { hashPassword } from '../../src/services/auth.ts'; + +describe('per-IP rate limit on login routes', () => { + let ctx: TestContext; + + beforeEach(async () => { + ctx = createTestApp({ + loginRateLimitMax: 3, + loginRateLimitWindowSeconds: 60, + lockoutThreshold: 100, // disable lockout for this suite + loginMinResponseMs: 0, + }); + const hash = await hashPassword('correct-pw'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + async function loginRequest() { + return ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'wrong' }), + }); + } + + it('returns 429 once IP exceeds the per-route limit and logs AUTH_RATE_LIMITED', async () => { + expect((await loginRequest()).statusCode).toBe(401); + expect((await loginRequest()).statusCode).toBe(401); + expect((await loginRequest()).statusCode).toBe(401); + const fourth = await loginRequest(); + expect(fourth.statusCode).toBe(429); + expect(fourth.json().error).toBe('Too many requests'); + + // Wait for fire-and-forget log write + await new Promise((r) => setTimeout(r, 50)); + const log = readFileSync(ctx.logFile, 'utf-8'); + expect(log).toMatch(/AUTH_RATE_LIMITED/); + expect(log).toMatch(/route="\/api\/v1\/auth\/login"/); + }); + + it('does NOT throttle the file upload endpoint', async () => { + // First, get a valid session + const loginRes = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'correct-pw' }), + }); + expect(loginRes.statusCode).toBe(200); + const cookie = (loginRes.headers['set-cookie'] as string).split(';')[0].replace('token=', ''); + + // Now hit /upload (GET) repeatedly past the login-route limit threshold + for (let i = 0; i < 6; i++) { + const r = await ctx.app.inject({ + method: 'GET', + url: '/upload', + cookies: { token: cookie }, + }); + expect(r.statusCode).toBe(200); + } + }); +});