/** * 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 { appPasswordModel } from './database/schemas/management/apppassword.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) 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); } } 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 authenticateWithAppPassword = async (username, secret) => { if (!username || !secret) return null; const user = await userModel.findOne({ username }).lean(); if (!user) return null; const appPasswords = await appPasswordModel .find({ user: user._id, active: true }) .select('+secret') .lean(); for (const appPassword of appPasswords) { const storedHash = appPassword.secret; if (storedHash && (await bcrypt.compare(secret, storedHash))) { return user; } } return null; }; const isAppAuthenticated = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; const apiKey = req.headers['x-api-key']; const userParam = req.params.username; const passwordParam = req.params.password; logger.debug('App authentication request', { hasBasicAuth: authHeader?.startsWith('Basic ') === true, hasApiKey: Boolean(apiKey), hasPasswordParam: Boolean(passwordParam), userParam, }); // Supports HTTP Basic Auth (username + app password secret) if (authHeader?.startsWith('Basic ')) { try { logger.debug('Basic auth header present'); const base64Credentials = authHeader.substring(6); const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); const colonIndex = credentials.indexOf(':'); const username = credentials.substring(0, colonIndex).trim(); const secret = credentials.substring(colonIndex + 1).trim(); const user = await authenticateWithAppPassword(username, secret); if (user) { req.user = user; req.session = { user }; return next(); } } catch (error) { logger.error('Basic auth error:', error.message); } } const appPassword = apiKey || passwordParam; if (appPassword && userParam) { logger.debug('App password and username present', { userParam }); const user = await authenticateWithAppPassword(userParam, appPassword); if (user) { req.user = user; req.session = { user }; return next(); } } return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; const isAppQueryAuthenticated = async (req, res, next) => { try { const username = typeof req.query.u === 'string' ? req.query.u.trim() : ''; const secret = typeof req.query.p === 'string' ? req.query.p : ''; const user = await authenticateWithAppPassword(username, secret); if (user) { req.user = user; req.session = { user }; return next(); } } catch (error) { logger.error('Query auth error:', error.message); } 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, isAppAuthenticated, isAppQueryAuthenticated, lookupUser, clearUserCache, getUserCacheStats, removeUserFromCache, getEnvironment, };