/ src / middleware / auth.js
auth.js
  1  import crypto from "crypto";
  2  import { authService } from "@/services/userService.js";
  3  import { sendError, ErrorCodes, StatusCodes } from "@/utils/responses.js";
  4  import { logger } from "@/middleware/logging.js";
  5  import config from "@/config.js";
  6  import { timingSafeCompare } from "@/utils/cryptoUtils.js";
  7  import cache, { CacheEvents } from "@/utils/cache.js";
  8  
  9  const requestTracker = new Map();
 10  
 11  const invalidateUserCache = (userId) => {
 12    const authDeleted = cache.invalidateByPattern("auth", `.*:${userId}:.*`);
 13    const sessionDeleted = cache.invalidateByPattern("session", `.*:${userId}:.*`);
 14  
 15    logger.info(`Invalidated ${authDeleted + sessionDeleted} cache entries for user ${userId}`);
 16  
 17    cache.emit(CacheEvents.CACHE_INVALIDATED, {
 18      type: "user",
 19      userId,
 20      entriesDeleted: authDeleted + sessionDeleted,
 21      timestamp: new Date(),
 22    });
 23  };
 24  
 25  const cleanupRequestTracker = () => {
 26    const now = Date.now();
 27    const windowStart = now - (config.timeouts.short || 5000);
 28    const maxAge = now - (config.timeouts.long || 300000);
 29    let cleanedCount = 0;
 30  
 31    for (const [key, value] of requestTracker.entries()) {
 32      const hasRecentRequests = value.requests.some(t => t > windowStart);
 33      const isExpired = !hasRecentRequests && (!value.blocked || now > value.blockedUntil);
 34      const isVeryOld = value.requests.length > 0 && value.requests[0] < maxAge;
 35  
 36      if (isExpired || isVeryOld) {
 37        requestTracker.delete(key);
 38        cleanedCount++;
 39      }
 40    }
 41  
 42    if (cleanedCount > 0) {
 43      logger.info(`Cleaned ${cleanedCount} expired entries from request tracker`);
 44    }
 45  
 46    if (requestTracker.size > config.security.auth.requestTrackerMaxSize) {
 47      const entriesToKeep = config.security.auth.requestTrackerMaxSize * 0.8;
 48      const sortedEntries = Array.from(requestTracker.entries())
 49        .sort((a, b) => {
 50          const aLatest = Math.max(...a[1].requests, a[1].blockedUntil || 0);
 51          const bLatest = Math.max(...b[1].requests, b[1].blockedUntil || 0);
 52          return bLatest - aLatest;
 53        });
 54  
 55      const toDelete = sortedEntries.slice(entriesToKeep);
 56      toDelete.forEach(([key]) => requestTracker.delete(key));
 57  
 58      logger.warn(`Request tracker exceeded max size, removed ${toDelete.length} oldest entries`);
 59    }
 60  };
 61  
 62  setInterval(() => {
 63    const stats = cache.getStats();
 64    logger.debug("Cache stats:", stats);
 65  }, config.timeouts.veryLong || 300000);
 66  
 67  const hashToken = (token) => {
 68    return crypto.createHash("sha256").update(token).digest("hex");
 69  };
 70  
 71  const validateTokenFormat = (token) => {
 72    if (!token || typeof token !== "string") {
 73      return false;
 74    } else if (token.length < 32 || token.length > 128) {
 75      return false;
 76    } else if (!/^[a-zA-Z0-9_-]+$/.test(token)) {
 77      return false;
 78    } else {
 79      return true;
 80    }
 81  };
 82  
 83  const forceRevalidation = async (token, req) => {
 84    try {
 85      logger.warn("Forcing token revalidation due to potential cache poisoning", {
 86        requestId: req.requestId,
 87        ip: req.ip,
 88      });
 89  
 90      const validation = await authService.validateSession(token);
 91  
 92      if (validation.valid && validation.user && !validation.user.isBanned) {
 93        const cacheKey = `token:${hashToken(token)}`;
 94        cache.set("auth", cacheKey, {
 95          valid: true,
 96          user: validation.user,
 97          userId: validation.user.id,
 98          timestamp: Date.now(),
 99          forceValidated: true,
100        });
101        return validation.user;
102      } else if (validation.user && validation.user.isBanned) {
103        invalidateUserCache(validation.user.id);
104        throw new Error("Account is suspended");
105      }
106      throw new Error("Invalid session");
107    } catch (error) {
108      logger.error("Revalidation failed:", error);
109      throw error;
110    }
111  };
112  
113  const checkRequestRate = (identifier) => {
114    const now = Date.now();
115    const windowStart = now - (config.timeouts.short || 5000);
116    let tracker = requestTracker.get(identifier);
117    if (!tracker) {
118      tracker = { requests: [], blocked: false, blockedUntil: 0 };
119      requestTracker.set(identifier, tracker);
120    }
121    if (tracker.blocked && now < tracker.blockedUntil) {
122      return false;
123    }
124    tracker.blocked = false;
125    tracker.requests = tracker.requests.filter(t => t > windowStart);
126    if (tracker.requests.length >= config.security.auth.requestTrackerMaxRequestsPerSecond) {
127      tracker.blocked = true;
128      tracker.blockedUntil = now + 60000;
129      return false;
130    }
131    tracker.requests.push(now);
132    return true;
133  };
134  
135  setInterval(cleanupRequestTracker, config.timeouts.medium || 10000);
136  
137  export const verifySession = async (req, res, next) => {
138    const startTime = Date.now();
139    const requestId = crypto.randomBytes(8).toString("hex");
140    req.requestId = requestId;
141  
142    try {
143      const clientIp = req.ip || req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || "unknown";
144      if (!checkRequestRate(clientIp)) {
145        logger.warn("Rate limit exceeded for session verification", { ip: clientIp, requestId });
146        return sendError(
147          res,
148          ErrorCodes.TOO_MANY_REQUESTS,
149          "Too many requests",
150          StatusCodes.TOO_MANY_REQUESTS,
151        );
152      }
153  
154      const authHeader = req.headers["authorization"];
155      if (!authHeader || typeof authHeader !== "string") {
156        return sendError(
157          res,
158          ErrorCodes.UNAUTHORIZED,
159          "Authentication required",
160          StatusCodes.UNAUTHORIZED,
161        );
162      }
163  
164      const parts = authHeader.split(" ");
165      if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") {
166        return sendError(
167          res,
168          ErrorCodes.UNAUTHORIZED,
169          "Invalid authorization format",
170          StatusCodes.UNAUTHORIZED,
171        );
172      }
173  
174      const token = parts[1];
175      if (!validateTokenFormat(token)) {
176        await new Promise(resolve => setTimeout(resolve, crypto.randomInt(50, 150)));
177        return sendError(
178          res,
179          ErrorCodes.UNAUTHORIZED,
180          "Invalid token format",
181          StatusCodes.UNAUTHORIZED,
182        );
183      }
184  
185      const tokenHash = hashToken(token);
186      const cacheKey = `token:${tokenHash}`;
187      const cached = cache.get("auth", cacheKey);
188  
189      if (cached) {
190        if (cached.valid && !cached.user.isBanned) {
191          if (cached.forceValidated || (crypto.randomBytes(4).readUInt32LE(0) / 0xFFFFFFFF) > 0.01) {
192            req.user = cached.user;
193            return next();
194          } else {
195            try {
196              req.user = await forceRevalidation(token, req);
197              return next();
198            } catch {
199              return sendError(
200                res,
201                ErrorCodes.SESSION_EXPIRED,
202                "Session expired",
203                StatusCodes.FORBIDDEN,
204              );
205            }
206          }
207        } else if (cached.user.isBanned) {
208          invalidateUserCache(cached.user.id);
209          return sendError(
210            res,
211            ErrorCodes.FORBIDDEN,
212            "Account is suspended",
213            StatusCodes.FORBIDDEN,
214          );
215        }
216        return sendError(
217          res,
218          ErrorCodes.SESSION_EXPIRED,
219          "Session expired",
220          StatusCodes.FORBIDDEN,
221        );
222      }
223  
224      const validation = await authService.validateSession(token);
225      const processingTime = Date.now() - startTime;
226      const minProcessingTime = 100;
227      if (processingTime < minProcessingTime) {
228        await new Promise(resolve => setTimeout(resolve, minProcessingTime - processingTime + crypto.randomInt(0, 50)));
229      }
230  
231      if (validation.valid && validation.user && !validation.user.isBanned) {
232        cache.set("auth", cacheKey, {
233          valid: true,
234          user: validation.user,
235          userId: validation.user.id,
236          timestamp: Date.now(),
237        });
238        req.user = validation.user;
239        return next();
240      } else if (validation.user && validation.user.isBanned) {
241        invalidateUserCache(validation.user.id);
242        return sendError(
243          res,
244          ErrorCodes.FORBIDDEN,
245          "Account is suspended",
246          StatusCodes.FORBIDDEN,
247        );
248      }
249  
250      cache.set("auth", cacheKey, {
251        valid: false,
252        timestamp: Date.now(),
253      });
254  
255      return sendError(
256        res,
257        ErrorCodes.SESSION_EXPIRED,
258        "Invalid or expired session",
259        StatusCodes.FORBIDDEN,
260      );
261    } catch (error) {
262      logger.error("Session verification error", {
263        requestId,
264        message: error.message,
265        ip: req.ip,
266      });
267      await new Promise(resolve => setTimeout(resolve, crypto.randomInt(100, 200)));
268      return sendError(
269        res,
270        ErrorCodes.INTERNAL_ERROR,
271        "Authentication failed",
272        StatusCodes.INTERNAL_SERVER_ERROR,
273      );
274    }
275  };
276  
277  export const requirePassword = async (req, res, next) => {
278    try {
279      if (!req.user || !req.user.id) {
280        return sendError(
281          res,
282          ErrorCodes.UNAUTHORIZED,
283          "Authentication required",
284          StatusCodes.UNAUTHORIZED,
285        );
286      }
287      if (!req.user.hasPassword) {
288        return sendError(
289          res,
290          ErrorCodes.PASSWORD_REQUIRED,
291          "Password setup required",
292          StatusCodes.FORBIDDEN,
293        );
294      }
295      return next();
296    } catch (error) {
297      logger.error("Password requirement check error", { message: error.message });
298      return sendError(
299        res,
300        ErrorCodes.INTERNAL_ERROR,
301        "Authorization check failed",
302        StatusCodes.INTERNAL_SERVER_ERROR,
303      );
304    }
305  };
306  
307  export const requireUnbanned = async (req, res, next) => {
308    try {
309      if (!req.user || !req.user.id) {
310        return sendError(
311          res,
312          ErrorCodes.UNAUTHORIZED,
313          "Authentication required",
314          StatusCodes.UNAUTHORIZED,
315        );
316      }
317      if (req.user.isBanned) {
318        return sendError(
319          res,
320          ErrorCodes.FORBIDDEN,
321          "Account suspended",
322          StatusCodes.FORBIDDEN,
323        );
324      }
325      return next();
326    } catch (error) {
327      logger.error("Ban check error", { message: error.message });
328      return sendError(
329        res,
330        ErrorCodes.INTERNAL_ERROR,
331        "Authorization check failed",
332        StatusCodes.INTERNAL_SERVER_ERROR,
333      );
334    }
335  };
336  
337  export const invalidateSessionCache = (token) => {
338    if (!token) {
339      return;
340    }
341    const tokenHash = hashToken(token);
342    cache.delete("auth", `token:${tokenHash}`);
343  };
344  
345  export { timingSafeCompare };