/** * Authentication middleware - uses Redis session store. * Keycloak is used only for login/refresh; session validation is done via Redis. */ import config, { getEnvironment } from './config.js'; import log4js from 'log4js'; import NodeCache from 'node-cache'; import bcrypt from 'bcrypt'; import { userModel } from './database/schemas/management/user.schema.js'; import { getObject } from './database/database.js'; import { hostModel } from './database/schemas/management/host.schema.js'; import { getSession, lookupUserByToken } from './services/misc/auth.js'; const logger = log4js.getLogger('Keycloak'); logger.level = config.server.logLevel || 'info'; const userCache = new NodeCache({ stdTTL: 300 }); userCache.on('expired', (key, value) => { logger.debug(`Cache entry expired: ${key}`); }); userCache.on('flush', () => { logger.info('Cache flushed'); }); const lookupUser = async (preferredUsername) => { try { const cachedUser = userCache.get(preferredUsername); if (cachedUser) { logger.debug(`User found in cache: ${preferredUsername}`); return cachedUser; } logger.debug(`User not in cache, querying database: ${preferredUsername}`); const user = await userModel.findOne({ username: preferredUsername }); if (user) { userCache.set(preferredUsername, user); logger.debug(`User stored in cache: ${preferredUsername}`); return user; } logger.warn(`User not found in database: ${preferredUsername}`); return null; } catch (error) { logger.error(`Error looking up user ${preferredUsername}:`, error.message); return null; } }; /** * Middleware to check if the user is authenticated. * Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer), * 3) HTTP Basic Auth (username + app password), 4) x-host-id + x-auth-code (host auth) */ const isAuthenticated = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); try { const session = await getSession(token); if (session && session.expiresAt > Date.now()) { req.user = session.user; req.session = session; return next(); } // Try email-render JWT (short-lived token for Puppeteer email notifications) const user = await lookupUserByToken(token); if (user) { req.user = user; req.session = { user }; return next(); } } catch (error) { logger.error('Session lookup error:', error.message); } } // Try HTTP Basic Auth (username + app password) if (authHeader && authHeader.startsWith('Basic ')) { try { logger.debug('Basic auth header:', authHeader); const base64Credentials = authHeader.substring(6); const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); const [username, password] = credentials.split(':'); logger.debug('Basic auth credentials:', { username, password }); if (username && password) { const user = await userModel.findOne({ username }).select('+appPasswordHash'); if (user?.appPasswordHash && (await bcrypt.compare(password, user.appPasswordHash))) { user.appPasswordHash = undefined; // don't expose hash downstream req.user = user; req.session = { user }; return next(); } } } catch (error) { logger.error('Basic auth error:', error.message); } } const hostId = req.headers['x-host-id']; const authCode = req.headers['x-auth-code']; if (hostId && authCode) { const host = await getObject({ model: hostModel, id: hostId }); if (host && host.authCode === authCode) { return next(); } } logger.debug('Not authenticated', { hostId, authCode }, 'req.headers', req.headers); return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; const clearUserCache = () => { userCache.flushAll(); logger.info('User cache cleared'); }; const getUserCacheStats = () => { return userCache.getStats(); }; const removeUserFromCache = (username) => { userCache.del(username); logger.debug(`User removed from cache: ${username}`); }; export { isAuthenticated, lookupUser, clearUserCache, getUserCacheStats, removeUserFromCache, getEnvironment, };