| 1 |
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; |
| 2 |
|
| 3 |
declare module 'fastify' { |
| 4 |
interface FastifyRequest { |
| 5 |
user?: { |
| 6 |
userId: string; |
| 7 |
username: string; |
| 8 |
sessionId: string; |
| 9 |
}; |
| 10 |
} |
| 11 |
|
| 12 |
interface FastifyInstance { |
| 13 |
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>; |
| 14 |
config: import('../config.js').Config; |
| 15 |
} |
| 16 |
} |
| 17 |
|
| 18 |
export async function authMiddleware(fastify: FastifyInstance) { |
| 19 |
// Register authentication hook |
| 20 |
fastify.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) { |
| 21 |
try { |
| 22 |
// Extract token from Authorization header |
| 23 |
const authHeader = request.headers.authorization; |
| 24 |
if (!authHeader || !authHeader.startsWith('Bearer ')) { |
| 25 |
throw fastify.httpErrors.unauthorized('Authorization header required'); |
| 26 |
} |
| 27 |
|
| 28 |
const token = authHeader.substring(7); // Remove 'Bearer ' prefix |
| 29 |
|
| 30 |
// Verify JWT token |
| 31 |
const decoded = fastify.jwt.verify(token) as { |
| 32 |
userId: string; |
| 33 |
username: string; |
| 34 |
sessionId: string; |
| 35 |
}; |
| 36 |
|
| 37 |
// Attach user info to request |
| 38 |
request.user = { |
| 39 |
userId: decoded.userId, |
| 40 |
username: decoded.username, |
| 41 |
sessionId: decoded.sessionId, |
| 42 |
}; |
| 43 |
} catch (error) { |
| 44 |
if (error.code === 'FST_JWT_AUTHORIZATION_TOKEN_EXPIRED') { |
| 45 |
throw fastify.httpErrors.unauthorized('Token expired'); |
| 46 |
} else if (error.code === 'FST_JWT_AUTHORIZATION_TOKEN_INVALID') { |
| 47 |
throw fastify.httpErrors.unauthorized('Invalid token'); |
| 48 |
} else if (error.statusCode) { |
| 49 |
throw error; |
| 50 |
} else { |
| 51 |
fastify.log.error(error, 'Authentication failed'); |
| 52 |
throw fastify.httpErrors.unauthorized('Authentication failed'); |
| 53 |
} |
| 54 |
} |
| 55 |
}); |
| 56 |
|
| 57 |
// Optional authentication hook (for endpoints that work with or without auth) |
| 58 |
fastify.decorate('optionalAuth', async function (request: FastifyRequest, reply: FastifyReply) { |
| 59 |
try { |
| 60 |
const authHeader = request.headers.authorization; |
| 61 |
if (authHeader && authHeader.startsWith('Bearer ')) { |
| 62 |
const token = authHeader.substring(7); |
| 63 |
const decoded = fastify.jwt.verify(token) as { |
| 64 |
userId: string; |
| 65 |
username: string; |
| 66 |
sessionId: string; |
| 67 |
}; |
| 68 |
|
| 69 |
request.user = { |
| 70 |
userId: decoded.userId, |
| 71 |
username: decoded.username, |
| 72 |
sessionId: decoded.sessionId, |
| 73 |
}; |
| 74 |
} |
| 75 |
} catch (error) { |
| 76 |
// Ignore authentication errors for optional auth |
| 77 |
fastify.log.debug(error, 'Optional authentication failed'); |
| 78 |
} |
| 79 |
}); |
| 80 |
|
| 81 |
// Rate limiting middleware (simple in-memory implementation) |
| 82 |
const rateLimitStore = new Map<string, { count: number; resetTime: number }>(); |
| 83 |
|
| 84 |
fastify.addHook('preHandler', async (request, reply) => { |
| 85 |
// Skip rate limiting for health checks |
| 86 |
if (request.url === '/api/health') { |
| 87 |
return; |
| 88 |
} |
| 89 |
|
| 90 |
const clientIp = request.ip; |
| 91 |
const now = Date.now(); |
| 92 |
const windowMs = 60 * 1000; // 1 minute |
| 93 |
const maxRequests = 100; // 100 requests per minute |
| 94 |
|
| 95 |
const key = `${clientIp}:${Math.floor(now / windowMs)}`; |
| 96 |
const current = rateLimitStore.get(key) || { count: 0, resetTime: now + windowMs }; |
| 97 |
|
| 98 |
if (current.count >= maxRequests) { |
| 99 |
reply.header('Retry-After', Math.ceil((current.resetTime - now) / 1000)); |
| 100 |
throw fastify.httpErrors.tooManyRequests('Rate limit exceeded'); |
| 101 |
} |
| 102 |
|
| 103 |
current.count++; |
| 104 |
rateLimitStore.set(key, current); |
| 105 |
|
| 106 |
// Clean up old entries |
| 107 |
if (Math.random() < 0.01) { // 1% chance to clean up |
| 108 |
for (const [k, v] of rateLimitStore.entries()) { |
| 109 |
if (v.resetTime < now) { |
| 110 |
rateLimitStore.delete(k); |
| 111 |
} |
| 112 |
} |
| 113 |
} |
| 114 |
}); |
| 115 |
|
| 116 |
// Security headers |
| 117 |
fastify.addHook('onSend', async (request, reply) => { |
| 118 |
reply.header('X-Content-Type-Options', 'nosniff'); |
| 119 |
reply.header('X-Frame-Options', 'DENY'); |
| 120 |
reply.header('X-XSS-Protection', '1; mode=block'); |
| 121 |
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin'); |
| 122 |
|
| 123 |
if (fastify.config.nodeEnv === 'production') { |
| 124 |
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); |
| 125 |
} |
| 126 |
}); |
| 127 |
} |